Skip to content

ENH: Events Class and Flight Rework - #968

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

MateusStano wants to merge 52 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
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.
MateusStano and others added 27 commits September 14, 2026 19:51
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.
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