Skip to content

Commit 5ba7598

Browse files
committed
BUG: seed list/position sampling and decorrelate rocket components
StochasticModel list-valued attributes were sampled with the stdlib random.choice (an unseeded global) and StochasticRocket._randomize_position did the same for list-valued component positions, so random_seed did not govern either. Both now draw the index through the model's seeded generator via a shared _random_choice helper -- indexing, not numpy.random.choice, so heterogeneous objects (Function, paths, arrays) stay intact. StochasticRocket._set_stochastic also handed the same seed to the rocket body and every surface, motor, rail button and parachute, so components sampling the same distribution drew identical values (a main and a drogue parachute got the same cd_s and lag quantiles). Each component is now reseeded from its own spawned child of a SeedSequence root, in a fixed order, so they stay independent and reproducible under random_seed. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
1 parent 1e4670d commit 5ba7598

3 files changed

Lines changed: 86 additions & 23 deletions

File tree

rocketpy/stochastic/stochastic_model.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,21 @@ def _validate_airfoil(self, airfoil):
506506
"the first item"
507507
)
508508

509+
def _random_choice(self, values):
510+
"""Choose one value from a list using this model's seeded generator.
511+
512+
The index is drawn from the seeded generator, not the stdlib global
513+
``random.choice`` (an unseeded shared instance), so the choice is
514+
governed by ``random_seed``. Indexing rather than ``numpy.random.choice``
515+
keeps a heterogeneous list -- ``Function`` objects, paths, arrays --
516+
returned as itself instead of coerced to a common dtype. An empty
517+
``values`` is returned unchanged.
518+
"""
519+
if not values:
520+
return values
521+
index = int(self.__random_number_generator.integers(len(values)))
522+
return values[index]
523+
509524
def dict_generator(self):
510525
"""
511526
Generate a dictionary with randomly generated input arguments.
@@ -530,16 +545,7 @@ def dict_generator(self):
530545
dist_sampler = value[-1]
531546
generated_dict[arg] = dist_sampler(value[0], value[1])
532547
elif isinstance(value, list):
533-
# Draw the index from this model's seeded generator so a
534-
# list-valued attribute is reproducible under random_seed. The
535-
# stdlib random.choice draws from an unseeded global instance,
536-
# and numpy's choice coerces a heterogeneous list (Function,
537-
# paths, arrays) to a single dtype; indexing avoids both.
538-
if value:
539-
index = int(self.__random_number_generator.integers(len(value)))
540-
generated_dict[arg] = value[index]
541-
else:
542-
generated_dict[arg] = value
548+
generated_dict[arg] = self._random_choice(value)
543549
elif isinstance(value, CustomSampler):
544550
try:
545551
generated_dict[arg] = value.sample(n_samples=1)[0]

rocketpy/stochastic/stochastic_rocket.py

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Defines the StochasticRocket class."""
22

33
import warnings
4-
from random import choice
4+
5+
import numpy as np
56

67
from rocketpy.control import _Controller
78
from rocketpy.mathutils.vector_matrix import Vector
@@ -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_to_int
2426

