From e43b8a8234f1a481ab67952822730b5c5ea9cd2e Mon Sep 17 00:00:00 2001 From: Joshua Terranova Date: Mon, 10 Aug 2026 18:56:55 -0700 Subject: [PATCH] BUG: seed parachute pressure noise with per-instance RNG (#1091) --- rocketpy/rocket/parachute.py | 18 +++- rocketpy/stochastic/stochastic_parachute.py | 22 +++- .../unit/rocket/test_parachute_noise_seed.py | 100 ++++++++++++++++++ 3 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 tests/unit/rocket/test_parachute_noise_seed.py diff --git a/rocketpy/rocket/parachute.py b/rocketpy/rocket/parachute.py index da99743ce..d56a24b63 100644 --- a/rocketpy/rocket/parachute.py +++ b/rocketpy/rocket/parachute.py @@ -137,6 +137,7 @@ def __init__( height=None, porosity=0.0432, drag_coefficient=1.4, + seed=None, ): """Initializes Parachute class. @@ -217,6 +218,12 @@ def __init__( - **1.5** — extended-skirt canopy Has no effect when ``radius`` is explicitly provided. + seed : int, array_like, SeedSequence, BitGenerator, Generator or None, optional + Seed for the per-instance NumPy Generator used by pressure noise. + A fixed seed makes the noise reproducible and independent of the + process-global NumPy RNG (and therefore usable under Monte Carlo). + ``None`` keeps the noise random but still drawn from this instance's + generator. Default is ``None``. """ # Save arguments as attributes @@ -229,6 +236,11 @@ def __init__( self.drag_coefficient = drag_coefficient self.porosity = porosity + # Per-instance RNG: pressure noise must not draw from the process-global + # NumPy RNG, or Monte Carlo cannot reproduce deployment (see #1091). + self._seed = seed + self._rng = np.random.default_rng(seed) + # Initialize derived attributes self.radius = self.__resolve_radius(radius, cd_s, drag_coefficient) self.height = self.__resolve_height(height, self.radius) @@ -267,7 +279,7 @@ def __init_noise(self, noise): noise : tuple, list List in the format (mean, standard deviation, time-correlation). """ - self.noise_signal = [[-1e-6, np.random.normal(noise[0], noise[1])]] + self.noise_signal = [[-1e-6, self._rng.normal(noise[0], noise[1])]] self.noisy_pressure_signal = [] self.clean_pressure_signal = [] self.noise_bias = noise[0] @@ -282,7 +294,7 @@ def __init_noise(self, noise): else: self.noise_function = lambda: ( alpha * self.noise_signal[-1][1] - + beta * np.random.normal(noise[0], noise[1]) + + beta * self._rng.normal(noise[0], noise[1]) ) def __evaluate_trigger_function(self, trigger): # pylint: disable=too-many-statements @@ -431,6 +443,7 @@ def to_dict(self, **kwargs): "drag_coefficient": self.drag_coefficient, "height": self.height, "porosity": self.porosity, + "seed": self._seed, } if kwargs.get("include_outputs", False): @@ -465,6 +478,7 @@ def from_dict(cls, data): drag_coefficient=data.get("drag_coefficient", 1.4), height=data.get("height", None), porosity=data.get("porosity", 0.0432), + seed=data.get("seed", None), ) return parachute diff --git a/rocketpy/stochastic/stochastic_parachute.py b/rocketpy/stochastic/stochastic_parachute.py index 19ab3dab0..c0c49298c 100644 --- a/rocketpy/stochastic/stochastic_parachute.py +++ b/rocketpy/stochastic/stochastic_parachute.py @@ -2,7 +2,7 @@ from rocketpy.rocket import Parachute -from .stochastic_model import StochasticModel +from .stochastic_model import StochasticModel, _sampler_seed def _is_a_trigger(member): @@ -111,6 +111,7 @@ def __init__( self.drag_coefficient = drag_coefficient self.height = height self.porosity = porosity + self._seed = None self._validate_trigger(trigger) self._validate_noise(noise) @@ -128,6 +129,18 @@ def __init__( porosity=porosity, ) + def _set_stochastic(self, seed=None): + """Reseed parameter samplers and remember the seed for pressure noise. + + Parameters + ---------- + seed : int, optional + Seed for the random number generator and the derived parachute + pressure-noise seed. + """ + self._seed = seed + super()._set_stochastic(seed) + def _validate_trigger(self, trigger): """Validates the trigger input. If not None, it must be a non-empty list whose members are each a callable, the string "apogee", or a @@ -175,4 +188,11 @@ def create_object(self): Parachute object with the randomly generated input arguments. """ generated_dict = next(self.dict_generator()) + # Tie pressure noise into the Monte Carlo seed tree when one is set. + # Key by parachute name so drogue and main on the same rocket do not + # share one noise stream. + if self._seed is not None: + generated_dict["seed"] = _sampler_seed( + self._seed, ("pressure_noise", generated_dict["name"]) + ) return Parachute(**generated_dict) diff --git a/tests/unit/rocket/test_parachute_noise_seed.py b/tests/unit/rocket/test_parachute_noise_seed.py new file mode 100644 index 000000000..1cd702eaf --- /dev/null +++ b/tests/unit/rocket/test_parachute_noise_seed.py @@ -0,0 +1,100 @@ +"""Determinism tests for seeded parachute pressure noise (#1091). + +Pressure noise is drawn from a per-instance ``numpy.random.Generator`` created +from the ``seed`` argument, instead of the process-global ``numpy.random``. +A seed makes the noise reproducible and independent of the global RNG state. +""" + +import numpy as np + +from rocketpy import Parachute +from rocketpy.stochastic import StochasticParachute + + +def _parachute(seed, noise=(0, 8.3, 0.5)): + return Parachute( + name="main", + cd_s=10.0, + trigger="apogee", + sampling_rate=100, + noise=noise, + seed=seed, + ) + + +def _noise_sequence(parachute, n=16): + # Include the initial sample stored at construction, then draw from + # ``noise_function`` the way Flight does while sampling the trigger. + samples = [parachute.noise_signal[0][1]] + for _ in range(n): + value = parachute.noise_function() + parachute.noise_signal.append([0.0, value]) + samples.append(value) + return samples + + +def test_same_seed_is_reproducible(): + assert _noise_sequence(_parachute(42)) == _noise_sequence(_parachute(42)) + + +def test_different_seeds_decorrelate(): + assert _noise_sequence(_parachute(1)) != _noise_sequence(_parachute(2)) + + +def test_default_unseeded_still_draws_noise(): + """seed=None keeps the default path working with non-zero noise.""" + parachute = _parachute(None) + samples = _noise_sequence(parachute, n=8) + assert any(sample != 0.0 for sample in samples) + + +def test_noise_independent_of_global_numpy_rng(): + np.random.seed(0) + first = _noise_sequence(_parachute(7)) + np.random.seed(999) + _ = [np.random.random() for _ in range(1000)] + second = _noise_sequence(_parachute(7)) + assert first == second + + +def test_seeded_parachute_does_not_consume_global_rng(): + np.random.seed(0) + position_before = np.random.get_state()[2] + _noise_sequence(_parachute(7)) + position_after = np.random.get_state()[2] + assert position_before == position_after + + +def test_zero_noise_still_returns_zero(): + parachute = _parachute(42, noise=(0, 0, 0)) + assert parachute.noise_function() == 0.0 + + +def test_seed_survives_serialization_round_trip(): + original = _parachute(11) + restored = Parachute.from_dict(original.to_dict()) + assert restored.to_dict()["seed"] == 11 + assert _noise_sequence(restored) == _noise_sequence(_parachute(11)) + + +def test_from_dict_defaults_seed_to_none_when_absent(): + data = _parachute(11).to_dict() + del data["seed"] + assert Parachute.from_dict(data).to_dict()["seed"] is None + + +def test_stochastic_parachute_threads_seed_into_created_object(): + template = _parachute(None, noise=(0, 8.3, 0.5)) + stochastic = StochasticParachute(template) + stochastic._set_stochastic(seed=123) + first = stochastic.create_object() + stochastic._set_stochastic(seed=123) + second = stochastic.create_object() + assert first._seed is not None + assert first._seed == second._seed + assert _noise_sequence(first) == _noise_sequence(second) + + stochastic._set_stochastic(seed=456) + other = stochastic.create_object() + assert other._seed != first._seed + assert _noise_sequence(other) != _noise_sequence(first)