Skip to content

Commit ba9d130

Browse files
authored
ENH: seed sensor measurement noise per instance (#1052)
* ENH: seed sensor measurement noise per instance Sensor noise (Accelerometer, Gyroscope, Barometer, GnssReceiver) was drawn from the process-global NumPy RNG, so it could not be seeded or reproduced, and it was unsafe under parallel or forked execution where workers share or reset the global RNG (issue #1042). Thread an optional seed argument through the Sensor base classes. Each sensor now owns a numpy.random.Generator from np.random.default_rng(seed) and draws its white noise and random walk from it instead of np.random. seed=None keeps the noise random but per-instance, so existing behaviour is unchanged unless a seed is given. Also adds tests/unit/sensors/test_sensor_seeding.py covering reproducibility, decorrelation across seeds, independence from the global RNG, and the GnssReceiver path. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> * DOC: add CHANGELOG entry for sensor noise seeding (#1052) Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> * TST: seed the noisy sensor fixtures instead of the global RNG Sensor noise now comes from each sensor's own Generator, so the autouse `_seed_rng` fixture that seeded the global `np.random` no longer made the noisy assertions deterministic; they would flake on CI. Pass `seed=42` to the noisy accelerometer, gyroscope, barometer, and gnss fixtures so their noise is reproducible through the new per-instance seeding, and drop the now-defunct `_seed_rng` fixture. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> * ENH: serialize the sensor seed and document it Address review feedback: document the seed argument in every sensor __init__ (this also fills in the name entry that was missing from Gyroscope's docstring), and carry seed through to_dict/from_dict so it survives serialization, including GnssReceiver's custom to_dict. from_dict reads it with data.get("seed") so dicts saved before this change still load, defaulting to None. Add serialization tests: the seed round-trips through the JSON encoder for every sensor type, and from_dict defaults the seed to None when the key is absent. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> * TST: cover sensor base-class validation and dunders Add tests for the argument-validation error paths (measurement range, orientation, vectorized inputs, export file_format) and the __repr__ / __call__ helpers on Sensor and InertialSensor. These paths were untested; with them rocketpy/sensors reaches full statement coverage. Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --------- Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
1 parent f589ba8 commit ba9d130

10 files changed

Lines changed: 279 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Attention: The newest changes should be on top -->
4444

4545
- ENH: ENH: Refactor flight.py latitude/longitude to use inverted_haversine [#1055](https://github.com/RocketPy-Team/RocketPy/pull/1055)
4646
- ENH: TST: Add unit tests for evaluate_reduced_mass and remove TODO [#1051](https://github.com/RocketPy-Team/RocketPy/pull/1051)
47+
- ENH: seed sensor measurement noise per instance [#1052](https://github.com/RocketPy-Team/RocketPy/pull/1052)
4748
- ENH: FIX: pre release hardening [#1047](https://github.com/RocketPy-Team/RocketPy/pull/1047)
4849
- ENH: DEV: Claude Code ruff hooks (auto-format on edit + pre-push lint guard) [#1046](https://github.com/RocketPy-Team/RocketPy/pull/1046)
4950
- ENH: CI: create a CI for testing docs updates + solve different docs issues [#1045](https://github.com/RocketPy-Team/RocketPy/pull/1045)

rocketpy/sensors/accelerometer.py

Lines changed: 8 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
@@ -169,6 +170,11 @@ def __init__(
169170
acceleration. Default is False.
170171
name : str, optional
171172
The name of the sensor. Default is "Accelerometer".
173+
seed : int, optional
174+
Seed for the random number generator that draws the measurement
175+
noise. If given, the noise becomes reproducible and independent of
176+
the process-global NumPy RNG. Default is None, meaning the noise is
177+
seeded from fresh entropy per instance.
172178
173179
Returns
174180
-------
@@ -193,6 +199,7 @@ def __init__(
193199
temperature_scale_factor=temperature_scale_factor,
194200
cross_axis_sensitivity=cross_axis_sensitivity,
195201
name=name,
202+
seed=seed,
196203
)
197204
self.consider_gravity = consider_gravity
198205
self.prints = _InertialSensorPrints(self)
@@ -299,4 +306,5 @@ def from_dict(cls, data):
299306
cross_axis_sensitivity=data["cross_axis_sensitivity"],
300307
consider_gravity=data["consider_gravity"],
301308
name=data["name"],
309+
seed=data.get("seed"),
302310
)

rocketpy/sensors/barometer.py

Lines changed: 8 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
@@ -110,6 +111,11 @@ def __init__(
110111
meaning no temperature scale factor is applied.
111112
name : str, optional
112113
The name of the sensor. Default is "Barometer".
114+
seed : int, optional
115+
Seed for the random number generator that draws the measurement
116+
noise. If given, the noise becomes reproducible and independent of
117+
the process-global NumPy RNG. Default is None, meaning the noise is
118+
seeded from fresh entropy per instance.
113119
114120
Returns
115121
-------
@@ -132,6 +138,7 @@ def __init__(
132138
temperature_bias=temperature_bias,
133139
temperature_scale_factor=temperature_scale_factor,
134140
name=name,
141+
seed=seed,
135142
)
136143
self.prints = _SensorPrints(self)
137144

@@ -206,4 +213,5 @@ def from_dict(cls, data):
206213
temperature_bias=data["temperature_bias"],
207214
temperature_scale_factor=data["temperature_scale_factor"],
208215
name=data["name"],
216+
seed=data.get("seed"),
209217
)

rocketpy/sensors/gnss_receiver.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import math
22

3-
import numpy as np
4-
53
from rocketpy.tools import inverted_haversine
64

75
from ..mathutils.vector_matrix import Matrix, Vector
@@ -40,6 +38,7 @@ def __init__(
4038
position_accuracy=0,
4139
altitude_accuracy=0,
4240
name="GnssReceiver",
41+
seed=None,
4342
):
4443
"""Initialize the Gnss Receiver sensor.
4544
@@ -55,8 +54,13 @@ def __init__(
5554
position in meters. Default is 0.
5655
name : str
5756
The name of the sensor. Default is "GnssReceiver".
57+
seed : int, optional
58+
Seed for the random number generator that draws the measurement
59+
noise. If given, the noise becomes reproducible and independent of
60+
the process-global NumPy RNG. Default is None, meaning the noise is
61+
seeded from fresh entropy per instance.
5862
"""
59-
super().__init__(sampling_rate=sampling_rate, name=name)
63+
super().__init__(sampling_rate=sampling_rate, name=name, seed=seed)
6064
self.position_accuracy = position_accuracy
6165
self.altitude_accuracy = altitude_accuracy
6266

@@ -90,9 +94,9 @@ def measure(self, time, **kwargs):
9094
# Get from state u and add relative position
9195
x, y, z = (Matrix.transformation(u[6:10]) @ relative_position) + Vector(u[0:3])
9296
# Apply accuracy to the position
93-
x = np.random.normal(x, self.position_accuracy)
94-
y = np.random.normal(y, self.position_accuracy)
95-
altitude = np.random.normal(z, self.altitude_accuracy)
97+
x = self._rng.normal(x, self.position_accuracy)
98+
y = self._rng.normal(y, self.position_accuracy)
99+
altitude = self._rng.normal(z, self.altitude_accuracy)
96100

97101
# Convert x and y to latitude and longitude
98102
drift = (x**2 + y**2) ** 0.5
@@ -131,6 +135,7 @@ def to_dict(self, **kwargs):
131135
"position_accuracy": self.position_accuracy,
132136
"altitude_accuracy": self.altitude_accuracy,
133137
"name": self.name,
138+
"seed": self._seed,
134139
}
135140

136141
@classmethod
@@ -140,4 +145,5 @@ def from_dict(cls, data):
140145
position_accuracy=data["position_accuracy"],
141146
altitude_accuracy=data["altitude_accuracy"],
142147
name=data["name"],
148+
seed=data.get("seed"),
143149
)

rocketpy/sensors/gyroscope.py

Lines changed: 10 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
@@ -169,6 +170,13 @@ def __init__(
169170
float or int is given, the same sensitivity is applied to all axes.
170171
The values of each axis can be set individually by passing a list of
171172
length 3.
173+
name : str, optional
174+
The name of the sensor. Default is "Gyroscope".
175+
seed : int, optional
176+
Seed for the random number generator that draws the measurement
177+
noise. If given, the noise becomes reproducible and independent of
178+
the process-global NumPy RNG. Default is None, meaning the noise is
179+
seeded from fresh entropy per instance.
172180
173181
Returns
174182
-------
@@ -193,6 +201,7 @@ def __init__(
193201
temperature_scale_factor=temperature_scale_factor,
194202
cross_axis_sensitivity=cross_axis_sensitivity,
195203
name=name,
204+
seed=seed,
196205
)
197206
self.acceleration_sensitivity = self._vectorize_input(
198207
acceleration_sensitivity, "acceleration_sensitivity"
@@ -320,4 +329,5 @@ def from_dict(cls, data):
320329
cross_axis_sensitivity=data["cross_axis_sensitivity"],
321330
acceleration_sensitivity=data["acceleration_sensitivity"],
322331
name=data["name"],
332+
seed=data.get("seed"),
323333
)

rocketpy/sensors/sensor.py

Lines changed: 33 additions & 4 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="Sensor",
65+
seed=None,
6566
):
6667
"""
6768
Initialize the accelerometer sensor
@@ -111,6 +112,11 @@ def __init__(
111112
meaning no temperature scale factor is applied.
112113
name : str, optional
113114
The name of the sensor. Default is "Sensor".
115+
seed : int, optional
116+
Seed for the random number generator that draws the measurement
117+
noise. If given, the noise becomes reproducible and independent of
118+
the process-global NumPy RNG. Default is None, meaning the noise is
119+
seeded from fresh entropy per instance.
114120
115121
Returns
116122
-------
@@ -145,6 +151,14 @@ def __init__(
145151
self._random_walk_drift = 0
146152
self.normal_vector = Vector([0, 0, 0])
147153

154+
# Per-instance RNG, seeded deterministically when a seed is given, so
155+
# the measurement noise is reproducible and independent of the
156+
# process-global NumPy RNG (and therefore safe under parallel or
157+
# forked evaluation). seed=None keeps the noise random but still
158+
# drawn from this instance's generator.
159+
self._seed = seed
160+
self._rng = np.random.default_rng(seed)
161+
148162
# handle measurement range
149163
if isinstance(measurement_range, (tuple, list)):
150164
if len(measurement_range) != 2:
@@ -291,6 +305,7 @@ def to_dict(self, **kwargs):
291305
"temperature_bias": self.temperature_bias,
292306
"temperature_scale_factor": self.temperature_scale_factor,
293307
"name": self.name,
308+
"seed": self._seed,
294309
}
295310

296311

@@ -358,6 +373,7 @@ def __init__(
358373
temperature_scale_factor=0,
359374
cross_axis_sensitivity=0,
360375
name="Sensor",
376+
seed=None,
361377
):
362378
"""
363379
Initialize the accelerometer sensor
@@ -444,6 +460,11 @@ def __init__(
444460
no cross-axis sensitivity is applied.
445461
name : str, optional
446462
The name of the sensor. Default is "Sensor".
463+
seed : int, optional
464+
Seed for the random number generator that draws the measurement
465+
noise. If given, the noise becomes reproducible and independent of
466+
the process-global NumPy RNG. Default is None, meaning the noise is
467+
seeded from fresh entropy per instance.
447468
448469
Returns
449470
-------
@@ -474,6 +495,7 @@ def __init__(
474495
temperature_scale_factor, "temperature_scale_factor"
475496
),
476497
name=name,
498+
seed=seed,
477499
)
478500

479501
self.orientation = orientation
@@ -558,12 +580,12 @@ def apply_noise(self, value):
558580
"""
559581
# white noise
560582
white_noise = Vector(
561-
[np.random.normal(0, self.noise_variance[i] ** 0.5) for i in range(3)]
583+
[self._rng.normal(0, self.noise_variance[i] ** 0.5) for i in range(3)]
562584
) & (self.noise_density * self.sampling_rate**0.5)
563585

564586
# random walk
565587
self._random_walk_drift = self._random_walk_drift + Vector(
566-
[np.random.normal(0, self.random_walk_variance[i] ** 0.5) for i in range(3)]
588+
[self._rng.normal(0, self.random_walk_variance[i] ** 0.5) for i in range(3)]
567589
) & (self.random_walk_density / self.sampling_rate**0.5)
568590

569591
# add noise
@@ -660,6 +682,7 @@ def __init__(
660682
temperature_bias=0,
661683
temperature_scale_factor=0,
662684
name="Sensor",
685+
seed=None,
663686
):
664687
"""
665688
Initialize the accelerometer sensor
@@ -709,6 +732,11 @@ def __init__(
709732
meaning no temperature scale factor is applied.
710733
name : str, optional
711734
The name of the sensor. Default is "Sensor".
735+
seed : int, optional
736+
Seed for the random number generator that draws the measurement
737+
noise. If given, the noise becomes reproducible and independent of
738+
the process-global NumPy RNG. Default is None, meaning the noise is
739+
seeded from fresh entropy per instance.
712740
713741
Returns
714742
-------
@@ -731,6 +759,7 @@ def __init__(
731759
temperature_bias=temperature_bias,
732760
temperature_scale_factor=temperature_scale_factor,
733761
name=name,
762+
seed=seed,
734763
)
735764

736765
def quantize(self, value):
@@ -768,15 +797,15 @@ def apply_noise(self, value):
768797
"""
769798
# white noise
770799
white_noise = (
771-
np.random.normal(0, self.noise_variance**0.5)
800+
self._rng.normal(0, self.noise_variance**0.5)
772801
* self.noise_density
773802
* self.sampling_rate**0.5
774803
)
775804

776805
# random walk
777806
self._random_walk_drift = (
778807
self._random_walk_drift
779-
+ np.random.normal(0, self.random_walk_variance**0.5)
808+
+ self._rng.normal(0, self.random_walk_variance**0.5)
780809
* self.random_walk_density
781810
/ self.sampling_rate**0.5
782811
)

tests/fixtures/sensors/sensors_fixtures.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def noisy_rotated_accelerometer():
2424
cross_axis_sensitivity=0.5,
2525
consider_gravity=True,
2626
name="Accelerometer",
27+
seed=42,
2728
)
2829

2930

@@ -46,6 +47,7 @@ def noisy_rotated_gyroscope():
4647
cross_axis_sensitivity=0.5,
4748
acceleration_sensitivity=[0, 0.0008, 0.0017],
4849
name="Gyroscope",
50+
seed=42,
4951
)
5052

5153

@@ -62,6 +64,7 @@ def noisy_barometer():
6264
operating_temperature=25 + 273.15,
6365
temperature_bias=0.02,
6466
temperature_scale_factor=0.02,
67+
seed=42,
6568
)
6669

6770

@@ -71,6 +74,7 @@ def noisy_gnss():
7174
sampling_rate=1,
7275
position_accuracy=1,
7376
altitude_accuracy=1,
77+
seed=42,
7478
)
7579

7680

tests/unit/sensors/test_sensor.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,6 @@
99
from rocketpy.tools import euler313_to_quaternions
1010

1111

12-
@pytest.fixture(autouse=True)
13-
def _seed_rng():
14-
"""Seed NumPy's global RNG before each test so that the noise-based
15-
sensor measurements are deterministic. Without this, tests such as
16-
``test_noisy_barometer`` are flaky because the random white noise can
17-
occasionally exceed the assertion tolerance."""
18-
np.random.seed(42)
19-
20-
2112
# calisto standard simulation no wind solution index 200
2213
TIME = 3.338513236767685
2314
U = [

0 commit comments

Comments
 (0)