Skip to content

Commit c2e3e45

Browse files
thc1006Gui-FernandesBR
authored andcommitted
ENH: reproducible Monte Carlo via per-simulation-index seeding
MonteCarlo seeded the stochastic models per worker in parallel mode (from a fresh, unseeded SeedSequence) and once at construction in serial mode, so the sampled inputs depended on the execution mode and the worker count, and parallel runs were not reproducible run to run. Add a keyword-only random_seed to simulate() (SPEC 7 style: accepts an int, a SeedSequence, or a Generator; None keeps the previous fresh-entropy behavior). Spawn one child seed per simulation index from that root and reseed the stochastic models from child_seeds[i] before simulation i. SeedSequence.spawn is prefix-stable, so index i maps to the same seed regardless of which worker runs it, making the inputs identical across serial, parallel(2) and parallel(N). Each index seed is split three ways so the environment, rocket and flight draw from independent streams rather than sharing one. The serial index field now counts from 0 to match the parallel path. Both changes alter the numbers a fixed seed produces, so stored baselines regenerate. Adds tests/unit/simulation/test_monte_carlo_determinism.py: serial reproducibility, worker invariance (serial == parallel(2) == parallel(4)), and the None-seed path. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
1 parent 7d232fa commit c2e3e45

2 files changed

Lines changed: 269 additions & 18 deletions

File tree

rocketpy/simulation/monte_carlo.py

Lines changed: 76 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ def simulate(
170170
append=False,
171171
parallel=False,
172172
n_workers=None,
173+
*,
174+
random_seed=None,
173175
**kwargs,
174176
): # pylint: disable=too-many-statements
175177
"""
@@ -189,6 +191,15 @@ def simulate(
189191
number of workers will be equal to the number of CPUs available.
190192
A minimum of 2 workers is required for parallel mode.
191193
Default is None.
194+
random_seed : int, numpy.random.SeedSequence, numpy.random.Generator, optional
195+
Root seed for the run. When provided, the sampled inputs are
196+
reproducible and identical across serial and parallel execution
197+
and across any number of workers, because each simulation index
198+
draws from its own child stream spawned from this root
199+
(``SeedSequence(random_seed).spawn(number_of_simulations)``). It
200+
accepts an int, a ``SeedSequence`` or a ``Generator``. Default is
201+
None, which draws fresh entropy (not reproducible), preserving the
202+
previous behavior.
192203
kwargs : dict
193204
Custom arguments for simulation export of the ``inputs`` file. Options
194205
are:
@@ -228,9 +239,9 @@ def simulate(
228239
self.__setup_files(append)
229240

230241
if parallel:
231-
self.__run_in_parallel(n_workers)
242+
self.__run_in_parallel(n_workers, random_seed)
232243
else:
233-
self.__run_in_serial()
244+
self.__run_in_serial(random_seed)
234245

235246
self.__terminate_simulation()
236247

@@ -267,10 +278,46 @@ def __setup_files(self, append):
267278
except OSError as error:
268279
raise OSError(f"Error creating files: {error}") from error
269280

270-
def __run_in_serial(self):
281+
@staticmethod
282+
def __root_seed_sequence(random_seed):
283+
"""Return a ``SeedSequence`` root from a flexible seed argument.
284+
285+
Accepts what the scientific-Python SPEC 7 seeding convention accepts
286+
(an int, a ``SeedSequence``, a ``Generator`` or ``BitGenerator``, or
287+
None for fresh entropy) and returns a ``SeedSequence`` so it can be
288+
spawned into one independent child stream per simulation index.
289+
"""
290+
if isinstance(random_seed, np.random.SeedSequence):
291+
return random_seed
292+
if isinstance(random_seed, np.random.Generator):
293+
return random_seed.bit_generator.seed_seq
294+
if isinstance(random_seed, np.random.BitGenerator):
295+
return random_seed.seed_seq
296+
return np.random.SeedSequence(random_seed)
297+
298+
def __seed_simulation(self, child_seed):
299+
"""Reseed the stochastic models for a single simulation index.
300+
301+
The per-index child seed is split three ways so the environment,
302+
rocket and flight draw from independent streams instead of sharing
303+
one. Seeding per simulation index (not per worker) is what makes the
304+
sampled inputs invariant to the execution mode and to the number of
305+
workers.
306+
"""
307+
env_seed, rocket_seed, flight_seed = child_seed.spawn(3)
308+
self.environment._set_stochastic(env_seed)
309+
self.rocket._set_stochastic(rocket_seed)
310+
self.flight._set_stochastic(flight_seed)
311+
312+
def __run_in_serial(self, random_seed=None): # pylint: disable=too-many-statements
271313
"""
272314
Runs the monte carlo simulation in serial mode.
273315
316+
Parameters
317+
----------
318+
random_seed : int, SeedSequence, Generator, optional
319+
Root seed for the run. See ``simulate``.
320+
274321
Returns
275322
-------
276323
None
@@ -280,14 +327,18 @@ def __run_in_serial(self):
280327
n_simulations=self.number_of_simulations,
281328
start_time=time(),
282329
)
330+
child_seeds = self.__root_seed_sequence(random_seed).spawn(
331+
self.number_of_simulations
332+
)
283333
try:
284334
while sim_monitor.keep_simulating():
285-
sim_monitor.increment()
335+
sim_idx = sim_monitor.increment() - 1
286336
inputs_json, outputs_json = "", ""
287337

