Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/user/stochastic.rst
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,30 @@ passed in a few different ways:
A ``CustomSampler`` given for this argument has to yield a whole outline per
sample, since what it returns replaces the outline instead of perturbing it.

.. note::
Where the nominal value comes from the deterministic object, it is read when
that input is configured and kept from then on, so changing the deterministic
object afterwards does not move what is sampled around. Neither does a
``MonteCarlo`` run: some ``create_object`` paths write the sampled value
back onto the object they were given rather than building a copy, and
re-reading it would take one simulation's output as the next one's nominal.
Arrays and the built-in containers are copied on the way in and on the way
out, so writing through a generated object does not reach the kept value
either.

Four things sit outside that rule on purpose:

- an input installed by an ``add_*`` method, an eccentricity for instance, is
read when it is added rather than when the object is built;
- a component's position is read from its own component on every reset, since
each of them arrives under the one name ``position``;
- an ensemble wind factor scales the selected member's own profile, because
``select_ensemble_member`` rebuilds the wind and the value from before it
belongs to whichever member was loaded then;
- anything that is not an array or a built-in container, a ``Function`` or a
callable among them, is kept by reference and follows the object it came
from.

.. note::
In statistics, the terms "Normal" and "Gaussian" refer to the same type of \
distribution. This distribution is commonly used and is the default for the \
Expand Down
3 changes: 3 additions & 0 deletions rocketpy/stochastic/stochastic_aero_surfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,9 @@ def dict_generator(self):
generated_dict["shape_points"] = self._keep_root_on_body_line(
self.shape_points[0], generated_dict["shape_points"]
)
# The outline moved after the base class recorded it, and what the
# fins are built from is what the record has to hold.
self._record_draw(generated_dict)
yield generated_dict

@staticmethod
Expand Down
122 changes: 101 additions & 21 deletions rocketpy/stochastic/stochastic_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,29 @@
from ..tools import get_distribution


def _snapshot_of(value):
"""Returns a copy of value that a later write cannot reach.

Numeric arrays and the built-in containers are copied entry by entry, since
an array inside an ``airfoil`` tuple would otherwise stay shared. This is
not a general deep copy: anything else is returned as it is, shared
structure is not rebuilt, and a cycle recurses until Python stops it.
"""
if isinstance(value, np.ndarray):
return value.copy()
if isinstance(value, list):
return [_snapshot_of(item) for item in value]
if isinstance(value, tuple):
entries = [_snapshot_of(item) for item in value]
# A namedtuple takes its fields positionally.
return type(value)(*entries) if hasattr(value, "_fields") else tuple(entries)
if isinstance(value, set):
return {_snapshot_of(item) for item in value}
if isinstance(value, dict):
return {key: _snapshot_of(item) for key, item in value.items()}
return value


def _names_as_spawn_key(input_names):
"""Encode names into spawn-key words that no other set of names produces.

Expand Down Expand Up @@ -122,23 +145,78 @@ def __init__(self, obj, seed=None, **kwargs):
self.obj = obj
self.last_rnd_dict = {}
self.__stochastic_dict = kwargs
self.__nominal_values = {}
self._set_stochastic(seed)

def _declare_stochastic_input(self, input_name, input_value):
"""Declare an input that an ``add_*`` method installs after ``__init__``.
def _record_draw(self, generated_dict):
"""Records what this model published, after any subclass has finished.

``dict_generator`` walks the inputs a model declared rather than every
attribute on it (#1109), and that list is built in ``__init__``. Anything
added afterwards is set on the instance and never drawn from unless it
says so here.
A record of the draw rather than a window onto what was built from it,
since the object handed those values can be written through afterwards.
A subclass that adjusts a value has to call this again, or the record
keeps what it replaced.
"""
self.last_rnd_dict = _snapshot_of(generated_dict)

