Skip to content

ENH: Events Class and Flight Rework - #968

Draft
MateusStano wants to merge 63 commits into
developfrom
enh/events
Draft

MateusStano wants to merge 63 commits into
developfrom
enh/events

Conversation

@MateusStano

@MateusStano MateusStano commented Jun 15, 2026

Copy link
Copy Markdown
Member

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:

  • Make class Event serializable
    • Fix broken tests (broken due to fails on controller serialization)
  • Rework controllers (minor) so they are better designed for use with specific classes, like what is done in FEAT: new actuator class for roll, throttle, and thrust vector control #965.
  • Make flight.solution an instance of a new Solution class. 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.
  • Aerodynamic classes refactor to make GenericSurface the mother class of all other aero surfaces.

Here is a summary of all the changes:

flight.py

Some flight class methods were reworked, some were moved:

  • The flight now moves forward using events, not a fixed list of phases.
  • flight_derivatives.py: the motion functions (u_dot, u_dot_generalized,
    u_dot_generalized_3dof, rail and parachute), moved out of flight.py.
  • flight_phase.py: now has the _FlightPhases and _TimeNodes classes.
  • event_calling.py: utilities for callling the events in the flight class correctly
  • event_commands.py: applies the commands an event returns back to the flight.
  • a propper logging feature was added

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 like apogee and burnout. 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 a
    new 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 an Event. The built-in flight milestones (apogee, out of rail, impact) are now events too.

Parachute triggers and Controllers controller_function now take **kwargs only and read what they need by name (pressure, height_agl, state, sensors, etc.); the old positional parachute and controller signatures still work but raise a DeprecationWarning.

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 by event_commands.py.

Parachute noise was also removed. The noise parameter on Parachute is now deprecated and has no effect (removal in v1.13). To model a noisy trigger, a Barometer should be used and accesses the noisy measurement in the trigger via kwargs['sensors_by_name']. This fits the new model, where sensors are events and the trigger reads their measurements.

Plots and prints

  • flight_plots.py and flight_prints.py now show sensor data too.
  • compare_flights.py updated.
  • New plots to all_info, and now they show case events:
image

Docs

  • New guides: docs/user/event_usage.rst and docs/user/sensors_usage.rst.
  • New technical page: docs/technical/simulation_loop.rst.
  • Update all places that used old definition of trigger functions (with positional arguments) to now be used with **kwargs

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

  • No (I hope)

…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
MateusStano requested a review from a team as a code owner June 15, 2026 17:39
@MateusStano MateusStano added Enhancement New feature or request, including adjustments in current codes Parachute Related to parachutes methods and usage Refactor Flight Flight Class related features Sensors Events labels Jun 15, 2026
@MateusStano
MateusStano marked this pull request as draft June 15, 2026 17:47
MateusStano and others added 18 commits September 14, 2026 20:50
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.
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.
MateusStano and others added 6 commits September 19, 2026 11:57
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Enhancement New feature or request, including adjustments in current codes Events Flight Flight Class related features Parachute Related to parachutes methods and usage Refactor Sensors

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants