Skip to content
Merged
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 rocketpy/sensors/accelerometer.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def __init__(
cross_axis_sensitivity=0,
consider_gravity=False,
name="Accelerometer",
seed=None,
):
"""
Initialize the accelerometer sensor
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions rocketpy/sensors/barometer.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def __init__(
temperature_bias=0,
temperature_scale_factor=0,
name="Barometer",
seed=None,
):
"""
Initialize the barometer sensor
Expand Down Expand Up @@ -132,6 +133,7 @@ def __init__(
temperature_bias=temperature_bias,
temperature_scale_factor=temperature_scale_factor,
name=name,
seed=seed,
)
self.prints = _SensorPrints(self)

Expand Down
17 changes: 8 additions & 9 deletions rocketpy/sensors/gnss_receiver.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -40,6 +38,7 @@ def __init__(
altitude_accuracy=0,
velocity_accuracy=0,
name="GnssReceiver",
seed=None,
):
"""Initialize the Gnss Receiver sensor.

Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand Down
2 changes: 2 additions & 0 deletions rocketpy/sensors/gyroscope.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def __init__(
cross_axis_sensitivity=0,
acceleration_sensitivity=0,
name="Gyroscope",
seed=None,
):
"""
Initialize the gyroscope sensor
Expand Down Expand Up @@ -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"
Expand Down
19 changes: 15 additions & 4 deletions rocketpy/sensors/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ def __init__(
temperature_bias=0,
temperature_scale_factor=0,
name="Sensor",
seed=None,
):
"""
Initialize the accelerometer sensor
Expand Down Expand Up @@ -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)):
Expand Down Expand Up @@ -353,6 +360,7 @@ def __init__(
temperature_scale_factor=0,
cross_axis_sensitivity=0,
name="Sensor",
seed=None,
):
"""
Initialize the accelerometer sensor
Expand Down Expand Up @@ -469,6 +477,7 @@ def __init__(
temperature_scale_factor, "temperature_scale_factor"
),
name=name,
seed=seed,
)

self.orientation = orientation
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -655,6 +664,7 @@ def __init__(
temperature_bias=0,
temperature_scale_factor=0,
name="Sensor",
seed=None,
):
"""
Initialize the accelerometer sensor
Expand Down Expand Up @@ -726,6 +736,7 @@ def __init__(
temperature_bias=temperature_bias,
temperature_scale_factor=temperature_scale_factor,
name=name,
seed=seed,
)

def quantize(self, value):
Expand Down Expand Up @@ -763,15 +774,15 @@ 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
)

# 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
)
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/sensors/sensors_fixtures.py
Comment thread
zuorenchen marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ def noisy_barometer():
operating_temperature=25 + 273.15,
temperature_bias=0.02,
temperature_scale_factor=0.02,
seed=42,
)


Expand Down
99 changes: 99 additions & 0 deletions tests/unit/sensors/test_sensor_seeding.py
Original file line number Diff line number Diff line change
@@ -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)
Loading