Skip to content

ENH : Multistage mission architecture implementation - #1155

Open
aZira371 wants to merge 11 commits into
developfrom
enh/mission-architecture-implementation
Open

ENH : Multistage mission architecture implementation#1155
aZira371 wants to merge 11 commits into
developfrom
enh/mission-architecture-implementation

Conversation

@aZira371

Copy link
Copy Markdown
Collaborator

Pull request type

  • Code changes (bugfix, features)
  • Code maintenance (refactoring, formatting, tests)
  • ReadMe, Docs and GitHub updates
  • Other (please describe):

Checklist

  • Tests for the changes have been added (if needed)
  • Docs have been reviewed and added / updated
  • Lint (black rocketpy/ tests/) has passed locally
  • All tests (pytest tests -m slow --runslow) have passed locally
  • CHANGELOG.md has been updated (if relevant)

Current behavior

RocketPy has no first-class representation of multistage rockets or deployable
payloads. A Rocket is always one rigid body with one motor; anything beyond that
requires hand-rolling multiple Flight runs and manually computing state handoff.

This branch previously carried a scaffold attempting to solve this (0caf343d) that
never actually worked: a misspelled __init__.py silently turned the package into an
empty namespace, four files were 0 bytes, three others didn't even parse
(IndentationError), and none of it was imported anywhere. That scaffold is removed
in 0099ac29.

New behavior

Rebuilds the same goal from a simpler design:

  • SeparableBody / Stage / Deployable / MultiStageRocket
    (rocketpy/rocket/multistage.py) — the vehicle-composition layer. Stage wraps a
    fully built Rocket as one stage; Deployable is a carried payload (mass/inertia
    while attached, plus either a free_rocket or add_surface()-built aerodynamics
    after ejection); MultiStageRocket.flight_rocket() composes any number of active
    stages plus carried deployables into one real, working Rocket (mass/inertia/CoM
    via the parallel axis theorem, motor + aerodynamic surfaces attached) — verified
    against hand-computed values, not just round-tripped through its own formulas.

  • Mission (rocketpy/simulation/mission.py) — orchestrates one Flight per
    vehicle configuration:

    • single-stage vehicle → degenerates to one Flight (confirms the design doc's
      stated invariant)
    • two-stage vehicle → three Flights (full stack, spent booster falling away,
      sustainer continuing), with deterministic burnout+delay separation timing and a
      physically-derived state handoff (_handoff_state: rotates the body-frame
      offset between parent/child center-of-dry-mass into the inertial frame, splits
      separation_delta_v by momentum conservation)
    • single-stage + one deployable ejecting at apogee → three Flights, reusing
      Flight's existing terminate_on_apogee flag rather than inventing new
      trigger machinery
  • docs/notebooks/multistage_mission.ipynb — runnable, executed end-to-end demo
    of every feature above, growing alongside the commits. Caught two real bugs before
    they shipped: a test rig with thrust below its own weight (crashed Flight's
    impact-detection code), and a max_time too short to reach a genuine impact
    (mislabeled a mid-flight cutoff as impact:).

**Deliberate simplifications:

  • No generic mid-flight Event/trigger solver — Flight only exposes max_time
    and terminate_on_apogee today, so Stage.separation is a plain float (delay in
    seconds after burnout) and deployable ejection is presently "apogee" only.
  • Stack positions aren't derived from interstage_lengths + physical stage extent;
    every active stage's Rocket coordinate system is assumed to already share one
    frame.
  • At most two stages, at most one deployable, deployables require free_rocket
    (not yet buildable from add_surface() alone).

Breaking change

  • Yes
  • No

Purely additive: 5 new files, 0 lines changed in any existing file
(git diff --stat 7f85c072..HEAD+1998/-0). Nothing is wired into
rocketpy/__init__.py, rocketpy/rocket/__init__.py, or
rocketpy/simulation/__init__.py yet — new classes are reachable only via their
submodule paths (from rocketpy.rocket.multistage import Stage), so this can't
collide with anything existing.

Additional information

aZira371 added 9 commits May 28, 2026 19:49
- ENH: added code architecture a new class called body

- ENH: added code architecture a new class called mission aimed at deployable and stage implementation

- ENH: added code architecture for flight branch class for multistage integration class to simulation
rocketpy/body/, rocketpy/mission/, and rocketpy/simulation/
flight_branch.py (added in 0caf343) never actually worked:
rocketpy/mission/__initi__.py is misspelled so the package init
never ran, four files were empty, and three others had function
signatures with no body (IndentationError). None of it was
imported by rocketpy/__init__.py or referenced anywhere else in
the repo.

Clearing the way for a simpler multistage/mission design
(SeparableBody/Stage/Deployable/MultiStageRocket + Mission
orchestrator) to be built incrementally.
First slice of the new multistage/mission architecture (see
mission_multistage_design.md). SeparableBody is the shared base
for anything that starts attached to a vehicle and becomes a free
body at separation; Stage wraps a fully built Rocket representing
one stage flying on its own.

