Skip to content

Commit ff11f45

Browse files
committed
ENH: seed Monte Carlo per simulation index for worker-invariant inputs
MonteCarlo seeded the stochastic models once per worker (parallel) or once before the loop (serial), so the generated inputs depended on how many workers ran and which worker drew which simulation. Two runs of the same seed with different worker counts produced different inputs. Seed per simulation index instead: spawn one SeedSequence child per index from the run's root seed, and reseed the stochastic models from child_seeds[i] before simulation i. SeedSequence.spawn is invariant to the spawn count, so index i always maps to the same child, including in append mode. Each index seed is split three ways so the environment, rocket and flight do not share a stream. Inputs are now identical across serial, parallel(2) and parallel(N) for a fixed seed, and a split append run reproduces a single run. This changes the numbers a fixed seed produces (the per-model decorrelation, plus a serial index that now counts from 0 like the parallel path already did), so stored baselines regenerate. Adds tests/unit/simulation/test_montecarlo_determinism.py covering serial reproducibility, worker-invariance, append reproducibility, and the None-seed path. Marked slow (each simulation rebuilds a full rocket) per the existing test_monte_carlo_simulate convention.
1 parent e06285b commit ff11f45

2 files changed

Lines changed: 450 additions & 18 deletions

File tree

rocketpy/simulation/monte_carlo.py

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -291,16 +291,23 @@ def __run_in_serial(self, random_seed=None):
291291
start_time=time(),
292292
)
293293
inputs_json = ""
294+
sim_idx = self._initial_sim_idx
295+
# One independent seed per simulation index makes the generated inputs
296+
# invariant to the execution mode and to the worker count (see
297+
# ``__seed_components``). The ``SeedSequence`` is created fresh here so
298+
# that index ``i`` always maps to the same child seed, including in
299+
# ``append`` mode (``spawn`` is invariant to the spawn count).
300+
child_seeds = np.random.SeedSequence(random_seed).spawn(
301+
self.number_of_simulations
302+
)
294303
try:
295-
self.environment._set_stochastic(random_seed)
296-
self.rocket._set_stochastic(random_seed)
297-
self.flight._set_stochastic(random_seed)
298304
while sim_monitor.keep_simulating():
299-
sim_monitor.increment()
305+
sim_idx = sim_monitor.increment() - 1
306+
self.__seed_components(child_seeds[sim_idx])
300307

301308
flight = self.__run_single_simulation()
302-
inputs_json = self.__evaluate_flight_inputs(sim_monitor.count)
303-
outputs_json = self.__evaluate_flight_outputs(flight, sim_monitor.count)
309+
inputs_json = self.__evaluate_flight_inputs(sim_idx)
310+
outputs_json = self.__evaluate_flight_outputs(flight, sim_idx)
304311
self.__append_serial_results(inputs_json, outputs_json)
305312

306313
sim_monitor.print_update_status()
@@ -312,7 +319,7 @@ def __run_in_serial(self, random_seed=None):
312319

313320
except Exception as error:
314321
self.__save_serial_error(
315-
inputs_json, f"Error on iteration {sim_monitor.count}: {error}"
322+
inputs_json, f"Error on iteration {sim_idx}: {error}"
316323
)
317324
raise error
318325

@@ -359,13 +366,20 @@ def __run_in_parallel(self, random_seed=None, n_workers=None):
359366
)
360367

361368
processes = []
362-
seeds = np.random.SeedSequence(random_seed).spawn(n_workers)
369+
# One independent seed per simulation index (not per worker) makes
370+
# the generated inputs invariant to ``n_workers``: the worker that
371+
# runs simulation ``i`` always seeds from ``child_seeds[i]``,
372+
# regardless of how many workers there are. The full list is shared
373+
# with every worker; the shared atomic counter assigns indices.
374+
child_seeds = np.random.SeedSequence(random_seed).spawn(
375+
self.number_of_simulations
376+
)
363377

364-
for seed in seeds:
378+
for _ in range(n_workers):
365379
sim_producer = multiprocess.Process(
366380
target=self.__sim_producer,
367381
args=(
368-
seed,
382+
child_seeds,
369383
sim_monitor,
370384
mutex,
371385
simulation_error_event,
@@ -407,13 +421,16 @@ def __validate_number_of_workers(self, n_workers):
407421
raise ValueError("Number of workers must be at least 2 for parallel mode.")
408422
return n_workers
409423

410-
def __sim_producer(self, seed, sim_monitor, mutex, error_event): # pylint: disable=too-many-statements
424+
def __sim_producer(self, child_seeds, sim_monitor, mutex, error_event): # pylint: disable=too-many-statements
411425
"""Simulation producer to be used in parallel by multiprocessing.
412426
413427
Parameters
414428
----------
415-
seed : int
416-
The seed to set the random number generator.
429+
child_seeds : list[numpy.random.SeedSequence]
430+
One seed sequence per simulation index. Before each simulation the
431+
worker seeds the stochastic models from ``child_seeds[sim_idx]``,
432+
where ``sim_idx`` is pulled from the shared atomic counter. This
433+
keeps the generated inputs invariant to the number of workers.
417434
sim_monitor : _SimMonitor
418435
The simulation monitor object to keep track of the simulations.
419436
mutex : multiprocess.Lock
@@ -422,15 +439,14 @@ def __sim_producer(self, seed, sim_monitor, mutex, error_event): # pylint: disa
422439
Event signaling an error occurred during the simulation.
423440
"""
424441
try:
425-
# Ensure Processes generate different random numbers
426-
self.environment._set_stochastic(seed)
427-
self.rocket._set_stochastic(seed)
428-
self.flight._set_stochastic(seed)
429-
430442
while sim_monitor.keep_simulating():
431443
sim_idx = sim_monitor.increment() - 1
432444
inputs_json, outputs_json = "", ""
433445

446+
# Seed per simulation index so the inputs are reproducible and
447+
# independent of which worker happens to run this index.
448+
self.__seed_components(child_seeds[sim_idx])
449+
434450
flight = self.__run_single_simulation()
435451
inputs_json = self.__evaluate_flight_inputs(sim_idx)
436452
outputs_json = self.__evaluate_flight_outputs(flight, sim_idx)
@@ -466,6 +482,31 @@ def __sim_producer(self, seed, sim_monitor, mutex, error_event): # pylint: disa
466482
error_event.set()
467483
mutex.release()
468484

485+
def __seed_components(self, simulation_seed):
486+
"""Seed the stochastic models for a single simulation index.
487+
488+
The given seed sequence is split into three independent sub-streams so
489+
that the environment, rocket and flight do not share the same random
490+
draws (sharing a single seed would correlate their first sampled
491+
values). Seeding per simulation index -- rather than once per worker --
492+
is what makes the Monte Carlo inputs invariant to the execution mode
493+
(serial vs parallel) and to the number of workers.
494+
495+
Parameters
496+
----------
497+
simulation_seed : numpy.random.SeedSequence
498+
The seed sequence assigned to the current simulation index. It is
499+
spawned into three child sequences, one per stochastic model.
500+
501+
Returns
502+
-------
503+
None
504+
"""
505+
env_seed, rocket_seed, flight_seed = simulation_seed.spawn(3)
506+
self.environment._set_stochastic(env_seed)
507+
self.rocket._set_stochastic(rocket_seed)
508+
self.flight._set_stochastic(flight_seed)
509+
469510
def __run_single_simulation(self):
470511
"""Runs a single simulation and returns the inputs and outputs.
471512

0 commit comments

Comments
 (0)