Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
12 changes: 8 additions & 4 deletions rocketpy/sensors/accelerometer.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from collections.abc import Sequence

import numpy as np

from ..mathutils.vector_matrix import Matrix, Vector
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
-------
Expand Down
12 changes: 8 additions & 4 deletions rocketpy/sensors/barometer.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from collections.abc import Sequence

import numpy as np

from ..mathutils.vector_matrix import Matrix
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
-------
Expand Down
13 changes: 9 additions & 4 deletions rocketpy/sensors/gnss_receiver.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import math
from collections.abc import Sequence

import numpy as np

from rocketpy.tools import inverted_haversine

Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down
12 changes: 8 additions & 4 deletions rocketpy/sensors/gyroscope.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from collections.abc import Sequence

import numpy as np

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

import numpy as np

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

np.random.default_rng also accepts np.random.RandomState starting with NumPy 2.2. RocketPy supports numpy>=1.23 with no upper bound, so this still leaves the same late-failure path on current NumPy: the sensor constructs, the RandomState remains live state, and RocketPyEncoder cannot encode it.

Could we reject RandomState here as well and add it to the parametrized rejection test? More generally, validating against the stable seed-descriptor contract (SeedSequence entropy inputs) would be less brittle than blacklisting whichever live RNG types default_rng happens to accept today.

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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
-------
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
-------
Expand Down
23 changes: 23 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,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(
Expand Down
39 changes: 39 additions & 0 deletions tests/unit/sensors/test_sensor_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down