ENH : Multistage mission architecture implementation - #1155
Conversation
- 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.
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
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.
|
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 |
|
1. ( 2. The docs build with Since this branch lives on the main repo I can push both fixes myself if you prefer — say the word. Otherwise please push them and I will review the substance (2500 lines deserves a real read, and I would rather do that against green CI). |
Pull request type
Checklist
black rocketpy/ tests/) has passed locallypytest tests -m slow --runslow) have passed locallyCHANGELOG.mdhas been updated (if relevant)Current behavior
RocketPy has no first-class representation of multistage rockets or deployable
payloads. A
Rocketis always one rigid body with one motor; anything beyond thatrequires hand-rolling multiple
Flightruns and manually computing state handoff.This branch previously carried a scaffold attempting to solve this (
0caf343d) thatnever actually worked: a misspelled
__init__.pysilently turned the package into anempty namespace, four files were 0 bytes, three others didn't even parse
(
IndentationError), and none of it was imported anywhere. That scaffold is removedin
0099ac29.New behavior
Rebuilds the same goal from a simpler design:
SeparableBody/Stage/Deployable/MultiStageRocket(
rocketpy/rocket/multistage.py) — the vehicle-composition layer.Stagewraps afully built
Rocketas one stage;Deployableis a carried payload (mass/inertiawhile attached, plus either a
free_rocketoradd_surface()-built aerodynamicsafter ejection);
MultiStageRocket.flight_rocket()composes any number of activestages plus carried deployables into one real, working
Rocket(mass/inertia/CoMvia 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 oneFlightpervehicle configuration:
Flight(confirms the design doc'sstated invariant)
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-frameoffset between parent/child center-of-dry-mass into the inertial frame, splits
separation_delta_vby momentum conservation)Flights, reusingFlight's existingterminate_on_apogeeflag rather than inventing newtrigger machinery
docs/notebooks/multistage_mission.ipynb— runnable, executed end-to-end demoof 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'simpact-detection code), and a
max_timetoo short to reach a genuine impact(mislabeled a mid-flight cutoff as
impact:).**Deliberate simplifications:
Event/trigger solver —Flightonly exposesmax_timeand
terminate_on_apogeetoday, soStage.separationis a plain float (delay inseconds after burnout) and deployable ejection is presently
"apogee"only.interstage_lengths+ physical stage extent;every active stage's
Rocketcoordinate system is assumed to already share oneframe.
free_rocket(not yet buildable from
add_surface()alone).Breaking change
Purely additive: 5 new files, 0 lines changed in any existing file
(
git diff --stat 7f85c072..HEAD→+1998/-0). Nothing is wired intorocketpy/__init__.py,rocketpy/rocket/__init__.py, orrocketpy/simulation/__init__.pyyet — new classes are reachable only via theirsubmodule paths (
from rocketpy.rocket.multistage import Stage), so this can'tcollide with anything existing.
Additional information