Skip to content

BUG: give each StochasticRocket component its own random stream - #1170

Open
thc1006 wants to merge 11 commits into
RocketPy-Team:developfrom
thc1006:bug/independent-component-streams
Open

BUG: give each StochasticRocket component its own random stream#1170
thc1006 wants to merge 11 commits into
RocketPy-Team:developfrom
thc1006:bug/independent-component-streams

Conversation

@thc1006

@thc1006 thc1006 commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

41 lines of this are production code: a seeding helper in tools.py and the reset in StochasticRocket. The other 340 are tests and a section of user documentation.

Second of the split, after #1169. It does not depend on that one and applies to develop as it stands, so the two can be read in either order; #1169 is the smaller of them if only one gets a look. Whichever lands first, the other rebases onto it, and the two have been merged into a throwaway tree and run there.

Problem

Two things, both measurable on develop today.

StochasticRocket._set_stochastic hands one seed to the rocket body and to every nested component alike, so two components built from the same spec draw the same values. Two parachutes, both cd_s=0.1, lag=0.2, one reseed:

develop    (10.008249, 1.637246)    (10.008249, 1.637246)
this PR    ( 9.808764, 1.535586)    (10.227746, 1.195682)

A study of a main and a drogue was a study of one chute counted twice.

Air brakes are not in the reseed at all. They are built and sampled in create_object, and _set_stochastic never reaches them, so their values come from wherever the generator was left rather than from the seed. One seed asked three times, deployment_level=(0.5, 0.1):

develop    0.683250    0.586075    0.487737
this PR    0.545481    0.545481    0.545481

Invariant established by this PR

Every time a rocket is reset, each distinct nested stochastic model attached to it at that moment is reseeded from its own child of its collection's root, so two with the same spec are no longer made to consume the same draws, and one seed still reproduces the rocket. Independent streams can still land on equal values; what goes away is being forced to.

The reset is what builds the tree. A rocket resets itself once while being constructed, with no components attached yet, so anything added afterwards keeps the generator it was built with until something resets it again. Resetting the rocket explicitly is what does that today: a serial MonteCarlo run never resets, and a parallel one hands the model a SeedSequence where an integer is wanted, so it stops before the tree is built. Per-simulation reset from an integer seed is #1054.

Read that as the nested models' own parameters. A component's position is validated by the rocket and still belongs to the rocket's generator, which this PR does not change.

Changes

  • give each collection a root of its own, spawned from the run's seed, and each component its own child of that root;
  • bring air brakes into the reseed;
  • name the collections once, in _POSITIONED_COLLECTIONS and _PLAIN_COLLECTIONS, and compare them with what create_object iterates, because the collection no fixture populates is the one that gets missed;
  • add rocketpy.tools._seed_sequence_to_int, which turns a child into the plain int that numpy.random.default_rng and the stdlib random.Random both accept;
  • say in docs/user/stochastic.rst how a rocket's components are seeded, which nothing there covered before or after.

Non-goals

Breaking change

Yes, for anything with nested components.

Every component's sampled values move, which is the point: they were correlated with each other before. The rocket body is not affected. It keeps the seed as it was given, so stochastic_calisto under seed 42 still reads mass=14.906007947 and radius=0.063501935, the same as develop.

Within a collection, a component's stream is addressed by its position in that collection, so inserting one ahead of another still moves the later ones. Collections do not move each other.

Verification

pytest tests/unit/stochastic/test_stochastic_rocket_seeding.py    10 passed
pytest tests/unit                                               2171 passed
pytest rocketpy --doctest-modules                                 48 passed
pytest tests/integration                                         154 passed
pytest tests/acceptance                                           18 passed
ruff check .  /  ruff format --check .                           clean
pylint rocketpy/ tests/ docs/                                 10.00/10, exit 0

The four tests/unit/test_sensitivity.py failures on this machine are a missing statsmodels, and they fail the same way on an unmodified develop.

On develop at 4263fa95 the new file is six red and three green. The three that survive are meant to: a single parachute already reproduces there, the body already keeps the seed it was given, and adding a surface already leaves the other collections alone, because on develop there is nothing to move them apart in the first place.

Each mechanism is pinned by a mutation, and each leaves a control standing:

undone goes red still passes
every component gets the root seed again the two correlation tests the air brake test, the source scan
air_brakes dropped from the reseed the air brake test, the source scan the two correlation tests
one root shared by every collection the collection-isolation test the rest
a collection declared that create_object never uses the source scan the rest
an entry reseeded twice the exact-once count the rest
the body moved back onto a spawned child the body-seed test the rest
_seed_sequence_to_int keeps only the first word test_seed_sequence_to_int_keeps_the_full_width all the seeding tests

That last row is why the helper has a test of its own. Collapsing it to 32 bits still hands every component a different number, so every seeding test stays green over a change that puts two streams back together near 2**16 of them.

Together with #1169

Both touch the stochastic package, so they are merged into a throwaway tree on develop and run there rather than trusted separately: pylint exit 0, ruff clean, the unit suite, doctests, integration and acceptance. The mutations that undo either change still fail their own tests in that tree, and stochastic_calisto under seed 42 still reads mass=14.906007947. Whichever of the two lands first, the other is rebased onto it and the count is taken again there rather than carried over from here.

Extracted from

#1054, second of the split. The first is #1169.

The one thing deliberately left behind is that branch's __apply_eccentricity_specs, since #1167 and #1168 replaced it on develop with something better.

