Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ Attention: The newest changes should be on top -->

### Fixed

- BUG: seed Monte Carlo per simulation index, not per worker [#1071](https://github.com/RocketPy-Team/RocketPy/pull/1071)

## [v1.13.0] - 2026-07-04

### Added
Expand Down
52 changes: 40 additions & 12 deletions rocketpy/simulation/monte_carlo.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ def __init__(
flight,
export_list=None,
data_collector=None,
seed=None,
):
"""
Initialize a MonteCarlo object.
Expand Down Expand Up @@ -132,6 +133,14 @@ def __init__(
"date": lambda flight: flight.env.date,
}

seed : int, optional
Master seed for the whole analysis. Per-simulation seeds are
derived from it by simulation index, so a given index draws the
same inputs whether the run is serial or parallel and regardless
of the number of workers. If None (default), a random master seed
is drawn once; results are then random but still consistent across
execution modes within that run.

Returns
-------
None
Expand Down Expand Up @@ -160,6 +169,9 @@ def __init__(
self._check_data_collector(data_collector)
self.data_collector = data_collector

self._seed = seed
self._base_entropy = np.random.SeedSequence(seed).entropy

self.import_inputs(self.filename.with_suffix(".inputs.txt"))
self.import_outputs(self.filename.with_suffix(".outputs.txt"))
self.import_errors(self.filename.with_suffix(".errors.txt"))
Expand Down Expand Up @@ -285,6 +297,7 @@ def __run_in_serial(self):
sim_monitor.increment()
inputs_json, outputs_json = "", ""

self.__seed_stochastic_models(sim_monitor.count - 1)
flight = self.__run_single_simulation()
inputs_json = self.__evaluate_flight_inputs(sim_monitor.count)
outputs_json = self.__evaluate_flight_outputs(flight, sim_monitor.count)
Expand Down Expand Up @@ -339,13 +352,11 @@ def __run_in_parallel(self, n_workers=None):
)

processes = []
seeds = np.random.SeedSequence().spawn(n_workers)

for seed in seeds:
for _ in range(n_workers):
sim_producer = multiprocess.Process(
target=self.__sim_producer,
args=(
seed,
sim_monitor,
mutex,
simulation_error_event,
Expand Down Expand Up @@ -387,13 +398,11 @@ def __validate_number_of_workers(self, n_workers):
raise ValueError("Number of workers must be at least 2 for parallel mode.")
return n_workers

def __sim_producer(self, seed, sim_monitor, mutex, error_event): # pylint: disable=too-many-statements
def __sim_producer(self, sim_monitor, mutex, error_event): # pylint: disable=too-many-statements
"""Simulation producer to be used in parallel by multiprocessing.

Parameters
----------
seed : int
The seed to set the random number generator.
sim_monitor : _SimMonitor
The simulation monitor object to keep track of the simulations.
mutex : multiprocess.Lock
Expand All @@ -402,15 +411,11 @@ def __sim_producer(self, seed, sim_monitor, mutex, error_event): # pylint: disa
Event signaling an error occurred during the simulation.
"""
try:
# Ensure Processes generate different random numbers
self.environment._set_stochastic(seed)
self.rocket._set_stochastic(seed)
self.flight._set_stochastic(seed)

while sim_monitor.keep_simulating():
sim_idx = sim_monitor.increment() - 1
inputs_json, outputs_json = "", ""

self.__seed_stochastic_models(sim_idx)
flight = self.__run_single_simulation()
inputs_json = self.__evaluate_flight_inputs(sim_idx)
outputs_json = self.__evaluate_flight_outputs(flight, sim_idx)
Expand Down Expand Up @@ -453,6 +458,29 @@ def __sim_producer(self, seed, sim_monitor, mutex, error_event): # pylint: disa
error_event.set()
mutex.release()

def __seed_stochastic_models(self, sim_idx):
"""Reseed the stochastic models deterministically for one simulation.

The seed for simulation ``sim_idx`` depends only on the master seed and
the index, so the same index samples the same inputs in serial and
parallel runs and for any number of workers (issue #1053). Each model
gets its own derived seed so their draws stay decorrelated.

Parameters
----------
sim_idx : int
Zero-based index of the simulation being run.
"""
seed_sequence = np.random.SeedSequence(
entropy=self._base_entropy, spawn_key=(sim_idx,)
)
env_seed, rocket_seed, flight_seed = (
int(s) for s in seed_sequence.generate_state(3)
)
self.environment._set_stochastic(env_seed)
self.rocket._set_stochastic(rocket_seed)
self.flight._set_stochastic(flight_seed)

def __run_single_simulation(self):
"""Runs a single simulation and returns the inputs and outputs.

Expand Down Expand Up @@ -1377,7 +1405,7 @@ def export_ellipses_to_kml( # pylint: disable=too-many-statements
except KeyError as e:
raise KeyError("No impact data found. Skipping impact ellipses.") from e

(apogee_ellipses, impact_ellipses) = generate_monte_carlo_ellipses(
apogee_ellipses, impact_ellipses = generate_monte_carlo_ellipses(
impact_x,
impact_y,
apogee_x,
Expand Down
72 changes: 72 additions & 0 deletions tests/unit/simulation/test_monte_carlo.py
Original file line number Diff line number Diff line change
Expand Up @@ -513,3 +513,75 @@ def test_simulate_convergence_runs_until_max_when_not_converging():
assert mc.num_of_loaded_sims == 200
assert all(width > 0.5 for width in history)
assert len(history) == 4 # 200 / 50 batches


def test_seed_makes_inputs_reproducible_by_index(
stochastic_environment, stochastic_calisto, stochastic_flight
):
"""Inputs for a given simulation index depend only on the master seed and
the index, so serial and parallel runs (any worker count) sample the same
inputs and a fixed seed reproduces a run. See issue #1053.

Parameters
----------
stochastic_environment : StochasticEnvironment
The stochastic environment object, this is a pytest fixture.
stochastic_calisto : StochasticRocket
The stochastic rocket object, this is a pytest fixture.
stochastic_flight : StochasticFlight
The stochastic flight object, this is a pytest fixture.
"""

def elevation_for(mc, sim_idx):
mc._MonteCarlo__seed_stochastic_models(sim_idx) # pylint: disable=protected-access
return mc.environment.create_object().elevation

def make(seed):
return MonteCarlo(
filename="monte_carlo_test",
environment=stochastic_environment,
rocket=stochastic_calisto,
flight=stochastic_flight,
seed=seed,
)

mc = make(42)
mc_same = make(42)
mc_other = make(7)

assert elevation_for(mc, 0) == elevation_for(mc_same, 0)
assert elevation_for(mc, 9) == elevation_for(mc_same, 9)

value = elevation_for(mc, 3)
assert elevation_for(mc, 3) == value

assert elevation_for(mc, 0) != elevation_for(mc, 1)
assert elevation_for(mc_other, 0) != elevation_for(mc, 0)


def test_unseeded_monte_carlo_is_index_consistent(
stochastic_environment, stochastic_calisto, stochastic_flight
):
"""seed=None still yields inputs that depend only on the index within a
run, so serial and parallel stay consistent even without a fixed seed.

Parameters
----------
stochastic_environment : StochasticEnvironment
The stochastic environment object, this is a pytest fixture.
stochastic_calisto : StochasticRocket
The stochastic rocket object, this is a pytest fixture.
stochastic_flight : StochasticFlight
The stochastic flight object, this is a pytest fixture.
"""
mc = MonteCarlo(
filename="monte_carlo_test",
environment=stochastic_environment,
rocket=stochastic_calisto,
flight=stochastic_flight,
)

mc._MonteCarlo__seed_stochastic_models(2) # pylint: disable=protected-access
first = mc.environment.create_object().elevation
mc._MonteCarlo__seed_stochastic_models(2) # pylint: disable=protected-access
assert mc.environment.create_object().elevation == first