Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Attention: The newest changes should be on top -->

### Fixed

- BUG: Give each `CustomSampler` input its own deterministic stream, and seed samplers sharing one generator once as a group. Existing fixed-seed `CustomSampler` baselines change, and samplers built on the legacy `RandomState` must move to `default_rng` because seeds now carry the full 128 bits. [#1102](https://github.com/RocketPy-Team/RocketPy/pull/1102)
- BUG: rocket with a late-starting thrust curve never leaves the rail [#1085](https://github.com/RocketPy-Team/RocketPy/pull/1085)

## [v1.13.0] - 2026-07-21
Expand Down
26 changes: 24 additions & 2 deletions docs/user/custom_sampler.rst
Original file line number Diff line number Diff line change
Expand Up @@ -243,14 +243,16 @@ below implements an example of such a generator
"""Reseeds the generator and discards the samples drawn before it

The cached samples came from the previous generator, so keeping them
would let the first 1000 draws after a reseed ignore the new seed.
would let the first draws after a reseed ignore the new seed. Nothing
is generated here: ``top_up`` fills the shortfall when samples are
first asked for, and a reseed happens once per simulation, so filling
eagerly would build a thousand pairs to use one.
"""
self.rng = np.random.default_rng(seed)
self.samples_list = []
self.samples_generated = 0
self.used_samples_x = 0
self.used_samples_y = 0
self.generate_samples(1000)

def top_up(self, used_samples, n_samples):
"""Generates enough samples to cover the request, if it is short"""
Expand Down Expand Up @@ -300,6 +302,16 @@ sample list.
def __init__(self, bivariate_gaussian_generator):
self.generator = bivariate_gaussian_generator

@property
def seed_group(self):
"""The generator this shares with the other wrapper.

Both return the same object, so the pair is seeded once between
them. Without this each would be seeded separately and one would
silently overwrite the other.
"""
return self.generator

def sample(self, n_samples=1):
samples_list = self.generator.get_samples(n_samples, "x")
return samples_list
Expand All @@ -313,6 +325,16 @@ sample list.
def __init__(self, bivariate_gaussian_generator):
self.generator = bivariate_gaussian_generator

@property
def seed_group(self):
"""The generator this shares with the other wrapper.

Both return the same object, so the pair is seeded once between
them. Without this each would be seeded separately and one would
silently overwrite the other.
"""
return self.generator

def sample(self, n_samples=1):
samples_list = self.generator.get_samples(n_samples, "y")
return samples_list
Expand Down
26 changes: 26 additions & 0 deletions rocketpy/stochastic/custom_sampler.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,32 @@
class CustomSampler(ABC):
"""Abstract subclass for user defined samplers"""

@property
def seed_group(self):
"""The generator state this sampler shares, if it shares one.

Samplers are independent by default and each is seeded on its own. Two
wrappers over one generator, as the correlated wind pair in the
documentation are, should both return that generator here, so the pair
is seeded once as a unit rather than one of them silently overwriting
the other's seed.

Return the same object on every call. Building the answer each time,
which a property invites, gives each member a different identity and
puts it back in a group of its own.

A group belongs to one model. Declaring the same generator on two
models has them both seed it, and whichever is seeded last decides the
stream, which is the overwrite this is here to avoid.

Returns
-------
object
Identity is what counts, not equality. ``self`` by default, which
makes every sampler its own group.
"""
return self

@abstractmethod
def sample(self, n_samples=1):
"""Generates samples from the custom distribution
Expand Down
111 changes: 95 additions & 16 deletions rocketpy/stochastic/stochastic_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,44 @@

from ..tools import get_distribution


def _names_as_spawn_key(input_names):
"""Encode names into spawn-key words that no other set of names produces.

A hash would be shorter, but a collision puts two samplers back on one
stream, which is the bug this keying exists to prevent. The length prefix
before each name is what makes it injective.
"""
payload = b""
for name in input_names:
encoded = name.encode("utf-8")
payload += len(encoded).to_bytes(4, "little") + encoded
payload += b"\0" * (-len(payload) % 4)
return tuple(
int.from_bytes(payload[at : at + 4], "little")
for at in range(0, len(payload), 4)
)


def _sampler_seed(seed, input_names):
"""Derive a seed for one sampler, or for one group that shares a generator.

Keyed by the names rather than by position, so declaring another parameter
does not move the stream of the ones already there. A group is keyed by all
of its members, so its stream does not depend on which of them happens to
be reset last.
"""
if isinstance(input_names, str):
input_names = (input_names,)
# Sorted here rather than trusting the caller, so a future call site cannot
# give one group two different seeds by listing its members another way.
root = np.random.SeedSequence(
entropy=seed, spawn_key=_names_as_spawn_key(tuple(sorted(input_names)))
)
words = root.generate_state(4, dtype=np.uint32)
return sum(int(word) << (32 * position) for position, word in enumerate(words))


# TODO: Stop using assert in production code. Use exceptions instead.
# TODO: Each validation method should have a test case.

Expand Down Expand Up @@ -82,16 +120,16 @@ def _set_stochastic(self, seed=None):
self.__random_number_generator = np.random.default_rng(seed)
self.last_rnd_dict = {}

self._reset_custom_samplers(seed)

# TODO: This code block is too complex. Refactor it.
# TODO: Resetting a instance should not require re-validation.
for input_name, input_value in self.__stochastic_dict.items():
if input_name not in self.exception_list:
attr_value = None
if input_value is not None:
if "factor" in input_name:
attr_value = self._validate_factors(
input_name, input_value, seed
)
attr_value = self._validate_factors(input_name, input_value)
elif input_name not in self.exception_list:
if isinstance(input_value, tuple):
attr_value = self._validate_tuple(input_name, input_value)
Expand All @@ -101,7 +139,7 @@ def _set_stochastic(self, seed=None):
attr_value = self._validate_scalar(input_name, input_value)
elif isinstance(input_value, CustomSampler):
attr_value = self._validate_custom_sampler(
input_name, input_value, seed
input_name, input_value
)
else:
raise AssertionError(
Expand Down Expand Up @@ -288,7 +326,7 @@ def _validate_scalar(self, input_name, input_value, getattr=getattr): # pylint:
get_distribution("normal", self.__random_number_generator),
)

def _validate_factors(self, input_name, input_value, seed):
def _validate_factors(self, input_name, input_value):
"""
Validate factor arguments.

Expand Down Expand Up @@ -317,7 +355,7 @@ def _validate_factors(self, input_name, input_value, seed):
elif isinstance(input_value, list):
return self._validate_list_factor(input_name, input_value)
elif isinstance(input_value, CustomSampler):
return self._validate_custom_sampler(input_name, input_value, seed)
return self._validate_custom_sampler(input_name, input_value)
else:
raise AssertionError(
f"`{input_name}`: must be either a tuple or listor a custom sampler"
Expand Down Expand Up @@ -448,31 +486,72 @@ def _validate_positive_int_list(self, input_name, input_value):
isinstance(member, int) and member >= 0 for member in input_value
), f"`{input_name}` must be a list of positive integers"

def _validate_custom_sampler(self, input_name, sampler, seed=None):
def _reset_custom_samplers(self, seed):
"""Give each sampler its own stream, and each shared group one between
them.

Samplers that share a generator, as the documented wind pair do, are
seeded once as a unit. Resetting each member in turn would leave every
seed but the last discarded and the group's stream decided by whichever
member happened to go last.

Its own pass rather than the validation loop below, whose order sets
``__dict__`` and so the order every other input is drawn in.
"""
groups = {}
for input_name in sorted(self.__stochastic_dict):
sampler = self.__stochastic_dict[input_name]
if isinstance(sampler, CustomSampler):
# Held in the value as well as keyed on, because `id` is
# unique only among live objects. Defensive: a `seed_group`
# that builds its answer did not merge in practice here.
group = sampler.seed_group
shared = groups.setdefault(id(group), ([], sampler, group))
shared[0].append(input_name)

for names, sampler, group in groups.values():
# The group itself when it can be reset, since it is the thing that
# holds the shared state. Going through one member instead assumes
# every member resets the same way and keeps nothing of its own.
resetter = group if hasattr(group, "reset_seed") else sampler
try:
resetter.reset_seed(_sampler_seed(seed, names))
except Exception as error:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Broad exception handler. We should avoid it

            except Exception as error:

# Not just RuntimeError. The seed handed over is now 128 bits,
# which the legacy RandomState refuses with a ValueError, and a
# bare one of those does not say which sampler raised it.
raise RuntimeError(
f"An error occurred in the 'reset_seed' method of the "
f"CustomSampler for {', '.join(names)}"
) from error

def _validate_custom_sampler(self, input_name, sampler):
"""
Validate a custom sampler.

Seeding is not done here. It happens in ``_reset_custom_samplers``,
which runs in a fixed order because two samplers can share one
generator and whichever is reset last decides the stream.

Parameters
----------
input_name : str
Name of the input argument.
sampler : CustomSampler object
Custom sampler provided by the user
seed : int, optional
Seed for the random number generator. The default is None

Raises
------
AssertionError
If the input is not in a valid format.
"""
try:
sampler.reset_seed(seed)
except RuntimeError as e:
raise RuntimeError(
f"An error occurred in the 'reset_seed' method of {input_name} CustomSampler"
) from e

# Raised rather than asserted, the same way #1103 handles it: `python -O`
# strips an assert, and the documented AssertionError is kept so callers
# that already catch it still do.
if not isinstance(sampler, CustomSampler):
raise AssertionError(
f"`{input_name}` must be a CustomSampler, not {type(sampler).__name__}"
)
return sampler

def _validate_airfoil(self, airfoil):
Expand Down
Loading