338+
self.__seed_simulation(child_seeds[sim_idx])
288339
flight = self.__run_single_simulation()
289-
inputs_json = self.__evaluate_flight_inputs(sim_monitor.count)
290-
outputs_json = self.__evaluate_flight_outputs(flight, sim_monitor.count)
340+
inputs_json = self.__evaluate_flight_inputs(sim_idx)
341+
outputs_json = self.__evaluate_flight_outputs(flight, sim_idx)
291342

292343
with open(self.input_file, "a", encoding="utf-8") as f:
293344
f.write(inputs_json)
@@ -309,7 +360,7 @@ def __run_in_serial(self):
309360
f.write(inputs_json)
310361
raise error
311362

312-
def __run_in_parallel(self, n_workers=None):
363+
def __run_in_parallel(self, n_workers=None, random_seed=None):
313364
"""
314365
Runs the monte carlo simulation in parallel.
315366
@@ -318,6 +369,8 @@ def __run_in_parallel(self, n_workers=None):
318369
n_workers: int, optional
319370
Number of workers to be used. If None, the number of workers
320371
will be equal to the number of CPUs available. Default is None.
372+
random_seed : int, SeedSequence, Generator, optional
373+
Root seed for the run. See ``simulate``.
321374
322375
Returns
323376
-------
@@ -339,13 +392,19 @@ def __run_in_parallel(self, n_workers=None):
339392
)
340393

341394
processes = []
342-
seeds = np.random.SeedSequence().spawn(n_workers)
395+
# One independent child seed per simulation index (not per
396+
# worker), shared with every worker. The shared counter assigns
397+
# indices, and index i always seeds from child_seeds[i], so the
398+
# sampled inputs do not depend on the number of workers.
399+
child_seeds = self.__root_seed_sequence(random_seed).spawn(
400+
self.number_of_simulations
401+
)
343402

344-
for seed in seeds:
403+
for _ in range(n_workers):
345404
sim_producer = multiprocess.Process(
346405
target=self.__sim_producer,
347406
args=(
348-
seed,
407+
child_seeds,
349408
sim_monitor,
350409
mutex,
351410
simulation_error_event,
@@ -387,13 +446,16 @@ def __validate_number_of_workers(self, n_workers):
387446
raise ValueError("Number of workers must be at least 2 for parallel mode.")
388447
return n_workers
389448

390-
def __sim_producer(self, seed, sim_monitor, mutex, error_event): # pylint: disable=too-many-statements
449+
def __sim_producer(self, child_seeds, sim_monitor, mutex, error_event): # pylint: disable=too-many-statements
391450
"""Simulation producer to be used in parallel by multiprocessing.
392451
393452
Parameters
394453
----------
395-
seed : int
396-
The seed to set the random number generator.
454+
child_seeds : list[numpy.random.SeedSequence]
455+
One seed sequence per simulation index. Before each simulation
456+
the worker seeds the stochastic models from
457+
``child_seeds[sim_idx]``, where ``sim_idx`` comes from the shared
458+
counter, so the inputs are invariant to the number of workers.
397459
sim_monitor : _SimMonitor
398460
The simulation monitor object to keep track of the simulations.
399461
mutex : multiprocess.Lock
@@ -402,15 +464,11 @@ def __sim_producer(self, seed, sim_monitor, mutex, error_event): # pylint: disa
402464
Event signaling an error occurred during the simulation.
403465
"""
404466
try:
405-
# Ensure Processes generate different random numbers
406-
self.environment._set_stochastic(seed)
407-
self.rocket._set_stochastic(seed)
408-
self.flight._set_stochastic(seed)
409-
410467
while sim_monitor.keep_simulating():
411468
sim_idx = sim_monitor.increment() - 1
412469
inputs_json, outputs_json = "", ""
413470

471+
self.__seed_simulation(child_seeds[sim_idx])
414472
flight = self.__run_single_simulation()
415473
inputs_json = self.__evaluate_flight_inputs(sim_idx)
416474
outputs_json = self.__evaluate_flight_outputs(flight, sim_idx)
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""Determinism tests for the ``random_seed`` argument of ``MonteCarlo.simulate``.
2+
3+
With a fixed ``random_seed`` the generated random *inputs* are reproducible and
4+
identical across serial and parallel execution and across any number of workers.
5+
Each simulation index draws from its own child stream spawned from the run's root
6+
seed, and ``SeedSequence.spawn`` is prefix-stable, so index ``i`` maps to the same
7+
seed regardless of the worker that runs it.
8+
9+
The trajectory integration (``Flight``) is stubbed so the tests stay fast: worker
10+
invariance is a property of the input sampling, which happens before ``Flight`` is
11+
built. Stubbing the module-level ``Flight`` symbol reaches the parallel workers
12+
only under the ``fork`` start method, so those tests are guarded accordingly.
13+
14+
A dedicated numpy-only rocket is used so *all* randomness flows through the seeded
15+
numpy generator. List-valued stochastic attributes are sampled with the standard
16+
library ``random.choice`` (an unseeded global generator) which ``random_seed``
17+
does not govern; the fixture drops the only such attribute (a multi-element
18+
``thrust_source``) so the inputs are byte-for-byte reproducible from the seed.
19+
"""
20+
21+
import json
22+
23+
import multiprocess
24+
import pytest
25+
26+
import rocketpy.simulation.monte_carlo as mc_module
27+
from rocketpy.simulation import MonteCarlo
28+
from rocketpy.stochastic import StochasticRocket, StochasticSolidMotor
29+
30+
pytestmark = pytest.mark.slow
31+
32+
requires_fork = pytest.mark.skipif(
33+
multiprocess.get_start_method() != "fork",
34+
reason="stub-based parallel determinism test requires the 'fork' start method",
35+
)
36+
37+
38+
class _StubFlight:
39+
"""Minimal stand-in for ``Flight`` that skips trajectory integration."""
40+
41+
def __init__(self, **kwargs): # accepts and ignores MonteCarlo's Flight kwargs
42+
pass
43+
44+
def __getattr__(self, name):
45+
return 0.0
46+
47+
48+
@pytest.fixture
49+
def stochastic_calisto_numpy_only(
50+
cesaroni_m1670,
51+
calisto_robust,
52+
stochastic_nose_cone,
53+
stochastic_trapezoidal_fins,
54+
stochastic_tail,
55+
stochastic_rail_buttons,
56+
stochastic_main_parachute,
57+
stochastic_drogue_parachute,
58+
):
59+
"""A ``StochasticRocket`` whose randomness flows entirely through numpy.
60+
61+
Mirrors the shared ``stochastic_calisto`` fixture but gives the solid motor a
62+
single ``thrust_source`` instead of a multi-element list, so no attribute is
63+
sampled through the unseeded standard-library ``random.choice``.
64+
"""
65+
motor = StochasticSolidMotor(
66+
solid_motor=cesaroni_m1670,
67+
burn_out_time=(4, 0.1),
68+
grains_center_of_mass_position=0.001,
69+
grain_density=50,
70+
grain_separation=1 / 1000,
71+
grain_initial_height=1 / 1000,
72+
grain_initial_inner_radius=0.375 / 1000,
73+
grain_outer_radius=0.375 / 1000,
74+
total_impulse=(6500, 1000),
75+
throat_radius=0.5 / 1000,
76+
nozzle_radius=0.5 / 1000,
77+
nozzle_position=0.001,
78+
)
79+
rocket = StochasticRocket(
80+
rocket=calisto_robust,
81+
radius=0.0127 / 2000,
82+
mass=(15.426, 0.5, "normal"),
83+
inertia_11=(6.321, 0),
84+
inertia_22=0.01,
85+
inertia_33=0.01,
86+
center_of_mass_without_motor=0,
87+
)
88+
rocket.add_motor(motor, position=0.001)
89+
rocket.add_nose(stochastic_nose_cone, position=(1.134, 0.001))
90+
rocket.add_trapezoidal_fins(stochastic_trapezoidal_fins, position=(0.001, "normal"))
91+
rocket.add_tail(stochastic_tail)
92+
rocket.set_rail_buttons(
93+
stochastic_rail_buttons, lower_button_position=(-0.618, 0.001, "normal")
94+
)
95+
rocket.add_parachute(parachute=stochastic_main_parachute)
96+
rocket.add_parachute(parachute=stochastic_drogue_parachute)
97+
return rocket
98+
99+
100+
def _read_inputs_by_index(input_file):
101+
"""Read a ``.inputs.txt`` file into ``{index: raw_json_line}``."""
102+
by_index = {}
103+
with open(input_file, mode="r", encoding="utf-8") as rows:
104+
for line in rows:
105+
line = line.strip()
106+
if not line:
107+
continue
108+
by_index[json.loads(line)["index"]] = line
109+
return by_index
110+
111+
112+
def _simulate_inputs(
113+
monkeypatch, tmp_path, environment, rocket, flight, tag, **simulate_kwargs
114+
):
115+
"""Run a Monte Carlo with a stubbed ``Flight`` and return inputs by index."""
116+
monkeypatch.setattr(mc_module, "Flight", _StubFlight)
117+
montecarlo = MonteCarlo(
118+
filename=str(tmp_path / tag),
119+
environment=environment,
120+
rocket=rocket,
121+
flight=flight,
122+
)
123+
montecarlo.simulate(**simulate_kwargs)
124+
return _read_inputs_by_index(montecarlo.input_file)
125+
126+
127+
def test_serial_inputs_are_reproducible(
128+
monkeypatch,
129+
tmp_path,
130+
stochastic_environment,
131+
stochastic_calisto_numpy_only,
132+
stochastic_flight,
133+
):
134+
"""Two serial runs with the same random_seed yield identical inputs."""
135+
models = (stochastic_environment, stochastic_calisto_numpy_only, stochastic_flight)
136+
run_a = _simulate_inputs(
137+
monkeypatch, tmp_path, *models, "a", number_of_simulations=6, random_seed=7
138+
)
139+
run_b = _simulate_inputs(
140+
monkeypatch, tmp_path, *models, "b", number_of_simulations=6, random_seed=7
141+
)
142+
assert sorted(run_a) == list(range(6))
143+
assert run_a == run_b
144+
145+
146+
@requires_fork
147+
def test_inputs_are_worker_invariant(
148+
monkeypatch,
149+
tmp_path,
150+
stochastic_environment,
151+
stochastic_calisto_numpy_only,
152+
stochastic_flight,
153+
):
154+
"""serial == parallel(2) == parallel(4): inputs are bit-identical per index."""
155+
models = (stochastic_environment, stochastic_calisto_numpy_only, stochastic_flight)
156+
common = {"number_of_simulations": 8, "random_seed": 314159}
157+
158+
serial = _simulate_inputs(monkeypatch, tmp_path, *models, "serial", **common)
159+
par2 = _simulate_inputs(
160+
monkeypatch, tmp_path, *models, "par2", parallel=True, n_workers=2, **common
161+
)
162+
par4 = _simulate_inputs(
163+
monkeypatch, tmp_path, *models, "par4", parallel=True, n_workers=4, **common
164+
)
165+
166+
expected = list(range(8))
167+
assert sorted(serial) == expected
168+
assert sorted(par2) == expected
169+
assert sorted(par4) == expected
170+
for index in expected:
171+
assert serial[index] == par2[index], f"serial vs parallel(2) differ at {index}"
172+
assert serial[index] == par4[index], f"serial vs parallel(4) differ at {index}"
173+
174+
175+
def test_none_seed_still_runs(
176+
monkeypatch,
177+
tmp_path,
178+
stochastic_environment,
179+
stochastic_calisto_numpy_only,
180+
stochastic_flight,
181+
):
182+
"""random_seed=None draws fresh entropy but still exports one record per index."""
183+
inputs = _simulate_inputs(
184+
monkeypatch,
185+
tmp_path,
186+
stochastic_environment,
187+
stochastic_calisto_numpy_only,
188+
stochastic_flight,
189+
"none",
190+
number_of_simulations=5,
191+
random_seed=None,
192+
)
193+
assert sorted(inputs) == list(range(5))

0 commit comments

Comments
 (0)