ENH : Multistage mission architecture implementation - #1155
Open
aZira371 wants to merge 11 commits into
Open
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.
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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