Skip to content

Commit c7ff674

Browse files
committed
ENH: seed sensor measurement noise per instance
Sensor noise (white noise and random-walk bias drift in InertialSensor and ScalarSensor, plus the GNSS position/velocity accuracy) was drawn from the global numpy RNG. That made it impossible to reproduce from a seed, and unsafe under multiprocess where workers share the global state. Add a seed argument to the sensor constructors, draw all noise from a per-instance numpy Generator, and re-create it in _reset so each flight replays the same sequence from the same seed. seed=None keeps the random-by-default behaviour but per instance, so it no longer touches the global RNG, matching how StochasticModel and MonteCarlo already seed. Adds tests/unit/sensors/test_sensor_seeding.py.
1 parent a6dda6a commit c7ff674

6 files changed

Lines changed: 131 additions & 11 deletions

File tree

rocketpy/sensors/accelerometer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def __init__(
7878
cross_axis_sensitivity=0,
7979
consider_gravity=False,
8080
name="Accelerometer",
81+
seed=None,
8182
):
8283
"""
8384
Initialize the accelerometer sensor
@@ -193,6 +194,7 @@ def __init__(
193194
temperature_scale_factor=temperature_scale_factor,
194195
cross_axis_sensitivity=cross_axis_sensitivity,
195196
name=name,
197+
seed=seed,
196198
)
197199
self.consider_gravity = consider_gravity
198200
self.prints = _InertialSensorPrints(self)

rocketpy/sensors/barometer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def __init__(
6262
temperature_bias=0,
6363
temperature_scale_factor=0,
6464
name="Barometer",
65+
seed=None,
6566
):
6667
"""
6768
Initialize the barometer sensor
@@ -132,6 +133,7 @@ def __init__(
132133
temperature_bias=temperature_bias,
133134
temperature_scale_factor=temperature_scale_factor,
134135
name=name,
136+
seed=seed,
135137
)
136138
self.prints = _SensorPrints(self)
137139

rocketpy/sensors/gnss_receiver.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def __init__(
4040
altitude_accuracy=0,
4141
velocity_accuracy=0,
4242
name="GnssReceiver",
43+
seed=None,
4344
):
4445
"""Initialize the Gnss Receiver sensor.
4546
@@ -59,7 +60,7 @@ def __init__(
5960
name : str
6061
The name of the sensor. Default is "GnssReceiver".
6162
"""
62-
super().__init__(sampling_rate=sampling_rate, name=name)
63+
super().__init__(sampling_rate=sampling_rate, name=name, seed=seed)
6364
self.position_accuracy = position_accuracy
6465
self.altitude_accuracy = altitude_accuracy
6566
self.velocity_accuracy = velocity_accuracy
@@ -96,12 +97,12 @@ def measure(self, time, **kwargs):
9697
) + Vector(u[3:6])
9798

9899
# Apply accuracy to the position
99-
x = np.random.normal(x, self.position_accuracy)
100-
y = np.random.normal(y, self.position_accuracy)
101-
z = np.random.normal(z, self.altitude_accuracy)
102-
vx = np.random.normal(vx, self.velocity_accuracy)
103-
vy = np.random.normal(vy, self.velocity_accuracy)
104-
vz = np.random.normal(vz, self.velocity_accuracy)
100+
x = self._rng.normal(x, self.position_accuracy)
101+
y = self._rng.normal(y, self.position_accuracy)
102+
z = self._rng.normal(z, self.altitude_accuracy)
103+
vx = self._rng.normal(vx, self.velocity_accuracy)
104+
vy = self._rng.normal(vy, self.velocity_accuracy)
105+
vz = self._rng.normal(vz, self.velocity_accuracy)
105106

106107
self.measurement = (x, y, z, vx, vy, vz)
107108
self._save_data((time, *self.measurement))