For now Stage is a thin wrapper: dry_mass and burn_out_time just
forward to the wrapped Rocket's own already-computed attributes.
Composition into a full multistage vehicle (MultiStageRocket) and
orchestration (Mission) land in later commits.
Second slice of the multistage/mission architecture. Deployable is
the mission-layer wrapper for an inert carried body (payload, nose
cone, etc.) that gets ejected during flight: mass/inertia/position
while attached, plus either a fully built free_rocket or surfaces
added via add_surface() for its free-flight aerodynamics -
mutually exclusive, matching mission_multistage_design.md.

Also revises SeparableBody from the previous commit: it no longer
holds a shared "separation" attribute. Stage's release event is
named separation, Deployable's is named ejection - forcing both
through one base attribute name was misleading, so each subclass
now owns its own.
Third slice of the multistage/mission architecture. MultiStageRocket
composes a Stage plus any Deployables aboard into one flight-ready
Rocket via flight_rocket(): mass, inertia and center of mass are
combined with the parallel axis theorem (reusing
tools.parallel_axis_theorem_from_com, the same helper Rocket itself
uses for structure+motor composition), then the stage's motor and
aerodynamic surfaces are attached to the result.

Only a single active stage is supported for now; flight_rocket()
raises NotImplementedError otherwise. Multi-stage composition
(booster + sustainer together, surfaces repositioned into stack
coordinates) is a later commit, matching the roadmap in
mission_multistage_design.md.

