Skip to content
Draft
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Attention: The newest changes should be on top -->

### Added

- ENH: Reproducible Monte Carlo through per-simulation-index seeding [#1054](https://github.com/RocketPy-Team/RocketPy/pull/1054) [#1053](https://github.com/RocketPy-Team/RocketPy/issues/1053)
- ENH: Support fixed-time parachute deployment triggers [#1133](https://github.com/RocketPy-Team/RocketPy/pull/1133) [#437](https://github.com/RocketPy-Team/RocketPy/issues/437)
- DOC: Add SIL parachute ejection integration example [#1131](https://github.com/RocketPy-Team/RocketPy/pull/1131) [#524](https://github.com/RocketPy-Team/RocketPy/issues/524)
- ENH: List NOAA atmosphere datasets and fetch latest [#1136](https://github.com/RocketPy-Team/RocketPy/pull/1136) [#660](https://github.com/RocketPy-Team/RocketPy/issues/660)
Expand Down
8 changes: 8 additions & 0 deletions docs/user/stochastic.rst
Original file line number Diff line number Diff line change
Expand Up @@ -289,3 +289,11 @@ better reflecting the inherent uncertainties in rocketry.
.. note::
See the ``MonteCarlo`` class documentation for more information on how to run \
Monte Carlo simulations with stochastic objects.

.. note::
A whole run is fixed by ``MonteCarlo.simulate(random_seed=...)`` rather than
by seeding these models yourself. Each simulation takes its seed from its
own index, so simulation 7 draws the same inputs whether the run was serial
or split over any number of workers, and appending with the same seed
carries the same stream on. Without it a run draws fresh entropy and
reproduces nothing.
118 changes: 103 additions & 15 deletions rocketpy/simulation/monte_carlo.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import os
import traceback
import warnings
from copy import deepcopy
from numbers import Real
from pathlib import Path
from time import time
Expand All @@ -31,6 +32,7 @@
from rocketpy.prints.monte_carlo_prints import _MonteCarloPrints
from rocketpy.simulation.flight import Flight
from rocketpy.tools import (
_seed_sequence_to_int,
generate_monte_carlo_ellipses,
generate_monte_carlo_ellipses_coordinates,
import_optional_dependency,
Expand All @@ -44,6 +46,57 @@
_SIMULATION_LOG_SUFFIX = ".txt"


def _root_seed_sequence(random_seed):
"""The immutable root a run derives every simulation's seed from.

A ``SeedSequence`` is rebuilt from its full state rather than used as
given, since ``spawn`` advances a counter the caller still holds. A
``Generator`` is refused rather than read, because using a consume-on-use
object as an immutable seed cannot mean what it says.
"""
if isinstance(random_seed, np.random.SeedSequence):
return np.random.SeedSequence(**random_seed.state)
if isinstance(random_seed, (np.random.Generator, np.random.BitGenerator)):
raise TypeError(
f"random_seed must be an int, a sequence of non-negative integers, "
f"or a numpy.random.SeedSequence, not a "
f"{type(random_seed).__name__}. Pass the seed the generator was "
f"built from."
)
return np.random.SeedSequence(random_seed)


def _root_state_of(root):
"""A root as the four picklable values a worker can rebuild it from.

Sent to each worker instead of the object, and instead of the list of
children, so a run of a million simulations costs four values. The entropy
is copied because a sequence one is kept by reference all the way from the
caller, who could otherwise still move every child by editing their list.
"""
return (
deepcopy(root.entropy),
tuple(root.spawn_key),
root.pool_size,
root.n_children_spawned,
)


def _seed_of_simulation(root_state, sim_idx):
"""The seed for one simulation index, without spawning the ones before it.

``spawn`` derives child ``i`` by appending ``n_children_spawned + i`` to
the parent spawn key, so rebuilding that one child directly reproduces it
and any index can be reached from the four values above alone.
"""
entropy, spawn_key, pool_size, base = root_state
return np.random.SeedSequence(
entropy=entropy,
spawn_key=(*spawn_key, base + sim_idx),
pool_size=pool_size,
)


def _refuse_logs_this_run_cannot_write(
input_file, output_file, error_file, export_config=None
):
Expand Down Expand Up @@ -265,6 +318,8 @@ def simulate(
append=False,
parallel=False,
n_workers=None,
*,
random_seed=None,
**kwargs,
):
"""
Expand All @@ -284,6 +339,20 @@ def simulate(
number of workers will be equal to the number of CPUs available.
A minimum of 2 workers is required for parallel mode.
Default is None.
random_seed : int, sequence of int or numpy.random.SeedSequence, optional
Fixes what every simulation draws. Simulation ``i`` takes the same
inputs whichever way the run was split up, so serial and parallel
results agree and the number of workers does not reach the
sampling. Keyword-only. Default is None, which draws fresh entropy
and reproduces nothing.

Appending continues the same stream when the same seed is given
again, since an index maps to a seed and to nothing else. Nothing
here records the seed, so nothing here can tell you that a later
append was given the same one; that is #1075.

A ``Generator`` or ``BitGenerator`` is refused rather than read.
Pass the seed it was built from.
kwargs : dict
Custom arguments for simulation export of the ``inputs`` file. Options
are:
Expand Down Expand Up @@ -317,6 +386,11 @@ def simulate(
self._export_config = kwargs
self.number_of_simulations = number_of_simulations
self._initial_sim_idx = self.num_of_loaded_sims if append else 0
# Validated here, before __setup_files truncates anything, so an
# unusable seed cannot cost a previous run its results. Kept as four
# picklable values rather than as the object, since a worker rebuilds
# any index from them.
self.__root_state = _root_state_of(_root_seed_sequence(random_seed))

# Before anything is opened: __setup_files truncates for append=False.
_refuse_logs_this_run_cannot_write(
Expand Down Expand Up @@ -413,14 +487,19 @@ def __run_in_serial(self):
n_simulations=self.number_of_simulations,
start_time=time(),
)
sim_idx = sim_monitor.count
try:
while sim_monitor.keep_simulating():
sim_monitor.increment()
# Counted from zero, as the parallel path already does. The two
# named the same simulation differently: three of them wrote
# 1, 2, 3 here and 0, 1, 2 there.
sim_idx = sim_monitor.increment() - 1
inputs_json, outputs_json = "", ""

self.__seed_this_simulation(sim_idx)
flight = self.__run_single_simulation()
inputs_json = self.__evaluate_flight_inputs(sim_monitor.count)
outputs_json = self.__evaluate_flight_outputs(flight, sim_monitor.count)
inputs_json = self.__evaluate_flight_inputs(sim_idx)
outputs_json = self.__evaluate_flight_outputs(flight, sim_idx)

self._append_simulation_record(inputs_json, outputs_json)

Expand All @@ -434,7 +513,7 @@ def __run_in_serial(self):
f.write(inputs_json)

except Exception as error:
print(f"Error on iteration {sim_monitor.count}: {error}")
print(f"Error on iteration {sim_idx}: {error}")
with open(self._error_file, "a", encoding="utf-8") as f:
f.write(inputs_json)
raise error
Expand Down Expand Up @@ -469,13 +548,14 @@ def __run_in_parallel(self, n_workers=None):
)

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

for seed in seeds:
# No seed per worker any more: every simulation takes its own from
# its index, so the workers are interchangeable and how many there
# are does not reach the sampling.
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 @@ -517,13 +597,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):
"""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 @@ -532,15 +610,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_this_simulation(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 @@ -580,6 +654,20 @@ def __sim_producer(self, seed, sim_monitor, mutex, error_event): # pylint: disa
error_event.set()
mutex.release()

def __seed_this_simulation(self, sim_idx):
"""Reseed the three models from this index's own child of the root.

Per index rather than per worker, which is what makes a simulation's
inputs the same however the run was split up. The child is split three
ways so the environment, rocket and flight draw independently instead
of sharing one stream.
"""
child = _seed_of_simulation(self.__root_state, sim_idx)
environment, rocket, flight = child.spawn(3)
self.environment._set_stochastic(_seed_sequence_to_int(environment))
self.rocket._set_stochastic(_seed_sequence_to_int(rocket))
self.flight._set_stochastic(_seed_sequence_to_int(flight))

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

Expand Down
20 changes: 16 additions & 4 deletions rocketpy/stochastic/stochastic_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from rocketpy.mathutils.function import Function
from rocketpy.stochastic.custom_sampler import CustomSampler

from ..tools import get_distribution
from ..tools import _seed_sequence_to_int, get_distribution


def _names_as_spawn_key(input_names):
Expand Down Expand Up @@ -41,6 +41,18 @@ def _format_number(value):
return f"array of shape {np.shape(value)}"


def _seed_as_entropy(seed):
"""A seed as something ``SeedSequence`` will take as entropy.

A parallel run is handed a ``SeedSequence``, which it will not take. Any
other seed goes through untouched, so the stream an int reaches stays where
it was.
"""
if not isinstance(seed, np.random.SeedSequence):
return seed
return _seed_sequence_to_int(seed)


def _sampler_seed(seed, input_names):
"""Derive a seed for one sampler, or for one group that shares a generator.

Expand All @@ -54,10 +66,10 @@ def _sampler_seed(seed, input_names):
# Sorted here rather than trusting the caller, so a future call site cannot
# give one group two different seeds by listing its members another way.
root = np.random.SeedSequence(
entropy=seed, spawn_key=_names_as_spawn_key(tuple(sorted(input_names)))
entropy=_seed_as_entropy(seed),
spawn_key=_names_as_spawn_key(tuple(sorted(input_names))),
)
words = root.generate_state(4, dtype=np.uint32)
return sum(int(word) << (32 * position) for position, word in enumerate(words))
return _seed_sequence_to_int(root)


# TODO: Stop using assert in production code. Use exceptions instead.
Expand Down
11 changes: 11 additions & 0 deletions rocketpy/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1377,6 +1377,17 @@ def euler313_to_quaternions(phi, theta, psi):
return e0, e1, e2, e3


def _seed_sequence_to_int(seed_sequence):
"""Returns a ``SeedSequence`` as the 128-bit ``int`` it can be rebuilt from.

Folded through ``generate_state`` rather than read off ``entropy``, since
the children of one root differ only by ``spawn_key``, and 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))


def get_matplotlib_supported_file_endings():
"""Gets the file endings supported by matplotlib.

Expand Down
32 changes: 32 additions & 0 deletions tests/unit/simulation/test_monte_carlo_parallel_runs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import pytest

from rocketpy.simulation.monte_carlo import MonteCarlo


@pytest.mark.parametrize("parallel", [False, True])
def test_a_monte_carlo_run_finishes(
stochastic_environment, stochastic_calisto, stochastic_flight, tmp_path, parallel
):
# The parallel path hands each worker a SeedSequence rather than an int, and
# nothing else in the suite exercises that. A worker that dies on it is not
# reported, so this reads as a hang rather than as a failure.
#
# Built here rather than taken from the monte_carlo_calisto fixture, whose
# own filename is fixed, since `filename` is a plain attribute and the three
# working paths are settled when the object is constructed.
analysis = MonteCarlo(
filename=str(tmp_path / "study"),
environment=stochastic_environment,
rocket=stochastic_calisto,
flight=stochastic_flight,
)

analysis.simulate(
number_of_simulations=2,
append=False,
parallel=parallel,
n_workers=2 if parallel else None,
)

assert analysis.num_of_loaded_sims == 2
assert str(tmp_path) in str(analysis.output_file)
Loading
Loading