Skip to content

Commit 06bdb4b

Browse files
thc1006Gui-FernandesBRclaude
authored
BUG: give each StochasticRocket component its own random stream (#1170)
* BUG: give each StochasticRocket component its own random stream _set_stochastic handed the same seed to the rocket body and to every surface, motor, rail button and parachute, so two components built from one spec drew identical values: a main and a drogue with the same cd_s and lag spec drew the same cd_s and the same lag, every time, and a study of both was a study of one counted twice. Air brakes were worse. They are built and sampled in create_object and were not in the reseed at all, so their values came from wherever the generator had been left rather than from the seed: 0.683, then 0.586, then 0.488 for one seed asked three times. Each component now takes its own child of a SeedSequence root, spawned in a fixed order so one seed still reproduces the whole rocket. The collections are named in one place and checked against create_object's own source, since the collection no fixture populates is the one that gets missed. Extracted from #1054. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> * DOC: shorten the comments on the component seeding Measured against the register the repository uses: inline comments in flight.py average 5.6 words and none of its docstrings run longer than the code they describe. The three added here were four to seven lines of prose where a line would do, and the seed helper carried seven lines of docstring over two lines of code. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> * BUG: leave the rocket body's stream where it was, and isolate each collection Moving the body to child zero broke every fixed-seed baseline for mass, radius and the body inputs, and nothing about the nested-component fix needed that. The body keeps the seed as given now: stochastic_calisto under seed 42 reads mass=14.906007947 on develop and the same here. Components were also addressed by one global traversal index, so adding a fin moved every motor, rail button, parachute and air brake. Each collection has a root of its own now, spawned from the same seed, so an unrelated component in one of them leaves the others where they were. The source scan compares the two sets both ways. A collection left in the reseed after create_object stops using it still spawns a child and moves every stream after it, which the subset check let through. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> * TST: count the reseeds, and cover two air brakes on one spec The source scan reads create_object for a literal loop over self.collection, so a helper, a local alias or a getattr would hide a collection from it. Counting what each entry actually receives is the check that survives a refactor, and it is the only one that fails when an entry is reseeded twice. The air brakes are a plain list and take a different route through the reseed than the positioned collections, so two of them on one spec are worth their own case. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> * TST: stop the isolation test from storing one wrapper twice stochastic_calisto already holds the stochastic_nose_cone fixture, so adding it again put the test into the state #1172 describes: one wrapper in two entries, its position overwritten, and two reseeds landing on the same object. The assertion looked at a different collection and passed anyway. It adds the deterministic nose now, so add_nose builds a wrapper of its own. Nothing pinned the body keeping the seed as given either. Reproducibility and seed uniqueness both hold with the body on a spawned child, so neither would have noticed it going back there. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> * DOC: say how a rocket's components are seeded The change moves every fixed-seed component baseline and nothing in the user documentation said how components are seeded at all, before or after. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> * DOC: say what separate streams do and do not promise Two independent streams are not made to consume the same draws; they can still land on equal values, and a specification with no spread always will. The text promised unequal results, which is a stronger claim than spawning gives. It also said each kind of component is spawned separately. The unit is the collection: a nose cone, the fins and the tail share one root. And a stream belongs to one wrapper, so storing one twice or sharing it between rockets is outside what this establishes. That is #1172. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> * DOC: say that the reset builds the tree, not the add A rocket resets itself while being constructed, when it holds no components yet, so a parachute added afterwards keeps the generator it was built with until the next reset. The text read as though attaching a component gave it a stream, which is only true once something resets the rocket, and a Monte Carlo is what does that. Two wrappers sharing a CustomSampler seed_group are also one stream on purpose. Separate component streams are not meant to take that apart, so the note says so rather than leaving it to be discovered. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> * DOC: correct what Monte Carlo and a shared sampler group actually do A serial MonteCarlo run never resets the rocket, and a parallel one resets each worker once rather than once per simulation, so the text saying a run resets the rocket for you was wrong for both. Per-simulation reset is the Monte Carlo seeding work, not this change. CustomSampler.seed_group already documents that a group belongs to one model and that the last to seed it wins. Saying two components sharing one stay one stream on purpose read as a guarantee this does not make. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> * DOC: the parallel path does not get as far as building the tree Saying it resets each worker once reads as though it works and only the grain differs. It hands the model a SeedSequence where an integer is wanted, so it stops before the tree exists, which the PR already records as the Monte Carlo seeding work rather than this change. The two air brake test also says what it is not: both are added with one controller because the rocket keeps a single one, which is #1172. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> * TST: pin every word of the seed, and say what append-only means Dropping the fourth word left all 33 tools tests passing: the checks were that the high bits are not zero, that the low word matches, that two children differ and that reading twice agrees, none of which a 96 bit truncation breaks. It compares against the integer rebuilt from all four words now, and that mutation fails. A collection's stream is addressed by where its name falls in the two tuples read end to end, so appending to the first moves every name in the second. The comment said append rather than reorder, which reads as though appending to either one is safe. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> * BUG: take the seed type a parallel run hands the rocket A parallel run spawns a SeedSequence per worker and passes it down, and SeedSequence does not take another one as entropy, so rooting the collections from it raised TypeError. It was unreachable until now: the base _set_stochastic refuses the same type one frame earlier, so a worker never got this far. Once that is fixed the call here is the next one to fail, which is why it is fixed in the same series rather than left for whoever hits it. Copied from the full state rather than spawned from directly. spawn() advances the counter of an object the caller still holds, and a second use of the same seed would then build the components a different tree. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> * MNT: drop the _seed_sequence_to_int this branch no longer needs to add #1181 landed the same helper on develop, with the same body, so merging develop in left tools.py defining it twice a hundred lines apart. Git had no conflict to report: the two copies were added at different points in the file, so the second simply shadowed the first, and pylint would have failed the branch with E0102 rather than anything explaining why. Develop's copy is kept, its docstring being the fuller of the two. What this branch still adds on its own is _seed_sequence_from, which is left where it was, and the test in test_tools.py now covers the surviving definition. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> Co-authored-by: Gui-FernandesBR <guilherme.fernandes@maggu.ai> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent fa38028 commit 06bdb4b

5 files changed

Lines changed: 420 additions & 11 deletions

File tree

‎docs/user/stochastic.rst‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,45 @@ reliability of your simulations over time.
278278
.. which parameters most significantly impact your simulation results.
279279
280280
281+
Seeding a rocket's components
282+
-----------------------------
283+
284+
A ``StochasticRocket`` holds nested stochastic objects: the motors, the
285+
aerodynamic surfaces, the rail buttons, the parachutes and the air brakes. Each
286+
time the rocket is reset, every component attached to it at that moment is given
287+
a stream of its own, spawned from the seed the reset was given. A main and a
288+
drogue parachute built from the same ``cd_s`` and ``lag`` are then no longer
289+
made to consume the same draws as each other. Independent streams can still land
290+
on equal values, and a specification with no spread always will.
291+
292+
The reset is what builds the tree, not ``add_parachute`` or ``add_nose``. A
293+
rocket resets itself once while being constructed, when it has no components
294+
yet, so anything attached afterwards keeps the generator it was built with until
295+
something resets it again. A serial ``MonteCarlo`` run does not reset it at
296+
all. A parallel one tries to, once per worker, but hands the model a
297+
``SeedSequence`` where an integer is wanted, so that path does not get as far
298+
as building the tree either. Resetting per simulation, from an integer seed the
299+
caller chooses, is what the Monte Carlo seeding work adds.
300+
301+
Each collection is spawned separately, so adding an aerodynamic surface does not
302+
move what the parachutes draw. Within a collection the stream follows insertion
303+
order, so adding a component ahead of another does change what the later one
304+
draws under a fixed seed. The rocket's own inputs, such as ``mass`` and
305+
``radius``, use the seed exactly as given.
306+
307+
.. note::
308+
A component's *position* is a property of the rocket rather than of the
309+
component, so it is drawn from the rocket's own stream.
310+
311+
.. note::
312+
A stream belongs to one stochastic wrapper. Storing the same wrapper twice,
313+
or sharing one between two rockets, is not supported: the second reset
314+
replaces the first, and the two entries end up drawing from one generator.
315+
316+
A shared ``CustomSampler.seed_group`` keeps its own rule: a group belongs to
317+
one model. Sharing one between two components leaves each of them seeding it
318+
from their own child, and the last one to be reset decides what both draw.
319+
281320
Conclusion
282321
----------
283322

‎rocketpy/stochastic/stochastic_rocket.py‎

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import warnings
44

5+
56
from rocketpy.control import _Controller
67
from rocketpy.mathutils.vector_matrix import Vector
78
from rocketpy.motors.empty_motor import EmptyMotor
@@ -21,6 +22,7 @@
2122
from rocketpy.rocket.rocket import Rocket
2223
from rocketpy.stochastic.stochastic_generic_motor import StochasticGenericMotor
2324
from rocketpy.stochastic.stochastic_motor_model import StochasticMotorModel
25+
from rocketpy.tools import _seed_sequence_from, _seed_sequence_to_int
2426

2527
from .stochastic_aero_surfaces import (
2628
StochasticAirBrakes,
@@ -173,25 +175,45 @@ def __init__(
173175
coordinate_system_orientation=None,
174176
)
175177

178+
# Nested stochastic objects, in spawn order. A collection's stream is
179+
# addressed by where its name falls in the two tuples read end to end, so
180+
# appending to the first one moves every name in the second.
181+
_POSITIONED_COLLECTIONS = ("aerodynamic_surfaces", "motors", "rail_buttons")
182+
_PLAIN_COLLECTIONS = ("parachutes", "air_brakes")
183+
184+
@classmethod
185+
def _stochastic_collections(cls):
186+
"""The names of every attribute holding nested stochastic objects."""
187+
return cls._POSITIONED_COLLECTIONS + cls._PLAIN_COLLECTIONS
188+
176189
def _set_stochastic(self, seed=None):
177190
"""Set the stochastic attributes for Components, positions and
178191
inputs.
179192
193+
Each component takes its own child of its collection's root, so
194+
components stay independent and one seed still reproduces the rocket.
195+
The body keeps the seed as given, and a collection has a root of its
196+
own, so adding a fin does not move every parachute.
197+
180198
Parameters
181199
----------
182200
seed : int, optional
183201
Seed for the random number generator.
184202
"""
185203
super()._set_stochastic(seed)
186-
self.aerodynamic_surfaces = self.__reset_components(
187-
self.aerodynamic_surfaces, seed
188-
)
189-
self.motors = self.__reset_components(self.motors, seed)
190-
self.rail_buttons = self.__reset_components(self.rail_buttons, seed)
191-
for parachute in self.parachutes:
192-
parachute._set_stochastic(seed)
204+
names = self._stochastic_collections()
205+
roots = dict(zip(names, _seed_sequence_from(seed).spawn(len(names))))
206+
for name in self._POSITIONED_COLLECTIONS:
207+
setattr(
208+
self, name, self.__reset_components(getattr(self, name), roots[name])
209+
)
210+
for name in self._PLAIN_COLLECTIONS:
211+
for component in getattr(self, name):
212+
component._set_stochastic(
213+
_seed_sequence_to_int(roots[name].spawn(1)[0])
214+
)
193215

194-
def __reset_components(self, components, seed):
216+
def __reset_components(self, components, root):
195217
"""Creates a new Components whose stochastic structures
196218
and their positions are reset.
197219
@@ -200,8 +222,8 @@ def __reset_components(self, components, seed):
200222
components : Components
201223
The components which contains the stochastic structure that
202224
will be used to create the new components.
203-
seed : int, optional
204-
Seed for the random number generator.
225+
root : numpy.random.SeedSequence
226+
The reseed's root. Each component takes its own spawned child.
205227
206228
Returns
207229
-------
@@ -213,7 +235,7 @@ def __reset_components(self, components, seed):
213235
new_components = Components()
214236
for stochastic_obj, _ in components:
215237
stochastic_obj_position_info = self.__components_map[stochastic_obj]
216-
stochastic_obj._set_stochastic(seed)
238+
stochastic_obj._set_stochastic(_seed_sequence_to_int(root.spawn(1)[0]))
217239
new_components.add(
218240
stochastic_obj,
219241
self._validate_position(stochastic_obj, stochastic_obj_position_info),

‎rocketpy/tools.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1479,6 +1479,18 @@ def find_obj_from_hash(obj, hash_, depth_limit=None):
14791479
return None
14801480

14811481

1482+
def _seed_sequence_from(seed):
1483+
"""Returns a ``SeedSequence`` of the caller's own to spawn from.
1484+
1485+
A parallel run is handed one that ``SeedSequence`` will not take as
1486+
entropy, and spawning from it directly would advance the counter of an
1487+
object the caller still holds, so it is copied from its full state.
1488+
"""
1489+
if isinstance(seed, np.random.SeedSequence):
1490+
return np.random.SeedSequence(**seed.state)
1491+
return np.random.SeedSequence(seed)
1492+
1493+
14821494
if __name__ == "__main__": # pragma: no cover
14831495
import doctest
14841496

0 commit comments

Comments
 (0)