|
| 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