rocketpy/sensors/gyroscope.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def __init__(
7878
cross_axis_sensitivity=0,
7979
acceleration_sensitivity=0,
8080
name="Gyroscope",
81+
seed=None,
8182
):
8283
"""
8384
Initialize the gyroscope sensor
@@ -193,6 +194,7 @@ def __init__(
193194
temperature_scale_factor=temperature_scale_factor,
194195
cross_axis_sensitivity=cross_axis_sensitivity,
195196
name=name,
197+
seed=seed,
196198
)
197199
self.acceleration_sensitivity = self._vectorize_input(
198200
acceleration_sensitivity, "acceleration_sensitivity"

rocketpy/sensors/sensor.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def __init__(
5959
temperature_bias=0,
6060
temperature_scale_factor=0,
6161
name="Sensor",
62+
seed=None,
6263
):
6364
"""
6465
Initialize the accelerometer sensor
@@ -141,6 +142,12 @@ def __init__(
141142
self._save_data = self._save_data_single
142143
self._random_walk_drift = 0
143144
self.normal_vector = Vector([0, 0, 0])
145+
# Per-instance RNG seeded deterministically (from the scenario seed when
146+
# provided) so sensor noise is reproducible and independent of the
147+
# process-global numpy RNG -- and therefore safe under parallel/forked
148+
# evaluation. ``seed=None`` keeps the noise random but still per-instance.
149+
self._seed = seed
150+
self._rng = np.random.default_rng(seed)
144151

145152
# handle measurement range
146153
if isinstance(measurement_range, (tuple, list)):
@@ -163,6 +170,9 @@ def __call__(self, *args, **kwargs):
163170

164171
def _reset(self, simulated_rocket):
165172
"""Reset the sensor data for a new simulation."""
173+
# Re-seed so each flight reproduces the same noise sequence from the same
174+
# seed, regardless of how many draws a previous flight consumed.
175+
self._rng = np.random.default_rng(self._seed)
166176
self._random_walk_drift = (
167177
Vector([0, 0, 0]) if isinstance(self._random_walk_drift, Vector) else 0
168178
)
@@ -353,6 +363,7 @@ def __init__(
353363
temperature_scale_factor=0,
354364
cross_axis_sensitivity=0,
355365
name="Sensor",
366+
seed=None,
356367
):
357368
"""
358369
Initialize the accelerometer sensor
@@ -469,6 +480,7 @@ def __init__(
469480
temperature_scale_factor, "temperature_scale_factor"
470481
),
471482
name=name,
483+
seed=seed,
472484
)
473485

474486
self.orientation = orientation
@@ -553,12 +565,12 @@ def apply_noise(self, value):
553565
"""
554566
# white noise
555567
white_noise = Vector(
556-
[np.random.normal(0, self.noise_variance[i] ** 0.5) for i in range(3)]
568+
[self._rng.normal(0, self.noise_variance[i] ** 0.5) for i in range(3)]
557569
) & (self.noise_density * self.sampling_rate**0.5)
558570

559571
# random walk
560572
self._random_walk_drift = self._random_walk_drift + Vector(
561-
[np.random.normal(0, self.random_walk_variance[i] ** 0.5) for i in range(3)]
573+
[self._rng.normal(0, self.random_walk_variance[i] ** 0.5) for i in range(3)]
562574
) & (self.random_walk_density / self.sampling_rate**0.5)
563575

564576
# add noise
@@ -655,6 +667,7 @@ def __init__(
655667
temperature_bias=0,
656668
temperature_scale_factor=0,
657669
name="Sensor",
670+
seed=None,
658671
):
659672
"""
660673
Initialize the accelerometer sensor
@@ -726,6 +739,7 @@ def __init__(
726739
temperature_bias=temperature_bias,
727740
temperature_scale_factor=temperature_scale_factor,
728741
name=name,
742+
seed=seed,
729743
)
730744

731745
def quantize(self, value):
@@ -763,15 +777,15 @@ def apply_noise(self, value):
763777
"""
764778
# white noise
765779
white_noise = (
766-
np.random.normal(0, self.noise_variance**0.5)
780+
self._rng.normal(0, self.noise_variance**0.5)
767781
* self.noise_density
768782
* self.sampling_rate**0.5
769783
)
770784

771785
# random walk
772786
self._random_walk_drift = (
773787
self._random_walk_drift
774-
+ np.random.normal(0, self.random_walk_variance**0.5)
788+
+ self._rng.normal(0, self.random_walk_variance**0.5)
775789
* self.random_walk_density
776790
/ self.sampling_rate**0.5
777791
)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
"""Determinism tests for seeded sensor noise (additive, isolated).
2+
3+
Sensor noise is drawn from a per-instance ``numpy.random.Generator`` seeded
4+
deterministically, instead of the process-global ``numpy.random``. This makes a
5+
seed-reproducible run (e.g. a judged competition) yield the identical sensor
6+
noise for a given seed, regardless of the global RNG state or parallel/forked
7+
execution -- mirroring the seeding the Monte Carlo layer already uses
8+
(``stochastic_model.py`` / ``monte_carlo.py``).
9+
10+
The tests construct sensors directly and do not touch the existing fixtures or
11+
inherited tests. Sensor noise is sampled on a fixed time grid (not per adaptive
12+
solver step), so a fixed number of sequential draws is a faithful stand-in for a
13+
flight of a given duration.
14+
"""
15+
16+
import numpy as np
17+
18+
from rocketpy.mathutils.vector_matrix import Vector
19+
from rocketpy.sensors.accelerometer import Accelerometer
20+
from rocketpy.sensors.gnss_receiver import GnssReceiver
21+
22+
23+
def _accelerometer(seed):
24+
# Non-zero white noise + random walk so the draws actually exercise the RNG.
25+
return Accelerometer(
26+
sampling_rate=10,
27+
noise_density=1.0,
28+
noise_variance=1.0,
29+
random_walk_density=0.5,
30+
random_walk_variance=1.0,
31+
seed=seed,
32+
)
33+
34+
35+
def _noise_sequence(sensor, n=16):
36+
return [tuple(sensor.apply_noise(Vector([0.0, 0.0, 0.0]))) for _ in range(n)]
37+
38+
39+
def test_same_seed_is_reproducible():
40+
assert _noise_sequence(_accelerometer(42)) == _noise_sequence(_accelerometer(42))
41+
42+
43+
def test_different_seeds_decorrelate():
44+
assert _noise_sequence(_accelerometer(1)) != _noise_sequence(_accelerometer(2))
45+
46+
47+
def test_noise_independent_of_global_numpy_rng():
48+
# Perturbing the process-global RNG must NOT change a seeded sensor's noise.
49+
# This is the regression guard for the original bug (noise drawn from the
50+
# global ``np.random``).
51+
np.random.seed(0)
52+
first = _noise_sequence(_accelerometer(7))
53+
np.random.seed(999)
54+
_ = [np.random.random() for _ in range(1000)]
55+
second = _noise_sequence(_accelerometer(7))
56+
assert first == second
57+
58+
59+
def test_seeded_sensor_does_not_consume_global_rng():
60+
# A seeded sensor draws only from its own generator, leaving the global RNG
61+
# position untouched -- so it cannot contaminate other code or parallel envs.
62+
np.random.seed(0)
63+
position_before = np.random.get_state()[2]
64+
_noise_sequence(_accelerometer(7))
65+
position_after = np.random.get_state()[2]
66+
assert position_before == position_after
67+
68+
69+
def test_reseed_reproduces_sequence():
70+
# The per-flight ``_reset`` hook re-seeds the generator; reproduce that and
71+
# confirm the sequence restarts identically. The random walk is cumulative,
72+
# so re-seeding (and zeroing the drift) is load-bearing for reproducibility.
73+
sensor = _accelerometer(99)
74+
first = _noise_sequence(sensor)
75+
sensor._rng = np.random.default_rng(sensor._seed)
76+
sensor._random_walk_drift = Vector([0.0, 0.0, 0.0])
77+
assert _noise_sequence(sensor) == first
78+
79+
80+
def _gnss_sequence(seed, n=8):
81+
gnss = GnssReceiver(
82+
sampling_rate=1,
83+
position_accuracy=5.0,
84+
altitude_accuracy=5.0,
85+
velocity_accuracy=1.0,
86+
seed=seed,
87+
)
88+
# Minimal launch-frame state: 100 m up, identity attitude quaternion.
89+
state = np.array([0, 0, 100, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0], dtype=float)
90+
measurements = []
91+
for _ in range(n):
92+
gnss.measure(0.0, u=state, relative_position=Vector([0.0, 0.0, 0.0]))
93+
measurements.append(gnss.measurement)
94+
return measurements
95+
96+
97+
def test_gnss_is_seeded_and_reproducible():
98+
assert _gnss_sequence(5) == _gnss_sequence(5)
99+
assert _gnss_sequence(5) != _gnss_sequence(6)

0 commit comments

Comments
 (0)