Skip to content

ENH: StochasticFreeFormFins for Monte Carlo simulations - #1117

Open
Gui-FernandesBR wants to merge 6 commits into
developfrom
enh/stochastic-free-form-fins
Open

ENH: StochasticFreeFormFins for Monte Carlo simulations#1117
Gui-FernandesBR wants to merge 6 commits into
developfrom
enh/stochastic-free-form-fins

Conversation

@Gui-FernandesBR

Copy link
Copy Markdown
Member

Closes #953.

Why

Free-form fin sets were the only aerodynamic surface without a stochastic counterpart, so a rocket using them could not be varied in a Monte Carlo run.

What

StochasticFreeFormFins (ENH)

shape_points does not fit the one-number-per-input assumption StochasticModel makes, and a fin shape is only meaningful as a complete set of points. So the outline is randomized as a block: one sampled deviation applied to every coordinate of every point.

Two formats needed handling before reaching the base class, both of them the natural thing for a user to write:

  • a bare outline [(0, 0), (0.08, 0.1), (0.12, 0)] is a list, which the base class reads as a list of candidate values and would have sampled a single (x, y) point from. It is wrapped as the one candidate outline it is, so a list of outlines still means a choice between shapes.
  • (nominal outline, standard deviation) has a list where _validate_tuple requires a number. Only that first item is special-cased; the deviation and the distribution name still go through the base class, so they are checked the same way as everywhere else and the distribution is drawn from this model's generator.

All six formats are accepted:

StochasticFreeFormFins(fins, shape_points=0.001)                    # std on every coordinate
StochasticFreeFormFins(fins, shape_points=(0.001, "uniform"))       # std + distribution
StochasticFreeFormFins(fins, shape_points=(outline, 0.001))         # nominal + std
StochasticFreeFormFins(fins, shape_points=(outline, 0.001, "wald")) # nominal + std + distribution
StochasticFreeFormFins(fins, shape_points=outline)                  # one fixed outline
StochasticFreeFormFins(fins, shape_points=[outline_a, outline_b])   # choose between shapes

An outline that cannot mean a fin shape — empty, ragged, fewer than three points, three-dimensional — now fails during validation rather than reaching FreeFormFins.

Also adds StochasticRocket.add_free_form_fins, the rocketpy / rocketpy.stochastic exports, the API reference page, and a note in the stochastic usage docs about shape_points being the one argument randomized as a block.

Deterministic surfaces were never accepted (BUG)

Found while wiring the above, kept as its own commit so it can be reviewed or reverted independently.

_add_surfaces wrapped a plain surface with stochastic_type(component=...), but none of the stochastic aero-surface classes take a component keyword — each names its first parameter after its own surface (nosecone, tail, trapezoidal_fins, ...). Passing anything other than an already-stochastic surface to add_nose, add_trapezoidal_fins, add_elliptical_fins or add_tail therefore raised a TypeError, even though all four document the deterministic type as accepted:

TypeError: StochasticTrapezoidalFins.__init__() got an unexpected keyword argument 'component'

Passed positionally instead, which reaches every class regardless of what it calls that parameter.

Tests

24 new tests: the six accepted formats, nine invalid inputs, choice between outlines, the nominal outline surviving when nothing is randomized, and StochasticRocket integration. Plus an integration test that runs a real 2-simulation Monte Carlo and asserts the perturbed shape_points survives JSON serialization as an outline rather than as a single point.

The regression test for the component= bug fails with the TypeError above without the fix.

Locally: 1926 unit + 153 integration passing, plus the 5 slow Monte Carlo tests. ruff check, ruff format and pylint clean (10.00/10).

🤖 Generated with Claude Code

Gui-FernandesBR and others added 2 commits August 8, 2026 16:53
`_add_surfaces` wrapped a plain surface with `stochastic_type(component=...)`,
but none of the stochastic aero-surface classes take a `component` keyword:
each names its first parameter after its own surface (`nosecone`, `tail`,
`trapezoidal_fins`, ...). Passing anything other than an already-stochastic
surface to `add_nose`, `add_trapezoidal_fins`, `add_elliptical_fins` or
`add_tail` therefore raised a TypeError, even though all four document the
deterministic type as accepted.

