Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions rocketpy/sensors/accelerometer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
-------
Expand Down
13 changes: 8 additions & 5 deletions rocketpy/sensors/barometer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
-------
Expand Down
13 changes: 8 additions & 5 deletions rocketpy/sensors/gnss_receiver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down
13 changes: 8 additions & 5 deletions rocketpy/sensors/gyroscope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
-------
Expand Down
113 changes: 101 additions & 12 deletions rocketpy/sensors/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,49 @@
import logging
import warnings
from abc import ABC, abstractmethod
from collections.abc import Sequence

import numpy as np

from rocketpy.mathutils.vector_matrix import Matrix, Vector

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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
-------
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
-------
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/sensors/test_sensor_seeding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading