Skip to content

Commit 18b9952

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 and draw all noise from a per-instance numpy Generator. 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. A freshly-constructed sensor replays the same sequence, so reproducibility lives at construction rather than in _reset. Adds tests/unit/sensors/test_sensor_seeding.py.
1 parent a6dda6a commit 18b9952

6 files changed

Lines changed: 128 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: 15 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)):
@@ -353,6 +360,7 @@ def __init__(
353360
temperature_scale_factor=0,
354361
cross_axis_sensitivity=0,
355362
name="Sensor",
363+
seed=None,
356364
):
357365
"""
358366
Initialize the accelerometer sensor
@@ -469,6 +477,7 @@ def __init__(
469477
temperature_scale_factor, "temperature_scale_factor"
470478
),
471479
name=name,
480+
seed=seed,
472481
)
473482

474483
self.orientation = orientation
@@ -553,12 +562,12 @@ def apply_noise(self, value):
553562
"""
554563
# white noise
555564
white_noise = Vector(
556-
[np.random.normal(0, self.noise_variance[i] ** 0.5) for i in range(3)]
565+
[self._rng.normal(0, self.noise_variance[i] ** 0.5) for i in range(3)]
557566
) & (self.noise_density * self.sampling_rate**0.5)
558567

559568
# random walk
560569
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)]
570+
[self._rng.normal(0, self.random_walk_variance[i] ** 0.5) for i in range(3)]
562571
) & (self.random_walk_density / self.sampling_rate**0.5)
563572

564573
# add noise
@@ -655,6 +664,7 @@ def __init__(
655664
temperature_bias=0,
656665
temperature_scale_factor=0,
657666
name="Sensor",
667+
seed=None,
658668
):
659669
"""
660670
Initialize the accelerometer sensor
@@ -726,6 +736,7 @@ def __init__(
726736
temperature_bias=temperature_bias,
727737
temperature_scale_factor=temperature_scale_factor,
728738
name=name,
739+
seed=seed,
729740
)
730741

731742
def quantize(self, value):
@@ -763,15 +774,15 @@ def apply_noise(self, value):
763774
"""
764775
# white noise
765776
white_noise = (
766-
np.random.normal(0, self.noise_variance**0.5)
777+
self._rng.normal(0, self.noise_variance**0.5)
767778
* self.noise_density
768779
* self.sampling_rate**0.5
769780
)
770781

771782
# random walk
772783
self._random_walk_drift = (
773784
self._random_walk_drift
774-
+ np.random.normal(0, self.random_walk_variance**0.5)
785+
+ self._rng.normal(0, self.random_walk_variance**0.5)
775786
* self.random_walk_density
776787
/ self.sampling_rate**0.5
777788
)
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_recreating_generator_from_seed_reproduces_sequence():
70+
# Reproducibility comes from the seed: re-creating the generator from the
71+
# same seed (which is what a freshly-constructed sensor does) replays the
72+
# same sequence. The random walk is cumulative, so zeroing it matters too.
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)