Passed positionally instead, which reaches every class regardless of what it
calls that parameter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #953. Free-form fin sets were the only aerodynamic surface without a
stochastic counterpart, so a rocket using them could not be varied in a Monte
Carlo run.

`shape_points` does not fit the one-number-per-input assumption the base class
makes, and a fin shape is only meaningful as a complete set of points, so the
outline is randomized as a block: one sampled deviation applied to every
coordinate. Two formats needed handling before reaching the base class, both of
them the natural thing to write:

- a bare outline is a `list`, which the base class reads as a list of candidate
  values and would have sampled a single (x, y) point from. It is wrapped as the
  one candidate outline it is, and a list of outlines still means a choice
  between shapes.
- `(nominal outline, standard deviation)` has a list where `_validate_tuple`
  requires a number. Only that first item is special-cased; the deviation and
  the distribution name still go through the base class, so the distribution is
  drawn from this model's generator like every other input.

An outline that cannot mean a fin shape - empty, ragged, fewer than three
points, three-dimensional - now fails during validation rather than reaching
FreeFormFins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Gui-FernandesBR
Gui-FernandesBR requested a review from a team as a code owner August 10, 2026 02:38
@github-actions

Copy link
Copy Markdown
Contributor

Failed to generate code suggestions for PR

@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.95833% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 82.94%. Comparing base (e0ff281) to head (033f28e).
⚠️ Report is 47 commits behind head on develop.

Files with missing lines Patch % Lines
rocketpy/stochastic/stochastic_model.py 94.73% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #1117      +/-   ##
===========================================
+ Coverage    82.18%   82.94%   +0.76%     
===========================================
  Files          122      129       +7     
  Lines        16355    16901     +546     
===========================================
+ Hits         13441    14019     +578     
+ Misses        2914     2882      -32     

☔ 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.

@Gui-FernandesBR Gui-FernandesBR linked an issue Aug 10, 2026 that may be closed by this pull request
@Gui-FernandesBR

Copy link
Copy Markdown
Member Author

LGTM

Copilot AI left a comment

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.

Pull request overview

Adds stochastic free-form fin support for Monte Carlo simulations and fixes deterministic aerodynamic-surface wrapping.

Changes:

  • Adds StochasticFreeFormFins, exports, validation, and rocket integration.
  • Adds unit and Monte Carlo integration tests.
  • Adds API documentation and changelog entries.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
rocketpy/stochastic/stochastic_aero_surfaces.py Implements stochastic free-form fins.
rocketpy/stochastic/stochastic_rocket.py Adds free-form fins and fixes surface wrapping.
rocketpy/stochastic/__init__.py Exports the new model.
rocketpy/__init__.py Adds the public top-level export.
tests/unit/stochastic/test_stochastic_aero_surfaces.py Tests formats, sampling, and validation.
tests/unit/stochastic/test_stochastic_rocket.py Tests rocket integration and wrapping.
tests/integration/simulation/test_monte_carlo.py Tests end-to-end Monte Carlo serialization.
tests/fixtures/monte_carlo/stochastic_fixtures.py Adds a stochastic fins fixture.
docs/user/stochastic.rst Documents block outline randomization.
docs/reference/classes/monte_carlo/stochastic_models/stochastic_free_form_fins.rst Adds API reference.
docs/reference/classes/monte_carlo/stochastic_models/index.rst Adds reference navigation.
CHANGELOG.md Records enhancement and bug fix.
Suppressed comments (2)

rocketpy/stochastic/stochastic_aero_surfaces.py:528

  • get_distribution returns raw NumPy Generator methods, and the base generator later calls dist_func(nominal_outline, std_dev). This does not implement the promised block perturbation: normal makes an independent draw for every coordinate, uniform treats the outline as low and 0.001 as high (and raises for coordinates above 0.001), and wald rejects the outline's zero-valued means. Sample one scalar deviation under a defined distribution contract and then add/broadcast that value over the complete outline; please cover the advertised uniform and wald forms with regression tests.
            _, std_dev, dist_func = super()._validate_tuple(
                input_name, (0.0,) + tuple(input_value[1:]), getattr
            )
            return (nominal_outline, std_dev, dist_func)

