From 6ac4ad92d555e6d2e971f9071f06e3fba64e3809 Mon Sep 17 00:00:00 2001 From: myungjunlee Date: Wed, 12 Aug 2026 22:06:07 +0900 Subject: [PATCH 1/3] BUG: reject live RNG objects as Sensor seeds `Sensor.__init__` passes the seed straight to `numpy.random.default_rng`, which also accepts `Generator` and `BitGenerator` objects. The sensor then constructs successfully and stores the object on `self._seed`, where `to_dict()` emits it verbatim, so the failure only surfaces later at `json.dumps()`, far from the call that caused it. #1124 closed the `SeedSequence` case in #1087 by teaching `RocketPyEncoder` to write one out. That works because a `SeedSequence` is defined by its entropy and spawn key, so it still describes the stream after a round trip. A `Generator` has no such description: its state advances on every draw, so whatever `to_dict()` wrote would depend on when it ran, and restoring it would not reproduce the stream the sensor actually used. Reject those two in the constructor instead, so the failure stays at the call that caused it. Ints, numpy ints, `SeedSequence` and `None` are untouched, as are the sequences of ints `default_rng` accepts and the encoder already serializes, so no seed that works today is rejected. Annotate `seed` on every constructor that takes one, with the type the issue itself names, so the contract is stated where the argument is declared rather than only in the docstring. --- rocketpy/sensors/accelerometer.py | 12 +++-- rocketpy/sensors/barometer.py | 12 +++-- rocketpy/sensors/gnss_receiver.py | 13 +++-- rocketpy/sensors/gyroscope.py | 12 +++-- rocketpy/sensors/sensor.py | 57 +++++++++++++++----- tests/unit/sensors/test_sensor_seeding.py | 23 ++++++++ tests/unit/sensors/test_sensor_validation.py | 39 ++++++++++++++ 7 files changed, 140 insertions(+), 28 deletions(-) diff --git a/rocketpy/sensors/accelerometer.py b/rocketpy/sensors/accelerometer.py index 42d6d04d3..766f12f75 100644 --- a/rocketpy/sensors/accelerometer.py +++ b/rocketpy/sensors/accelerometer.py @@ -1,3 +1,5 @@ +from collections.abc import Sequence + import numpy as np from ..mathutils.vector_matrix import Matrix, Vector @@ -78,7 +80,7 @@ def __init__( cross_axis_sensitivity=0, consider_gravity=False, name="Accelerometer", - seed=None, + seed: int | Sequence[int] | np.random.SeedSequence | None = None, ): """ Initialize the accelerometer sensor @@ -170,11 +172,13 @@ def __init__( acceleration. Default is False. name : str, optional The name of the sensor. Default is "Accelerometer". - seed : int, optional + seed : int, Sequence[int], 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. ``Generator`` and ``BitGenerator`` + 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..a9f5ae8cb 100644 --- a/rocketpy/sensors/barometer.py +++ b/rocketpy/sensors/barometer.py @@ -1,3 +1,5 @@ +from collections.abc import Sequence + import numpy as np from ..mathutils.vector_matrix import Matrix @@ -62,7 +64,7 @@ def __init__( temperature_bias=0, temperature_scale_factor=0, name="Barometer", - seed=None, + seed: int | Sequence[int] | np.random.SeedSequence | None = None, ): """ Initialize the barometer sensor @@ -111,11 +113,13 @@ 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, Sequence[int], 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. ``Generator`` and ``BitGenerator`` + 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..4c0ab6b44 100644 --- a/rocketpy/sensors/gnss_receiver.py +++ b/rocketpy/sensors/gnss_receiver.py @@ -1,4 +1,7 @@ import math +from collections.abc import Sequence + +import numpy as np from rocketpy.tools import inverted_haversine @@ -38,7 +41,7 @@ def __init__( position_accuracy=0, altitude_accuracy=0, name="GnssReceiver", - seed=None, + seed: int | Sequence[int] | np.random.SeedSequence | None = None, ): """Initialize the Gnss Receiver sensor. @@ -54,11 +57,13 @@ def __init__( position in meters. Default is 0. name : str The name of the sensor. Default is "GnssReceiver". - seed : int, optional + seed : int, Sequence[int], 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. ``Generator`` and ``BitGenerator`` + 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..4fd6f166e 100644 --- a/rocketpy/sensors/gyroscope.py +++ b/rocketpy/sensors/gyroscope.py @@ -1,3 +1,5 @@ +from collections.abc import Sequence + import numpy as np from ..mathutils.vector_matrix import Vector @@ -78,7 +80,7 @@ def __init__( cross_axis_sensitivity=0, acceleration_sensitivity=0, name="Gyroscope", - seed=None, + seed: int | Sequence[int] | np.random.SeedSequence | None = None, ): """ Initialize the gyroscope sensor @@ -172,11 +174,13 @@ def __init__( length 3. name : str, optional The name of the sensor. Default is "Gyroscope". - seed : int, optional + seed : int, Sequence[int], 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. ``Generator`` and ``BitGenerator`` + 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..a75250675 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 @@ -62,7 +63,7 @@ def __init__( temperature_bias=0, temperature_scale_factor=0, name="Sensor", - seed=None, + seed: int | Sequence[int] | np.random.SeedSequence | None = None, ): """ Initialize the accelerometer sensor @@ -112,16 +113,26 @@ 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, Sequence[int], 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``. The + ``Generator`` and ``BitGenerator`` objects that + ``numpy.random.default_rng`` takes 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 a ``Generator`` or a ``BitGenerator``. + See Also -------- TODO link to documentation on noise model @@ -151,6 +162,24 @@ def __init__( self._random_walk_drift = 0 self.normal_vector = Vector([0, 0, 0]) + # default_rng() also accepts Generator and BitGenerator objects, which + # are not a description of a stream but a stream already in progress: + # their state advances on every draw, so what to_dict() writes depends + # on when it ran. #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. + # Without this check the sensor builds fine and only fails at + # json.dumps(), far from the call that caused it. + if isinstance(seed, (np.random.Generator, np.random.BitGenerator)): + raise TypeError( + f"Invalid seed type '{type(seed).__name__}'. The seed must be " + "an int, a numpy.random.SeedSequence or None. " + "numpy.random.default_rng also accepts Generator and " + "BitGenerator objects, but their state advances as noise is " + "drawn, so they cannot be represented in the dictionary " + "to_dict() returns." + ) + # 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 +402,7 @@ def __init__( # pylint: disable=too-many-arguments temperature_scale_factor=0, cross_axis_sensitivity=0, name="Sensor", - seed=None, + seed: int | Sequence[int] | np.random.SeedSequence | None = None, ): """ Initialize the accelerometer sensor @@ -460,11 +489,13 @@ 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, Sequence[int], 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. ``Generator`` and ``BitGenerator`` + 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 +713,7 @@ def __init__( temperature_bias=0, temperature_scale_factor=0, name="Sensor", - seed=None, + seed: int | Sequence[int] | np.random.SeedSequence | None = None, ): """ Initialize the accelerometer sensor @@ -732,11 +763,13 @@ 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, Sequence[int], 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. ``Generator`` and ``BitGenerator`` + 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..8d6f5e267 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,28 @@ def test_seed_survives_serialization_round_trip(): assert type(sensor).from_dict(data).to_dict()["seed"] == 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..1a69bb32a 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,44 @@ 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)], + ids=["generator", "bit_generator"], +) +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.""" + with pytest.raises(TypeError, match="seed"): + Barometer(sampling_rate=1, seed=seed) + + +@pytest.mark.parametrize( + "seed", + [None, 0, 5, np.int64(5), 2**128 - 1], + ids=["none", "zero", "int", "numpy_int", "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 + + +def test_sequence_of_ints_is_accepted(): + """``default_rng`` takes a sequence of ints and json writes it out as a + list, so the signature names it and the check has to let it by.""" + assert Barometer(sampling_rate=1, seed=[1, 2]).to_dict()["seed"] == [1, 2] + + def test_repr_returns_name(): assert repr(Barometer(sampling_rate=1, name="baro")) == "baro" From 57d260e31ff582f4c2abf9c967086b7367692863 Mon Sep 17 00:00:00 2001 From: myungjunlee Date: Sun, 16 Aug 2026 18:29:24 +0900 Subject: [PATCH 2/3] BUG: reject RandomState and other non-descriptor Sensor seeds default_rng also accepts RandomState from NumPy 2.2 on, and RocketPy pins no upper bound on numpy, so the previous isinstance list let it through to the same late TypeError at json.dumps() that #1087 reported. Check the stable half of the contract instead of enumerating the live types: accept ints, array_like of ints and SeedSequence, and refuse the rest. A seed kind numpy starts accepting later is now refused at construction rather than reaching serialization. Widen the annotation to the array_like integer contract the check actually takes. It goes through a SeedLike union so the seven signatures stay inside the line limit while help() and inspect.signature() still expand the members. --- rocketpy/sensors/accelerometer.py | 11 +- rocketpy/sensors/barometer.py | 11 +- rocketpy/sensors/gnss_receiver.py | 12 +- rocketpy/sensors/gyroscope.py | 11 +- rocketpy/sensors/sensor.py | 114 ++++++++++++++----- tests/unit/sensors/test_sensor_validation.py | 64 +++++++++-- 6 files changed, 160 insertions(+), 63 deletions(-) diff --git a/rocketpy/sensors/accelerometer.py b/rocketpy/sensors/accelerometer.py index 766f12f75..5eb4fa15d 100644 --- a/rocketpy/sensors/accelerometer.py +++ b/rocketpy/sensors/accelerometer.py @@ -1,10 +1,8 @@ -from collections.abc import Sequence - import numpy as np 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 @@ -80,7 +78,7 @@ def __init__( cross_axis_sensitivity=0, consider_gravity=False, name="Accelerometer", - seed: int | Sequence[int] | np.random.SeedSequence | None = None, + seed: SeedLike | None = None, ): """ Initialize the accelerometer sensor @@ -172,10 +170,11 @@ def __init__( acceleration. Default is False. name : str, optional The name of the sensor. Default is "Accelerometer". - seed : int, Sequence[int], numpy.random.SeedSequence, 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. ``Generator`` and ``BitGenerator`` + 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. diff --git a/rocketpy/sensors/barometer.py b/rocketpy/sensors/barometer.py index a9f5ae8cb..593be2fc9 100644 --- a/rocketpy/sensors/barometer.py +++ b/rocketpy/sensors/barometer.py @@ -1,10 +1,8 @@ -from collections.abc import Sequence - import numpy as np 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): @@ -64,7 +62,7 @@ def __init__( temperature_bias=0, temperature_scale_factor=0, name="Barometer", - seed: int | Sequence[int] | np.random.SeedSequence | None = None, + seed: SeedLike | None = None, ): """ Initialize the barometer sensor @@ -113,10 +111,11 @@ def __init__( meaning no temperature scale factor is applied. name : str, optional The name of the sensor. Default is "Barometer". - seed : int, Sequence[int], numpy.random.SeedSequence, 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. ``Generator`` and ``BitGenerator`` + 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. diff --git a/rocketpy/sensors/gnss_receiver.py b/rocketpy/sensors/gnss_receiver.py index 4c0ab6b44..d7978280d 100644 --- a/rocketpy/sensors/gnss_receiver.py +++ b/rocketpy/sensors/gnss_receiver.py @@ -1,13 +1,10 @@ import math -from collections.abc import Sequence - -import numpy as np from rocketpy.tools import inverted_haversine 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): @@ -41,7 +38,7 @@ def __init__( position_accuracy=0, altitude_accuracy=0, name="GnssReceiver", - seed: int | Sequence[int] | np.random.SeedSequence | None = None, + seed: SeedLike | None = None, ): """Initialize the Gnss Receiver sensor. @@ -57,10 +54,11 @@ def __init__( position in meters. Default is 0. name : str The name of the sensor. Default is "GnssReceiver". - seed : int, Sequence[int], numpy.random.SeedSequence, 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. ``Generator`` and ``BitGenerator`` + 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. diff --git a/rocketpy/sensors/gyroscope.py b/rocketpy/sensors/gyroscope.py index 4fd6f166e..579c9f6da 100644 --- a/rocketpy/sensors/gyroscope.py +++ b/rocketpy/sensors/gyroscope.py @@ -1,10 +1,8 @@ -from collections.abc import Sequence - import numpy as np 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 @@ -80,7 +78,7 @@ def __init__( cross_axis_sensitivity=0, acceleration_sensitivity=0, name="Gyroscope", - seed: int | Sequence[int] | np.random.SeedSequence | None = None, + seed: SeedLike | None = None, ): """ Initialize the gyroscope sensor @@ -174,10 +172,11 @@ def __init__( length 3. name : str, optional The name of the sensor. Default is "Gyroscope". - seed : int, Sequence[int], numpy.random.SeedSequence, 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. ``Generator`` and ``BitGenerator`` + 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. diff --git a/rocketpy/sensors/sensor.py b/rocketpy/sensors/sensor.py index a75250675..53cb3ad3a 100644 --- a/rocketpy/sensors/sensor.py +++ b/rocketpy/sensors/sensor.py @@ -10,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): @@ -63,7 +98,7 @@ def __init__( temperature_bias=0, temperature_scale_factor=0, name="Sensor", - seed: int | Sequence[int] | np.random.SeedSequence | None = None, + seed: SeedLike | None = None, ): """ Initialize the accelerometer sensor @@ -113,16 +148,17 @@ def __init__( meaning no temperature scale factor is applied. name : str, optional The name of the sensor. Default is "Sensor". - seed : int, Sequence[int], numpy.random.SeedSequence, 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. A ``numpy.random.SeedSequence`` is - also accepted and round trips through ``RocketPyEncoder``. The - ``Generator`` and ``BitGenerator`` objects that - ``numpy.random.default_rng`` takes 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. + 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 ------- @@ -131,7 +167,9 @@ def __init__( Raises ------ TypeError - If ``seed`` is a ``Generator`` or a ``BitGenerator``. + 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 -------- @@ -162,22 +200,38 @@ def __init__( self._random_walk_drift = 0 self.normal_vector = Vector([0, 0, 0]) - # default_rng() also accepts Generator and BitGenerator objects, which - # are not a description of a stream but a stream already in progress: - # their state advances on every draw, so what to_dict() writes depends - # on when it ran. #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. - # Without this check the sensor builds fine and only fails at - # json.dumps(), far from the call that caused it. - if isinstance(seed, (np.random.Generator, np.random.BitGenerator)): + # 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, a numpy.random.SeedSequence or None. " - "numpy.random.default_rng also accepts Generator and " - "BitGenerator objects, but their state advances as noise is " - "drawn, so they cannot be represented in the dictionary " - "to_dict() returns." + "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 @@ -402,7 +456,7 @@ def __init__( # pylint: disable=too-many-arguments temperature_scale_factor=0, cross_axis_sensitivity=0, name="Sensor", - seed: int | Sequence[int] | np.random.SeedSequence | None = None, + seed: SeedLike | None = None, ): """ Initialize the accelerometer sensor @@ -489,10 +543,11 @@ 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, Sequence[int], numpy.random.SeedSequence, 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. ``Generator`` and ``BitGenerator`` + 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. @@ -713,7 +768,7 @@ def __init__( temperature_bias=0, temperature_scale_factor=0, name="Sensor", - seed: int | Sequence[int] | np.random.SeedSequence | None = None, + seed: SeedLike | None = None, ): """ Initialize the accelerometer sensor @@ -763,10 +818,11 @@ def __init__( meaning no temperature scale factor is applied. name : str, optional The name of the sensor. Default is "Sensor". - seed : int, Sequence[int], numpy.random.SeedSequence, 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. ``Generator`` and ``BitGenerator`` + 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. diff --git a/tests/unit/sensors/test_sensor_validation.py b/tests/unit/sensors/test_sensor_validation.py index 1a69bb32a..d78300995 100644 --- a/tests/unit/sensors/test_sensor_validation.py +++ b/tests/unit/sensors/test_sensor_validation.py @@ -42,20 +42,46 @@ def test_vectorize_input_wrong_type_raises(): @pytest.mark.parametrize( "seed", - [np.random.default_rng(5), np.random.PCG64(5)], - ids=["generator", "bit_generator"], + [ + 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.""" + 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), 2**128 - 1], - ids=["none", "zero", "int", "numpy_int", "wide_int"], + [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. @@ -72,10 +98,30 @@ def test_seed_sequence_is_accepted(): assert Barometer(sampling_rate=1, seed=seed).to_dict()["seed"] is seed -def test_sequence_of_ints_is_accepted(): - """``default_rng`` takes a sequence of ints and json writes it out as a - list, so the signature names it and the check has to let it by.""" - assert Barometer(sampling_rate=1, seed=[1, 2]).to_dict()["seed"] == [1, 2] +@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(): From 5c079d2579752d23bdca7ba6334faf9bc99caa90 Mon Sep 17 00:00:00 2001 From: myungjunlee Date: Sun, 16 Aug 2026 18:31:20 +0900 Subject: [PATCH 3/3] TST: compare the noise stream across a seed round trip The existing round-trip tests assert on the stored seed value, which would still pass for a seed that survives JSON without naming the stream the original sensor used. Draw from the restored sensor instead and compare it against a fresh one built from the same seed, across the four descriptor kinds the constructor accepts. --- tests/unit/sensors/test_sensor_seeding.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/unit/sensors/test_sensor_seeding.py b/tests/unit/sensors/test_sensor_seeding.py index 8d6f5e267..f4ec23157 100644 --- a/tests/unit/sensors/test_sensor_seeding.py +++ b/tests/unit/sensors/test_sensor_seeding.py @@ -133,6 +133,25 @@ 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.