From b4a970c310a2f9addb8df4122594cac587587d79 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:53:33 +0800 Subject: [PATCH 1/5] 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> --- rocketpy/sensors/accelerometer.py | 2 + rocketpy/sensors/barometer.py | 2 + rocketpy/sensors/gnss_receiver.py | 11 ++- rocketpy/sensors/gyroscope.py | 2 + rocketpy/sensors/sensor.py | 21 ++++- tests/unit/sensors/test_sensor_seeding.py | 93 +++++++++++++++++++++++ 6 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 tests/unit/sensors/test_sensor_seeding.py 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 548f9c879..64ffc4b52 100644 --- a/rocketpy/sensors/gnss_receiver.py +++ b/rocketpy/sensors/gnss_receiver.py @@ -1,7 +1,5 @@ import math -import numpy as np - from rocketpy.tools import inverted_haversine from ..mathutils.vector_matrix import Matrix, Vector @@ -40,6 +38,7 @@ def __init__( position_accuracy=0, altitude_accuracy=0, name="GnssReceiver", + seed=None, ): """Initialize the Gnss Receiver sensor. @@ -56,7 +55,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 @@ -90,9 +89,9 @@ def measure(self, time, **kwargs): # Get from state u and add relative position x, y, z = (Matrix.transformation(u[6:10]) @ relative_position) + Vector(u[0:3]) # Apply accuracy to the position - x = np.random.normal(x, self.position_accuracy) - y = np.random.normal(y, self.position_accuracy) - altitude = np.random.normal(z, self.altitude_accuracy) + x = self._rng.normal(x, self.position_accuracy) + y = self._rng.normal(y, self.position_accuracy) + altitude = self._rng.normal(z, self.altitude_accuracy) # Convert x and y to latitude and longitude drift = (x**2 + y**2) ** 0.5 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 137505272..42666aa28 100644 --- a/rocketpy/sensors/sensor.py +++ b/rocketpy/sensors/sensor.py @@ -62,6 +62,7 @@ def __init__( temperature_bias=0, temperature_scale_factor=0, name="Sensor", + seed=None, ): """ Initialize the accelerometer sensor @@ -145,6 +146,14 @@ def __init__( self._random_walk_drift = 0 self.normal_vector = Vector([0, 0, 0]) + # Per-instance RNG, seeded deterministically when a seed is given, so + # the measurement noise is reproducible and independent of the + # process-global NumPy RNG (and therefore safe under parallel or + # forked evaluation). seed=None keeps the noise random but still + # drawn from this instance's generator. + self._seed = seed + self._rng = np.random.default_rng(seed) + # handle measurement range if isinstance(measurement_range, (tuple, list)): if len(measurement_range) != 2: @@ -358,6 +367,7 @@ def __init__( temperature_scale_factor=0, cross_axis_sensitivity=0, name="Sensor", + seed=None, ): """ Initialize the accelerometer sensor @@ -474,6 +484,7 @@ def __init__( temperature_scale_factor, "temperature_scale_factor" ), name=name, + seed=seed, ) self.orientation = orientation @@ -558,12 +569,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 @@ -660,6 +671,7 @@ def __init__( temperature_bias=0, temperature_scale_factor=0, name="Sensor", + seed=None, ): """ Initialize the accelerometer sensor @@ -731,6 +743,7 @@ def __init__( temperature_bias=temperature_bias, temperature_scale_factor=temperature_scale_factor, name=name, + seed=seed, ) def quantize(self, value): @@ -768,7 +781,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 ) @@ -776,7 +789,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/unit/sensors/test_sensor_seeding.py b/tests/unit/sensors/test_sensor_seeding.py new file mode 100644 index 000000000..43a627aba --- /dev/null +++ b/tests/unit/sensors/test_sensor_seeding.py @@ -0,0 +1,93 @@ +"""Determinism tests for seeded sensor noise (additive, isolated). + +Sensor measurement noise is drawn from a per-instance ``numpy.random.Generator`` +created from the new ``seed`` argument, instead of the process-global +``numpy.random``. A seed makes the noise reproducible for a given input and keeps +it independent of the global RNG state, so it stays deterministic under parallel +or forked execution. This addresses #1042. + +The tests build sensors directly and do not touch the existing fixtures or the +inherited tests. Noise is sampled on a fixed grid, so a fixed number of +sequential draws is a faithful stand-in for a run of a given length. +""" + +from types import SimpleNamespace + +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 and 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 a forked worker. + 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 _gnss_measurements(seed, n=8): + gnss = GnssReceiver( + sampling_rate=1, + position_accuracy=5.0, + altitude_accuracy=5.0, + seed=seed, + ) + # Minimal launch-frame state: 100 m up with an identity attitude quaternion. + state = np.array([0, 0, 100, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0], dtype=float) + environment = SimpleNamespace(latitude=0.0, longitude=0.0, earth_radius=6.371e6) + measurements = [] + for _ in range(n): + gnss.measure( + 0.0, + u=state, + relative_position=Vector([0.0, 0.0, 0.0]), + environment=environment, + ) + measurements.append(gnss.measurement) + return measurements + + +def test_gnss_noise_is_seeded_and_reproducible(): + assert _gnss_measurements(5) == _gnss_measurements(5) + assert _gnss_measurements(5) != _gnss_measurements(6) From b4019933fe0612048c1eb710567ea034ff2438bf Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:56:26 +0800 Subject: [PATCH 2/5] DOC: add CHANGELOG entry for sensor noise seeding (#1052) Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com> --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf574c883..c3a117008 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Attention: The newest changes should be on top --> - ENH: ENH: Refactor flight.py latitude/longitude to use inverted_haversine [#1055](https://github.com/RocketPy-Team/RocketPy/pull/1055) - ENH: TST: Add unit tests for evaluate_reduced_mass and remove TODO [#1051](https://github.com/RocketPy-Team/RocketPy/pull/1051) +- ENH: seed sensor measurement noise per instance [#1052](https://github.com/RocketPy-Team/RocketPy/pull/1052) - ENH: FIX: pre release hardening [#1047](https://github.com/RocketPy-Team/RocketPy/pull/1047) - ENH: DEV: Claude Code ruff hooks (auto-format on edit + pre-push lint guard) [#1046](https://github.com/RocketPy-Team/RocketPy/pull/1046) - ENH: CI: create a CI for testing docs updates + solve different docs issues [#1045](https://github.com/RocketPy-Team/RocketPy/pull/1045) From ba4a7cc80eedc42693441b20a8be32f840576407 Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Tue, 7 Jul 2026 06:46:32 +0800 Subject: [PATCH 3/5] 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> --- tests/fixtures/sensors/sensors_fixtures.py | 4 ++++ tests/unit/sensors/test_sensor.py | 9 --------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/fixtures/sensors/sensors_fixtures.py b/tests/fixtures/sensors/sensors_fixtures.py index 1d01a59c3..aa515c808 100644 --- a/tests/fixtures/sensors/sensors_fixtures.py +++ b/tests/fixtures/sensors/sensors_fixtures.py @@ -24,6 +24,7 @@ def noisy_rotated_accelerometer(): cross_axis_sensitivity=0.5, consider_gravity=True, name="Accelerometer", + seed=42, ) @@ -46,6 +47,7 @@ def noisy_rotated_gyroscope(): cross_axis_sensitivity=0.5, acceleration_sensitivity=[0, 0.0008, 0.0017], name="Gyroscope", + seed=42, ) @@ -62,6 +64,7 @@ def noisy_barometer(): operating_temperature=25 + 273.15, temperature_bias=0.02, temperature_scale_factor=0.02, + seed=42, ) @@ -71,6 +74,7 @@ def noisy_gnss(): sampling_rate=1, position_accuracy=1, altitude_accuracy=1, + seed=42, ) diff --git a/tests/unit/sensors/test_sensor.py b/tests/unit/sensors/test_sensor.py index 7273282dc..2601b4a6b 100644 --- a/tests/unit/sensors/test_sensor.py +++ b/tests/unit/sensors/test_sensor.py @@ -9,15 +9,6 @@ from rocketpy.tools import euler313_to_quaternions -@pytest.fixture(autouse=True) -def _seed_rng(): - """Seed NumPy's global RNG before each test so that the noise-based - sensor measurements are deterministic. Without this, tests such as - ``test_noisy_barometer`` are flaky because the random white noise can - occasionally exceed the assertion tolerance.""" - np.random.seed(42) - - # calisto standard simulation no wind solution index 200 TIME = 3.338513236767685 U = [ From 300e96e28992580e7851d4326026888e421bb69b Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:04:44 +0800 Subject: [PATCH 4/5] 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> --- rocketpy/sensors/accelerometer.py | 6 +++ rocketpy/sensors/barometer.py | 6 +++ rocketpy/sensors/gnss_receiver.py | 7 ++++ rocketpy/sensors/gyroscope.py | 8 ++++ rocketpy/sensors/sensor.py | 16 ++++++++ tests/unit/sensors/test_sensor_seeding.py | 48 +++++++++++++++++++++++ 6 files changed, 91 insertions(+) diff --git a/rocketpy/sensors/accelerometer.py b/rocketpy/sensors/accelerometer.py index a6ee7b27a..b6a477c11 100644 --- a/rocketpy/sensors/accelerometer.py +++ b/rocketpy/sensors/accelerometer.py @@ -170,6 +170,11 @@ def __init__( acceleration. Default is False. name : str, optional The name of the sensor. Default is "Accelerometer". + seed : int, optional + Seed for the random number generator that draws the measurement + noise. If given, the noise becomes reproducible and independent of + the process-global NumPy RNG. Default is None, meaning the noise is + seeded from fresh entropy per instance. Returns ------- @@ -301,4 +306,5 @@ def from_dict(cls, data): cross_axis_sensitivity=data["cross_axis_sensitivity"], consider_gravity=data["consider_gravity"], name=data["name"], + seed=data.get("seed"), ) diff --git a/rocketpy/sensors/barometer.py b/rocketpy/sensors/barometer.py index 9a41df893..afb6c09eb 100644 --- a/rocketpy/sensors/barometer.py +++ b/rocketpy/sensors/barometer.py @@ -111,6 +111,11 @@ def __init__( meaning no temperature scale factor is applied. name : str, optional The name of the sensor. Default is "Barometer". + seed : int, optional + Seed for the random number generator that draws the measurement + noise. If given, the noise becomes reproducible and independent of + the process-global NumPy RNG. Default is None, meaning the noise is + seeded from fresh entropy per instance. Returns ------- @@ -208,4 +213,5 @@ def from_dict(cls, data): temperature_bias=data["temperature_bias"], temperature_scale_factor=data["temperature_scale_factor"], name=data["name"], + seed=data.get("seed"), ) diff --git a/rocketpy/sensors/gnss_receiver.py b/rocketpy/sensors/gnss_receiver.py index 64ffc4b52..09a064157 100644 --- a/rocketpy/sensors/gnss_receiver.py +++ b/rocketpy/sensors/gnss_receiver.py @@ -54,6 +54,11 @@ def __init__( position in meters. Default is 0. name : str The name of the sensor. Default is "GnssReceiver". + seed : int, optional + Seed for the random number generator that draws the measurement + noise. If given, the noise becomes reproducible and independent of + the process-global NumPy RNG. Default is None, meaning the noise is + seeded from fresh entropy per instance. """ super().__init__(sampling_rate=sampling_rate, name=name, seed=seed) self.position_accuracy = position_accuracy @@ -130,6 +135,7 @@ def to_dict(self, **kwargs): "position_accuracy": self.position_accuracy, "altitude_accuracy": self.altitude_accuracy, "name": self.name, + "seed": self._seed, } @classmethod @@ -139,4 +145,5 @@ def from_dict(cls, data): position_accuracy=data["position_accuracy"], altitude_accuracy=data["altitude_accuracy"], name=data["name"], + seed=data.get("seed"), ) diff --git a/rocketpy/sensors/gyroscope.py b/rocketpy/sensors/gyroscope.py index 942411d8c..8d4169a19 100644 --- a/rocketpy/sensors/gyroscope.py +++ b/rocketpy/sensors/gyroscope.py @@ -170,6 +170,13 @@ def __init__( float or int is given, the same sensitivity is applied to all axes. The values of each axis can be set individually by passing a list of length 3. + name : str, optional + The name of the sensor. Default is "Gyroscope". + seed : int, optional + Seed for the random number generator that draws the measurement + noise. If given, the noise becomes reproducible and independent of + the process-global NumPy RNG. Default is None, meaning the noise is + seeded from fresh entropy per instance. Returns ------- @@ -322,4 +329,5 @@ def from_dict(cls, data): cross_axis_sensitivity=data["cross_axis_sensitivity"], acceleration_sensitivity=data["acceleration_sensitivity"], name=data["name"], + seed=data.get("seed"), ) diff --git a/rocketpy/sensors/sensor.py b/rocketpy/sensors/sensor.py index 42666aa28..0879d903a 100644 --- a/rocketpy/sensors/sensor.py +++ b/rocketpy/sensors/sensor.py @@ -112,6 +112,11 @@ def __init__( meaning no temperature scale factor is applied. name : str, optional The name of the sensor. Default is "Sensor". + seed : int, optional + Seed for the random number generator that draws the measurement + noise. If given, the noise becomes reproducible and independent of + the process-global NumPy RNG. Default is None, meaning the noise is + seeded from fresh entropy per instance. Returns ------- @@ -300,6 +305,7 @@ def to_dict(self, **kwargs): "temperature_bias": self.temperature_bias, "temperature_scale_factor": self.temperature_scale_factor, "name": self.name, + "seed": self._seed, } @@ -454,6 +460,11 @@ def __init__( no cross-axis sensitivity is applied. name : str, optional The name of the sensor. Default is "Sensor". + seed : int, optional + Seed for the random number generator that draws the measurement + noise. If given, the noise becomes reproducible and independent of + the process-global NumPy RNG. Default is None, meaning the noise is + seeded from fresh entropy per instance. Returns ------- @@ -721,6 +732,11 @@ def __init__( meaning no temperature scale factor is applied. name : str, optional The name of the sensor. Default is "Sensor". + seed : int, optional + Seed for the random number generator that draws the measurement + noise. If given, the noise becomes reproducible and independent of + the process-global NumPy RNG. Default is None, meaning the noise is + seeded from fresh entropy per instance. Returns ------- diff --git a/tests/unit/sensors/test_sensor_seeding.py b/tests/unit/sensors/test_sensor_seeding.py index 43a627aba..d8474d317 100644 --- a/tests/unit/sensors/test_sensor_seeding.py +++ b/tests/unit/sensors/test_sensor_seeding.py @@ -11,13 +11,17 @@ sequential draws is a faithful stand-in for a run of a given length. """ +import json from types import SimpleNamespace import numpy as np +from rocketpy._encoders import RocketPyEncoder from rocketpy.mathutils.vector_matrix import Vector from rocketpy.sensors.accelerometer import Accelerometer +from rocketpy.sensors.barometer import Barometer from rocketpy.sensors.gnss_receiver import GnssReceiver +from rocketpy.sensors.gyroscope import Gyroscope def _accelerometer(seed): @@ -91,3 +95,47 @@ def _gnss_measurements(seed, n=8): def test_gnss_noise_is_seeded_and_reproducible(): assert _gnss_measurements(5) == _gnss_measurements(5) assert _gnss_measurements(5) != _gnss_measurements(6) + + +def test_seed_survives_serialization_round_trip(): + """to_dict exposes the seed and from_dict restores it, across all sensor types. + + Sensors serialize through the JSON encoder, which turns the inertial sensors' + Vector fields into lists, so this exercises the round trip the same way the + library actually saves and loads them. + """ + cases = [ + ( + Accelerometer( + sampling_rate=10, noise_density=1.0, noise_variance=1.0, seed=11 + ), + 11, + ), + ( + Gyroscope(sampling_rate=10, noise_density=1.0, noise_variance=1.0, seed=22), + 22, + ), + ( + Barometer(sampling_rate=10, noise_density=1.0, noise_variance=1.0, seed=33), + 33, + ), + ( + GnssReceiver( + sampling_rate=1, position_accuracy=5.0, altitude_accuracy=5.0, seed=44 + ), + 44, + ), + ] + for sensor, seed in cases: + assert sensor.to_dict()["seed"] == seed + data = json.loads(json.dumps(sensor.to_dict(), cls=RocketPyEncoder)) + assert type(sensor).from_dict(data).to_dict()["seed"] == seed + + +def test_from_dict_defaults_seed_to_none_when_absent(): + """Dicts serialized before this change (no seed key) still load, seed None.""" + data = GnssReceiver( + sampling_rate=1, position_accuracy=5.0, altitude_accuracy=5.0, seed=44 + ).to_dict() + del data["seed"] + assert GnssReceiver.from_dict(data).to_dict()["seed"] is None From 2d722af359f53e4121b5d813fb82ba76c8a5d2cf Mon Sep 17 00:00:00 2001 From: thc1006 <84045975+thc1006@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:20:35 +0800 Subject: [PATCH 5/5] 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> --- tests/unit/sensors/test_sensor_validation.py | 62 ++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/unit/sensors/test_sensor_validation.py diff --git a/tests/unit/sensors/test_sensor_validation.py b/tests/unit/sensors/test_sensor_validation.py new file mode 100644 index 000000000..1187a42c0 --- /dev/null +++ b/tests/unit/sensors/test_sensor_validation.py @@ -0,0 +1,62 @@ +"""Validation and dunder coverage for the sensor base classes. + +These exercise the argument-validation error paths and the small ``__repr__`` / +``__call__`` helpers on ``Sensor`` / ``InertialSensor`` that the noise-focused +tests never reach, so the base class is fully covered. +""" + +import pytest + +from rocketpy.mathutils.vector_matrix import Vector +from rocketpy.sensors.accelerometer import Accelerometer +from rocketpy.sensors.barometer import Barometer + + +def test_measurement_range_wrong_length_raises(): + with pytest.raises(ValueError, match="measurement range"): + Accelerometer(sampling_rate=1, measurement_range=(1, 2, 3)) + + +def test_measurement_range_wrong_type_raises(): + with pytest.raises(ValueError, match="measurement range"): + Accelerometer(sampling_rate=1, measurement_range="not-a-range") + + +def test_orientation_matrix_is_accepted(): + accel = Accelerometer( + sampling_rate=1, orientation=[[1, 0, 0], [0, 1, 0], [0, 0, 1]] + ) + assert accel.rotation_sensor_to_body is not None + + +def test_orientation_wrong_length_raises(): + with pytest.raises(ValueError, match="orientation"): + Accelerometer(sampling_rate=1, orientation=(1, 2)) + + +def test_vectorize_input_wrong_type_raises(): + with pytest.raises(ValueError, match="noise_density"): + Accelerometer(sampling_rate=1, noise_density="not-a-vector") + + +def test_repr_returns_name(): + assert repr(Barometer(sampling_rate=1, name="baro")) == "baro" + + +def test_export_measured_data_rejects_bad_format(tmp_path): + with pytest.raises(ValueError, match="file_format"): + Barometer(sampling_rate=1).export_measured_data( + str(tmp_path / "out"), file_format="xml" + ) + + +def test_call_dispatches_to_measure(example_plain_env): + """Calling a sensor forwards to ``measure`` and records one sample.""" + barometer = Barometer(sampling_rate=1) + barometer( + 3.3, + u=[0, 0, 1000, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0], + relative_position=Vector([0, 0, 0]), + environment=example_plain_env, + ) + assert len(barometer.measured_data) == 1