diff --git a/rocketpy/sensors/accelerometer.py b/rocketpy/sensors/accelerometer.py index 42d6d04d3..5eb4fa15d 100644 --- a/rocketpy/sensors/accelerometer.py +++ b/rocketpy/sensors/accelerometer.py @@ -2,7 +2,7 @@ from ..mathutils.vector_matrix import Matrix, Vector from ..prints.sensors_prints import _InertialSensorPrints -from ..sensors.sensor import InertialSensor +from ..sensors.sensor import InertialSensor, SeedLike # pylint: disable=too-many-arguments @@ -78,7 +78,7 @@ def __init__( cross_axis_sensitivity=0, consider_gravity=False, name="Accelerometer", - seed=None, + seed: SeedLike | None = None, ): """ Initialize the accelerometer sensor @@ -170,11 +170,14 @@ def __init__( acceleration. Default is False. name : str, optional The name of the sensor. Default is "Accelerometer". - seed : int, optional + seed : int, array_like of ints, numpy.random.SeedSequence, 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. + the process-global NumPy RNG. Only seeds that describe a stream are + taken: live ``Generator``, ``BitGenerator`` and ``RandomState`` + objects are rejected, because their state advances as noise is + drawn and so cannot be represented in ``to_dict()``. Default is + None, meaning the noise is seeded from fresh entropy per instance. Returns ------- diff --git a/rocketpy/sensors/barometer.py b/rocketpy/sensors/barometer.py index 3320cdc57..593be2fc9 100644 --- a/rocketpy/sensors/barometer.py +++ b/rocketpy/sensors/barometer.py @@ -2,7 +2,7 @@ from ..mathutils.vector_matrix import Matrix from ..prints.sensors_prints import _SensorPrints -from ..sensors.sensor import ScalarSensor +from ..sensors.sensor import ScalarSensor, SeedLike class Barometer(ScalarSensor): @@ -62,7 +62,7 @@ def __init__( temperature_bias=0, temperature_scale_factor=0, name="Barometer", - seed=None, + seed: SeedLike | None = None, ): """ Initialize the barometer sensor @@ -111,11 +111,14 @@ def __init__( meaning no temperature scale factor is applied. name : str, optional The name of the sensor. Default is "Barometer". - seed : int, optional + seed : int, array_like of ints, numpy.random.SeedSequence, 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. + the process-global NumPy RNG. Only seeds that describe a stream are + taken: live ``Generator``, ``BitGenerator`` and ``RandomState`` + objects are rejected, because their state advances as noise is + drawn and so cannot be represented in ``to_dict()``. Default is + None, meaning the noise is seeded from fresh entropy per instance. Returns ------- diff --git a/rocketpy/sensors/gnss_receiver.py b/rocketpy/sensors/gnss_receiver.py index 09a064157..d7978280d 100644 --- a/rocketpy/sensors/gnss_receiver.py +++ b/rocketpy/sensors/gnss_receiver.py @@ -4,7 +4,7 @@ from ..mathutils.vector_matrix import Matrix, Vector from ..prints.sensors_prints import _GnssReceiverPrints -from .sensor import ScalarSensor +from .sensor import ScalarSensor, SeedLike class GnssReceiver(ScalarSensor): @@ -38,7 +38,7 @@ def __init__( position_accuracy=0, altitude_accuracy=0, name="GnssReceiver", - seed=None, + seed: SeedLike | None = None, ): """Initialize the Gnss Receiver sensor. @@ -54,11 +54,14 @@ def __init__( position in meters. Default is 0. name : str The name of the sensor. Default is "GnssReceiver". - seed : int, optional + seed : int, array_like of ints, numpy.random.SeedSequence, 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. + the process-global NumPy RNG. Only seeds that describe a stream are + taken: live ``Generator``, ``BitGenerator`` and ``RandomState`` + objects are rejected, because their state advances as noise is + drawn and so cannot be represented in ``to_dict()``. 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 diff --git a/rocketpy/sensors/gyroscope.py b/rocketpy/sensors/gyroscope.py index ebb819b6c..579c9f6da 100644 --- a/rocketpy/sensors/gyroscope.py +++ b/rocketpy/sensors/gyroscope.py @@ -2,7 +2,7 @@ from ..mathutils.vector_matrix import Vector from ..prints.sensors_prints import _GyroscopePrints -from ..sensors.sensor import InertialSensor +from ..sensors.sensor import InertialSensor, SeedLike # pylint: disable=too-many-arguments @@ -78,7 +78,7 @@ def __init__( cross_axis_sensitivity=0, acceleration_sensitivity=0, name="Gyroscope", - seed=None, + seed: SeedLike | None = None, ): """ Initialize the gyroscope sensor @@ -172,11 +172,14 @@ def __init__( length 3. name : str, optional The name of the sensor. Default is "Gyroscope". - seed : int, optional + seed : int, array_like of ints, numpy.random.SeedSequence, 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. + the process-global NumPy RNG. Only seeds that describe a stream are + taken: live ``Generator``, ``BitGenerator`` and ``RandomState`` + objects are rejected, because their state advances as noise is + drawn and so cannot be represented in ``to_dict()``. Default is + None, meaning the noise is seeded from fresh entropy per instance. Returns ------- diff --git a/rocketpy/sensors/sensor.py b/rocketpy/sensors/sensor.py index e4dddb162..53cb3ad3a 100644 --- a/rocketpy/sensors/sensor.py +++ b/rocketpy/sensors/sensor.py @@ -2,6 +2,7 @@ import logging import warnings from abc import ABC, abstractmethod +from collections.abc import Sequence import numpy as np @@ -9,6 +10,41 @@ logger = logging.getLogger(__name__) +# The seed kinds that describe a stream, and so survive a to_dict() round trip. +# Named once because every concrete sensor repeats it in its signature; it is a +# union, so help() and inspect.signature() still show the members in full. +SeedLike = int | np.integer | Sequence[int] | np.ndarray | np.random.SeedSequence + + +def _is_int_array_like(value): + """Whether ``value`` is an int or a (possibly nested) sequence of ints. + + This is the half of ``numpy.random.default_rng``'s seed contract that + ``RocketPyEncoder`` can write out: integers keep their value across a JSON + round trip, so a seed read back names the same stream it named before. + """ + if isinstance(value, (int, np.integer)): + return True + if isinstance(value, np.ndarray): + return np.issubdtype(value.dtype, np.integer) + if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): + return all(_is_int_array_like(item) for item in value) + return False + + +def _is_seed_descriptor(seed): + """Whether ``seed`` describes a random stream rather than being one. + + ``SeedSequence`` counts because it is defined by its entropy and spawn key, + and #1124 taught ``RocketPyEncoder`` to serialize it, so it still names the + same stream after a round trip. + """ + return ( + seed is None + or isinstance(seed, np.random.SeedSequence) + or _is_int_array_like(seed) + ) + # pylint: disable=too-many-statements class Sensor(ABC): @@ -62,7 +98,7 @@ def __init__( temperature_bias=0, temperature_scale_factor=0, name="Sensor", - seed=None, + seed: SeedLike | None = None, ): """ Initialize the accelerometer sensor @@ -112,16 +148,29 @@ def __init__( meaning no temperature scale factor is applied. name : str, optional The name of the sensor. Default is "Sensor". - seed : int, optional + seed : int, array_like of ints, numpy.random.SeedSequence, 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. + the process-global NumPy RNG. A ``numpy.random.SeedSequence`` is + also accepted and round trips through ``RocketPyEncoder``. Only + seeds that describe a stream are taken: the live ``Generator``, + ``BitGenerator`` and ``RandomState`` objects that + ``numpy.random.default_rng`` also accepts are rejected here, + because their state advances as noise is drawn and so cannot be + represented in the dictionary returned by ``to_dict()``. Default is + None, meaning the noise is seeded from fresh entropy per instance. Returns ------- None + Raises + ------ + TypeError + If ``seed`` is not an int, an array_like of ints, a + ``SeedSequence`` or None -- in particular if it is a live + ``Generator``, ``BitGenerator`` or ``RandomState``. + See Also -------- TODO link to documentation on noise model @@ -151,6 +200,40 @@ def __init__( self._random_walk_drift = 0 self.normal_vector = Vector([0, 0, 0]) + # default_rng() takes two different kinds of argument. One describes a + # stream -- an int, an array_like of ints, a SeedSequence -- and can be + # written down and read back. The other is a stream already in + # progress: Generator, BitGenerator, and, since NumPy 2.2, RandomState. + # Their state advances on every draw, so what to_dict() writes is a + # snapshot of the moment it ran rather than the stream the sensor used. + # #1124 taught RocketPyEncoder to serialize a SeedSequence, which stays + # reproducible because it is defined by its entropy and spawn key; a + # live generator has no such description to write. + # + # The check names the descriptors instead of the live types because the + # descriptors are the stable half of that contract: a seed kind numpy + # starts accepting later is refused here rather than reaching + # json.dumps(). Without it the sensor builds fine and only fails at + # serialization, far from the call that caused it. + if isinstance( + seed, + (np.random.Generator, np.random.BitGenerator, np.random.RandomState), + ): + raise TypeError( + f"Invalid seed type '{type(seed).__name__}'. The seed must be " + "an int, an array_like of ints, a numpy.random.SeedSequence or " + "None. numpy.random.default_rng also accepts Generator, " + "BitGenerator and RandomState objects, but their state advances " + "as noise is drawn, so they cannot be represented in the " + "dictionary to_dict() returns." + ) + if not _is_seed_descriptor(seed): + raise TypeError( + f"Invalid seed type '{type(seed).__name__}'. The seed must be " + "an int, an array_like of ints, a numpy.random.SeedSequence or " + "None, so that to_dict() can write it out." + ) + # 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 @@ -373,7 +456,7 @@ def __init__( # pylint: disable=too-many-arguments temperature_scale_factor=0, cross_axis_sensitivity=0, name="Sensor", - seed=None, + seed: SeedLike | None = None, ): """ Initialize the accelerometer sensor @@ -460,11 +543,14 @@ def __init__( # pylint: disable=too-many-arguments no cross-axis sensitivity is applied. name : str, optional The name of the sensor. Default is "Sensor". - seed : int, optional + seed : int, array_like of ints, numpy.random.SeedSequence, 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. + the process-global NumPy RNG. Only seeds that describe a stream are + taken: live ``Generator``, ``BitGenerator`` and ``RandomState`` + objects are rejected, because their state advances as noise is + drawn and so cannot be represented in ``to_dict()``. Default is + None, meaning the noise is seeded from fresh entropy per instance. Returns ------- @@ -682,7 +768,7 @@ def __init__( temperature_bias=0, temperature_scale_factor=0, name="Sensor", - seed=None, + seed: SeedLike | None = None, ): """ Initialize the accelerometer sensor @@ -732,11 +818,14 @@ def __init__( meaning no temperature scale factor is applied. name : str, optional The name of the sensor. Default is "Sensor". - seed : int, optional + seed : int, array_like of ints, numpy.random.SeedSequence, 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. + the process-global NumPy RNG. Only seeds that describe a stream are + taken: live ``Generator``, ``BitGenerator`` and ``RandomState`` + objects are rejected, because their state advances as noise is + drawn and so cannot be represented in ``to_dict()``. 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 73b4c664b..f4ec23157 100644 --- a/tests/unit/sensors/test_sensor_seeding.py +++ b/tests/unit/sensors/test_sensor_seeding.py @@ -15,6 +15,7 @@ from types import SimpleNamespace import numpy as np +import pytest from rocketpy._encoders import RocketPyDecoder, RocketPyEncoder from rocketpy.mathutils.vector_matrix import Vector @@ -132,6 +133,47 @@ def test_seed_survives_serialization_round_trip(): assert type(sensor).from_dict(data).to_dict()["seed"] == seed +@pytest.mark.parametrize( + "seed", + [11, np.int64(11), [1, 2], np.random.SeedSequence(11)], + ids=["int", "numpy_int", "int_sequence", "seed_sequence"], +) +def test_round_trip_reproduces_the_noise_stream(seed): + """The point of writing a seed down is that the sensor read back draws the + same noise. + + Comparing only the stored value would still pass for a seed that survives + JSON without naming the stream the original sensor used, which is exactly + what a live generator would do, so this compares the draws themselves. + """ + encoded = json.dumps(_accelerometer(seed).to_dict(), cls=RocketPyEncoder) + restored = Accelerometer.from_dict(json.loads(encoded, cls=RocketPyDecoder)) + + assert _noise_sequence(restored) == _noise_sequence(_accelerometer(seed)) + + +def test_unserializable_seed_is_refused_before_it_can_be_stored(): + """Keep the failure at the constructor instead of at save time. + + ``default_rng`` accepts a ``Generator``, so the sensor builds successfully + and only raises once ``to_dict()`` reaches ``json.dumps()``, by which point + the call responsible for it is long gone. #1124 gave ``SeedSequence`` a + serializable form; a live generator has none. + """ + with pytest.raises(TypeError, match="seed"): + Accelerometer( + sampling_rate=10, noise_density=1.0, seed=np.random.default_rng(7) + ) + + +def test_numpy_int_seed_survives_serialization_round_trip(): + """``RocketPyEncoder`` writes numpy scalars out through ``.item()``, so a + numpy int is a valid seed and has to keep round tripping.""" + sensor = Barometer(sampling_rate=10, noise_density=1.0, seed=np.int64(77)) + data = json.loads(json.dumps(sensor.to_dict(), cls=RocketPyEncoder)) + assert Barometer.from_dict(data).to_dict()["seed"] == 77 + + def test_from_dict_defaults_seed_to_none_when_absent(): """Dicts serialized before this change (no seed key) still load, seed None.""" data = GnssReceiver( diff --git a/tests/unit/sensors/test_sensor_validation.py b/tests/unit/sensors/test_sensor_validation.py index 1187a42c0..d78300995 100644 --- a/tests/unit/sensors/test_sensor_validation.py +++ b/tests/unit/sensors/test_sensor_validation.py @@ -5,6 +5,7 @@ tests never reach, so the base class is fully covered. """ +import numpy as np import pytest from rocketpy.mathutils.vector_matrix import Vector @@ -39,6 +40,90 @@ def test_vectorize_input_wrong_type_raises(): Accelerometer(sampling_rate=1, noise_density="not-a-vector") +@pytest.mark.parametrize( + "seed", + [ + np.random.default_rng(5), + np.random.PCG64(5), + np.random.MT19937(5), + np.random.RandomState(5), + ], + ids=["generator", "bit_generator", "legacy_bit_generator", "random_state"], +) +def test_live_rng_objects_are_rejected(seed): + """A generator's state advances as noise is drawn, so it cannot describe + the stream the way an int or a ``SeedSequence`` does. + + ``RandomState`` is here because ``default_rng`` accepts it from NumPy 2.2 + on and RocketPy pins no upper bound, so it reaches the same late failure at + ``json.dumps()`` that the other two do. + """ + with pytest.raises(TypeError, match="seed"): + Barometer(sampling_rate=1, seed=seed) + + +@pytest.mark.parametrize( + "seed", + ["5", 5.0, np.float64(5), np.bool_(True), object()], + ids=["str", "float", "numpy_float", "numpy_bool", "object"], +) +def test_non_descriptor_seeds_are_rejected(seed): + """Anything that is neither a live RNG nor a stream descriptor is refused. + + ``default_rng`` rejects these too, but only after the sensor has been + built, and its message never names ``seed``. Checking here keeps the error + at the call that caused it. + """ + with pytest.raises(TypeError, match="seed"): + Barometer(sampling_rate=1, seed=seed) + + +@pytest.mark.parametrize( + "seed", + [None, 0, 5, np.int64(5), np.uint32(5), 2**128 - 1], + ids=["none", "zero", "int", "numpy_int", "numpy_uint", "wide_int"], +) +def test_int_and_none_seeds_are_accepted(seed): + """The check must not catch seeds that already work. + + numpy integers serialize through ``RocketPyEncoder``, and #1054 hands each + model a plain 128-bit int, so both have to pass. + """ + assert Barometer(sampling_rate=1, seed=seed).to_dict()["seed"] == seed + + +def test_seed_sequence_is_accepted(): + """#1124 made ``SeedSequence`` serializable, so this check must let it by.""" + seed = np.random.SeedSequence(5) + assert Barometer(sampling_rate=1, seed=seed).to_dict()["seed"] is seed + + +@pytest.mark.parametrize( + "seed", + [[1, 2], (1, 2), [np.int64(1), np.int64(2)], [[1, 2], [3, 4]], []], + ids=["list", "tuple", "list_of_numpy_ints", "nested", "empty"], +) +def test_int_array_like_seeds_are_accepted(seed): + """``default_rng`` takes an array_like of ints and json writes it out, so + the check has to let every shape of it by -- including the empty and the + nested ones, which numpy accepts as entropy just the same.""" + assert Barometer(sampling_rate=1, seed=seed) is not None + + +def test_integer_ndarray_seed_is_accepted(): + """An ndarray of ints is array_like of ints, and ``RocketPyEncoder`` + writes it out as a list, so it round trips like a plain list does.""" + seed = np.array([1, 2], dtype=np.uint32) + assert Barometer(sampling_rate=1, seed=seed).to_dict()["seed"] is seed + + +def test_float_ndarray_seed_is_rejected(): + """The dtype is what decides it: a float array cannot seed + ``default_rng``, so it must be refused with the others.""" + with pytest.raises(TypeError, match="seed"): + Barometer(sampling_rate=1, seed=np.array([1.0, 2.0])) + + def test_repr_returns_name(): assert repr(Barometer(sampling_rate=1, name="baro")) == "baro"