def _nominal(self, input_name, getter=getattr):
"""Returns what ``self.obj`` held for ``input_name`` when it was
configured.

The value is the argument as given, not the validated form, because
``_set_stochastic`` validates it again on every reseed and binds the
distribution to the generator that is live then.
Kept and copied both ways, because ``create_object`` writes sampled
values back onto that object and what this returns reaches
``last_rnd_dict``. A position arrives through a ``getter`` and is read
live, since every component uses this one name.
"""
if input_value is None:
return
self.__stochastic_dict[input_name] = input_value
if getter is not getattr:
return getter(self.obj, input_name)
if input_name not in self.__nominal_values:
self.__nominal_values[input_name] = _snapshot_of(
getattr(self.obj, input_name)
)
return _snapshot_of(self.__nominal_values[input_name])

def _reconfigure_stochastic_inputs(self, inputs, validate):
"""Configures late inputs again, all of them or none of them.

The kept nominal is what ``configured`` means, so replacing an input
has to drop it before validation reads one. Whatever a caller passes
together is replaced together, since ``add_cp_eccentricity`` takes x
and y in one call and a y that will not validate must not leave x
already replaced.

``None`` leaves any earlier declaration alone. It reads the same way
whether the caller wrote it or left the argument out, and removing on
the second reading would take away an axis nobody mentioned.
"""
inputs = tuple(inputs)
# A None that already has a configuration is an axis the caller left
# out, since an omitted argument arrives the same way. It keeps what it
# had: not validated again, and its kept nominal not read again.
untouched = {
name
for name, value in inputs
if value is None and name in self.__stochastic_dict
}
kept = {
name: self.__nominal_values.pop(name)
for name, _ in inputs
if name not in untouched and name in self.__nominal_values
}
try:
validated = [
getattr(self, name) if name in untouched else validate(name, value)
for name, value in inputs
]
except BaseException:
for name, _ in inputs:
if name not in untouched:
self.__nominal_values.pop(name, None)
self.__nominal_values.update(kept)
raise
for name, value in inputs:
if value is not None:
self.__stochastic_dict[name] = value
return validated

def _set_stochastic(self, seed=None):
"""Set the stochastic attributes from the input dictionary.
Expand Down Expand Up @@ -186,7 +264,7 @@ def _set_stochastic(self, seed=None):
"or a custom sampler"
)
else:
attr_value = [getattr(self.obj, input_name)]
attr_value = [self._nominal(input_name)]
setattr(self, input_name, attr_value)

def __repr__(self):
Expand All @@ -209,11 +287,13 @@ def _choose(self, values):
Returns
-------
object
One of the candidates, or ``values`` itself when there are none.
A copy of one of the candidates, or of ``values`` when there are
none. Copied because what this returns is handed to the object
being built.
"""
if len(values) == 0:
return values
return values[self.__choice_generator.integers(len(values))]
return _snapshot_of(values)
return _snapshot_of(values[self.__choice_generator.integers(len(values))])

def _nominal_value(self, input_name, value):
"""Return the nominal value of an input as the distribution needs it.
Expand Down Expand Up @@ -321,7 +401,7 @@ def _validate_tuple_length_two(self, input_name, input_value, getattr=getattr):
# object passed.
dist_func = get_distribution(input_value[1], self.__random_number_generator)
return (
self._nominal_value(input_name, getattr(self.obj, input_name)),
self._nominal_value(input_name, self._nominal(input_name, getattr)),
input_value[0],
dist_func,
)
Expand Down Expand Up @@ -397,7 +477,7 @@ def _validate_list(self, input_name, input_value, getattr=getattr): # pylint: d
If the input is not in a valid format.
"""
if not input_value:
return [getattr(self.obj, input_name)]
return [self._nominal(input_name, getattr)]
else:
return input_value

