ENH: Events Class and Flight Rework - #968
Draft
MateusStano wants to merge 52 commits into
Draft
MateusStano wants to merge 52 commits into
MateusStano wants to merge 52 commits into
Conversation
…ved variables retrieval
…logging Expose recorded sensor measurements as the canonical, per-flight record on `Flight.sensor_data`, and route every flight-scoped consumer through it so results stay correct when a rocket/sensor is reused across simulations. - Sensors: add `flight.sensor_data` as the source of truth; `flight.prints. sensors()` and `flight.plots.sensor_data()` now read from it. Give the per-sensor plots/prints an optional `data=` argument so standalone use still works (`_SensorPlots`, `_SensorPrints`). - Flight plots: improve event markers and the trajectory/ground-track plots (square ground track with equal axis ticks, trajectory omitted from the legend) and related layout cleanups in `flight_plots.py`. - Logging: add a centralized `rocketpy._logging` module exposing `logger`, `set_log_level` and `enable_logging`, following the NullHandler library convention; wire it into the event/simulation code. - Simulation: supporting changes in flight, flight derivatives, flight phases, events (commands, exact-time solvers) and the data exporter. - Stochastic: rebuild the air-brakes `_Controller` with its current signature (controlled_objects / context), fixing a stale constructor call. - Docs: update getting-started and sensors notebooks to the `flight.exports.*` API and document sensor usage.
MateusStano
marked this pull request as draft
June 15, 2026 17:47
aZira371
added a commit
that referenced
this pull request
Aug 24, 2026
…vent plotting draw() fixes, previously only exercised at N=2, now verified and corrected at arbitrary stage count: - Every stage past the bottom one gets its own motor drawn at its own stacked position (a composed stack Rocket can only carry one active motor, so it previously showed only the firing stage's own). - A stage's declared length=... extent that isn't covered by its own surfaces (a bare interstage adapter, or a stage with only a nose or only fins/tail) is filled with a plain body-tube outline instead of being left blank. - Rocket.plots.draw()'s own _draw_tubes connects whichever surfaces end up adjacent once every stage is merged into one composed body - it has no notion of "stage", so it could draw a single straight tube bridging straight across an interstage gap at one stage's own (mismatched) radius. Removed post hoc, keyed off each stage's own declared boundary. - Motors are now sized to the stage's own radius in every demo/test rig (draw_motor() renders nothing for PointMassMotor, and reusing a much bigger motor from a different rocket visibly bulges out of a smaller one). Mission timeline gains burnout:<stage> and apogee:<flight> events, alongside the existing ignition/liftoff/separation/ejection/impact. Flight.apogee_time defaults to 0 (not a "not found" sentinel) when a flight ends before ever reaching its own apogee - recording it unconditionally would claim apogee happened at liftoff for any separation- or max_time-truncated flight. Corrected via each flight's own vz sign at its end, which also correctly includes an apogee-terminated ejection flight (ends exactly at its own apogee, by design) that a naive time-bounds check would exclude too. Mission.plot_timeline() and Mission.plot_trajectory_events() add mission-profile visualization: altitude-vs-time and 3D trajectory respectively, with every timeline event marked (color- and shape-coded by event type; the 3D view uses points, not lines, since an instant has no natural line in 3D). Both read only mission.timeline's (time, name) tuples, so they keep working unchanged if that timeline is ever built from real Event objects instead of Mission's own deterministic bookkeeping (see the "Advance Multistage" plan's Gap 3, still blocked on upstream PR #968). Extensively validated: a deliberate matrix (every Motor subclass, mixed coordinate_system_orientation across stages, a powered deployable, surfaces on a middle/adapter stage) plus randomized fuzzing (~2480 configs total across this and earlier sessions) found zero new Mission-architecture bugs - every completed Mission passed all invariant checks. Findings, comparisons, and the full test matrix are written up in the new docs/notebooks/multistage_mission_test_report.ipynb.
Gui-FernandesBR
pushed a commit
that referenced
this pull request
Sep 9, 2026
…vent plotting draw() fixes, previously only exercised at N=2, now verified and corrected at arbitrary stage count: - Every stage past the bottom one gets its own motor drawn at its own stacked position (a composed stack Rocket can only carry one active motor, so it previously showed only the firing stage's own). - A stage's declared length=... extent that isn't covered by its own surfaces (a bare interstage adapter, or a stage with only a nose or only fins/tail) is filled with a plain body-tube outline instead of being left blank. - Rocket.plots.draw()'s own _draw_tubes connects whichever surfaces end up adjacent once every stage is merged into one composed body - it has no notion of "stage", so it could draw a single straight tube bridging straight across an interstage gap at one stage's own (mismatched) radius. Removed post hoc, keyed off each stage's own declared boundary. - Motors are now sized to the stage's own radius in every demo/test rig (draw_motor() renders nothing for PointMassMotor, and reusing a much bigger motor from a different rocket visibly bulges out of a smaller one). Mission timeline gains burnout:<stage> and apogee:<flight> events, alongside the existing ignition/liftoff/separation/ejection/impact. Flight.apogee_time defaults to 0 (not a "not found" sentinel) when a flight ends before ever reaching its own apogee - recording it unconditionally would claim apogee happened at liftoff for any separation- or max_time-truncated flight. Corrected via each flight's own vz sign at its end, which also correctly includes an apogee-terminated ejection flight (ends exactly at its own apogee, by design) that a naive time-bounds check would exclude too. Mission.plot_timeline() and Mission.plot_trajectory_events() add mission-profile visualization: altitude-vs-time and 3D trajectory respectively, with every timeline event marked (color- and shape-coded by event type; the 3D view uses points, not lines, since an instant has no natural line in 3D). Both read only mission.timeline's (time, name) tuples, so they keep working unchanged if that timeline is ever built from real Event objects instead of Mission's own deterministic bookkeeping (see the "Advance Multistage" plan's Gap 3, still blocked on upstream PR #968). Extensively validated: a deliberate matrix (every Motor subclass, mixed coordinate_system_orientation across stages, a powered deployable, surfaces on a middle/adapter stage) plus randomized fuzzing (~2480 configs total across this and earlier sessions) found zero new Mission-architecture bugs - every completed Mission passed all invariant checks. Findings, comparisons, and the full test matrix are written up in the new docs/notebooks/multistage_mission_test_report.ipynb.
Gui-FernandesBR
pushed a commit
that referenced
this pull request
Sep 12, 2026
…vent plotting draw() fixes, previously only exercised at N=2, now verified and corrected at arbitrary stage count: - Every stage past the bottom one gets its own motor drawn at its own stacked position (a composed stack Rocket can only carry one active motor, so it previously showed only the firing stage's own). - A stage's declared length=... extent that isn't covered by its own surfaces (a bare interstage adapter, or a stage with only a nose or only fins/tail) is filled with a plain body-tube outline instead of being left blank. - Rocket.plots.draw()'s own _draw_tubes connects whichever surfaces end up adjacent once every stage is merged into one composed body - it has no notion of "stage", so it could draw a single straight tube bridging straight across an interstage gap at one stage's own (mismatched) radius. Removed post hoc, keyed off each stage's own declared boundary. - Motors are now sized to the stage's own radius in every demo/test rig (draw_motor() renders nothing for PointMassMotor, and reusing a much bigger motor from a different rocket visibly bulges out of a smaller one). Mission timeline gains burnout:<stage> and apogee:<flight> events, alongside the existing ignition/liftoff/separation/ejection/impact. Flight.apogee_time defaults to 0 (not a "not found" sentinel) when a flight ends before ever reaching its own apogee - recording it unconditionally would claim apogee happened at liftoff for any separation- or max_time-truncated flight. Corrected via each flight's own vz sign at its end, which also correctly includes an apogee-terminated ejection flight (ends exactly at its own apogee, by design) that a naive time-bounds check would exclude too. Mission.plot_timeline() and Mission.plot_trajectory_events() add mission-profile visualization: altitude-vs-time and 3D trajectory respectively, with every timeline event marked (color- and shape-coded by event type; the 3D view uses points, not lines, since an instant has no natural line in 3D). Both read only mission.timeline's (time, name) tuples, so they keep working unchanged if that timeline is ever built from real Event objects instead of Mission's own deterministic bookkeeping (see the "Advance Multistage" plan's Gap 3, still blocked on upstream PR #968). Extensively validated: a deliberate matrix (every Motor subclass, mixed coordinate_system_orientation across stages, a powered deployable, surfaces on a middle/adapter stage) plus randomized fuzzing (~2480 configs total across this and earlier sessions) found zero new Mission-architecture bugs - every completed Mission passed all invariant checks. Findings, comparisons, and the full test matrix are written up in the new docs/notebooks/multistage_mission_test_report.ipynb.
These changes ship in v1.15, so what they deprecate cannot be removed before v1.16. The removal versions the branch had (v1.13 and v1.14) are already in the past.
The sensors entry is a list of the rocket's sensors; the dictionary keyed by name is sensors_by_name, which was missing. step_size is the time elapsed inside the solver step being evaluated, not the last step size.
Introduce the container that will let each flight phase store only the state variables it integrates. A StateSchema names a phase's variables and knows how to rebuild the full 13-variable canonical state (reconstruction hooks with a freeze fallback). SolutionSegment holds one phase's rows; Solution behaves like the old list of rows and also answers queries by variable name. Nothing imports it yet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cover StateSchema (canonicalize/freeze/reconstruction/atol mapping), StateView, Solution list-like behaviour across a mixed-width segment boundary, named queries, and serialization (round-trip, legacy flat list). Flight-free. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_Dynamics pairs a phase derivative with its state schema, its derived-quantity outputs, and a seeding rule; _BoundDynamics binds it to a flight and is callable with the solver's (t, u, post_processing) signature. Instances cover the rail, solid-propulsion, 6-DOF, 3-DOF and parachute phases (the parachute still uses the canonical 13-state schema for now). Not wired into the pipeline yet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the flat solution list with the Solution container and drive each phase through a bound _Dynamics. The integration loop opens one segment per phase, seeds a new phase from the canonicalized state that ended the previous one, and maps atol onto the phase's schema. Event triggers and callbacks receive the reconstructed canonical state (with the raw state kept for rollbacks); the apogee preset and exact-time solver read the state schema-aware. Retrieval properties and serialization go through named queries / the segment-based to_dict, and old flat-list .rpy files still load. All schemas remain canonical here, so results are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each flight phase records its derived quantities (accelerations, aerodynamic forces and moments, net thrust) into its own segment, ordered by the phase's declared derived names. Outputs like flight.ax and flight.M1 are reassembled by name across segments, zero-filling phases that do not report a given quantity, so every derived Function keeps covering the whole flight. Replaces the bisect-based re-run of __evaluate_post_process with a clean per-segment replay; flights with dynamics-changing events still record live. Results unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The parachute descent now integrates only position and velocity; attitude and angular rates are held fixed at their deployment values via the schema's freeze fallback. u_dot_parachute returns a 6-element derivative and records the six derived quantities it produces (accelerations and drag force). Removing the seven unused states from the solver's error control changes descent results slightly. Update the solution-time-ordering test to use flight.solution.time, since np.array(solution) no longer applies to a mixed-width solution. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Flight.solution_array and Flight.get_solution_at_time now warn and point to the named queries on flight.solution (removal targeted v1.14); they still return the canonical projection. The no-arguments FlightDataExporter fast path now writes flight.solution.canonical_array (so it works for reduced-state flights too), corrects its header to include the missing Vx/Vy/Vz columns, and warns that explicit variable names will be required. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Describe flight.solution as the new Solution container (list-like plus queries by variable name), note that event/parachute callbacks always receive the full canonical state even during reduced-state phases, and record the change, deprecations and result shift in the changelog. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move the Solution import in _encoders to module scope (no import cycle) and document the unused NumPy-protocol argument on Solution.__array__. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
apogee_trigger now reads the previous state through flight.solution, so the hand-built mock must use a Solution instead of a plain list of rows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ties Replace the flat 13-state solution table with a Solution container holding one PhaseSolution per flight phase. A phase declares the states it integrates through a StateSchema and still reports the full canonical state, reconstructing or freezing whatever it does not integrate. The parachute descent now integrates only position and velocity. Derived quantities (accelerations, aerodynamic forces and moments, net thrust) are stored per phase alongside the states. A phase's derivative returns them rather than writing into the flight, and the caller records them, so the flight no longer has to track which phase is currently receiving rows. DerivedQuantity says how to label each quantity and what to report for the phases that do not compute it. Also in this change: - pass the descending parachute to the parachute derivative as an argument instead of reading it off the flight - guard the degenerate cases in find_roots_cubic_function, which a cubic Hermite fit reaches whenever a quantity changes at a constant rate - when loading a .rpy, rebuild the solution before the other attributes, since the fallbacks read flight outputs computed from it, and stop the net_thrust fallback from resampling the motor's own thrust curve
Each PhaseSolution owned its own rows, so reading row i of the flight meant walking the phase list to find its owner. Solution now holds every row in one list and each phase records where its rows begin, so the owner is found with a bisect over those start indices. This turns penultimate_raw_time, which the event loop asks for on every check, from a backwards scan over the phases into a plain list index. It also removes the hand-maintained _length counter and the canonical-state prefix cache, whose invalidation depended on _length still holding its pre-mutation value. PhaseSolution keeps its name and its canonicalization helpers, which never needed rows, and gains a `start`. Its row-reading members move to Solution as phase_rows, phase_time, phase_canonical_array and phase_series. Solution.canonical_states goes: removing the state_history event kwarg left it with no callers. last_row and __iadd__ go with it, having none either. Saved solutions now store the rows once for the whole flight (version 2). Readers for the earlier per-phase layout and for the pre-phase flat list are kept, and both were checked against real saved files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Flight kept the post-process values recorded during a simulation in a dict keyed by phase index. Rollback removes and replaces solution rows without touching that dict, so the values and the rows they belong to could drift out of length, leaving the post-process series on a different time grid from the states. They now live on the Solution as a list running alongside the rows, one entry per row. Every mutation moves both together, so they cannot drift. Replacing a row's states clears its values, which the step loop then records again. A row inserted to mark the exact time of an event records nothing of its own, and takes the values of the row beside it, microseconds away. Working them out again instead would read the rocket in its end-of-flight configuration. _PhaseDynamics.post_process_row becomes post_process_values and no longer returns the time: values sit beside the row they came from, so they already share its time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Starting a new flight phase rebuilt the canonical state from solution.tail, the phase being flown. That is not always the phase holding the most recent row: a phase that ends without taking a solver step stores none, and the last row still belongs to the phase before it. The two phases can integrate different states, so reading the row through the wrong one would rebuild it from the wrong layout. Nothing goes wrong today, because every phase RocketPy ships integrates the full canonical state and the rebuild returns the row untouched, but the guarantee should not rest on that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The apogee trigger read the previous vertical velocity with at_index(-2)["vz"], which builds a dictionary of all thirteen canonical states to return one number, on every apogee check. Solution.value_at(index, name) reads just the one value: from the row itself when the phase integrates that state, from its reconstruction rule when it does not, or from the state the phase began with when it is held. Measured at 315 ns against 1986 ns for the dictionary it replaces. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PhaseSolution stopped being something a user reads from. It holds no rows, and what is left describes a phase rather than answering questions about it. The nine members removed from it in the previous commits went without a deprecation cycle, which is the clearest sign it was already being treated as internal. Renaming it to _PhaseSolution also settles an inconsistency: a phase's dynamics is already the private _PhaseDynamics, and the reference page had to caveat it. Now both are internal and the page says so once. Solution.phases still hands these out, and the reference page lists the values worth reading off one, so nothing a user could reasonably want is lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Picked up by ruff format, which the project treats as the source of truth for formatting. Whitespace only, no behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They stopped being rows when they dropped their time column: they carry only the values, and take their time from the state row they sit beside. The two functions that build them already said values, so the names now agree. _post_rows becomes _post_values, phase_post becomes phase_post_values, and set_last_post becomes set_last_post_values. Flight's private helper is renamed to __resolve_post_values so it does not read like the Solution accessor it calls. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Version 1 of the solution format, with the rows stored inside each phase, only ever existed on this branch: solution.py is absent from master, develop and v1.12.1. The only files in that layout are local working artifacts, so nothing released can produce one. from_dict is now a single reader. A version 1 file is refused with a clear message rather than read: the rows are not where this layout looks for them, so it would otherwise give back a solution with phases and no states at all. from_legacy_list stays untouched. That one reads the bare list of rows that did ship, which the .rpy fixture still exercises. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each solver is now a plain function taking the value function and the step bounds, wrapped by the Event that owns the context. The cubic root finder handles degenerate cubics (a straight line or a perfect cube), which is what a cubic Hermite fit gives when a quantity changes at a constant rate across a step.
A phase that integrates a different set of states now gives one optional to_canonical function (and to_canonical_dot for its derivative) instead of declaring reconstructed states with a dual-mode callable. The default holds unintegrated canonical states at their start-of-phase value. _BoundDynamics keeps only what needs the flight; the phase name registry, the states-only loader and the unused helpers are gone. A phase read back from a file simply has no derivative.
Per-phase read methods are replaced by phase_span; the phases list is a plain attribute; last_time, last_state and the like are read through raw_row and canonical_row. Writes are private, since only the simulation loop performs them, and rows are checked for width only where user data enters: the initial solution and a file being loaded. The canonical table is the one cache; time and canonical state histories are views or slices of it, and only phase-specific state histories are cached apart. The data export keeps its no-argument form (the 14-column state table) and can also export a state only some flight phases integrate, by name.
Event functions take one argument, the context, instead of keyword arguments, and read from it what they need; values that are expensive to compute (state derivatives, pressure, the phase's own states) are worked out only when a function reads them. The needs parameter is gone. The per-event persistent dictionary is renamed from context to memory, so it is not confused with the context passed to the functions. Commands.alters_trajectory is renamed changes_trajectory.
A phase started with a lag (a parachute opening) left the flight frozen between the trigger and the new phase: the current phase ended at the trigger and the new one was seeded from that state. The current phase now keeps flying its own equations until the new phase begins, with its schedule cut there and its solver restarted from the trigger. Post-process values are recorded once per step for every stored row that lacks them, plus an exact-time row at insertion, since a later event of the same step may change the rocket. call_events reports only whether the trajectory changed; the solver bound is set again after every node's events instead of on a flag.
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.
Summary
The addition of custom events was long overdue. In this PR the Event class was added. It was basically impossible to do this without changing a lot of the flight class, so I took the opportunity to rework it and solve all TODOs there.
Several upcoming features will build on top of this (parafoil, multistage, more controllers...), so I want this branch to act as a hub for a few related changes:
class Eventserializableflight.solutionan instance of a newSolutionclass. It should behave like the current array, but also allow a variable state length. This is important for adding new parachute models such as parafoil, and for letting us define more complex derivative functions.GenericSurfacethe mother class of all other aero surfaces.Here is a summary of all the changes:
flight.pySome flight class methods were reworked, some were moved:
flight_derivatives.py: the motion functions (u_dot,u_dot_generalized,u_dot_generalized_3dof, rail and parachute), moved out offlight.py.flight_phase.py: now has the_FlightPhasesand_TimeNodesclasses.event_calling.py: utilities for callling the events in the flight class correctlyevent_commands.py: applies the commands an event returns back to the flight.New event code (
rocketpy/simulation/events/)event.py(Event): An event has a trigger and a callback. The trigger is checked each step; when it is true, the callback runs. Callbacks can read the state, save data, log results, and send commands that change the simulation. It comes with ready-made presets likeapogeeandburnout. It also only computes expensive values when an event actually needs them.commands.py(Commands): what a callback can ask for. An event can start anew phase, swap the motion function, turn other events on or off, add or
remove controllers, undo a step (rollback), or stop the flight.
event_builders.py: ready-made events that recreate the normal flight steps(like leaving the rail).
exact_time_solvers.py: finds the exact time an event happens inside a step(using a few math methods).
Parachutes, controllers and sensors are no longer special cases in the flight loop. Each one now has a
to_event()method that wraps it into anEvent. The built-in flight milestones (apogee, out of rail, impact) are now events too.Parachute triggers and Controllers controller_function now take
**kwargsonly and read what they need by name (pressure,height_agl,state,sensors, etc.); the old positional parachute and controller signatures still work but raise aDeprecationWarning.Flight.__init_events()just collects all events (core events, then sensors, parachutes, controllers, and user custom events) and runs them uniformly, and commands an event returns (like swapping the derivative or starting a phase) are applied back to the flight byevent_commands.py.Parachute noise was also removed. The
noiseparameter onParachuteis now deprecated and has no effect (removal in v1.13). To model a noisy trigger, aBarometershould be used and accesses the noisy measurement in the trigger viakwargs['sensors_by_name']. This fits the new model, where sensors are events and the trigger reads their measurements.Plots and prints
flight_plots.pyandflight_prints.pynow show sensor data too.compare_flights.pyupdated.Docs
docs/user/event_usage.rstanddocs/user/sensors_usage.rst.docs/technical/simulation_loop.rst.Notes for reviewers
This will be a difficult one to review, specially because the diff tracking in flight.py is not that helpful given I changed the order of a few methods. So I suggest that the best way to understand how events work is to read the user guide:
docs/user/event_usage.rst. It walks through the API with runnable examples.Breaking change