diff --git a/rocketpy/sensors/accelerometer.py b/rocketpy/sensors/accelerometer.py index 1037c0e9a..a6ee7b27a 100644 --- a/rocketpy/sensors/accelerometer.py +++ b/rocketpy/sensors/accelerometer.py @@ -78,6 +78,7 @@ def __init__( cross_axis_sensitivity=0, consider_gravity=False, name="Accelerometer", + seed=None, ): """ Initialize the accelerometer sensor @@ -193,6 +194,7 @@ def __init__( temperature_scale_factor=temperature_scale_factor, cross_axis_sensitivity=cross_axis_sensitivity, name=name, + seed=seed, ) self.consider_gravity = consider_gravity self.prints = _InertialSensorPrints(self) diff --git a/rocketpy/sensors/barometer.py b/rocketpy/sensors/barometer.py index cc26bb056..9a41df893 100644 --- a/rocketpy/sensors/barometer.py +++ b/rocketpy/sensors/barometer.py @@ -62,6 +62,7 @@ def __init__( temperature_bias=0, temperature_scale_factor=0, name="Barometer", + seed=None, ): """ Initialize the barometer sensor @@ -132,6 +133,7 @@ def __init__( temperature_bias=temperature_bias, temperature_scale_factor=temperature_scale_factor, name=name, + seed=seed, ) self.prints = _SensorPrints(self) diff --git a/rocketpy/sensors/gnss_receiver.py b/rocketpy/sensors/gnss_receiver.py index 478bbbd8c..467e8531b 100644 --- a/rocketpy/sensors/gnss_receiver.py +++ b/rocketpy/sensors/gnss_receiver.py @@ -1,5 +1,3 @@ -import numpy as np - from ..mathutils.vector_matrix import Matrix, Vector from ..prints.sensors_prints import _GnssReceiverPrints from .sensor import ScalarSensor @@ -40,6 +38,7 @@ def __init__( altitude_accuracy=0, velocity_accuracy=0, name="GnssReceiver", + seed=None, ): """Initialize the Gnss Receiver sensor. @@ -59,7 +58,7 @@ def __init__( name : str The name of the sensor. Default is "GnssReceiver". """ - super().__init__(sampling_rate=sampling_rate, name=name) + super().__init__(sampling_rate=sampling_rate, name=name, seed=seed) self.position_accuracy = position_accuracy self.altitude_accuracy = altitude_accuracy self.velocity_accuracy = velocity_accuracy @@ -96,12 +95,12 @@ def measure(self, time, **kwargs): ) + Vector(u[3:6]) # Apply accuracy to the position - x = np.random.normal(x, self.position_accuracy) - y = np.random.normal(y, self.position_accuracy) - z = np.random.normal(z, self.altitude_accuracy) - vx = np.random.normal(vx, self.velocity_accuracy) - vy = np.random.normal(vy, self.velocity_accuracy) - vz = np.random.normal(vz, self.velocity_accuracy) + x = self._rng.normal(x, self.position_accuracy) + y = self._rng.normal(y, self.position_accuracy) + z = self._rng.normal(z, self.altitude_accuracy) + vx = self._rng.normal(vx, self.velocity_accuracy) + vy = self._rng.normal(vy, self.velocity_accuracy) + vz = self._rng.normal(vz, self.velocity_accuracy) self.measurement = (x, y, z, vx, vy, vz) self._save_data((time, *self.measurement)) diff --git a/rocketpy/sensors/gyroscope.py b/rocketpy/sensors/gyroscope.py index 6a18af601..942411d8c 100644 --- a/rocketpy/sensors/gyroscope.py +++ b/rocketpy/sensors/gyroscope.py @@ -78,6 +78,7 @@ def __init__( cross_axis_sensitivity=0, acceleration_sensitivity=0, name="Gyroscope", + seed=None, ): """ Initialize the gyroscope sensor @@ -193,6 +194,7 @@ def __init__( temperature_scale_factor=temperature_scale_factor, cross_axis_sensitivity=cross_axis_sensitivity, name=name, + seed=seed, ) self.acceleration_sensitivity = self._vectorize_input( acceleration_sensitivity, "acceleration_sensitivity" diff --git a/rocketpy/sensors/sensor.py b/rocketpy/sensors/sensor.py index 0b44aeb18..73a532db9 100644 --- a/rocketpy/sensors/sensor.py +++ b/rocketpy/sensors/sensor.py @@ -59,6 +59,7 @@ def __init__( temperature_bias=0, temperature_scale_factor=0, name="Sensor", + seed=None, ): """ Initialize the accelerometer sensor @@ -141,6 +142,12 @@ def __init__( self._save_data = self._save_data_single self._random_walk_drift = 0 self.normal_vector = Vector([0, 0, 0]) + # Per-instance RNG seeded deterministically (from the scenario seed when + # provided) so sensor noise is reproducible and independent of the + # process-global numpy RNG -- and therefore safe under parallel/forked + # evaluation. ``seed=None`` keeps the noise random but still per-instance. + self._seed = seed + self._rng = np.random.default_rng(seed) # handle measurement range if isinstance(measurement_range, (tuple, list)): @@ -353,6 +360,7 @@ def __init__( temperature_scale_factor=0, cross_axis_sensitivity=0, name="Sensor", + seed=None, ): """ Initialize the accelerometer sensor @@ -469,6 +477,7 @@ def __init__( temperature_scale_factor, "temperature_scale_factor" ), name=name, + seed=seed, ) self.orientation = orientation @@ -553,12 +562,12 @@ def apply_noise(self, value): """ # white noise white_noise = Vector( - [np.random.normal(0, self.noise_variance[i] ** 0.5) for i in range(3)] + [self._rng.normal(0, self.noise_variance[i] ** 0.5) for i in range(3)] ) & (self.noise_density * self.sampling_rate**0.5) # random walk self._random_walk_drift = self._random_walk_drift + Vector( - [np.random.normal(0, self.random_walk_variance[i] ** 0.5) for i in range(3)] + [self._rng.normal(0, self.random_walk_variance[i] ** 0.5) for i in range(3)] ) & (self.random_walk_density / self.sampling_rate**0.5) # add noise @@ -655,6 +664,7 @@ def __init__( temperature_bias=0, temperature_scale_factor=0, name="Sensor", + seed=None, ): """ Initialize the accelerometer sensor @@ -726,6 +736,7 @@ def __init__( temperature_bias=temperature_bias, temperature_scale_factor=temperature_scale_factor, name=name, + seed=seed, ) def quantize(self, value): @@ -763,7 +774,7 @@ def apply_noise(self, value): """ # white noise white_noise = ( - np.random.normal(0, self.noise_variance**0.5) + self._rng.normal(0, self.noise_variance**0.5) * self.noise_density * self.sampling_rate**0.5 ) @@ -771,7 +782,7 @@ def apply_noise(self, value): # random walk self._random_walk_drift = ( self._random_walk_drift - + np.random.normal(0, self.random_walk_variance**0.5) + + self._rng.normal(0, self.random_walk_variance**0.5) * self.random_walk_density / self.sampling_rate**0.5 ) diff --git a/tests/fixtures/sensors/sensors_fixtures.py b/tests/fixtures/sensors/sensors_fixtures.py index 1d01a59c3..0fae0c218 100644 --- a/tests/fixtures/sensors/sensors_fixtures.py +++ b/tests/fixtures/sensors/sensors_fixtures.py @@ -62,6 +62,7 @@ def noisy_barometer(): operating_temperature=25 + 273.15, temperature_bias=0.02, temperature_scale_factor=0.02, + seed=42, ) diff --git a/tests/unit/sensors/test_sensor_seeding.py b/tests/unit/sensors/test_sensor_seeding.py new file mode 100644 index 000000000..21be6e299 --- /dev/null +++ b/tests/unit/sensors/test_sensor_seeding.py @@ -0,0 +1,99 @@ +"""Determinism tests for seeded sensor noise (additive, isolated). + +Sensor noise is drawn from a per-instance ``numpy.random.Generator`` seeded +deterministically, instead of the process-global ``numpy.random``. This makes a +seed-reproducible run (e.g. a judged competition) yield the identical sensor +noise for a given seed, regardless of the global RNG state or parallel/forked +execution -- mirroring the seeding the Monte Carlo layer already uses +(``stochastic_model.py`` / ``monte_carlo.py``). + +The tests construct sensors directly and do not touch the existing fixtures or +inherited tests. Sensor noise is sampled on a fixed time grid (not per adaptive +solver step), so a fixed number of sequential draws is a faithful stand-in for a +flight of a given duration. +""" + +import numpy as np + +from rocketpy.mathutils.vector_matrix import Vector +from rocketpy.sensors.accelerometer import Accelerometer +from rocketpy.sensors.gnss_receiver import GnssReceiver + + +def _accelerometer(seed): + # Non-zero white noise + random walk so the draws actually exercise the RNG. + return Accelerometer( + sampling_rate=10, + noise_density=1.0, + noise_variance=1.0, + random_walk_density=0.5, + random_walk_variance=1.0, + seed=seed, + ) + + +def _noise_sequence(sensor, n=16): + return [tuple(sensor.apply_noise(Vector([0.0, 0.0, 0.0]))) for _ in range(n)] + + +def test_same_seed_is_reproducible(): + assert _noise_sequence(_accelerometer(42)) == _noise_sequence(_accelerometer(42)) + + +def test_different_seeds_decorrelate(): + assert _noise_sequence(_accelerometer(1)) != _noise_sequence(_accelerometer(2)) + + +def test_noise_independent_of_global_numpy_rng(): + # Perturbing the process-global RNG must NOT change a seeded sensor's noise. + # This is the regression guard for the original bug (noise drawn from the + # global ``np.random``). + np.random.seed(0) + first = _noise_sequence(_accelerometer(7)) + np.random.seed(999) + _ = [np.random.random() for _ in range(1000)] + second = _noise_sequence(_accelerometer(7)) + assert first == second + + +def test_seeded_sensor_does_not_consume_global_rng(): + # A seeded sensor draws only from its own generator, leaving the global RNG + # position untouched -- so it cannot contaminate other code or parallel envs. + np.random.seed(0) + position_before = np.random.get_state()[2] + _noise_sequence(_accelerometer(7)) + position_after = np.random.get_state()[2] + assert position_before == position_after + + +def test_recreating_generator_from_seed_reproduces_sequence(): + # Reproducibility comes from the seed: re-creating the generator from the + # same seed (which is what a freshly-constructed sensor does) replays the + # same sequence. The random walk is cumulative, so zeroing it matters too. + sensor = _accelerometer(99) + first = _noise_sequence(sensor) + sensor._rng = np.random.default_rng(sensor._seed) + sensor._random_walk_drift = Vector([0.0, 0.0, 0.0]) + assert _noise_sequence(sensor) == first + + +def _gnss_sequence(seed, n=8): + gnss = GnssReceiver( + sampling_rate=1, + position_accuracy=5.0, + altitude_accuracy=5.0, + velocity_accuracy=1.0, + seed=seed, + ) + # Minimal launch-frame state: 100 m up, identity attitude quaternion. + state = np.array([0, 0, 100, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0], dtype=float) + measurements = [] + for _ in range(n): + gnss.measure(0.0, u=state, relative_position=Vector([0.0, 0.0, 0.0])) + measurements.append(gnss.measurement) + return measurements + + +def test_gnss_is_seeded_and_reproducible(): + assert _gnss_sequence(5) == _gnss_sequence(5) + assert _gnss_sequence(5) != _gnss_sequence(6)