Expand All @@ -423,7 +503,7 @@ def _validate_scalar(self, input_name, input_value, getattr=getattr): # pylint:
distribution function).
"""
return (
self._nominal_value(input_name, getattr(self.obj, input_name)),
self._nominal_value(input_name, self._nominal(input_name, getattr)),
input_value,
get_distribution("normal", self.__random_number_generator),
)
Expand All @@ -450,7 +530,7 @@ def _validate_factors(self, input_name, input_value):
If the input is not in a valid format.
"""
attribute_name = input_name.replace("_factor", "")
setattr(self, f"_{attribute_name}", getattr(self.obj, attribute_name))
setattr(self, f"_{attribute_name}", self._nominal(attribute_name))

if isinstance(input_value, tuple):
return self._validate_tuple_factor(input_name, input_value)
Expand Down Expand Up @@ -733,7 +813,7 @@ def dict_generator(self):
raise RuntimeError(
f"An error occurred in the 'sample' method of {arg} CustomSampler"
) from e
self.last_rnd_dict = generated_dict
self._record_draw(generated_dict)
yield generated_dict

# pylint: disable=too-many-statements
Expand Down
3 changes: 3 additions & 0 deletions rocketpy/stochastic/stochastic_parachute.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,4 +211,7 @@ def create_object(self):
generated_dict["seed"] = _sampler_seed(
self._seed, ("pressure_noise", generated_dict["name"])
)
# Recorded after the seed is in, or the inputs describe a parachute
# with noise nobody can reproduce.
self._record_draw(generated_dict)
return Parachute(**generated_dict)
36 changes: 24 additions & 12 deletions rocketpy/stochastic/stochastic_rocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,15 +451,23 @@ def add_cp_eccentricity(self, x=None, y=None):
the y direction relative to the center of dry mass axial line.
The y axis is defined according to the body axes coordinate system.


Calling this again replaces what was configured before. An axis left
out keeps the setting it already had, since ``None`` is what an omitted
argument arrives as and cannot be told apart from one written by hand.
Taking an axis away again is not supported (#1171).

Returns
-------
self : StochasticRocket
Object of the StochasticRocket class.
"""
self.cp_eccentricity_x = self._validate_eccentricity("cp_eccentricity_x", x)
self._declare_stochastic_input("cp_eccentricity_x", x)
self.cp_eccentricity_y = self._validate_eccentricity("cp_eccentricity_y", y)
self._declare_stochastic_input("cp_eccentricity_y", y)
self.cp_eccentricity_x, self.cp_eccentricity_y = (
self._reconfigure_stochastic_inputs(
(("cp_eccentricity_x", x), ("cp_eccentricity_y", y)),
self._validate_eccentricity,
)
)
return self

def add_thrust_eccentricity(self, x=None, y=None):
Expand All @@ -479,19 +487,23 @@ def add_thrust_eccentricity(self, x=None, y=None):
relative to the center of dry mass axial line. The y axis
is defined according to the body axes coordinate system.


Calling this again replaces what was configured before. An axis left
out keeps the setting it already had, since ``None`` is what an omitted
argument arrives as and cannot be told apart from one written by hand.
Taking an axis away again is not supported (#1171).

Returns
-------
self : StochasticRocket
Object of the StochasticRocket class.
"""
self.thrust_eccentricity_x = self._validate_eccentricity(
"thrust_eccentricity_x", x
)
self._declare_stochastic_input("thrust_eccentricity_x", x)
self.thrust_eccentricity_y = self._validate_eccentricity(
"thrust_eccentricity_y", y
self.thrust_eccentricity_x, self.thrust_eccentricity_y = (
self._reconfigure_stochastic_inputs(
(("thrust_eccentricity_x", x), ("thrust_eccentricity_y", y)),
self._validate_eccentricity,
)
)
self._declare_stochastic_input("thrust_eccentricity_y", y)
return self

def _validate_eccentricity(self, eccentricity, position):
Expand Down Expand Up @@ -682,7 +694,7 @@ def dict_generator(self):
generated_dict["rail_buttons"] = []
generated_dict["air_brakes"] = []
generated_dict["parachutes"] = []
self.last_rnd_dict = generated_dict
self._record_draw(generated_dict)
yield generated_dict

def _create_motor(self, component_stochastic_motor):
Expand Down
Loading
Loading