Also adds docs/notebooks/multistage_mission.ipynb, a runnable demo
of everything landed so far (Stage, Deployable,
MultiStageRocket.flight_rocket single-stage case). Executed
end-to-end to confirm it runs, outputs then cleared to match this
repo's existing notebook convention (see docs/notebooks/
utilities_usage.ipynb).
Fourth slice of the multistage/mission architecture. flight_rocket()
now composes any number of active stages, not just one:
active_stages[0] is the currently firing stage (its motor becomes
the composed Rocket's own motor); every stage above it is inert
cargo, contributing its full current mass/inertia/CoM (structure +
motor + unburned propellant, evaluated at that stage's own t=0)
since its own motor clock hasn't started yet.

Default stack drag (no stack_power_off_drag/on_drag override) is
now an area-weighted sum of each active stage's own drag curve -
the approach mission_multistage_design.md itself leans toward for
_derive_stack_drag. This subsumes the single-stage case from the
previous commit, so that code path is unchanged in behavior.

Split flight_rocket() into _compose_mass_and_center_of_mass and
_compose_inertia to keep it under pylint's statement-count limit,
mirroring how Rocket itself splits mass/inertia composition into
separate evaluate_* methods.

Deviation from the design doc: _stack_position_of() (deriving each
stage's axial position from interstage_lengths + physical extent)
is not implemented. Every active stage's Rocket coordinate system
is assumed to already share one common stack frame; positions are
used as-is. Flagged in the notebook's "Not built yet" section as
remaining work.

Also updates docs/notebooks/multistage_mission.ipynb with a
two-stage (booster + inert sustainer) demo, executed end-to-end
and verified against the same hand-computed values used in
test_multistage.py.
Fifth slice of the multistage/mission architecture. Mission is the
orchestrator that will eventually walk a vehicle's separation/
ejection events and run one Flight per configuration with state
handoff between them (see mission_multistage_design.md). This
commit implements only the base case the doc itself calls out as
an invariant: "a single-stage vehicle with no deployables
degenerates to a thin wrapper around one Flight."

A plain Rocket is accepted directly (wrapped internally as a
single-stage MultiStageRocket, matching flight_rocket's own
auto-wrap behavior). Anything with more than one stage or any
deployables raises NotImplementedError for now - multi-stage
orchestration and deployable ejection are separate, later commits.

mission.timeline records (time, event_name) pairs - ignition,
liftoff, rail_departure, impact - sorted by time.

Also updates docs/notebooks/multistage_mission.ipynb with a live
Mission run against Calisto, executed end-to-end.
Sixth slice of the multistage/mission architecture, and the most
physics-heavy one so far. Mission now runs a real two-stage
sequence: full stack (booster firing) -> spent booster falling away
on its own -> sustainer continuing on its own, with correct state
handoff at each split.

Separation and sustainer ignition are deterministic, computed from
booster.burn_out_time + booster.separation and
sustainer.ignition_delay - there is still no generic mid-flight
event/trigger solver in RocketPy, only Flight's max_time and
terminate_on_apogee, so Stage.separation is a plain float (delay in
seconds after burnout), not an Event. Event-triggered ignition
(Stage.ignition) raises NotImplementedError for now.

_handoff_state implements mission_multistage_design.md's formula:
r_C = r_P + R(q) d, v_C = v_P + R(q)(omega x d) + this child's share
of separation_delta_v, using Matrix.transformation for R(q) and the
body-frame offset between parent/child center-of-dry-mass for d.
separation_delta_v is split between the two children by momentum
conservation (_split_separation_delta_v): the booster is spent
(dry_mass) and the sustainer hasn't ignited yet (full total_mass at
its own t=0).

_shift_motor_ignition re-anchors the sustainer's motor so it ignites
at its actual mission time rather than its own local t=0 - necessary
because Mission runs every Flight in one absolute clock
(mission_multistage_design.md's stated design commitment) and
Flight has no built-in way to offset a motor's own time origin.

Caught two real bugs via the notebook's end-to-end run rather than
just unit tests: a test rig with insufficient thrust-to-weight ratio
that never left the pad (crashed Flight's impact-detection code with
an IndexError), and a notebook max_time too short to reach a genuine
impact (mislabeled a mid-flight cutoff as "impact:sustainer").

flight_rocket() is called multiple times per Mission run (once for
the stack, again for each child, again inside _shift_motor_ignition)
- some redundant Rocket construction, acceptable for a first correct
implementation.

Also updates docs/notebooks/multistage_mission.ipynb with a full
two-stage mission run, executed end-to-end.
Final slice of the multistage/mission architecture roadmap. A
single-stage vehicle with exactly one deployable whose ejection is
the string "apogee" now runs three Flights: the carrier stage with
the deployable aboard up to apogee (reusing Flight's existing
terminate_on_apogee flag - no new event machinery needed, matching
the plan's stated approach), then two children from that apogee
state onward - the carrier continuing without the deployable's
mass, and the deployable flying its own free_rocket.

Generalizes the two-stage commit's momentum split
(_split_separation_delta_v) into _momentum_split(mass_a, mass_b,
delta_v), reused as-is for the carrier/deployable split: same
physics, same _handoff_state, just different masses (carrier's
total_mass at apogee time vs. the deployable's free_rocket total
mass at its own t=0).

Deployables built from add_surface() instead of a fully-built
free_rocket, and ejection triggers other than "apogee", raise
NotImplementedError - out of scope, per the roadmap.

Also updates docs/notebooks/multistage_mission.ipynb with the
deployable-ejection demo, executed end-to-end, and finalizes the
notebook's "Not built yet" section now that all 8 planned commits
have landed.
@aZira371
aZira371 requested a review from a team as a code owner August 14, 2026 09:53
@aZira371 aZira371 self-assigned this Aug 14, 2026
@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.46743% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.73%. Comparing base (e0ff281) to head (234666a).
⚠️ Report is 59 commits behind head on develop.

Files with missing lines Patch % Lines
rocketpy/simulation/mission.py 97.38% 4 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #1155      +/-   ##
===========================================
+ Coverage    82.18%   83.73%   +1.55%     
===========================================
  Files          122      132      +10     
  Lines        16355    17343     +988     
===========================================
+ Hits         13441    14522    +1081     
+ Misses        2914     2821      -93     

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

aZira371 and others added 2 commits August 14, 2026 16:05
flight_rocket() copies aerodynamic surfaces directly onto the
composed Rocket (bypassing add_surfaces() to avoid double-
transforming fin leading-edge positions - see the comment at the
copy site). That also skipped evaluate_surfaces_cp_to_cdm(), a
step add_surfaces() normally triggers and that
Flight.u_dot_generalized requires to apply aerodynamic forces
during a real 6DOF simulation.

Every existing test flew bare rockets with no surfaces, so this
never got exercised - only surfaced when actually running a stage
with real aerodynamic surfaces through Mission end-to-end (via the
new visualization notebook work). KeyError on
rocket.surfaces_cp_to_cdm[aero_surface] the moment such a Flight's
ODE integration starts.

ENH: add MultiStageRocket.draw() and Mission.all_flights

Visualization support, reusing RocketPy's existing plotting
entirely - no new drawing or comparison code:

- MultiStageRocket.draw() composes every stage via flight_rocket()
  and hands the result to Rocket's own plots.draw() unchanged. A
  real multi-diameter stack renders correctly for free, since
  Rocket's tube-drawing already handles a radius change between
  surfaces.
- Mission.all_flights collects every Flight Mission runs, in
  execution order, each exactly once (a Flight shared by several
  bodies, e.g. the full stack, isn't duplicated) - built
  specifically to feed CompareFlights directly, matching
  mission_multistage_design.md's stated design ("feeds
  CompareFlights").
- Every Flight Mission creates now gets a distinguishing name
  (e.g. "booster+sustainer", "booster", "sustainer") instead of
  Flight's own "Flight" default, so CompareFlights legends are
  actually readable with more than one flight in the comparison.

Also updates docs/notebooks/multistage_mission.ipynb with draw()
and trajectory-comparison demos, executed end-to-end. Departs from
this notebook's earlier convention of clearing outputs: plot image
outputs are now kept, matching docs/notebooks/getting_started.ipynb
rather than the print-only utilities_usage.ipynb, since the whole
point of these cells is the rendered image.
@aZira371

Copy link
Copy Markdown
Collaborator Author

studying if changes from #968 can be accommodated or not. ideally need the events PR to be closed for cleaner implementation and avoid double work

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