2527
from .stochastic_aero_surfaces import (
2628
StochasticAirBrakes,
@@ -176,21 +178,29 @@ def _set_stochastic(self, seed=None):
176178
"""Set the stochastic attributes for Components, positions and
177179
inputs.
178180
181+
Every nested component -- the rocket body, each aerodynamic surface,
182+
motor, rail button and parachute -- is reseeded from its own child of a
183+
``SeedSequence`` root, so components that sample the same distribution do
184+
not draw identical values (a main and a drogue parachute get independent
185+
``cd_s`` and ``lag`` samples, not the same one). Children are spawned in a
186+
fixed order, so the result stays reproducible under ``random_seed``.
187+
179188
Parameters
180189
----------
181190
seed : int, optional
182191
Seed for the random number generator.
183192
"""
184-
super()._set_stochastic(seed)
193+
root = np.random.SeedSequence(seed)
194+
super()._set_stochastic(_seed_sequence_to_int(root.spawn(1)[0]))
185195
self.aerodynamic_surfaces = self.__reset_components(
186-
self.aerodynamic_surfaces, seed
196+
self.aerodynamic_surfaces, root
187197
)
188-
self.motors = self.__reset_components(self.motors, seed)
189-
self.rail_buttons = self.__reset_components(self.rail_buttons, seed)
198+
self.motors = self.__reset_components(self.motors, root)
199+
self.rail_buttons = self.__reset_components(self.rail_buttons, root)
190200
for parachute in self.parachutes:
191-
parachute._set_stochastic(seed)
201+
parachute._set_stochastic(_seed_sequence_to_int(root.spawn(1)[0]))
192202

193-
def __reset_components(self, components, seed):
203+
def __reset_components(self, components, root):
194204
"""Creates a new Components whose stochastic structures
195205
and their positions are reset.
196206
@@ -199,8 +209,9 @@ def __reset_components(self, components, seed):
199209
components : Components
200210
The components which contains the stochastic structure that
201211
will be used to create the new components.
202-
seed : int, optional
203-
Seed for the random number generator.
212+
root : numpy.random.SeedSequence
213+
The run's seed root. Each component is reseeded from its own spawned
214+
child, so components sampling the same distribution stay decorrelated.
204215
205216
Returns
206217
-------
@@ -212,7 +223,7 @@ def __reset_components(self, components, seed):
212223
new_components = Components()
213224
for stochastic_obj, _ in components:
214225
stochastic_obj_position_info = self.__components_map[stochastic_obj]
215-
stochastic_obj._set_stochastic(seed)
226+
stochastic_obj._set_stochastic(_seed_sequence_to_int(root.spawn(1)[0]))
216227
new_components.add(
217228
stochastic_obj,
218229
self._validate_position(stochastic_obj, stochastic_obj_position_info),
@@ -628,7 +639,7 @@ def _randomize_position(self, position):
628639
return position[-1](position[0].z, position[1])
629640
return position[-1](position[0], position[1])
630641
elif isinstance(position, list):
631-
return choice(position) if position else position
642+
return self._random_choice(position)
632643

633644
# pylint: disable=stop-iteration-return
634645
def dict_generator(self):
@@ -638,8 +649,8 @@ def dict_generator(self):
638649
all attributes of the class and generating a random value for each
639650
attribute. The random values are generated according to the format of
640651
each attribute. Tuples are generated using the distribution function
641-
specified in the tuple. Lists are generated using the random.choice
642-
function.
652+
specified in the tuple. Lists are sampled through the model's seeded
653+
generator so the choice is governed by ``random_seed``.
643654
644655
Parameters
645656
----------
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Nested StochasticRocket components are reseeded from distinct SeedSequence
2+
children, so components that sample the same distribution (a main and a drogue
3+
parachute, for example) do not draw identical values. Reproducible under a fixed
4+
seed. See the seeding design in ``StochasticRocket._set_stochastic``.
5+
"""
6+
7+
from rocketpy.stochastic.stochastic_model import StochasticModel
8+
9+
# Captured once, before any patching, so wrapping it repeatedly in one test does
10+
# not stack (each recorder wraps the real method, not a previous recorder).
11+
_REAL_SET_STOCHASTIC = StochasticModel._set_stochastic
12+
13+
14+
def _record_component_seeds(monkeypatch, rocket, seed):
15+
"""Return the seeds handed to every nested component for one reseed."""
16+
recorded = []
17+
18+
def recording(self, seed=None):
19+
recorded.append(seed)
20+
return _REAL_SET_STOCHASTIC(self, seed)
21+
22+
monkeypatch.setattr(StochasticModel, "_set_stochastic", recording)
23+
rocket._set_stochastic(seed)
24+
return recorded
25+
26+
27+
def test_rocket_components_receive_distinct_seeds(monkeypatch, stochastic_calisto):
28+
"""Every nested component (body, aerodynamic surfaces, motor, rail buttons and
29+
the two parachutes) is reseeded from its own child, so none collide."""
30+
seeds = _record_component_seeds(monkeypatch, stochastic_calisto, 42)
31+
32+
assert len(seeds) > 3, "expected the rocket body plus several components"
33+
assert len(seeds) == len(set(seeds)), (
34+
"components share a seed -- they would draw perfectly correlated samples"
35+
)
36+
37+
38+
def test_rocket_component_seeds_are_reproducible(monkeypatch, stochastic_calisto):
39+
"""The same root seed reseeds every component identically; a different root
40+
seed changes them."""
41+
first = _record_component_seeds(monkeypatch, stochastic_calisto, 42)
42+
again = _record_component_seeds(monkeypatch, stochastic_calisto, 42)
43+
different = _record_component_seeds(monkeypatch, stochastic_calisto, 43)
44+
45+
assert again == first, "same seed must reproduce every component seed"
46+
assert different != first, "a different seed must change the component seeds"

0 commit comments

Comments
 (0)