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
18 changes: 16 additions & 2 deletions rocketpy/rocket/parachute.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ def __init__(
height=None,
porosity=0.0432,
drag_coefficient=1.4,
seed=None,
):
"""Initializes Parachute class.

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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]
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
22 changes: 21 additions & 1 deletion rocketpy/stochastic/stochastic_parachute.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
100 changes: 100 additions & 0 deletions tests/unit/rocket/test_parachute_noise_seed.py
Original file line number Diff line number Diff line change
@@ -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)