rocketpy/stochastic/stochastic_aero_surfaces.py:541

  • np.shape raises ValueError for ragged nested sequences, but this catches only IndexError. As a result, a malformed ragged outline leaks the wrong exception, and a valid candidate list whose outlines use different numbers of points cannot be accepted. Convert safely once and return False on conversion failure so the caller can validate each candidate separately.
        try:
            return len(np.shape(value)) == 2 and np.shape(value)[1] == 2
        except IndexError:  # pragma: no cover - ragged input
            return False

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +447 to +449
for outline in shape_points:
self._validate_outline(outline)
return shape_points

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in c685d63. The choice now comes from a generator of the model's own, derived from the same seed through _sampler_seed but on a stream separate from the one the distributions draw from, so declaring a list input does not shift the numbers every other input gets and existing fixed-seed baselines without a list input stay where they are. StochasticRocket._randomize_position had the same bug and is fixed too. Regression test: test_list_choices_are_reproducible.

Comment on lines +373 to +375
- ``list``: list of candidate outlines, one of which is chosen at
random. A single outline must therefore be wrapped in a list,
i.e. ``[[(0, 0), (0.1, 0.1), (0.1, 0)]]``.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 033f28e. The docstring now describes the two forms a list can take separately: one fixed outline, used as given, or a list of candidate outlines to choose between (which no longer have to have the same number of points). The extra nesting level is not required.

Comment thread docs/user/stochastic.rst Outdated
Comment on lines +101 to +104
coordinate of every point. A list is still a set of values to choose from,
which for this argument means a list of candidate outlines, so a single
outline must be wrapped in a list to be read as one candidate rather than as
a list of points::

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 033f28e, along with the matching docstring. The user guide now documents the bare-outline and candidate-list forms separately, states which distributions can be used for shape_points and why, and notes that the fin root is held on the body line.

Gui-FernandesBR and others added 4 commits August 12, 2026 00:36
Both branches added an entry at the top of the unreleased `Added` section,
so the conflict is only in the order of two lines. Both are kept, the new
one on top, as the changelog asks for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`dict_generator` and `StochasticRocket._randomize_position` picked from a
list with `random.choice`, which draws from the interpreter-wide stream.
`_set_stochastic` only rebuilds the model's own numpy generator, so that
stream was never reseeded: a fixed-seed run did not reproduce the values
chosen from a list, and Monte Carlo workers forked from one process
inherited a single `random` state and walked the same choice sequence
instead of sampling independently.

The choice now comes from a generator of the model's own, derived from the
same seed through `_sampler_seed` but kept apart from the one the
distributions draw from, so that declaring a list input does not shift the
numbers every other input gets and existing fixed-seed baselines that use
no list input stay where they are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`StochasticFreeFormFins` needed a whole fin outline where the base class
reads a single number, and got there by overriding `_validate_tuple`,
comparing the input name against the literal `"shape_points"` and
smuggling a `0.0` placeholder through `super()`. The rest of the machinery
was never told, so `_validate_scalar`, `dict_generator` and
`visualize_attributes` all still believed the value was a number: the
public `visualize_attributes` raised a `TypeError` formatting an outline
with `:.5f`, and the next array-valued input would have had to rediscover
the same workaround.

`array_valued_inputs` declares those inputs by name on the class instead,
so validation, sampling and the report agree on which ones they are.
`_nominal_value` converts them where the nominal value is looked up, and
the report prints the array's shape rather than trying to format its
coordinates as one number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `shape_points` contract did not hold up to the formats the docstring
and the user guide advertise:

- `_is_outline` caught only `IndexError`, but `np.shape` raises a
  `ValueError` on a ragged sequence. A list of candidate outlines with
  different numbers of points -- choosing between a three-point and a
  four-point fin, the plainest form of choosing between shapes -- died
  with an opaque numpy error, and a ragged outline did not fail during
  validation as claimed. Each candidate is now converted on its own.
