From bcc884f3d728e9acc6e73062ba55dd2730c31cf7 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:51:37 +0800 Subject: [PATCH 01/12] 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> --- rocketpy/stochastic/stochastic_rocket.py | 46 ++++-- rocketpy/tools.py | 14 ++ .../test_stochastic_rocket_seeding.py | 139 ++++++++++++++++++ tests/unit/test_tools.py | 21 +++ 4 files changed, 207 insertions(+), 13 deletions(-) create mode 100644 tests/unit/stochastic/test_stochastic_rocket_seeding.py diff --git a/rocketpy/stochastic/stochastic_rocket.py b/rocketpy/stochastic/stochastic_rocket.py index 65cfb5ebe..7aa4ccb2d 100644 --- a/rocketpy/stochastic/stochastic_rocket.py +++ b/rocketpy/stochastic/stochastic_rocket.py @@ -2,6 +2,8 @@ import warnings +import numpy as np + from rocketpy.control import _Controller from rocketpy.mathutils.vector_matrix import Vector from rocketpy.motors.empty_motor import EmptyMotor @@ -21,6 +23,7 @@ from rocketpy.rocket.rocket import Rocket from rocketpy.stochastic.stochastic_generic_motor import StochasticGenericMotor from rocketpy.stochastic.stochastic_motor_model import StochasticMotorModel +from rocketpy.tools import _seed_sequence_to_int from .stochastic_aero_surfaces import ( StochasticAirBrakes, @@ -173,25 +176,41 @@ def __init__( coordinate_system_orientation=None, ) + # Every collection of nested stochastic objects, in the order their child + # seeds are spawned. Named here rather than written out inline so a + # component type cannot reach create_object without reaching the reseed, + # which is how the air brakes were missed. + _POSITIONED_COLLECTIONS = ("aerodynamic_surfaces", "motors", "rail_buttons") + _PLAIN_COLLECTIONS = ("parachutes", "air_brakes") + + @classmethod + def _stochastic_collections(cls): + """The names of every attribute holding nested stochastic objects.""" + return cls._POSITIONED_COLLECTIONS + cls._PLAIN_COLLECTIONS + def _set_stochastic(self, seed=None): """Set the stochastic attributes for Components, positions and inputs. + The rocket body and each nested component are reseeded from their own + child of a ``SeedSequence`` root, so two that sample the same + distribution stop drawing the same values. Children are spawned in a + fixed order, so one seed still reproduces the whole rocket. + Parameters ---------- seed : int, optional Seed for the random number generator. """ - super()._set_stochastic(seed) - self.aerodynamic_surfaces = self.__reset_components( - self.aerodynamic_surfaces, seed - ) - self.motors = self.__reset_components(self.motors, seed) - self.rail_buttons = self.__reset_components(self.rail_buttons, seed) - for parachute in self.parachutes: - parachute._set_stochastic(seed) - - def __reset_components(self, components, seed): + root = np.random.SeedSequence(seed) + super()._set_stochastic(_seed_sequence_to_int(root.spawn(1)[0])) + for name in self._POSITIONED_COLLECTIONS: + setattr(self, name, self.__reset_components(getattr(self, name), root)) + for name in self._PLAIN_COLLECTIONS: + for component in getattr(self, name): + component._set_stochastic(_seed_sequence_to_int(root.spawn(1)[0])) + + def __reset_components(self, components, root): """Creates a new Components whose stochastic structures and their positions are reset. @@ -200,8 +219,9 @@ def __reset_components(self, components, seed): components : Components The components which contains the stochastic structure that will be used to create the new components. - seed : int, optional - Seed for the random number generator. + root : numpy.random.SeedSequence + The reseed's root. Each component takes its own spawned child, so + components sampling the same distribution stay independent. Returns ------- @@ -213,7 +233,7 @@ def __reset_components(self, components, seed): new_components = Components() for stochastic_obj, _ in components: stochastic_obj_position_info = self.__components_map[stochastic_obj] - stochastic_obj._set_stochastic(seed) + stochastic_obj._set_stochastic(_seed_sequence_to_int(root.spawn(1)[0])) new_components.add( stochastic_obj, self._validate_position(stochastic_obj, stochastic_obj_position_info), diff --git a/rocketpy/tools.py b/rocketpy/tools.py index 0d7f1a74e..bc27e2015 100644 --- a/rocketpy/tools.py +++ b/rocketpy/tools.py @@ -1467,6 +1467,20 @@ def find_obj_from_hash(obj, hash_, depth_limit=None): return None +def _seed_sequence_to_int(seed_sequence): + """Collapse a ``SeedSequence`` into a 128-bit Python ``int``. + + An ``int`` is what ``numpy.random.default_rng`` and the stdlib + ``random.Random`` both take, while ``random.Random`` rejects a + ``SeedSequence`` since Python 3.11, so a custom sampler whose ``reset_seed`` + documents an ``int`` keeps working. All four words are combined by value, + which keeps the full 128-bit pool and gives the same seed on either byte + order. + """ + words = seed_sequence.generate_state(4, dtype=np.uint32) + return sum(int(word) << (32 * position) for position, word in enumerate(words)) + + if __name__ == "__main__": # pragma: no cover import doctest diff --git a/tests/unit/stochastic/test_stochastic_rocket_seeding.py b/tests/unit/stochastic/test_stochastic_rocket_seeding.py new file mode 100644 index 000000000..94f5ee25c --- /dev/null +++ b/tests/unit/stochastic/test_stochastic_rocket_seeding.py @@ -0,0 +1,139 @@ +"""Every nested component of a StochasticRocket is reseeded from its own child +of the run's seed, so components that sample the same distribution do not draw +the same values, and one seed still reproduces the whole rocket. +""" + +import ast +import inspect + +from rocketpy.stochastic import StochasticAirBrakes, StochasticParachute +from rocketpy.stochastic.stochastic_model import StochasticModel + +# Captured before any patching, so wrapping it twice in one test does not stack. +_REAL_SET_STOCHASTIC = StochasticModel._set_stochastic + + +def _seeds_handed_out(monkeypatch, rocket, seed): + """The seeds every nested component received during one reseed.""" + recorded = [] + + def recording(self, seed=None): + recorded.append(seed) + return _REAL_SET_STOCHASTIC(self, seed) + + monkeypatch.setattr(StochasticModel, "_set_stochastic", recording) + rocket._set_stochastic(seed) + return recorded + + +def _drawn(component): + return next(component.dict_generator()) + + +def test_two_components_with_one_spec_do_not_draw_the_same_values( + stochastic_calisto, calisto_main_chute +): + """The whole rocket shared one seed, so two parachutes built from the same + spec drew the same ``cd_s`` and the same ``lag``, every time. A study of a + main and a drogue was really a study of one chute counted twice. + """ + stochastic_calisto.parachutes = [] + for _ in range(2): + stochastic_calisto.add_parachute( + StochasticParachute(parachute=calisto_main_chute, cd_s=0.1, lag=0.2) + ) + + stochastic_calisto._set_stochastic(99) + first, second = (_drawn(chute) for chute in stochastic_calisto.parachutes) + + assert first["cd_s"] != second["cd_s"] + assert first["lag"] != second["lag"] + + +def test_one_seed_reproduces_every_component(stochastic_calisto, calisto_main_chute): + """Independent is not enough on its own; it still has to follow the seed.""" + stochastic_calisto.parachutes = [] + stochastic_calisto.add_parachute( + StochasticParachute(parachute=calisto_main_chute, cd_s=0.1, lag=0.2) + ) + + def drawn_with(seed): + stochastic_calisto._set_stochastic(seed) + return [_drawn(chute) for chute in stochastic_calisto.parachutes] + [ + _drawn(stochastic_calisto)["mass"] + ] + + assert drawn_with(2718) == drawn_with(2718) + assert drawn_with(2718) != drawn_with(2719) + + +def test_component_seeds_do_not_collide(monkeypatch, stochastic_calisto): + """The same statement across every component type, not only the parachutes: + the body, each aerodynamic surface, the motor and the rail buttons. + """ + seeds = _seeds_handed_out(monkeypatch, stochastic_calisto, 42) + + assert len(seeds) > 3, "expected the rocket body and several components" + assert len(seeds) == len(set(seeds)), ( + "components share a seed, so they draw perfectly correlated samples" + ) + + +def test_the_reseed_covers_every_collection_create_object_uses(stochastic_calisto): + """Whatever ``create_object`` iterates has to be reseeded too. + + Read off the source rather than off a fixture, because the collection no + fixture populates is exactly the one that gets missed: air brakes were built + and sampled and never reseeded, and every seeding test passed anyway. + """ + rocket = stochastic_calisto + tree = ast.parse(inspect.getsource(type(rocket).create_object).lstrip()) + iterated = { + node.iter.attr + for node in ast.walk(tree) + # Comprehensions too. This scan exists to catch a collection added + # later, and one written as a comprehension would slip past a For walk. + if isinstance(node, (ast.For, ast.comprehension)) + and isinstance(node.iter, ast.Attribute) + and isinstance(node.iter.value, ast.Name) + and node.iter.value.id == "self" + and not node.iter.attr.startswith("_") + } + declared = set(type(rocket)._stochastic_collections()) + + assert iterated, "found no collections in create_object, so the scan is broken" + assert iterated <= declared, ( + f"create_object samples these but the reseed never reaches them: " + f"{sorted(iterated - declared)}" + ) + + +def test_an_air_brake_answers_to_the_seed( + stochastic_calisto, calisto_air_brakes_clamp_on +): + """Air brakes were in ``create_object`` and not in the reseed, so a fixed + seed did not reproduce them: 0.683, then 0.586, then 0.488 for one seed + asked three times. + + Built here rather than taken from the fixture, since wrapping an + ``AirBrakes`` with no arguments gives every parameter a zero standard + deviation, and that draws the same value under any seed whether or not + anything reseeds it. + """ + stochastic_calisto.add_air_brakes( + StochasticAirBrakes( + air_brakes=calisto_air_brakes_clamp_on.air_brakes[0], + deployment_level=(0.5, 0.1), + ), + calisto_air_brakes_clamp_on._controllers[0], + ) + air_brake = stochastic_calisto.air_brakes[0] + + def drawn(seed): + stochastic_calisto._set_stochastic(seed) + return _drawn(air_brake)["deployment_level"] + + first = drawn(1234) + + assert drawn(1234) == first, "the same seed drew a different air brake" + assert drawn(1235) != first, "a different seed drew the same air brake" diff --git a/tests/unit/test_tools.py b/tests/unit/test_tools.py index 3b8df37a3..d7bc6ff3e 100644 --- a/tests/unit/test_tools.py +++ b/tests/unit/test_tools.py @@ -5,6 +5,7 @@ from rocketpy import Environment from rocketpy.tools import ( + _seed_sequence_to_int, calculate_confidence_ellipse, calculate_cubic_hermite_coefficients, convert_local_extent_to_wgs84, @@ -347,3 +348,23 @@ def test_mercator_extent_to_local_preserves_offset_sign( assert local_extent[0] < local_extent[1] assert local_extent[2] < local_extent[3] assert all(expected_sign * value > 0 for value in local_extent) + + +def test_seed_sequence_to_int_keeps_the_full_width(): + """All four words have to reach the seed. + + Taking only the first one would still hand every component a different + number, so every seeding test would pass over a 32-bit collapse that puts + two streams back together near 2**16 of them. + """ + root = np.random.SeedSequence(12345) + a, b = root.spawn(2) + low = int(a.generate_state(4, dtype=np.uint32)[0]) + + seed = _seed_sequence_to_int(a) + + assert seed >> 32, "everything above the first word was dropped" + assert seed & 0xFFFFFFFF == low + assert seed.bit_length() <= 128 + assert seed != _seed_sequence_to_int(b) + assert seed == _seed_sequence_to_int(a), "reading it twice moved the seed" From ec61b6271513284847210dc2de11571ffa06c41a Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sun, 16 Aug 2026 03:07:14 +0800 Subject: [PATCH 02/12] 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> --- rocketpy/stochastic/stochastic_rocket.py | 15 +++++---------- rocketpy/tools.py | 10 ++-------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/rocketpy/stochastic/stochastic_rocket.py b/rocketpy/stochastic/stochastic_rocket.py index 7aa4ccb2d..b786fd932 100644 --- a/rocketpy/stochastic/stochastic_rocket.py +++ b/rocketpy/stochastic/stochastic_rocket.py @@ -176,10 +176,7 @@ def __init__( coordinate_system_orientation=None, ) - # Every collection of nested stochastic objects, in the order their child - # seeds are spawned. Named here rather than written out inline so a - # component type cannot reach create_object without reaching the reseed, - # which is how the air brakes were missed. + # Nested stochastic objects, in spawn order _POSITIONED_COLLECTIONS = ("aerodynamic_surfaces", "motors", "rail_buttons") _PLAIN_COLLECTIONS = ("parachutes", "air_brakes") @@ -192,10 +189,9 @@ def _set_stochastic(self, seed=None): """Set the stochastic attributes for Components, positions and inputs. - The rocket body and each nested component are reseeded from their own - child of a ``SeedSequence`` root, so two that sample the same - distribution stop drawing the same values. Children are spawned in a - fixed order, so one seed still reproduces the whole rocket. + Each component takes its own child of a ``SeedSequence`` root, spawned + in a fixed order, so components stay independent and one seed still + reproduces the rocket. Parameters ---------- @@ -220,8 +216,7 @@ def __reset_components(self, components, root): The components which contains the stochastic structure that will be used to create the new components. root : numpy.random.SeedSequence - The reseed's root. Each component takes its own spawned child, so - components sampling the same distribution stay independent. + The reseed's root. Each component takes its own spawned child. Returns ------- diff --git a/rocketpy/tools.py b/rocketpy/tools.py index bc27e2015..cfa1539fd 100644 --- a/rocketpy/tools.py +++ b/rocketpy/tools.py @@ -1468,14 +1468,8 @@ def find_obj_from_hash(obj, hash_, depth_limit=None): def _seed_sequence_to_int(seed_sequence): - """Collapse a ``SeedSequence`` into a 128-bit Python ``int``. - - An ``int`` is what ``numpy.random.default_rng`` and the stdlib - ``random.Random`` both take, while ``random.Random`` rejects a - ``SeedSequence`` since Python 3.11, so a custom sampler whose ``reset_seed`` - documents an ``int`` keeps working. All four words are combined by value, - which keeps the full 128-bit pool and gives the same seed on either byte - order. + """Returns a ``SeedSequence`` as the 128-bit ``int`` seed ``random.Random`` + accepts, combined by value so it does not depend on byte order. """ words = seed_sequence.generate_state(4, dtype=np.uint32) return sum(int(word) << (32 * position) for position, word in enumerate(words)) From d9e06f4b1906baab0737ac059b7af99dd16f80d8 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sun, 16 Aug 2026 04:26:48 +0800 Subject: [PATCH 03/12] 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> --- rocketpy/stochastic/stochastic_rocket.py | 20 ++++++++----- .../test_stochastic_rocket_seeding.py | 30 ++++++++++++++++--- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/rocketpy/stochastic/stochastic_rocket.py b/rocketpy/stochastic/stochastic_rocket.py index b786fd932..92eaf5819 100644 --- a/rocketpy/stochastic/stochastic_rocket.py +++ b/rocketpy/stochastic/stochastic_rocket.py @@ -189,22 +189,28 @@ def _set_stochastic(self, seed=None): """Set the stochastic attributes for Components, positions and inputs. - Each component takes its own child of a ``SeedSequence`` root, spawned - in a fixed order, so components stay independent and one seed still - reproduces the rocket. + Each component takes its own child of its collection's root, so + components stay independent and one seed still reproduces the rocket. + The body keeps the seed as given, and a collection has a root of its + own, so adding a fin does not move every parachute. Parameters ---------- seed : int, optional Seed for the random number generator. """ - root = np.random.SeedSequence(seed) - super()._set_stochastic(_seed_sequence_to_int(root.spawn(1)[0])) + super()._set_stochastic(seed) + names = self._stochastic_collections() + roots = dict(zip(names, np.random.SeedSequence(seed).spawn(len(names)))) for name in self._POSITIONED_COLLECTIONS: - setattr(self, name, self.__reset_components(getattr(self, name), root)) + setattr( + self, name, self.__reset_components(getattr(self, name), roots[name]) + ) for name in self._PLAIN_COLLECTIONS: for component in getattr(self, name): - component._set_stochastic(_seed_sequence_to_int(root.spawn(1)[0])) + component._set_stochastic( + _seed_sequence_to_int(roots[name].spawn(1)[0]) + ) def __reset_components(self, components, root): """Creates a new Components whose stochastic structures diff --git a/tests/unit/stochastic/test_stochastic_rocket_seeding.py b/tests/unit/stochastic/test_stochastic_rocket_seeding.py index 94f5ee25c..b9e686670 100644 --- a/tests/unit/stochastic/test_stochastic_rocket_seeding.py +++ b/tests/unit/stochastic/test_stochastic_rocket_seeding.py @@ -102,10 +102,12 @@ def test_the_reseed_covers_every_collection_create_object_uses(stochastic_calist declared = set(type(rocket)._stochastic_collections()) assert iterated, "found no collections in create_object, so the scan is broken" - assert iterated <= declared, ( - f"create_object samples these but the reseed never reaches them: " - f"{sorted(iterated - declared)}" - ) + # Both directions. One left in the reseed after create_object stopped using + # it still spawns a child and moves every stream that follows. + assert iterated == declared, { + "sampled but never reseeded": sorted(iterated - declared), + "reseeded but never sampled": sorted(declared - iterated), + } def test_an_air_brake_answers_to_the_seed( @@ -137,3 +139,23 @@ def drawn(seed): assert drawn(1234) == first, "the same seed drew a different air brake" assert drawn(1235) != first, "a different seed drew the same air brake" + + +def test_adding_a_surface_leaves_the_other_collections_alone( + stochastic_calisto, calisto_main_chute, stochastic_nose_cone +): + """Each collection has a root of its own, so an unrelated component in one + of them does not move the streams in the others.""" + stochastic_calisto.parachutes = [] + stochastic_calisto.add_parachute( + StochasticParachute(parachute=calisto_main_chute, cd_s=0.1, lag=0.2) + ) + + def parachute_draw(): + stochastic_calisto._set_stochastic(5) + return _drawn(stochastic_calisto.parachutes[0]) + + before = parachute_draw() + stochastic_calisto.add_nose(stochastic_nose_cone, position=1.1) + + assert parachute_draw() == before From dcc06644d67a52d0e5c03900642590c20ce7bf1f Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sun, 16 Aug 2026 04:41:46 +0800 Subject: [PATCH 04/12] 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> --- .../test_stochastic_rocket_seeding.py | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/unit/stochastic/test_stochastic_rocket_seeding.py b/tests/unit/stochastic/test_stochastic_rocket_seeding.py index b9e686670..d38af740c 100644 --- a/tests/unit/stochastic/test_stochastic_rocket_seeding.py +++ b/tests/unit/stochastic/test_stochastic_rocket_seeding.py @@ -6,6 +6,7 @@ import ast import inspect +from rocketpy.rocket.components import Components from rocketpy.stochastic import StochasticAirBrakes, StochasticParachute from rocketpy.stochastic.stochastic_model import StochasticModel @@ -30,6 +31,13 @@ def _drawn(component): return next(component.dict_generator()) +def _members_of(collection): + """Components yields (component, position) pairs; a plain list does not.""" + if isinstance(collection, Components): + return [component for component, _ in collection] + return list(collection) + + def test_two_components_with_one_spec_do_not_draw_the_same_values( stochastic_calisto, calisto_main_chute ): @@ -159,3 +167,71 @@ def parachute_draw(): stochastic_calisto.add_nose(stochastic_nose_cone, position=1.1) assert parachute_draw() == before + + +def test_every_entry_is_reseeded_exactly_once( + monkeypatch, stochastic_calisto, calisto_main_chute, calisto_air_brakes_clamp_on +): + """Counted rather than read off the source. + + The scan above reads ``create_object`` for ``for x in self.collection``, so + a helper, a local alias or a ``getattr`` would hide a collection from it. + This counts what actually happens. + """ + stochastic_calisto.add_parachute( + StochasticParachute(parachute=calisto_main_chute, cd_s=0.1, lag=0.2) + ) + stochastic_calisto.add_air_brakes( + StochasticAirBrakes( + air_brakes=calisto_air_brakes_clamp_on.air_brakes[0], + deployment_level=(0.5, 0.1), + ), + calisto_air_brakes_clamp_on._controllers[0], + ) + + counted = {} + + def recording(self, seed=None): + counted[id(self)] = counted.get(id(self), 0) + 1 + return _REAL_SET_STOCHASTIC(self, seed) + + monkeypatch.setattr(StochasticModel, "_set_stochastic", recording) + stochastic_calisto._set_stochastic(3) + + entries = [ + component + for name in type(stochastic_calisto)._stochastic_collections() + for component in _members_of(getattr(stochastic_calisto, name)) + ] + + assert entries, "no components to count" + assert all(counted.get(id(entry)) == 1 for entry in entries), { + type(entry).__name__: counted.get(id(entry)) for entry in entries + } + + +def test_two_air_brakes_with_one_spec_stay_independent( + stochastic_calisto, calisto_air_brakes_clamp_on +): + """The air brakes are a plain list, so they take a different route through + the reseed than the positioned collections do.""" + for _ in range(2): + stochastic_calisto.add_air_brakes( + StochasticAirBrakes( + air_brakes=calisto_air_brakes_clamp_on.air_brakes[0], + deployment_level=(0.5, 0.1), + ), + calisto_air_brakes_clamp_on._controllers[0], + ) + + def drawn(seed): + stochastic_calisto._set_stochastic(seed) + return [ + _drawn(brake)["deployment_level"] for brake in stochastic_calisto.air_brakes + ] + + first = drawn(808) + + assert first[0] != first[1], "two air brakes drew the same value" + assert drawn(808) == first + assert drawn(809) != first From cb70050ef53aaf2ffec4221814ca26bc190cfb7d Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sun, 16 Aug 2026 07:53:19 +0800 Subject: [PATCH 05/12] 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> --- rocketpy/stochastic/stochastic_rocket.py | 3 ++- .../stochastic/test_stochastic_rocket_seeding.py | 16 +++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/rocketpy/stochastic/stochastic_rocket.py b/rocketpy/stochastic/stochastic_rocket.py index 92eaf5819..fad0b719d 100644 --- a/rocketpy/stochastic/stochastic_rocket.py +++ b/rocketpy/stochastic/stochastic_rocket.py @@ -176,7 +176,8 @@ def __init__( coordinate_system_orientation=None, ) - # Nested stochastic objects, in spawn order + # Nested stochastic objects, in spawn order. Append rather than reorder: + # the position of a name here addresses its collection's stream. _POSITIONED_COLLECTIONS = ("aerodynamic_surfaces", "motors", "rail_buttons") _PLAIN_COLLECTIONS = ("parachutes", "air_brakes") diff --git a/tests/unit/stochastic/test_stochastic_rocket_seeding.py b/tests/unit/stochastic/test_stochastic_rocket_seeding.py index d38af740c..937c351d5 100644 --- a/tests/unit/stochastic/test_stochastic_rocket_seeding.py +++ b/tests/unit/stochastic/test_stochastic_rocket_seeding.py @@ -164,7 +164,9 @@ def parachute_draw(): return _drawn(stochastic_calisto.parachutes[0]) before = parachute_draw() - stochastic_calisto.add_nose(stochastic_nose_cone, position=1.1) + # The deterministic nose, so add_nose builds a wrapper of its own. Adding + # the fixture again would store one wrapper twice, which is #1172. + stochastic_calisto.add_nose(stochastic_nose_cone.obj, position=1.1) assert parachute_draw() == before @@ -235,3 +237,15 @@ def drawn(seed): assert first[0] != first[1], "two air brakes drew the same value" assert drawn(808) == first assert drawn(809) != first + + +def test_the_rocket_body_keeps_the_seed_as_given(monkeypatch, stochastic_calisto): + """Fixing the nested components did not need the body's stream to move. + + Reproducibility and seed uniqueness both hold with the body on a spawned + child, so neither of them would notice it going back there and taking every + fixed-seed mass and radius baseline with it. + """ + seeds = _seeds_handed_out(monkeypatch, stochastic_calisto, 42) + + assert seeds[0] == 42 From 9630ba2c81e26d677e5e430e02c1929427de42ed Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sun, 16 Aug 2026 07:57:24 +0800 Subject: [PATCH 06/12] 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> --- docs/user/stochastic.rst | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/user/stochastic.rst b/docs/user/stochastic.rst index 6e3376236..e6ab7e42b 100644 --- a/docs/user/stochastic.rst +++ b/docs/user/stochastic.rst @@ -278,6 +278,26 @@ reliability of your simulations over time. .. which parameters most significantly impact your simulation results. +Seeding a rocket's components +----------------------------- + +A ``StochasticRocket`` holds nested stochastic objects: the motors, the +aerodynamic surfaces, the rail buttons, the parachutes and the air brakes. Each +of them samples from a stream of its own, derived from the seed the rocket was +given, so two components built from the same spec draw independently. A main and +a drogue parachute given the same ``cd_s`` and ``lag`` will not draw the same +values as each other. + +Each kind of component is derived separately, so adding a fin does not move what +the parachutes draw. Within one kind the stream follows insertion order, so +adding a component ahead of another does change what the later one draws under a +fixed seed. The rocket's own inputs, such as ``mass`` and ``radius``, use the +seed exactly as given. + +.. note:: + A component's *position* is a property of the rocket rather than of the + component, so it is drawn from the rocket's own stream. + Conclusion ---------- From c7adc5c35e73dae7a07f30149335da2705189a36 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sun, 16 Aug 2026 09:10:39 +0800 Subject: [PATCH 07/12] 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> --- docs/user/stochastic.rst | 23 +++++++++++-------- .../test_stochastic_rocket_seeding.py | 2 +- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/docs/user/stochastic.rst b/docs/user/stochastic.rst index e6ab7e42b..ee9f226e9 100644 --- a/docs/user/stochastic.rst +++ b/docs/user/stochastic.rst @@ -283,21 +283,26 @@ Seeding a rocket's components A ``StochasticRocket`` holds nested stochastic objects: the motors, the aerodynamic surfaces, the rail buttons, the parachutes and the air brakes. Each -of them samples from a stream of its own, derived from the seed the rocket was -given, so two components built from the same spec draw independently. A main and -a drogue parachute given the same ``cd_s`` and ``lag`` will not draw the same -values as each other. +of them samples from a stream of its own, spawned from the seed the rocket was +given, so a main and a drogue parachute built from the same ``cd_s`` and ``lag`` +are no longer made to consume the same draws as each other. Independent streams +can still land on equal values, and a specification with no spread always will. -Each kind of component is derived separately, so adding a fin does not move what -the parachutes draw. Within one kind the stream follows insertion order, so -adding a component ahead of another does change what the later one draws under a -fixed seed. The rocket's own inputs, such as ``mass`` and ``radius``, use the -seed exactly as given. +Each collection is spawned separately, so adding an aerodynamic surface does not +move what the parachutes draw. Within a collection the stream follows insertion +order, so adding a component ahead of another does change what the later one +draws under a fixed seed. The rocket's own inputs, such as ``mass`` and +``radius``, use the seed exactly as given. .. note:: A component's *position* is a property of the rocket rather than of the component, so it is drawn from the rocket's own stream. +.. note:: + A stream belongs to one stochastic wrapper. Storing the same wrapper twice, + or sharing one between two rockets, is not supported: the second reset + replaces the first, and the two entries end up drawing from one generator. + Conclusion ---------- diff --git a/tests/unit/stochastic/test_stochastic_rocket_seeding.py b/tests/unit/stochastic/test_stochastic_rocket_seeding.py index 937c351d5..46297b9cd 100644 --- a/tests/unit/stochastic/test_stochastic_rocket_seeding.py +++ b/tests/unit/stochastic/test_stochastic_rocket_seeding.py @@ -38,7 +38,7 @@ def _members_of(collection): return list(collection) -def test_two_components_with_one_spec_do_not_draw_the_same_values( +def test_two_components_with_one_spec_do_not_share_one_stream( stochastic_calisto, calisto_main_chute ): """The whole rocket shared one seed, so two parachutes built from the same From c32ae509ae067adcbc2a79ec974867818d2cc8aa Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:17:24 +0800 Subject: [PATCH 08/12] 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> --- docs/user/stochastic.rst | 18 ++++++++++---- .../test_stochastic_rocket_seeding.py | 24 ++++++++++++++++++- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/docs/user/stochastic.rst b/docs/user/stochastic.rst index ee9f226e9..08cb63b7c 100644 --- a/docs/user/stochastic.rst +++ b/docs/user/stochastic.rst @@ -283,10 +283,16 @@ Seeding a rocket's components A ``StochasticRocket`` holds nested stochastic objects: the motors, the aerodynamic surfaces, the rail buttons, the parachutes and the air brakes. Each -of them samples from a stream of its own, spawned from the seed the rocket was -given, so a main and a drogue parachute built from the same ``cd_s`` and ``lag`` -are no longer made to consume the same draws as each other. Independent streams -can still land on equal values, and a specification with no spread always will. +time the rocket is reset, every component attached to it at that moment is given +a stream of its own, spawned from the seed the reset was given. A main and a +drogue parachute built from the same ``cd_s`` and ``lag`` are then no longer +made to consume the same draws as each other. Independent streams can still land +on equal values, and a specification with no spread always will. + +The reset is what builds the tree, not ``add_parachute`` or ``add_nose``. A +rocket resets itself once while being constructed, when it has no components +yet, so anything attached afterwards keeps the generator it was built with until +the next reset. A Monte Carlo run resets the rocket for you. Each collection is spawned separately, so adding an aerodynamic surface does not move what the parachutes draw. Within a collection the stream follows insertion @@ -303,6 +309,10 @@ draws under a fixed seed. The rocket's own inputs, such as ``mass`` and or sharing one between two rockets, is not supported: the second reset replaces the first, and the two entries end up drawing from one generator. + Two wrappers whose ``CustomSampler`` inputs share a ``seed_group`` are one + stream on purpose, and stay that way. That is what a shared group is for, + and separate component streams are not meant to take it apart. + Conclusion ---------- diff --git a/tests/unit/stochastic/test_stochastic_rocket_seeding.py b/tests/unit/stochastic/test_stochastic_rocket_seeding.py index 46297b9cd..02b2904c5 100644 --- a/tests/unit/stochastic/test_stochastic_rocket_seeding.py +++ b/tests/unit/stochastic/test_stochastic_rocket_seeding.py @@ -7,7 +7,11 @@ import inspect from rocketpy.rocket.components import Components -from rocketpy.stochastic import StochasticAirBrakes, StochasticParachute +from rocketpy.stochastic import ( + StochasticAirBrakes, + StochasticParachute, + StochasticRocket, +) from rocketpy.stochastic.stochastic_model import StochasticModel # Captured before any patching, so wrapping it twice in one test does not stack. @@ -249,3 +253,21 @@ def test_the_rocket_body_keeps_the_seed_as_given(monkeypatch, stochastic_calisto seeds = _seeds_handed_out(monkeypatch, stochastic_calisto, 42) assert seeds[0] == 42 + + +def test_the_tree_is_built_by_the_reset_not_by_the_add(calisto, calisto_main_chute): + """A rocket resets itself while being constructed, with nothing attached. + + So a component added afterwards keeps the generator it was built with until + the next reset, which is the guarantee the documentation states and the one + a Monte Carlo relies on. + """ + rocket = StochasticRocket(rocket=calisto, radius=0.0127 / 2) + chute = StochasticParachute(parachute=calisto_main_chute, cd_s=0.1, lag=0.2) + rocket.add_parachute(chute) + + assert getattr(chute, "_seed", None) is None + + rocket._set_stochastic(99) + + assert chute._seed is not None From 0e46b4bea6d9bd07be87b287c7025c5931010d23 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:59:10 +0800 Subject: [PATCH 09/12] 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> --- docs/user/stochastic.rst | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/user/stochastic.rst b/docs/user/stochastic.rst index 08cb63b7c..366f9264d 100644 --- a/docs/user/stochastic.rst +++ b/docs/user/stochastic.rst @@ -292,7 +292,11 @@ on equal values, and a specification with no spread always will. The reset is what builds the tree, not ``add_parachute`` or ``add_nose``. A rocket resets itself once while being constructed, when it has no components yet, so anything attached afterwards keeps the generator it was built with until -the next reset. A Monte Carlo run resets the rocket for you. +something resets it again. A serial ``MonteCarlo`` run does not reset it at +all, and a parallel one resets each worker once rather than once per +simulation, so what a study sees today depends on which of those it uses. +Resetting per simulation, from a seed the caller chooses, is what the Monte +Carlo seeding work adds. Each collection is spawned separately, so adding an aerodynamic surface does not move what the parachutes draw. Within a collection the stream follows insertion @@ -309,9 +313,9 @@ draws under a fixed seed. The rocket's own inputs, such as ``mass`` and or sharing one between two rockets, is not supported: the second reset replaces the first, and the two entries end up drawing from one generator. - Two wrappers whose ``CustomSampler`` inputs share a ``seed_group`` are one - stream on purpose, and stay that way. That is what a shared group is for, - and separate component streams are not meant to take it apart. + A shared ``CustomSampler.seed_group`` keeps its own rule: a group belongs to + one model. Sharing one between two components leaves each of them seeding it + from their own child, and the last one to be reset decides what both draw. Conclusion ---------- From b7e757bbdbdccc296d71cddf086edce2721f397e Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:59:27 +0800 Subject: [PATCH 10/12] 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> --- docs/user/stochastic.rst | 8 ++++---- tests/unit/stochastic/test_stochastic_rocket_seeding.py | 6 +++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/user/stochastic.rst b/docs/user/stochastic.rst index 366f9264d..904737b0d 100644 --- a/docs/user/stochastic.rst +++ b/docs/user/stochastic.rst @@ -293,10 +293,10 @@ The reset is what builds the tree, not ``add_parachute`` or ``add_nose``. A rocket resets itself once while being constructed, when it has no components yet, so anything attached afterwards keeps the generator it was built with until something resets it again. A serial ``MonteCarlo`` run does not reset it at -all, and a parallel one resets each worker once rather than once per -simulation, so what a study sees today depends on which of those it uses. -Resetting per simulation, from a seed the caller chooses, is what the Monte -Carlo seeding work adds. +all. A parallel one tries to, once per worker, but hands the model a +``SeedSequence`` where an integer is wanted, so that path does not get as far +as building the tree either. Resetting per simulation, from an integer seed the +caller chooses, is what the Monte Carlo seeding work adds. Each collection is spawned separately, so adding an aerodynamic surface does not move what the parachutes draw. Within a collection the stream follows insertion diff --git a/tests/unit/stochastic/test_stochastic_rocket_seeding.py b/tests/unit/stochastic/test_stochastic_rocket_seeding.py index 02b2904c5..b5cf30d70 100644 --- a/tests/unit/stochastic/test_stochastic_rocket_seeding.py +++ b/tests/unit/stochastic/test_stochastic_rocket_seeding.py @@ -220,7 +220,11 @@ def test_two_air_brakes_with_one_spec_stay_independent( stochastic_calisto, calisto_air_brakes_clamp_on ): """The air brakes are a plain list, so they take a different route through - the reseed than the positioned collections do.""" + the reseed than the positioned collections do. + + About the streams only. Both are added with one controller because the + rocket keeps a single one, which is a separate problem recorded in #1172. + """ for _ in range(2): stochastic_calisto.add_air_brakes( StochasticAirBrakes( From 72be1607ddc7f62fae5d76bb2a0ccf1f469a5764 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:52:09 +0800 Subject: [PATCH 11/12] 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> --- rocketpy/stochastic/stochastic_rocket.py | 5 +++-- tests/unit/test_tools.py | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/rocketpy/stochastic/stochastic_rocket.py b/rocketpy/stochastic/stochastic_rocket.py index fad0b719d..6f90c6ac0 100644 --- a/rocketpy/stochastic/stochastic_rocket.py +++ b/rocketpy/stochastic/stochastic_rocket.py @@ -176,8 +176,9 @@ def __init__( coordinate_system_orientation=None, ) - # Nested stochastic objects, in spawn order. Append rather than reorder: - # the position of a name here addresses its collection's stream. + # Nested stochastic objects, in spawn order. A collection's stream is + # addressed by where its name falls in the two tuples read end to end, so + # appending to the first one moves every name in the second. _POSITIONED_COLLECTIONS = ("aerodynamic_surfaces", "motors", "rail_buttons") _PLAIN_COLLECTIONS = ("parachutes", "air_brakes") diff --git a/tests/unit/test_tools.py b/tests/unit/test_tools.py index d7bc6ff3e..59f04c6eb 100644 --- a/tests/unit/test_tools.py +++ b/tests/unit/test_tools.py @@ -359,12 +359,14 @@ def test_seed_sequence_to_int_keeps_the_full_width(): """ root = np.random.SeedSequence(12345) a, b = root.spawn(2) - low = int(a.generate_state(4, dtype=np.uint32)[0]) + words = a.generate_state(4, dtype=np.uint32) + # Every word, in one comparison. Asserting only that the high bits are not + # zero leaves a 64 or 96 bit truncation passing. + expected = sum(int(word) << (32 * at) for at, word in enumerate(words)) seed = _seed_sequence_to_int(a) - assert seed >> 32, "everything above the first word was dropped" - assert seed & 0xFFFFFFFF == low + assert seed == expected assert seed.bit_length() <= 128 assert seed != _seed_sequence_to_int(b) assert seed == _seed_sequence_to_int(a), "reading it twice moved the seed" From a768e75fec49675bb2cc6ee3b95811b53cd102e0 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Tue, 18 Aug 2026 02:31:39 +0800 Subject: [PATCH 12/12] 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> --- rocketpy/stochastic/stochastic_rocket.py | 5 ++- rocketpy/tools.py | 12 +++++++ .../test_stochastic_rocket_seeding.py | 36 +++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/rocketpy/stochastic/stochastic_rocket.py b/rocketpy/stochastic/stochastic_rocket.py index 6f90c6ac0..9bf9d5d36 100644 --- a/rocketpy/stochastic/stochastic_rocket.py +++ b/rocketpy/stochastic/stochastic_rocket.py @@ -2,7 +2,6 @@ import warnings -import numpy as np from rocketpy.control import _Controller from rocketpy.mathutils.vector_matrix import Vector @@ -23,7 +22,7 @@ from rocketpy.rocket.rocket import Rocket from rocketpy.stochastic.stochastic_generic_motor import StochasticGenericMotor from rocketpy.stochastic.stochastic_motor_model import StochasticMotorModel -from rocketpy.tools import _seed_sequence_to_int +from rocketpy.tools import _seed_sequence_from, _seed_sequence_to_int from .stochastic_aero_surfaces import ( StochasticAirBrakes, @@ -203,7 +202,7 @@ def _set_stochastic(self, seed=None): """ super()._set_stochastic(seed) names = self._stochastic_collections() - roots = dict(zip(names, np.random.SeedSequence(seed).spawn(len(names)))) + roots = dict(zip(names, _seed_sequence_from(seed).spawn(len(names)))) for name in self._POSITIONED_COLLECTIONS: setattr( self, name, self.__reset_components(getattr(self, name), roots[name]) diff --git a/rocketpy/tools.py b/rocketpy/tools.py index cfa1539fd..c074be9e8 100644 --- a/rocketpy/tools.py +++ b/rocketpy/tools.py @@ -1475,6 +1475,18 @@ def _seed_sequence_to_int(seed_sequence): return sum(int(word) << (32 * position) for position, word in enumerate(words)) +def _seed_sequence_from(seed): + """Returns a ``SeedSequence`` of the caller's own to spawn from. + + A parallel run is handed one that ``SeedSequence`` will not take as + entropy, and spawning from it directly would advance the counter of an + object the caller still holds, so it is copied from its full state. + """ + if isinstance(seed, np.random.SeedSequence): + return np.random.SeedSequence(**seed.state) + return np.random.SeedSequence(seed) + + if __name__ == "__main__": # pragma: no cover import doctest diff --git a/tests/unit/stochastic/test_stochastic_rocket_seeding.py b/tests/unit/stochastic/test_stochastic_rocket_seeding.py index b5cf30d70..a3430762b 100644 --- a/tests/unit/stochastic/test_stochastic_rocket_seeding.py +++ b/tests/unit/stochastic/test_stochastic_rocket_seeding.py @@ -6,7 +6,10 @@ import ast import inspect +import numpy as np + from rocketpy.rocket.components import Components +from rocketpy.tools import _seed_sequence_from from rocketpy.stochastic import ( StochasticAirBrakes, StochasticParachute, @@ -275,3 +278,36 @@ def test_the_tree_is_built_by_the_reset_not_by_the_add(calisto, calisto_main_chu rocket._set_stochastic(99) assert chute._seed is not None + + +def test_a_worker_seed_sequence_is_copied_rather_than_spawned_from(): + # A parallel run hands each worker a SeedSequence. Spawning from it would + # advance a counter the caller still holds, so the next use of the same + # object would build a different tree. + worker = np.random.SeedSequence(7).spawn(2)[0] + + _seed_sequence_from(worker).spawn(3) + + assert worker.n_children_spawned == 0 + + +def test_a_worker_seed_sequence_keeps_its_place_in_the_tree(): + worker = np.random.SeedSequence(7).spawn(2)[0] + + children = _seed_sequence_from(worker).spawn(2) + + assert [child.spawn_key for child in children] == [(0, 0), (0, 1)] + + +def test_two_workers_do_not_get_the_same_collection_roots(): + first, second = np.random.SeedSequence(7).spawn(2) + + one = _seed_sequence_from(first).spawn(1)[0].generate_state(4) + other = _seed_sequence_from(second).spawn(1)[0].generate_state(4) + + assert not np.array_equal(one, other) + + +def test_an_integer_seed_still_roots_the_collections(): + assert _seed_sequence_from(42).spawn(1)[0].spawn_key == (0,) + assert _seed_sequence_from(None).spawn(1)[0].spawn_key == (0,)