ENH: Events Class and Flight Rework - #968
Draft
MateusStano wants to merge 63 commits into
Draft
MateusStano wants to merge 63 commits into
MateusStano wants to merge 63 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
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.
u_dot zeroed omega1, omega2 and omega3 right after unpacking the state, so the aerodynamics and the Euler equations saw a rocket that never rotates: no aerodynamic damping at all. A pitch rate produced no restoring moment and a canted fin spun the rocket up without bound (1000 rad/s for a 1.5 degree cant on Calisto). Every rocket with a SolidMotor is mapped to these equations, so every such 6-DOF flight was affected. Introduced with the 3-DOF work in #745. With the rates back, the solid propulsion equations agree with the generalized ones to 0.25% on apogee and 0.2% on roll rate. Tests: each body axis rate must produce a damping angular acceleration with both sets of equations, the two must agree when rotating, and a rolling flight's roll rate must stay bounded and track the airspeed.
5 tasks
The add_event example still built its follow-up event with a **kwargs callback, which Event rejects, so the docs did not build. The Camoes notebook's parachute trigger and air brakes controller are moved to context as well, and the remaining prose that called the context values keyword arguments now names them as context keys. The trigger=None entry says the callback runs unconditionally whenever the event is checked, and enable_on is documented as checked before the trigger and disable_on after the callback.
A small mermaid diagram at the top of the simulation loop page, which needs sphinxcontrib-mermaid in the docs build. A unit test pins that an event whose enable_on and disable_on are both true on one check is enabled, fires, and ends disabled.
With no cubic term, the textbook quadratic formula subtracts two nearly equal numbers when the quadratic term is tiny next to the linear one, and the root of the nearly straight line loses all its precision. Add terms of the same sign instead, and return a double root at zero when the linear and constant terms are both zero. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cubic Hermite solver fitted its cubic with Cardano's formula, which lost all precision on the nearly straight descent under a parachute: impact times came out up to 3 s off, or with no crossing found. Fit the step with scipy's CubicHermiteSpline and take its roots inside the step, which also drops the max_abs_imag option. The linear solver now rejects steps where the value does not change sign, instead of placing the event outside the step. When a search fails, the warning gives the step searched, the values of exact_time_function at its ends, its target, the solver's reason and what to check. The rail exit time moves by one ulp, which shifts the integrator's steps enough to move the freestream speed at apogee by 5.5e-6 m/s, so its reference values are updated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A rocket sitting still on the rail until a delayed ignition lets the solver's steps grow freely, so it could step over the whole burn and the rocket never left the pad. Add time nodes at the motor's ignition and burnout when it ignites after t = 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A saved phase kept only its dynamics' name and state names, and loading rebuilt it as bare dynamics without its to_canonical rule. A phase that rebuilds canonical states from its own then read them back frozen at their start-of-phase values. Keep a table of the dynamics RocketPy ships and use the saved name to get them back, as long as their states still match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A phase integrating 13 states of its own took the Flight's per-state atol by position, since its length matched the phase's width. The quaternion's tight tolerances then landed on whatever states sat in those positions. Read a 13-value atol as one tolerance per canonical state first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Several parameter and attribute descriptions explained saving and loading, post-processing or which method fills a value in, instead of what the value is. Keep them to the value itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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