- Every distribution name was accepted, but the deviation is applied as
  `dist_func(nominal_outline, std_dev)`, so only the ones that read the
  first argument as the centre of the draw can work. `uniform` read the
  outline as its lower bound and `wald` rejected the zeros of a root
  point, both raising in the middle of a Monte Carlo run. The four that
  can are accepted and the rest are rejected up front.
- An outline of non-numbers passed validation and died inside
  `_FreeFormGeometry.infer_dimensions`; a numpy array was rejected even
  though `create_object` produces one and `FreeFormFins` takes one; and
  `shape_points=[]` raised where an empty list means "use the nominal
  value" for every other input.

The fin root is also held on the body line now. `FreeFormFins` measures
the span from y = 0 and slices the chords over that interval, so
perturbing every y drove most samples off the line -- 227 of 300 outlines
had a point inside the airframe -- and the interference factors were
computed from the inflated span that followed. Points nominally on the
line stay there and none is allowed to cross it, which is what the root
edge of a fin does anyway.

The docstring and the user guide said a single outline "must be wrapped in
a list" while the code auto-detects a bare one, and that one deviation is
shared by every coordinate while each is in fact drawn on its own. Both
now describe what the code does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Gui-FernandesBR

Copy link
Copy Markdown
Member Author

Review comments addressed, and develop merged in (the only conflict was two competing entries at the top of the changelog's Added section; both are kept).

The two suppressed comments

The distribution contract (stochastic_aero_surfaces.py:528). The diagnosis was right: normal drew independently per coordinate, uniform read the outline as its lower bound, and wald rejected a root point's zeros. I did not take the suggested fix of sampling one scalar and broadcasting it, because a single scalar added to every x and y is a rigid diagonal translation — it leaves root chord, span and area unchanged, so it randomizes nothing, and it would fight the root anchoring below.

Instead the per-coordinate draw is kept, which is what a manufacturing tolerance actually is, and the contract is now stated and enforced: only normal, gumbel, laplace and logistic are accepted, since those are the ones that read their first argument as the centre of the draw. uniform and wald are rejected during validation with a message naming the alternatives, rather than being advertised and then raising in the middle of a Monte Carlo run. The docstring, the user guide and the changelog no longer claim one shared deviation.

The ragged input (stochastic_aero_surfaces.py:541). Fixed as suggested — a single safe conversion that returns False on failure, so each candidate is validated on its own. Choosing between a three-point and a four-point fin works now; it did not before, which made the headline "choose between shapes" case unusable. A genuinely ragged outline fails with the documented AssertionError, as the PR claimed it already did.

Further defects found while addressing the above

  • visualize_attributes() raised TypeError: unsupported format string passed to list.__format__ for any StochasticFreeFormFins that randomized shape_points, because the report formats every tuple nominal value with :.5f. The fixture is now in the parametrized test for that method.
  • An outline of non-numbers passed validation and died inside _FreeFormGeometry.infer_dimensions; a numpy array was rejected even though create_object produces one and FreeFormFins accepts one; and shape_points=[] raised, where an empty list means "use the nominal value" for every other stochastic input.
  • The fin root drifted off the body line. _FreeFormGeometry measures the span from y = 0 and slices the chords over [0, span], so perturbing every y freely put a point inside the airframe in 227 of 300 samples (seed 3) and computed tau and the interference factors from the inflated span that followed. Points nominally on the line now stay there and none may cross it — which is what a fin root does anyway. Now 0 of 300.
  • The if input_name == "shape_points" comparison inside the _validate_tuple override, and the 0.0 placeholder smuggled through super(), are replaced by a declarative StochasticModel.array_valued_inputs. That is what let validation, sampling and the report finally agree, and it is why the two crashes above existed.

Verification

2035 passed, 27 skipped across tests/unit and tests/integration/simulation; ruff check, ruff format --check and pylint rocketpy/ tests/ docs/ all clean.

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.

Free form fin in the stochastic method

2 participants