@codecov

codecov Bot commented Aug 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.61%. Comparing base (4263fa9) to head (72be160).

Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #1170      +/-   ##
===========================================
+ Coverage    84.57%   84.61%   +0.03%     
===========================================
  Files          131      131              
  Lines        17527    17539      +12     
===========================================
+ Hits         14824    14841      +17     
+ Misses        2703     2698       -5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

_set_stochastic handed the same seed to the rocket body and to every surface,
motor, rail button and parachute, so two components built from one spec drew
identical values: a main and a drogue with the same cd_s and lag spec drew the
same cd_s and the same lag, every time, and a study of both was a study of one
counted twice.

Air brakes were worse. They are built and sampled in create_object and were
not in the reseed at all, so their values came from wherever the generator had
been left rather than from the seed: 0.683, then 0.586, then 0.488 for one
seed asked three times.

Each component now takes its own child of a SeedSequence root, spawned in a
fixed order so one seed still reproduces the whole rocket. The collections are
named in one place and checked against create_object's own source, since the
collection no fixture populates is the one that gets missed.

Extracted from RocketPy-Team#1054.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Measured against the register the repository uses: inline comments in
flight.py average 5.6 words and none of its docstrings run longer than the
code they describe. The three added here were four to seven lines of prose
where a line would do, and the seed helper carried seven lines of docstring
over two lines of code.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
…llection

Moving the body to child zero broke every fixed-seed baseline for mass,
radius and the body inputs, and nothing about the nested-component fix needed
that. The body keeps the seed as given now: stochastic_calisto under seed 42
reads mass=14.906007947 on develop and the same here.

Components were also addressed by one global traversal index, so adding a fin
moved every motor, rail button, parachute and air brake. Each collection has
a root of its own now, spawned from the same seed, so an unrelated component
in one of them leaves the others where they were.

The source scan compares the two sets both ways. A collection left in the
reseed after create_object stops using it still spawns a child and moves
every stream after it, which the subset check let through.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The source scan reads create_object for a literal loop over self.collection,
so a helper, a local alias or a getattr would hide a collection from it.
Counting what each entry actually receives is the check that survives a
refactor, and it is the only one that fails when an entry is reseeded twice.

The air brakes are a plain list and take a different route through the reseed
than the positioned collections, so two of them on one spec are worth their
own case.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
stochastic_calisto already holds the stochastic_nose_cone fixture, so adding
it again put the test into the state RocketPy-Team#1172 describes: one wrapper in two
entries, its position overwritten, and two reseeds landing on the same object.
The assertion looked at a different collection and passed anyway. It adds the
deterministic nose now, so add_nose builds a wrapper of its own.

Nothing pinned the body keeping the seed as given either. Reproducibility and
seed uniqueness both hold with the body on a spawned child, so neither would
have noticed it going back there.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
The change moves every fixed-seed component baseline and nothing in the user
documentation said how components are seeded at all, before or after.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 force-pushed the bug/independent-component-streams branch from e151bf6 to 9630ba2 Compare August 16, 2026 00:00
Two independent streams are not made to consume the same draws; they can
still land on equal values, and a specification with no spread always will.
The text promised unequal results, which is a stronger claim than spawning
gives.

It also said each kind of component is spawned separately. The unit is the
collection: a nose cone, the fins and the tail share one root.

And a stream belongs to one wrapper, so storing one twice or sharing it
between rockets is outside what this establishes. That is RocketPy-Team#1172.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 marked this pull request as ready for review August 16, 2026 01:32
@thc1006
thc1006 requested a review from a team as a code owner August 16, 2026 01:32
@thc1006
thc1006 marked this pull request as draft August 16, 2026 02:13
A rocket resets itself while being constructed, when it holds no components
yet, so a parachute added afterwards keeps the generator it was built with
until the next reset. The text read as though attaching a component gave it a
stream, which is only true once something resets the rocket, and a Monte Carlo
is what does that.

Two wrappers sharing a CustomSampler seed_group are also one stream on
purpose. Separate component streams are not meant to take that apart, so the
note says so rather than leaving it to be discovered.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
A serial MonteCarlo run never resets the rocket, and a parallel one resets
each worker once rather than once per simulation, so the text saying a run
resets the rocket for you was wrong for both. Per-simulation reset is the
Monte Carlo seeding work, not this change.

CustomSampler.seed_group already documents that a group belongs to one model
and that the last to seed it wins. Saying two components sharing one stay one
stream on purpose read as a guarantee this does not make.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
@thc1006
thc1006 marked this pull request as ready for review August 17, 2026 04:44
Saying it resets each worker once reads as though it works and only the
grain differs. It hands the model a SeedSequence where an integer is wanted,
so it stops before the tree exists, which the PR already records as the Monte
Carlo seeding work rather than this change.

The two air brake test also says what it is not: both are added with one
controller because the rocket keeps a single one, which is RocketPy-Team#1172.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Dropping the fourth word left all 33 tools tests passing: the checks were
that the high bits are not zero, that the low word matches, that two children
differ and that reading twice agrees, none of which a 96 bit truncation
breaks. It compares against the integer rebuilt from all four words now, and
that mutation fails.

A collection's stream is addressed by where its name falls in the two tuples
read end to end, so appending to the first moves every name in the second.
The comment said append rather than reorder, which reads as though appending
to either one is safe.

Signed-off-by: thc1006 <84045975+thc1006@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant