Skip to main content

hpr_sim/
flight.rs

1//! A flight from ignition to the ground: the pad, the rail and free flight, their events, and how
2//! the flight ends.
3//!
4//! [`Simulation::run`] integrates the rigid-body equations (`crate::dynamics`) phase by phase:
5//!
6//! - **Pad:** the state holds still until the force along the rail exceeds friction (liftoff).
7//! - **Rail:** one degree of freedom along the rail until the last guide leaves its top (rail
8//!   exit, `crate::rail`). If the rocket stops on the rail it falls back to the pad phase.
9//! - **Free:** six degrees of freedom until the ground.
10//! - **Descent:** once a recovery device deploys, a point mass under the open devices' drag area
11//!   (`crate::recovery`), with the attitude frozen where it deployed.
12//!
13//! Every ignition, thrust-curve knot and burnout is a stop time, so no step straddles a change in
14//! the thrust's slope or the start or end of a burn, and each interval knows which motors burn.
15//! Each motor burns on its own clock from its ignition ([`hpr_design::Ignition`]).
16//!
17//! **Staging.** A [`Separation`] whose nose body still has a motor to burn is powered: that body,
18//! the sustainer, flies on in six degrees of freedom on its own stages' aerodynamics and mass, and
19//! the aft body, the booster, descends to its landing as a point mass ([`FlightResult::bodies`]).
20//! The state is the nose tip's, which the sustainer keeps, so it carries straight across the split
21//! (the decision record on staging, [ADR-074][adr-074]). Liftoff, rail
22//! exit, apogee (the center of mass's height rate crossing zero), ground contact (its ellipsoidal
23//! height reaching the site's) and user events are located by the integrator's event finder.
24//!
25//! A flight ends on the ground ([`Termination::GroundHit`]), with no liftoff by the last burnout
26//! ([`Termination::NoLiftoff`]), stalled back onto the pad ([`Termination::StalledOnRail`]), at
27//! the time cap ([`Termination::TimeCap`]) or at the step limit ([`Termination::StepLimit`]).
28//! Anything else that stops it is an error. With no recovery device the flight is ballistic to
29//! the ground.
30//!
31//! Method: `docs/physics/flight.md` and `docs/physics/recovery.md`.
32//!
33//! [adr-074]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-074-ignition-times-and-powered-staging-the-sustainer-flies-on-as-a-rigid-body-2026-09-25
34
35use std::fmt;
36use std::ops::ControlFlow;
37use std::sync::Arc;
38
39use hpr_aero::{AeroModel, DragModel, DragTable, NormalForceTable};
40use hpr_core::DVec3;
41use hpr_design::checks::has_errors;
42use hpr_design::{LaidOut, Rocket};
43use serde::{Deserialize, Serialize};
44
45use crate::dynamics::{Conditions, Evaluation, Phase, Vehicle};
46use crate::environment::Environment;
47use crate::error::SimError;
48use crate::events::Direction;
49use crate::integrator::{
50    Adaptive, Advance, IntegrationError, Integrator, Method, OdeSystem, Stats, Step,
51};
52use crate::metrics::Stability;
53use crate::pieces::{Ejection, Pieces};
54use crate::rail::{Guides, Rail};
55use crate::recorder::{FlightStep, Observer, Sample};
56use crate::recovery::{self, BodyEvent, BodyFlight, BodySample, Device, Run, Separation, Trigger};
57use crate::releases::{MassRelease, ReleasedFlight, Releases};
58use crate::shifts::{MassShift, Shifts};
59use crate::staging::Sustainer;
60use crate::state::{STATE_LEN, State};
61
62/// The airspeed below which a body with no attitude of its own is taken to be still in the air, m/s:
63/// the way an ejection's push points is then up rather than along its drift (ADR-086).
64const STILL_AIR_M_S: f64 = 1e-3;
65
66/// How the refusals of a trigger read for a mass shift or a mass release, where a device's would
67/// name a device.
68struct TriggerWords {
69    /// A height that is not positive and finite.
70    height: &'static str,
71    /// A time before launch.
72    time: &'static str,
73    /// A motor that isn't there, for a delay.
74    delay_motor: &'static str,
75    /// A motor with no ejection delay in seconds.
76    no_delay: &'static str,
77    /// A motor with no ignition known before the flight.
78    never: &'static str,
79}
80
81/// A mass shift's words.
82const SHIFT_WORDS: TriggerWords = TriggerWords {
83    height: "height above the launch site at which a part starts to move, m",
84    time: "start time of a mass shift after launch, s",
85    delay_motor: "index of the motor whose delay starts a mass shift",
86    no_delay: "the motor whose delay starts a mass shift has no ejection delay in seconds (it is \
87               plugged, or its delay is unset)",
88    never: "index of the motor a mass shift is timed from (it has no ignition time before the \
89            flight, so the shift could never start)",
90};
91
92/// A mass release's words.
93const RELEASE_WORDS: TriggerWords = TriggerWords {
94    height: "height above the launch site at which a part is released, m",
95    time: "time of a mass release after launch, s",
96    delay_motor: "index of the motor whose delay releases a part",
97    no_delay: "the motor whose delay releases a part has no ejection delay in seconds (it is \
98               plugged, or its delay is unset)",
99    never: "index of the motor a mass release is timed from (it has no ignition time before the \
100            flight, so the part could never be released)",
101};
102
103/// How a flight is integrated and when it gives up.
104#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
105#[serde(default, deny_unknown_fields)]
106pub struct FlightSettings {
107    /// The integration method and tolerances.
108    pub method: Method,
109    /// The flight stops with [`Termination::TimeCap`] at this time after launch, s.
110    pub max_time_s: f64,
111    /// The flight stops with [`Termination::StepLimit`] after this many attempted steps.
112    pub step_limit: u64,
113    /// Fly a design whose checks report errors.
114    pub accept_design_errors: bool,
115}
116
117impl Default for FlightSettings {
118    /// Dormand–Prince 5(4) with `rtol = atol = 1e-8` and unit weights (1.1 ms and an apogee
119    /// converged to 3e-5 m for a Level 2 flight, `docs/physics/flight.md`), a one-hour time cap, a
120    /// limit of 10⁶ steps, and design errors refused.
121    fn default() -> Self {
122        Self {
123            method: Method::DormandPrince54(Adaptive::default()),
124            max_time_s: 3600.0,
125            step_limit: crate::integrator::DEFAULT_STEP_LIMIT,
126            accept_design_errors: false,
127        }
128    }
129}
130
131/// What happened.
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134#[non_exhaustive]
135pub enum EventKind {
136    /// The force along the rail first exceeded friction.
137    Liftoff,
138    /// The last rail guide left the top of the rail.
139    RailExit,
140    /// Every motor lit, or due to light at a known time, has burned out: the last motor's thrust
141    /// curve ended. It is recorded again if a motor lights afterwards, as a sustainer lit by its
142    /// separation does.
143    Burnout,
144    /// The center of mass's height rate crossed zero from above.
145    Apogee,
146    /// The center of mass reached the launch site's ellipsoidal height, descending.
147    GroundHit,
148    /// A recovery device's charge fired, by its index in the flight's devices.
149    Trigger(usize),
150    /// A recovery device deployed (line stretch), its lag after the trigger, by its index. The
151    /// first deployment starts the descent phase. A device that was released before its charge
152    /// fired never deploys.
153    Deployment(usize),
154    /// A recovery device was released, by its index: the device that releases it is fully open
155    /// from this instant, so the drag area never dips between them.
156    Release(usize),
157    /// The stack came apart at its separation's stage boundary; the descents of the bodies that
158    /// don't fly on are in [`FlightResult::bodies`].
159    Separation,
160    /// A piece left the airframe at an ejection, by its index in the flight's ejections
161    /// ([`crate::Ejection`]); the bodies it leaves are in [`FlightResult::bodies`].
162    Ejection(usize),
163    /// A part started to move along the airframe, by its index in the flight's mass shifts
164    /// ([`crate::MassShift`]).
165    Shift(usize),
166    /// A part left the airframe, by its index in the flight's mass releases
167    /// ([`crate::MassRelease`]); its own flight is in [`FlightResult::released`], and the event's
168    /// sample is the rocket just before it left.
169    MassRelease(usize),
170    /// A user event, by its index in the order added.
171    User(usize),
172    /// A motor lit after launch, by its index in [`hpr_design::Assembly::motors`].
173    Ignition(usize),
174}
175
176/// An event and the flight's sample at it.
177#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
178pub struct FlightEvent {
179    /// What happened.
180    pub kind: EventKind,
181    /// The flight at that instant.
182    pub sample: Sample,
183}
184
185/// Why a flight ended ([Loft lesson L25][l25]: every way of stopping is named).
186///
187/// [l25]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l25
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
189#[serde(rename_all = "snake_case")]
190#[non_exhaustive]
191pub enum Termination {
192    /// The center of mass reached the ground.
193    GroundHit,
194    /// Every motor burned out before the rocket lifted off.
195    NoLiftoff,
196    /// The rocket lifted off but stopped on the rail and every motor has burned out.
197    StalledOnRail,
198    /// The time cap was reached.
199    TimeCap,
200    /// The integrator's step limit was reached.
201    StepLimit,
202    /// The stack separated, and each body flew on as its own descent
203    /// ([`FlightResult::bodies`]).
204    Separated,
205}
206
207/// A finished flight.
208#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209pub struct FlightResult {
210    /// Why it ended.
211    pub termination: Termination,
212    /// Its events, in order.
213    pub events: Vec<FlightEvent>,
214    /// The flight where it ended. After a [`Termination::Separated`] this is the **stack** at the
215    /// separation, not a landing: the landings are in `bodies` (see [`Self::landings`]). After a
216    /// powered separation it is the sustainer's.
217    pub final_sample: Sample,
218    /// The integrator's work.
219    pub stats: Stats,
220    /// The descents of the separated bodies, in body order: every body that flew on its own when
221    /// the flight ended with [`Termination::Separated`] (pieces whose parting never fired land as
222    /// one body), the booster alone after a powered separation (the sustainer's
223    /// flight is the rest of this result), and none otherwise.
224    #[serde(default, skip_serializing_if = "Vec::is_empty")]
225    pub bodies: Vec<BodyFlight>,
226    /// The flights of the parts released in flight ([`crate::MassRelease`]), in the order they
227    /// left; none without releases. They are not among [`Self::bodies`] or [`Self::landings`].
228    #[serde(default, skip_serializing_if = "Vec::is_empty")]
229    pub released: Vec<ReleasedFlight>,
230}
231
232impl FlightResult {
233    /// Whether every separated body in [`Self::bodies`] landed. A flight that ends with
234    /// [`Termination::Separated`] says only that the stack came apart: each body's own
235    /// [`BodyFlight::termination`] says whether it reached the ground, and a body can run out of
236    /// time or steps on its own. After a powered separation the sustainer's own landing is
237    /// [`Self::termination`].
238    #[must_use]
239    pub fn bodies_landed(&self) -> bool {
240        !self.bodies.is_empty()
241            && self
242                .bodies
243                .iter()
244                .all(|body| body.termination == Termination::GroundHit)
245    }
246
247    /// Where the flight put things on the ground: the final sample's position when it (or after a
248    /// powered separation, the sustainer) landed, then each separated body's landing.
249    #[must_use]
250    pub fn landings(&self) -> Vec<BodySample> {
251        let bodies = self
252            .bodies
253            .iter()
254            .filter(|body| body.termination == Termination::GroundHit)
255            .map(|body| body.final_sample);
256        if self.termination == Termination::GroundHit {
257            std::iter::once(BodySample {
258                time_s: self.final_sample.time_s,
259                cg_enu_m: self.final_sample.cg_enu_m,
260                cg_velocity_enu_m_s: self.final_sample.cg_velocity_enu_m_s,
261                height_above_ground_m: self.final_sample.height_above_ground_m,
262                vertical_speed_m_s: self.final_sample.vertical_speed_m_s,
263                airspeed_m_s: self.final_sample.airspeed_m_s,
264                recovery_drag_area_m2: self.final_sample.recovery_drag_area_m2,
265                mass_kg: self.final_sample.mass_kg,
266            })
267            .chain(bodies)
268            .collect()
269        } else {
270            bodies.collect()
271        }
272    }
273
274    /// The first event of `kind`.
275    #[must_use]
276    pub fn event(&self, kind: EventKind) -> Option<&FlightEvent> {
277        self.events.iter().find(|event| event.kind == kind)
278    }
279}
280
281/// A user event: `function(sample)` crossing zero in `direction` during free flight. It is
282/// recorded and the flight continues.
283pub struct UserEvent {
284    /// A name for reports.
285    pub name: String,
286    /// The crossing direction.
287    pub direction: Direction,
288    /// The event function.
289    pub function: Box<dyn Fn(&Sample) -> f64 + Send + Sync>,
290}
291
292impl fmt::Debug for UserEvent {
293    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294        f.debug_struct("UserEvent")
295            .field("name", &self.name)
296            .field("direction", &self.direction)
297            .finish_non_exhaustive()
298    }
299}
300
301/// A rocket, its surroundings, its rail and its settings, ready to fly.
302#[derive(Debug)]
303pub struct Simulation {
304    vehicle: Vehicle,
305    environment: Environment,
306    rail: Rail,
307    guides: Guides,
308    settings: FlightSettings,
309    user_events: Vec<UserEvent>,
310    devices: Vec<Device>,
311    /// The trigger times known before the flight, one per device: a time after launch or a
312    /// motor's delay after its burnout, and `None` for the triggers the flight watches for.
313    trigger_times_s: Vec<Option<f64>>,
314    /// The separations, in the order they fire, each further forward than the one before.
315    separations: Vec<Separation>,
316    /// Each separation's trigger time, when it is one that is known before the flight.
317    separation_times_s: Vec<Option<f64>>,
318    /// The pieces that leave the airframe, and when.
319    ejections: Vec<Ejection>,
320    /// Each ejection's trigger time, when it is one that is known before the flight.
321    ejection_times_s: Vec<Option<f64>>,
322    /// The parts that move along the airframe, in the order given.
323    shifts: Vec<MassShift>,
324    /// The parts released in flight, in the order given.
325    releases: Vec<MassRelease>,
326    /// Each released part as the design places it.
327    release_parts: Releases,
328    /// Each release's trigger time, when it is one that is known before the flight.
329    release_times_s: Vec<Option<f64>>,
330    /// The design, kept to build a sustainer's models from at a powered separation.
331    rocket: Rocket,
332    /// The configuration flown.
333    configuration_id: String,
334    /// Whether the aerodynamics were replaced by a table or a drag model, which is the whole
335    /// stack's only.
336    aero_overridden: bool,
337    /// Whether the recovery charges are held: no device on the stack fires, whatever its trigger
338    /// ([`crate::metrics::optimum_delays`] flies the ascent so).
339    recovery_held: bool,
340}
341
342impl Simulation {
343    /// Assembles `rocket` in configuration `configuration_id`, runs its checks and builds its
344    /// aerodynamic model.
345    ///
346    /// # Errors
347    ///
348    /// [`SimError::DesignChecks`] if the checks report errors and the settings don't accept them;
349    /// errors assembling the design or building its aerodynamics; a bad rail, rail geometry or
350    /// time cap.
351    pub fn new(
352        rocket: &Rocket,
353        configuration_id: &str,
354        environment: Environment,
355        rail: Rail,
356        settings: FlightSettings,
357    ) -> Result<Self, SimError> {
358        Self::from_laid_out(
359            rocket.lay_out()?,
360            configuration_id,
361            environment,
362            rail,
363            settings,
364        )
365    }
366
367    /// As [`Simulation::new`], on a design laid out already ([`hpr_design::LaidOut`]). The checks
368    /// and the assembly share its layout, which is most of the cost of building a simulation; a
369    /// Monte Carlo run lays out each flight from the nominal one's
370    /// ([`hpr_design::LaidOut::relay`]).
371    ///
372    /// # Errors
373    ///
374    /// As [`Simulation::new`].
375    pub fn from_laid_out(
376        laid_out: LaidOut,
377        configuration_id: &str,
378        environment: Environment,
379        rail: Rail,
380        settings: FlightSettings,
381    ) -> Result<Self, SimError> {
382        let findings = laid_out.check()?;
383        if has_errors(&findings) && !settings.accept_design_errors {
384            return Err(SimError::DesignChecks(findings));
385        }
386        settings.method.validate()?;
387        if !(settings.max_time_s.is_finite() && settings.max_time_s > 0.0) {
388            return Err(SimError::Domain {
389                what: "time cap",
390                value: settings.max_time_s,
391            });
392        }
393        rail.validate()?;
394        let (rocket, assembly) = laid_out.into_assembly(configuration_id)?;
395        let aero = AeroModel::new(&assembly.layout)?;
396        // No separation yet, so a motor lit by one has no time.
397        let ignition_s = assembly.ignition_times_s(|_| None);
398        let guides = Guides::of(&assembly);
399        let travel = guides.exit_travel_m(rail.length_m);
400        if travel <= 0.0 {
401            return Err(SimError::Domain {
402                what: "rail travel to exit (the rail is shorter than the aft end's lead on the last guide)",
403                value: travel,
404            });
405        }
406        Ok(Self {
407            vehicle: Vehicle::lit(assembly, aero, ignition_s)?,
408            environment,
409            rail,
410            guides,
411            settings,
412            user_events: Vec::new(),
413            devices: Vec::new(),
414            trigger_times_s: Vec::new(),
415            separations: Vec::new(),
416            separation_times_s: Vec::new(),
417            ejections: Vec::new(),
418            ejection_times_s: Vec::new(),
419            shifts: Vec::new(),
420            releases: Vec::new(),
421            release_parts: Releases::default(),
422            release_times_s: Vec::new(),
423            rocket,
424            configuration_id: configuration_id.to_owned(),
425            aero_overridden: false,
426            recovery_held: false,
427        })
428    }
429
430    /// Flies another tool's `C_D0(M)` table instead of the drag buildup, and instead of any drag
431    /// model ([`Simulation::with_drag_model`]). A negative coefficient where the flight meets one
432    /// stops it with [`hpr_aero::AeroError::Domain`].
433    #[must_use]
434    pub fn with_drag_table(mut self, table: DragTable) -> Self {
435        self.vehicle.aero = self.vehicle.aero.clone().with_drag_table(table);
436        self.aero_overridden = true;
437        self
438    }
439
440    /// Flies a drag model of your own instead of the drag buildup, and instead of any drag table
441    /// ([`hpr_aero::custom`]). The model gives the zero-lift drag coefficient on the rocket's
442    /// reference area, not rescaled; the flight scales it for the angle of attack, and the
443    /// normal force, center of pressure, roll and damping stay hpr's. A model's own errors reach
444    /// the caller as [`SimError::Aero`] around [`hpr_aero::AeroError::DragModel`]. Like a table,
445    /// the model is the whole stack's: a flight with a powered separation refuses it at the
446    /// separation, since the sustainer would fly on without it.
447    ///
448    /// # Examples
449    ///
450    /// Valetudo with a drag coefficient of 0.45 at every speed:
451    ///
452    /// ```
453    /// use hpr_aero::{AeroError, DragModel, DragQuery};
454    /// use hpr_core::geodesy::Geodetic;
455    /// use hpr_design::Rocket;
456    /// use hpr_sim::{Environment, EventKind, FlightSettings, Rail, Simulation};
457    ///
458    /// #[derive(Debug)]
459    /// struct Constant(f64);
460    ///
461    /// impl DragModel for Constant {
462    ///     fn zero_lift_drag(&self, _query: &DragQuery<'_>) -> Result<f64, AeroError> {
463    ///         Ok(self.0)
464    ///     }
465    /// }
466    ///
467    /// let rocket: Rocket = serde_json::from_str(include_str!(
468    ///     "../../../validation/designs/rocketpy-valetudo.json"
469    /// ))?;
470    /// let site = Geodetic::from_degrees(32.99, -106.97, 1400.0)?;
471    /// // The apogee's height with a constant drag coefficient `cd`.
472    /// let apogee_m = |cd: f64| -> Result<f64, Box<dyn std::error::Error>> {
473    ///     let flight = Simulation::new(
474    ///         &rocket,
475    ///         "example",
476    ///         Environment::standard(site)?,
477    ///         Rail::vertical(3.0),
478    ///         FlightSettings::default(),
479    ///     )?
480    ///     .with_drag_model(Constant(cd))
481    ///     .run(&mut ())?;
482    ///     let apogee = flight.event(EventKind::Apogee).ok_or("no apogee")?;
483    ///     Ok(apogee.sample.cg_enu_m.z)
484    /// };
485    /// assert!(apogee_m(0.9)? < apogee_m(0.45)?);
486    /// # Ok::<(), Box<dyn std::error::Error>>(())
487    /// ```
488    #[must_use]
489    pub fn with_drag_model(self, model: impl DragModel + 'static) -> Self {
490        self.with_shared_drag_model(Arc::new(model))
491    }
492
493    /// As [`Simulation::with_drag_model`], with a model already shared, as when one model flies
494    /// many simulations.
495    #[must_use]
496    pub fn with_shared_drag_model(mut self, model: Arc<dyn DragModel>) -> Self {
497        self.vehicle.aero = self.vehicle.aero.clone().with_shared_drag_model(model);
498        self.aero_overridden = true;
499        self
500    }
501
502    /// Multiplies the rocket's zero-lift drag coefficient by `scale`, whatever gives it: hpr's
503    /// buildup, a drag table or a drag model ([`hpr_aero::AeroModel::with_drag_scale`]). Unlike
504    /// a table or a model it is not the whole stack's: a sustainer lit at a powered separation
505    /// keeps the same scale. Recovery devices' drag is their own and is not scaled. A Monte Carlo
506    /// run disperses drag this way (`hpr_analysis::montecarlo`).
507    ///
508    /// # Errors
509    ///
510    /// [`SimError::Aero`] for a scale that is negative or not finite.
511    pub fn with_drag_scale(mut self, scale: f64) -> Result<Self, SimError> {
512        self.vehicle.aero = self.vehicle.aero.clone().with_drag_scale(scale)?;
513        Ok(self)
514    }
515
516    /// Keeps the aft base's whole drag while a motor burns, as OpenRocket 24.12 does, instead of
517    /// taking the burning motor's cross-section off it
518    /// ([`hpr_aero::AeroModel::with_full_base_drag_under_power`]). A sustainer lit at a powered
519    /// separation keeps the same rule. For sizing a difference from OpenRocket, not a better model
520    /// ([ADR-097][adr-097]).
521    ///
522    /// [adr-097]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-097-a-cause-in-the-drag-sized-by-hpr-flying-openrockets-drag-2026-09-28
523    #[must_use]
524    pub fn with_full_base_drag_under_power(mut self) -> Self {
525        self.vehicle.aero = self.vehicle.aero.clone().with_full_base_drag_under_power();
526        self
527    }
528
529    /// Flies another tool's normal force and center of pressure, against Mach number and angle of
530    /// attack, instead of hpr's own ([`hpr_aero::NormalForceTable`], read from a RASAero II
531    /// export). The table sets the static normal force at the center of mass's airflow; the pitch
532    /// and yaw damping stay hpr's, from the airspeed the rotation adds at each component, since a
533    /// table has none (the decision record on normal-force overrides, [ADR-032][adr-032]). The
534    /// flight still refuses Mach 5 and faster, where hpr's components, which give that damping,
535    /// end.
536    ///
537    /// [adr-032]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-032-normal-force-overrides-from-rasaero-ii-the-static-force-replaced-hprs-damping-kept-2026-09-19
538    ///
539    /// # Errors
540    ///
541    /// [`SimError::Aero`] around [`hpr_aero::AeroError::Domain`] for a center of pressure in the
542    /// table outside the rocket ([`hpr_aero::AeroModel::with_normal_force_table`]).
543    ///
544    /// # Examples
545    ///
546    /// Valetudo from a 3 m rail on a small export with invented numbers: 9 per radian, and the
547    /// center of pressure 55 inches from the nose tip.
548    ///
549    /// ```
550    /// use hpr_aero::NormalForceTable;
551    /// use hpr_core::geodesy::Geodetic;
552    /// use hpr_design::Rocket;
553    /// use hpr_sim::{Environment, EventKind, FlightSettings, Rail, Simulation};
554    ///
555    /// let rocket: Rocket = serde_json::from_str(include_str!(
556    ///     "../../../validation/designs/rocketpy-valetudo.json"
557    /// ))?;
558    /// // The text of a RASAero II export; a program would read it from the file.
559    /// let export = "Mach,Alpha,CN,CN Potential,CP\n\
560    ///               0,0,0,0,55\n\
561    ///               1,0,0,0,55\n\
562    ///               0,2,0.314159,0.314159,55\n\
563    ///               1,2,0.314159,0.314159,55\n";
564    /// // On RASAero II's reference, the body's largest section, which hpr rescales to the
565    /// // rocket's reference area.
566    /// let table = NormalForceTable::from_rasaero_csv(export)?;
567    /// let site = Geodetic::from_degrees(32.99, -106.97, 1400.0)?;
568    /// let simulation = Simulation::new(
569    ///     &rocket,
570    ///     "example",
571    ///     Environment::standard(site)?,
572    ///     Rail::vertical(3.0),
573    ///     FlightSettings::default(),
574    /// )?
575    /// .with_normal_force_table(table)?;
576    /// let flight = simulation.run(&mut ())?;
577    /// assert!(flight.event(EventKind::Apogee).is_some());
578    /// # Ok::<(), Box<dyn std::error::Error>>(())
579    /// ```
580    pub fn with_normal_force_table(mut self, table: NormalForceTable) -> Result<Self, SimError> {
581        self.vehicle.aero = self.vehicle.aero.clone().with_normal_force_table(table)?;
582        self.aero_overridden = true;
583        Ok(self)
584    }
585
586    /// Adds a user event, checked during free flight and the descent.
587    #[must_use]
588    pub fn with_event(mut self, event: UserEvent) -> Self {
589        self.user_events.push(event);
590        self
591    }
592
593    /// Flies with these recovery devices, in the order given: a device's index in this list names
594    /// it in [`EventKind`] and in [`crate::recovery::Device::released_by`].
595    ///
596    /// # Errors
597    ///
598    /// [`SimError::Domain`] for a device whose drag area, lag, inflation, trigger or release index
599    /// is outside its domain, or whose trigger names a motor that isn't there or has no ejection
600    /// delay in seconds.
601    pub fn with_recovery(mut self, devices: Vec<Device>) -> Result<Self, SimError> {
602        self.trigger_times_s = recovery::plan(
603            &devices,
604            &self.vehicle.assembly.motors,
605            self.vehicle.ignition_s(),
606        )?;
607        if self.parts() {
608            // Already given a separation or ejections, so the bodies are known; otherwise the
609            // check waits for them, and for the flight, so that the builders work in any order.
610            check_bodies(&devices, self.body_count(), false)?;
611        }
612        self.devices = devices;
613        Ok(self)
614    }
615
616    /// The recovery devices.
617    #[must_use]
618    pub fn recovery(&self) -> &[Device] {
619        &self.devices
620    }
621
622    /// Flies with a separation: at its trigger the stack comes apart at the stage boundary, and
623    /// each body descends under its own devices ([`crate::recovery::Separation`]); or, when the
624    /// nose's body still has a motor to burn, it flies on as a sustainer and the aft body
625    /// descends. The same as [`Self::with_separations`] with this one alone.
626    ///
627    /// Call this after [`Self::with_recovery`]: it checks the devices against the bodies.
628    ///
629    /// # Errors
630    ///
631    /// [`SimError::Domain`] if the design has no stage aft of the split, if a body carries no
632    /// device (the descent has no airframe drag, so it would fall as if in a vacuum), if the
633    /// trigger is out of its domain, or if its time is known and an aft body's motor burns past
634    /// it (but for a lit motor a powered separation drops burning when it says so,
635    /// [`Separation::drops_burning`]), or if it is timed from a motor with no ignition known
636    /// before the flight, so that it could never fire; [`SimError::Parting`] if an ejection
637    /// already given parts at its stage boundary. The same checks run again if [`Self::with_recovery`] is called afterwards, so the
638    /// builders can be given in any order. A device on a body that nothing makes is refused when
639    /// the flight starts, since an ejection given later can make it.
640    pub fn with_separation(self, separation: Separation) -> Result<Self, SimError> {
641        self.with_separations(vec![separation])
642    }
643
644    /// Flies with several separations, in the order they fire, each at a stage boundary further
645    /// forward than the one before: a stack that drops its stages one at a time under power, as
646    /// a three-stage rocket does. Separation `k` makes body `k + 1`, the stages between its
647    /// boundary and the one before it (or the tail); body 0 keeps the nose. With more than one,
648    /// each must leave the nose's body a motor to burn, so that it flies on as a sustainer and the
649    /// part behind descends ([`crate::recovery::Separation`]); a lone separation may also end the
650    /// ascent, as [`Self::with_separation`] says. An empty list flies none.
651    ///
652    /// Call this after [`Self::with_recovery`]: it checks the devices against the bodies.
653    ///
654    /// # Errors
655    ///
656    /// As [`Self::with_separation`], for each separation; [`SimError::Domain`] as well if the
657    /// boundaries don't move forward in the order given, or if two of the times known before the
658    /// flight come in the other order; [`SimError::Unsupported`] for more than one separation in
659    /// a flight with ejections, whose pieces a sustainer's cut design doesn't track. In flight, a
660    /// separation that leaves nothing ahead of it to burn, in a flight with more than one, is an
661    /// error, as is one that fires before the separation ahead of it in the list.
662    pub fn with_separations(mut self, separations: Vec<Separation>) -> Result<Self, SimError> {
663        if !separations.is_empty() {
664            self.check_no_moving_parts()?;
665        }
666        let stages = self.vehicle.assembly.layout.stages.len();
667        for (index, separation) in separations.iter().enumerate() {
668            if separation.stages_of(1, stages).is_none() {
669                return Err(SimError::Domain {
670                    what: "stage boundary of a separation (there is no stage aft of it)",
671                    value: separation.after_stage as f64,
672                });
673            }
674            if index > 0 && separation.after_stage >= separations[index - 1].after_stage {
675                return Err(SimError::Domain {
676                    what: "stage boundary of a separation (each must be forward of the one \
677                           before it, as the stack drops its stages from the tail)",
678                    value: separation.after_stage as f64,
679                });
680            }
681            let dropped = Separation::stages_of_body(&separations[..=index], index + 1, stages);
682            if !dropped.is_some_and(|(first, last)| {
683                hangs_together(&self.vehicle.assembly.layout.stages, first, last)
684            }) {
685                return Err(SimError::Domain {
686                    what: "stage boundary of a separation (what it drops must hang together: a \
687                           parallel stage hung on a stage it keeps can't go with a stage behind)",
688                    value: separation.after_stage as f64,
689                });
690            }
691        }
692        if separations.len() > 1 && !self.ejections.is_empty() {
693            return Err(Self::separations_and_ejections());
694        }
695        if !self.ejections.is_empty() {
696            // Its boundary may be a joint an ejection parts at too.
697            Pieces::new(
698                &self.rocket,
699                &self.vehicle.assembly,
700                &separations,
701                &self.ejections,
702            )?;
703        }
704        if !self.devices.is_empty() && !separations.is_empty() {
705            // With the devices already given they are checked now; given afterwards they are
706            // checked then, and either way again when the flight starts.
707            check_bodies(
708                &self.devices,
709                1 + separations.len() + self.ejections.len(),
710                false,
711            )?;
712        }
713        let mut times_s = Vec::with_capacity(separations.len());
714        for &separation in &separations {
715            if let Trigger::Altitude {
716                height_above_ground_m,
717            } = separation.trigger
718                && !(height_above_ground_m.is_finite() && height_above_ground_m > 0.0)
719            {
720                return Err(SimError::Domain {
721                    what: "height above the launch site at which the stack separates, m",
722                    value: height_above_ground_m,
723                });
724            }
725            let time_s = recovery::trigger_time_s(
726                separation.trigger,
727                &self.vehicle.assembly.motors,
728                self.vehicle.ignition_s(),
729            )?;
730            if let Some(time_s) = time_s {
731                // A body's mass is held constant through its descent. For a trigger whose time is
732                // known now, say so now rather than in the middle of a flight.
733                let powered = separation.powered_at(&self.vehicle.assembly, time_s);
734                self.check_aft_body_spent(separation, self.vehicle.ignition_s(), time_s, powered)?;
735            }
736            if let (None, Trigger::MotorDelay { motor } | Trigger::Burnout { motor, .. }) =
737                (time_s, separation.trigger)
738            {
739                // Timed from a motor with no ignition known before the flight: one lit by a
740                // separation, or one that never lights. It would never fire, and the stack would
741                // land whole. (A `Time` always has its time.)
742                return Err(SimError::Domain {
743                    what: "index of the motor a separation is timed from (it has no ignition time \
744                           before the separation, so the separation could never fire)",
745                    value: motor as f64,
746                });
747            }
748            if let (Some(time_s), Some(before_s)) = (
749                time_s,
750                times_s.iter().rev().find_map(|time: &Option<f64>| *time),
751            ) && time_s < before_s
752            {
753                return Err(SimError::Domain {
754                    what: "time of a separation, s (it comes before the separation ahead of it \
755                           in the list, which drops the stages behind it)",
756                    value: time_s,
757                });
758            }
759            times_s.push(time_s);
760        }
761        self.separation_times_s = times_s;
762        self.separations = separations;
763        Ok(self)
764    }
765
766    /// Flies with ejections: at each one's trigger a piece leaves the airframe, at the joint aft of
767    /// a body component or as a payload from inside it, and each body flies on to its own landing
768    /// under its own devices ([`crate::Ejection`]). With a separation as well, the separation's aft
769    /// body is body 1 and ejection `k` makes body `k + 2`; without one, ejection `k` makes body
770    /// `k + 1`. A flight with more than one separation takes none.
771    ///
772    /// Call this after [`Self::with_recovery`]: it checks the devices against the bodies. The
773    /// builders can be given in any order, and the checks run again when the flight starts.
774    ///
775    /// # Errors
776    ///
777    /// [`SimError::Parting`] for a parting the design can't make; [`SimError::Domain`] if a body
778    /// carries no device, if a trigger or impulse is out of its domain, if its time is known and a
779    /// motor burns past it, or if it is timed from a motor with no ignition known before the
780    /// flight; [`SimError::Unsupported`] with more than one separation. A device on a body that
781    /// nothing makes, and a pushed payload in the nose's piece, are
782    /// refused when the flight starts. In flight, an ejection that fires while a motor burns, or
783    /// ahead of a separation that would light one, is an error, and so are a powered separation in
784    /// a flight with ejections and a pushed payload whose section's forward joint hasn't parted.
785    pub fn with_ejections(mut self, ejections: Vec<Ejection>) -> Result<Self, SimError> {
786        if !ejections.is_empty() {
787            self.check_no_moving_parts()?;
788            if self.separations.len() > 1 {
789                return Err(Self::separations_and_ejections());
790            }
791        }
792        Pieces::new(
793            &self.rocket,
794            &self.vehicle.assembly,
795            &self.separations,
796            &ejections,
797        )?;
798        let bodies = 1 + self.separations.len() + ejections.len();
799        if !self.devices.is_empty() && !ejections.is_empty() {
800            check_bodies(&self.devices, bodies, false)?;
801        }
802        let mut times_s = Vec::with_capacity(ejections.len());
803        for ejection in &ejections {
804            if let Trigger::Altitude {
805                height_above_ground_m,
806            } = ejection.trigger
807                && !(height_above_ground_m.is_finite() && height_above_ground_m > 0.0)
808            {
809                return Err(SimError::Domain {
810                    what: "height above the launch site at which a piece is ejected, m",
811                    value: height_above_ground_m,
812                });
813            }
814            if !(ejection.impulse_n_s.is_finite() && ejection.impulse_n_s >= 0.0) {
815                return Err(SimError::Domain {
816                    what: "impulse of an ejection, N·s (zero or more: it pushes the two sides \
817                           apart)",
818                    value: ejection.impulse_n_s,
819                });
820            }
821            let time_s = recovery::trigger_time_s(
822                ejection.trigger,
823                &self.vehicle.assembly.motors,
824                self.vehicle.ignition_s(),
825            )?;
826            if let Some(time_s) = time_s {
827                // Every body is a point mass of constant mass once the airframe parts.
828                self.check_spent(self.vehicle.ignition_s(), time_s)?;
829            }
830            if let (None, Trigger::MotorDelay { motor } | Trigger::Burnout { motor, .. }) =
831                (time_s, ejection.trigger)
832            {
833                return Err(SimError::Domain {
834                    what: "index of the motor an ejection is timed from (it has no ignition time \
835                           before the flight, so the ejection could never fire)",
836                    value: motor as f64,
837                });
838            }
839            times_s.push(time_s);
840        }
841        self.ejections = ejections;
842        self.ejection_times_s = times_s;
843        Ok(self)
844    }
845
846    /// The ejections, in the order given.
847    #[must_use]
848    pub fn ejections(&self) -> &[Ejection] {
849        &self.ejections
850    }
851
852    /// Flies with parts that move along the airframe ([`MassShift`]), in the order given: a
853    /// shift's index in this list names it in [`EventKind::Shift`]. A shift with a trigger known
854    /// before the flight (a time, or a motor's burnout or delay) starts then; the flight watches
855    /// for the apogee and for a height, descending, as it does for a recovery device's.
856    ///
857    /// # Errors
858    ///
859    /// [`SimError::Shift`] for a part that can't move ([`MassShift`] says which);
860    /// [`SimError::Domain`] for a travel, duration or trigger outside its domain, or a trigger on
861    /// a motor with no ignition known before the flight, which could never fire (a shift that
862    /// starts before the rocket leaves the rail is refused by [`Self::run`] when it comes);
863    /// [`SimError::Unsupported`] with a separation or ejections, whose pieces are fixed before the
864    /// flight with every part where the design puts it.
865    pub fn with_shifts(mut self, shifts: Vec<MassShift>) -> Result<Self, SimError> {
866        if !shifts.is_empty() && self.parts() {
867            return Err(Self::shifts_and_partings());
868        }
869        if !shifts.is_empty() && !self.releases.is_empty() {
870            return Err(Self::releases_and_shifts());
871        }
872        let mut starts_s = Vec::with_capacity(shifts.len());
873        for shift in &shifts {
874            starts_s.push(self.planned_s(shift.trigger, &SHIFT_WORDS)?);
875        }
876        self.vehicle.shifts =
877            Shifts::new(&self.rocket, &self.vehicle.assembly, &shifts, &starts_s)?;
878        self.shifts = shifts;
879        Ok(self)
880    }
881
882    /// The mass shifts, in the order given.
883    #[must_use]
884    pub fn shifts(&self) -> &[MassShift] {
885        &self.shifts
886    }
887
888    /// Flies with parts released in flight ([`MassRelease`]), in the order given: a release's
889    /// index in this list names it in [`EventKind::MassRelease`] and in
890    /// [`FlightResult::released`]. A release with a trigger known before the flight (a time, or a
891    /// motor's burnout or delay) comes then; the flight watches for the apogee and for a height,
892    /// descending, as it does for a recovery device's.
893    ///
894    /// # Errors
895    ///
896    /// [`SimError::MassRelease`] for a part that can't be released ([`MassRelease`] says which);
897    /// [`SimError::Domain`] for a drag area or trigger outside its domain, or a trigger on a
898    /// motor with no ignition known before the flight, which could never fire (a release that
899    /// comes before the rocket leaves the rail is refused by [`Self::run`] when it comes);
900    /// [`SimError::Unsupported`] with a separation, ejections or mass shifts, whose parts are
901    /// fixed before the flight with every part where the design puts it.
902    pub fn with_releases(mut self, releases: Vec<MassRelease>) -> Result<Self, SimError> {
903        if !releases.is_empty() && (self.parts() || !self.shifts.is_empty()) {
904            return Err(Self::releases_and_shifts());
905        }
906        let mut times_s = Vec::with_capacity(releases.len());
907        for release in &releases {
908            times_s.push(self.planned_s(release.trigger, &RELEASE_WORDS)?);
909        }
910        self.release_parts = Releases::new(&self.rocket, &self.vehicle.assembly, &releases)?;
911        self.release_times_s = times_s;
912        self.releases = releases;
913        Ok(self)
914    }
915
916    /// The releases, in the order given.
917    #[must_use]
918    pub fn releases(&self) -> &[MassRelease] {
919        &self.releases
920    }
921
922    /// The time a shift's or a release's `trigger` gives before the flight, if it gives one,
923    /// refusing a trigger outside its domain in `words`. The checks are a device's; the errors
924    /// that name a device are put in `words`.
925    fn planned_s(&self, trigger: Trigger, words: &TriggerWords) -> Result<Option<f64>, SimError> {
926        if let Trigger::Altitude {
927            height_above_ground_m,
928        } = trigger
929            && !(height_above_ground_m.is_finite() && height_above_ground_m > 0.0)
930        {
931            return Err(SimError::Domain {
932                what: words.height,
933                value: height_above_ground_m,
934            });
935        }
936        let time_s = recovery::trigger_time_s(
937            trigger,
938            &self.vehicle.assembly.motors,
939            self.vehicle.ignition_s(),
940        )
941        .map_err(|error| match error {
942            SimError::Domain { what, value } => SimError::Domain {
943                what: match what {
944                    "deployment time after launch, s" => words.time,
945                    "index of the motor whose delay fires a device" => words.delay_motor,
946                    "the motor firing a device has no ejection delay in seconds (it is plugged, \
947                     or its delay is unset)" => words.no_delay,
948                    other => other,
949                },
950                value,
951            },
952            other => other,
953        })?;
954        if let (None, Trigger::MotorDelay { motor } | Trigger::Burnout { motor, .. }) =
955            (time_s, trigger)
956        {
957            return Err(SimError::Domain {
958                what: words.never,
959                value: motor as f64,
960            });
961        }
962        Ok(time_s)
963    }
964
965    /// The stack's mass properties at `t_s` as `flight` flew it: the design's, its motors burned
966    /// to `t_s`, with each part that moves where it was then. `flight` must be a flight of this
967    /// simulation; nothing checks that it is. A shift whose trigger is known before the flight (a
968    /// time, or a motor's burnout or delay) starts then, whether or not `flight` got that far; one
969    /// the flight watched for starts where `flight` records it ([`EventKind::Shift`]), and hasn't
970    /// started if it doesn't. A part released at or before `t_s` is gone: its release came when
971    /// its trigger's time is known before the flight, and otherwise where `flight` records it
972    /// ([`EventKind::MassRelease`]). They are the whole stack's, in body axes about its center of
973    /// mass, before any separation (and a flight with a separation has no shifts or releases).
974    #[must_use]
975    pub fn mass_properties(&self, flight: &FlightResult, t_s: f64) -> hpr_design::MassProperties {
976        let mut shifts = self.vehicle.shifts.clone();
977        for index in 0..self.shifts.len() {
978            if let Some(event) = flight.event(EventKind::Shift(index)) {
979                shifts.start(index, event.sample.time_s);
980            }
981        }
982        let mut gone: Vec<(f64, usize)> = (0..self.releases.len())
983            .filter_map(|index| {
984                self.release_times_s[index]
985                    .or_else(|| {
986                        flight
987                            .event(EventKind::MassRelease(index))
988                            .map(|event| event.sample.time_s)
989                    })
990                    .filter(|&time_s| time_s <= t_s)
991                    .map(|time_s| (time_s, index))
992            })
993            .collect();
994        // Taken out in the order the flight takes them, so the sums are the ones it flew.
995        gone.sort_by(|a, b| a.0.total_cmp(&b.0));
996        let assembly = &self.vehicle.assembly;
997        let structure = gone
998            .iter()
999            .fold(assembly.layout.structure, |structure, &(_, index)| {
1000                structure.without_part(self.release_parts.part(index))
1001            });
1002        let whole = assembly.mass_properties_lit_on(structure, t_s, self.vehicle.ignition_s());
1003        shifts.apply(whole, t_s)
1004    }
1005
1006    /// The stack's apogee at `sample`: its event, and the trigger of each of the stack's own
1007    /// devices waiting for it, whose deployment becomes a stop time (below `cap`).
1008    fn reach_apogee(
1009        &self,
1010        sample: Sample,
1011        run: &mut Run,
1012        (stops, cap): (&mut Vec<f64>, f64),
1013        events: &mut Vec<FlightEvent>,
1014        observer: &mut dyn Observer,
1015    ) {
1016        let mut record = |kind: EventKind| {
1017            let event = FlightEvent { kind, sample };
1018            observer.event(&event);
1019            events.push(event);
1020        };
1021        record(EventKind::Apogee);
1022        let t = sample.time_s;
1023        for device in 0..self.devices.len() {
1024            // Only the stack's own: a body's device waits for its body, which finds its own
1025            // apogee.
1026            if !self.recovery_held
1027                && self.devices[device].trigger == Trigger::Apogee
1028                && run.pending(device)
1029                && self.acts_before_separation(device)
1030            {
1031                let deploy_s = run.trigger(&self.devices, device, t);
1032                insert_stop(stops, deploy_s, cap);
1033                record(EventKind::Trigger(device));
1034            }
1035        }
1036    }
1037
1038    /// Refuses a separation or ejections in a flight with mass shifts or releases.
1039    fn check_no_moving_parts(&self) -> Result<(), SimError> {
1040        if !self.releases.is_empty() {
1041            Err(Self::releases_and_shifts())
1042        } else if self.shifts.is_empty() {
1043            Ok(())
1044        } else {
1045            Err(Self::shifts_and_partings())
1046        }
1047    }
1048
1049    /// The refusal of mass releases with a separation, ejections or mass shifts.
1050    fn releases_and_shifts() -> SimError {
1051        SimError::Unsupported {
1052            what: "a mass release in a flight with a separation, ejections or mass shifts (the \
1053                   parts are fixed before the flight, with every part where the design puts it)",
1054        }
1055    }
1056
1057    /// The refusal of several separations with ejections.
1058    fn separations_and_ejections() -> SimError {
1059        SimError::Unsupported {
1060            what: "ejections in a flight with more than one separation (a sustainer flies on a cut \
1061                   design whose components aren't the pieces the ejections were given for)",
1062        }
1063    }
1064
1065    /// The refusal of mass shifts with a separation or ejections.
1066    fn shifts_and_partings() -> SimError {
1067        SimError::Unsupported {
1068            what: "a mass shift in a flight with a separation or ejections (the pieces are fixed \
1069                   before the flight, with every part where the design puts it)",
1070        }
1071    }
1072
1073    /// The drag area of piece `piece` tumbling on its own, for a device on the body it leads:
1074    /// [`crate::recovery::DeviceDrag::tumbling`]'s model over the piece's own body components and
1075    /// fin sets. Piece 0 is the nose's, the separation makes piece 1, and each ejection the next
1076    /// ([`crate::Ejection`]), so piece `k` leads body `k`. Call it after [`Self::with_ejections`]
1077    /// and [`Self::with_separation`], which fix the pieces.
1078    ///
1079    /// It is the piece's own area only: a body that still carries another section, until that
1080    /// section's own parting, tumbles with its lead piece's area. The model was fitted to whole
1081    /// model rockets tumbling, so a lone nose cone is outside its fit: see the recovery page's
1082    /// [tumble section](https://nrdptel.github.io/hpr-sim/physics/recovery.html#tumble).
1083    ///
1084    /// # Errors
1085    ///
1086    /// [`SimError::Domain`] for a piece the airframe doesn't part into, a payload (it has no
1087    /// body tube or fin of its own), and as [`crate::recovery::DeviceDrag::tumbling`].
1088    pub fn tumbling_piece(&self, piece: usize) -> Result<recovery::DeviceDrag, SimError> {
1089        Pieces::new(
1090            &self.rocket,
1091            &self.vehicle.assembly,
1092            &self.separations,
1093            &self.ejections,
1094        )?
1095        .tumbling(piece, &self.vehicle.assembly)
1096    }
1097
1098    /// Refuses an ejection at `t_s` while any motor lit at `ignition_s` burns: once the airframe
1099    /// parts, every body is a point mass of constant mass.
1100    fn check_spent(&self, ignition_s: &[Option<f64>], t_s: f64) -> Result<(), SimError> {
1101        for (placed, ignition) in self.vehicle.assembly.motors.iter().zip(ignition_s) {
1102            if let Some(ignition_s) = ignition {
1103                let burnout_s = ignition_s + placed.mounted.motor.burnout_time_s();
1104                if burnout_s > t_s {
1105                    return Err(SimError::Domain {
1106                        what: "time of an ejection (every motor must have burned out by then; \
1107                               this is when one of them does)",
1108                        value: burnout_s,
1109                    });
1110                }
1111            }
1112        }
1113        Ok(())
1114    }
1115
1116    /// Refuses a separation at `t_s` while a motor of its aft body, lit at `ignition_s`, burns or
1117    /// is still to light: that body descends as a point mass of constant mass. A `powered`
1118    /// separation, whose nose body has a motor burning or lighting then, may drop a motor still
1119    /// burning when it says so ([`Separation::drops_burning`], ADR-172): the body's descent leaves
1120    /// out the thrust that motor had left ([`BodyFlight::impulse_left_n_s`]). A motor still to
1121    /// light is refused either way.
1122    fn check_aft_body_spent(
1123        &self,
1124        separation: Separation,
1125        ignition_s: &[Option<f64>],
1126        t_s: f64,
1127        powered: bool,
1128    ) -> Result<(), SimError> {
1129        for (placed, ignition) in self.vehicle.assembly.motors.iter().zip(ignition_s) {
1130            if placed.stage <= separation.after_stage {
1131                continue;
1132            }
1133            if let Some(ignition_s) = ignition {
1134                let burnout_s = ignition_s + placed.mounted.motor.burnout_time_s();
1135                let dropped_burning = separation.drops_burning && powered && *ignition_s < t_s;
1136                if burnout_s > t_s && !dropped_burning {
1137                    return Err(SimError::Domain {
1138                        what: "time of a separation (the aft body's motors must have burned out \
1139                               by then; this is when one of them does)",
1140                        value: burnout_s,
1141                    });
1142                }
1143            }
1144        }
1145        Ok(())
1146    }
1147
1148    /// The first separation, if the flight has one.
1149    #[must_use]
1150    pub fn separation(&self) -> Option<Separation> {
1151        self.separations.first().copied()
1152    }
1153
1154    /// The separations, in the order they fire.
1155    #[must_use]
1156    pub fn separations(&self) -> &[Separation] {
1157        &self.separations
1158    }
1159
1160    /// The assembled design.
1161    #[must_use]
1162    pub fn assembly(&self) -> &hpr_design::Assembly {
1163        &self.vehicle.assembly
1164    }
1165
1166    /// The aerodynamic model.
1167    #[must_use]
1168    pub fn aero(&self) -> &AeroModel {
1169        &self.vehicle.aero
1170    }
1171
1172    /// Takes `other`'s supersonic table for this flight's aerodynamic model where the two would
1173    /// build the same table ([`AeroModel::share_supersonic_table`]), so that it is built once for
1174    /// both: for many flights of one airframe, as a Monte Carlo run's. `other` is usually
1175    /// another simulation's [`Simulation::aero`]. The flight is unchanged, bit for bit. A
1176    /// sustainer's model, built at a powered separation, still builds its own table every
1177    /// flight. Returns `true` if the table is now shared.
1178    pub fn share_supersonic_table(&mut self, other: &AeroModel) -> bool {
1179        self.vehicle.aero.share_supersonic_table(other)
1180    }
1181
1182    /// The rail guides.
1183    #[must_use]
1184    pub fn guides(&self) -> Guides {
1185        self.guides
1186    }
1187
1188    /// The rail.
1189    #[must_use]
1190    pub fn rail(&self) -> Rail {
1191        self.rail
1192    }
1193
1194    /// The environment.
1195    #[must_use]
1196    pub fn environment(&self) -> &Environment {
1197        &self.environment
1198    }
1199
1200    /// The settings.
1201    #[must_use]
1202    pub fn settings(&self) -> FlightSettings {
1203        self.settings
1204    }
1205
1206    /// The state at ignition: on the rail, aft end at its foot, at rest.
1207    #[must_use]
1208    pub fn initial_state(&self) -> State {
1209        let attitude = self.rail.attitude();
1210        State {
1211            position_enu_m: self.rail.direction_enu() * self.guides.aft_station_m,
1212            velocity_enu_m_s: DVec3::ZERO,
1213            attitude,
1214            body_rate_rad_s: DVec3::ZERO,
1215        }
1216    }
1217
1218    /// This simulation with its recovery charges held: the stack's devices never fire, so it
1219    /// coasts through its apogee as if every delay were long. A separation that lights a motor
1220    /// ahead of it still happens, and a separated body's devices act as they would; one that
1221    /// doesn't is held with the charges, and so is a mass shift or release fired by a motor's
1222    /// delay: the charge that would fire it doesn't. User events, which can't be copied, are left
1223    /// out.
1224    pub(crate) fn with_recovery_held(&self) -> Self {
1225        let on_charge = |trigger: Trigger| matches!(trigger, Trigger::MotorDelay { .. });
1226        let mut vehicle = self.vehicle.clone();
1227        for (index, shift) in self.shifts.iter().enumerate() {
1228            if on_charge(shift.trigger) {
1229                vehicle.shifts.hold(index);
1230            }
1231        }
1232        Self {
1233            vehicle,
1234            environment: self.environment.clone(),
1235            rail: self.rail,
1236            guides: self.guides,
1237            settings: self.settings,
1238            user_events: Vec::new(),
1239            devices: self.devices.clone(),
1240            // No charge fires, so none of their times is a stop.
1241            trigger_times_s: vec![None; self.devices.len()],
1242            separations: self.separations.clone(),
1243            separation_times_s: self.separation_times_s.clone(),
1244            ejections: self.ejections.clone(),
1245            ejection_times_s: self.ejection_times_s.clone(),
1246            shifts: self.shifts.clone(),
1247            releases: self.releases.clone(),
1248            release_parts: self.release_parts.clone(),
1249            release_times_s: self
1250                .releases
1251                .iter()
1252                .zip(&self.release_times_s)
1253                .map(|(release, time)| time.filter(|_| !on_charge(release.trigger)))
1254                .collect(),
1255            rocket: self.rocket.clone(),
1256            configuration_id: self.configuration_id.clone(),
1257            aero_overridden: self.aero_overridden,
1258            recovery_held: true,
1259        }
1260    }
1261
1262    /// Flies from ignition on the pad until the flight ends.
1263    ///
1264    /// # Errors
1265    ///
1266    /// [`SimError`] from the models or the integrator (other than the step limit, which is a
1267    /// [`Termination`]), or from the observer. The checks that wait for every builder run here
1268    /// too: a device on a body nothing makes, and a pushed payload in the nose's piece
1269    /// ([`Self::with_ejections`]). A mass shift that starts, or a mass release that comes, before
1270    /// the rocket leaves the rail is [`SimError::Domain`] ([`Self::with_shifts`],
1271    /// [`Self::with_releases`]), as is a release that steps the rest's center of mass below the
1272    /// ground while it climbs, and a separation or ejection whose time is known before the flight
1273    /// and comes before the rocket leaves the rail ([`Self::with_separations`],
1274    /// [`Self::with_ejections`]).
1275    pub fn run(&self, observer: &mut dyn Observer) -> Result<FlightResult, SimError> {
1276        self.fly(0.0, self.initial_state(), Phase::Pad, observer)
1277    }
1278
1279    /// Flies freely from `state` at `t0_s` seconds after launch, as after a rail exit or from a
1280    /// restart: the motors burn as their curves say at that time.
1281    ///
1282    /// # Errors
1283    ///
1284    /// As [`Self::run`].
1285    pub fn run_free(
1286        &self,
1287        t0_s: f64,
1288        state: State,
1289        observer: &mut dyn Observer,
1290    ) -> Result<FlightResult, SimError> {
1291        self.fly(t0_s, state, Phase::Free, observer)
1292    }
1293
1294    /// The sample at `(t, y)` in `phase` during the interval `window`, with `drag_area_m2` of
1295    /// recovery devices open.
1296    fn sample(
1297        &self,
1298        vehicle: &Vehicle,
1299        phase: Phase,
1300        window: (f64, f64),
1301        t: f64,
1302        y: &[f64; STATE_LEN],
1303        drag_area_m2: f64,
1304    ) -> Result<Sample, SimError> {
1305        let evaluation = self.evaluate(vehicle, phase, window, t, y, drag_area_m2)?;
1306        Ok(sample_of(phase, t, y, &evaluation))
1307    }
1308
1309    fn evaluate(
1310        &self,
1311        vehicle: &Vehicle,
1312        phase: Phase,
1313        window: (f64, f64),
1314        t: f64,
1315        y: &[f64; STATE_LEN],
1316        drag_area_m2: f64,
1317    ) -> Result<Evaluation, SimError> {
1318        vehicle.evaluate(
1319            &self.environment,
1320            self.rail.friction_coefficient,
1321            Conditions {
1322                phase,
1323                window,
1324                drag_area_m2,
1325            },
1326            t,
1327            y,
1328        )
1329    }
1330
1331    fn fly(
1332        &self,
1333        t0: f64,
1334        state: State,
1335        start_phase: Phase,
1336        observer: &mut dyn Observer,
1337    ) -> Result<FlightResult, SimError> {
1338        if !t0.is_finite() || t0 < 0.0 || t0 >= self.settings.max_time_s {
1339            return Err(SimError::Domain {
1340                what: "start time (must be from ignition and before the time cap)",
1341                value: t0,
1342            });
1343        }
1344        // The builders can be given in either order, and the last one wins, so the devices and
1345        // the bodies are checked against each other here as well.
1346        check_bodies(&self.devices, self.body_count(), true)?;
1347        if self
1348            .ejections
1349            .iter()
1350            .any(|ejection| ejection.impulse_n_s > 0.0)
1351        {
1352            Pieces::new(
1353                &self.rocket,
1354                &self.vehicle.assembly,
1355                &self.separations,
1356                &self.ejections,
1357            )?
1358            .check_pushed_payloads(&self.ejections)?;
1359        }
1360        // Which parts have left; one that left before the flight's start isn't recorded, and the
1361        // stack starts without it.
1362        let mut released: Vec<bool> = self
1363            .release_times_s
1364            .iter()
1365            .map(|time| time.is_some_and(|time_s| time_s < t0))
1366            .collect();
1367        // The stack once a part has left it.
1368        let mut lightened: Option<Vehicle> = released.contains(&true).then(|| {
1369            let mut stack = self.vehicle.clone();
1370            // In the order they left, as the flight would have taken them out.
1371            let mut gone: Vec<(f64, usize)> = (0..released.len())
1372                .filter_map(|index| Some((self.release_times_s[index]?, index)))
1373                .filter(|&(_, index)| released[index])
1374                .collect();
1375            gone.sort_by(|a, b| a.0.total_cmp(&b.0));
1376            for (_, index) in gone {
1377                lighten(&mut stack.assembly, self.release_parts.part(index));
1378            }
1379            stack
1380        });
1381        if start_phase == Phase::Free {
1382            let height = self
1383                .evaluate(
1384                    lightened.as_ref().unwrap_or(&self.vehicle),
1385                    Phase::Free,
1386                    (t0, t0),
1387                    t0,
1388                    &state.to_array(),
1389                    0.0,
1390                )?
1391                .height_above_ground_m;
1392            if height <= 0.0 {
1393                return Err(SimError::Domain {
1394                    what: "starting height of the center of mass above the ground",
1395                    value: height,
1396                });
1397            }
1398        }
1399        let mut integrator = Integrator::new(self.settings.method, t0, state.to_array())?
1400            .with_step_limit(self.settings.step_limit);
1401        let cap = self.settings.max_time_s;
1402        // What a powered separation changes: the trigger and ignition times that count from it,
1403        // the vehicle flying, and the booster's descent.
1404        let mut trigger_times_s = self.trigger_times_s.clone();
1405        let mut ignition_s = self.vehicle.ignition_s().to_vec();
1406        let mut ignited: Vec<bool> = ignition_s
1407            .iter()
1408            .map(|ignition| ignition.is_some_and(|time| time <= t0))
1409            .collect();
1410        let mut sustainer: Option<Vehicle> = None;
1411        // The stack once a shift the flight watched for has started, with its start set.
1412        let mut shifted: Option<Vehicle> = None;
1413        // Which shifts have started; one that started before the flight's start isn't recorded.
1414        let mut shift_started: Vec<bool> = (0..self.shifts.len())
1415            .map(|index| {
1416                self.vehicle
1417                    .shifts
1418                    .start_s(index)
1419                    .is_some_and(|start_s| start_s < t0)
1420            })
1421            .collect();
1422        let mut released_flights: Vec<ReleasedFlight> = Vec::new();
1423        // How many separations have fired under power, so the next to come is
1424        // `separations[staged]`, and when each did.
1425        let mut staged = 0;
1426        let mut staged_at_s: Vec<f64> = Vec::new();
1427        let mut booster: Vec<BodyFlight> = Vec::new();
1428        let mut stops = self.vehicle.thrust_knots_s();
1429        stops.push(cap);
1430        stops.extend(trigger_times_s.iter().flatten().copied());
1431        stops.extend(self.separation_times_s.iter().flatten().copied());
1432        stops.extend(self.ejection_times_s.iter().flatten().copied());
1433        stops.extend(self.vehicle.shifts.knots_s());
1434        stops.extend(self.release_times_s.iter().flatten().copied());
1435        stops.retain(|t| *t <= cap);
1436        stops.sort_by(f64::total_cmp);
1437        stops.dedup();
1438        let mut burnout_s = self.vehicle.burnout_s();
1439        let rail_origin = state.position_enu_m;
1440        let exit_travel_m = self.guides.exit_travel_m(self.rail.length_m);
1441
1442        let mut phase = start_phase;
1443        // When a device froze the stack's attitude, which says nothing of its axis after that.
1444        let mut frozen_at_s = None;
1445        let mut lifted = start_phase != Phase::Pad;
1446        let mut burnout_recorded = t0 >= burnout_s;
1447        let mut separated = false;
1448        // Whether the stack came apart with nothing left to burn, by a separation or a charge,
1449        // while it still climbed: before its apogee, rising.
1450        let mut climbing = false;
1451        // The splits that have happened, in piece order: the separation, then the ejections.
1452        let mut opened = vec![false; self.body_count() - 1];
1453        // An unpowered separation or an ejection that a held flight skips.
1454        let mut separation_held = false;
1455        let mut events: Vec<FlightEvent> = Vec::new();
1456        let mut run = Run::new(self.devices.len());
1457        let record = |events: &mut Vec<FlightEvent>, observer: &mut dyn Observer, kind, sample| {
1458            let event = FlightEvent { kind, sample };
1459            observer.event(&event);
1460            events.push(event);
1461        };
1462
1463        let termination = loop {
1464            let t = integrator.time_s();
1465            let mut y = *integrator.state();
1466            if t >= cap {
1467                break Termination::TimeCap;
1468            }
1469
1470            // Mass shifts: one whose start is known begins at that stop time; the flight watches
1471            // for the apogee and the heights of the rest, as it does for a device's. A shift
1472            // makes no step in the state, so the integrator carries on.
1473            for (index, started) in shift_started.iter_mut().enumerate() {
1474                if *started {
1475                    continue;
1476                }
1477                let stack = shifted.as_ref().unwrap_or(&self.vehicle);
1478                let window = (t, next_stop(&stops, t, cap));
1479                let area = self.ascent_drag_area_m2(&run, t);
1480                let starts = match stack.shifts.start_s(index) {
1481                    Some(start_s) => t >= start_s,
1482                    None if matches!(phase, Phase::Free | Phase::Descent) => {
1483                        match self.shifts[index].trigger {
1484                            // At or past the apogee, as a device's apogee trigger is.
1485                            Trigger::Apogee => {
1486                                self.evaluate(stack, phase, window, t, &y, area)?
1487                                    .vertical_speed_m_s
1488                                    <= 0.0
1489                            }
1490                            Trigger::Altitude {
1491                                height_above_ground_m,
1492                            } => {
1493                                let e = self.evaluate(stack, phase, window, t, &y, area)?;
1494                                e.vertical_speed_m_s < 0.0
1495                                    && e.height_above_ground_m <= height_above_ground_m
1496                            }
1497                            Trigger::Time { .. }
1498                            | Trigger::MotorDelay { .. }
1499                            | Trigger::Burnout { .. } => false,
1500                        }
1501                    }
1502                    None => false,
1503                };
1504                if !starts {
1505                    continue;
1506                }
1507                if matches!(phase, Phase::Pad | Phase::Rail) {
1508                    // The rail has no stop at its foot: a part thrown aft on the pad could push
1509                    // the rocket up the rail and leave it there.
1510                    return Err(SimError::Domain {
1511                        what: "start time of a mass shift, s (it must start once the rocket has \
1512                               left the rail)",
1513                        value: t,
1514                    });
1515                }
1516                if stack.shifts.start_s(index).is_none() {
1517                    shifted
1518                        .get_or_insert_with(|| self.vehicle.clone())
1519                        .shifts
1520                        .start(index, t);
1521                }
1522                let stack = shifted.as_ref().unwrap_or(&self.vehicle);
1523                for stop_s in stack.shifts.stops_s(index) {
1524                    if stop_s > t {
1525                        insert_stop(&mut stops, stop_s, cap);
1526                    }
1527                }
1528                *started = true;
1529                let window = (t, next_stop(&stops, t, cap));
1530                let sample = self.sample(stack, phase, window, t, &y, area)?;
1531                record(&mut events, observer, EventKind::Shift(index), sample);
1532            }
1533
1534            // Mass releases: one whose time is known comes at that stop time; the flight watches
1535            // for the apogee and the heights of the rest, as it does for a device's. The part
1536            // leaves with the velocity its center had in the airframe, the stack flies on from
1537            // the same state without it, and the part falls on its own. The passes repeat until
1538            // no part leaves, since a release can make the apogee another release waits for.
1539            let mut landed: Option<Sample> = None;
1540            loop {
1541                // The stack as the pass begins, which the apogee and height triggers are judged
1542                // on, so that the parts listed before one don't decide whether it leaves. Past
1543                // the recorded apogee the stack is coming down, whatever a release did to its
1544                // center's speed; with none (a flight started falling) its own speed says.
1545                let apogee_recorded = events.iter().any(|event| event.kind == EventKind::Apogee);
1546                let start = if matches!(phase, Phase::Free | Phase::Descent)
1547                    && released.iter().zip(&self.releases).any(|(gone, release)| {
1548                        !gone
1549                            && matches!(release.trigger, Trigger::Apogee | Trigger::Altitude { .. })
1550                    }) {
1551                    let stack = lightened.as_ref().unwrap_or(&self.vehicle);
1552                    let window = (t, next_stop(&stops, t, cap));
1553                    let area = self.ascent_drag_area_m2(&run, t);
1554                    Some(self.evaluate(stack, phase, window, t, &y, area)?)
1555                } else {
1556                    None
1557                };
1558                let at_apogee = start
1559                    .as_ref()
1560                    .is_some_and(|e| apogee_recorded || e.vertical_speed_m_s <= 0.0);
1561                let descending = start
1562                    .as_ref()
1563                    .is_some_and(|e| apogee_recorded || e.vertical_speed_m_s < 0.0);
1564                // The rocket's vertical speed just before the first part left in this pass.
1565                let mut rising: Option<f64> = None;
1566                for (index, gone) in released.iter_mut().enumerate() {
1567                    if *gone {
1568                        continue;
1569                    }
1570                    let stack = lightened.as_ref().unwrap_or(&self.vehicle);
1571                    let window = (t, next_stop(&stops, t, cap));
1572                    let area = self.ascent_drag_area_m2(&run, t);
1573                    let leaves = match self.release_times_s[index] {
1574                        Some(time_s) => t >= time_s,
1575                        None if matches!(phase, Phase::Free | Phase::Descent) => {
1576                            match self.releases[index].trigger {
1577                                // At the flight's apogee, as a device's apogee trigger is, or
1578                                // past one it didn't see (a flight started falling).
1579                                Trigger::Apogee => at_apogee,
1580                                Trigger::Altitude {
1581                                    height_above_ground_m,
1582                                } => {
1583                                    descending
1584                                        && start.as_ref().is_some_and(|e| {
1585                                            e.height_above_ground_m <= height_above_ground_m
1586                                        })
1587                                }
1588                                Trigger::Time { .. }
1589                                | Trigger::MotorDelay { .. }
1590                                | Trigger::Burnout { .. } => false,
1591                            }
1592                        }
1593                        None => false,
1594                    };
1595                    if !leaves {
1596                        continue;
1597                    }
1598                    if matches!(phase, Phase::Pad | Phase::Rail) {
1599                        // On the pad or the rail the part has nowhere to go.
1600                        return Err(SimError::Domain {
1601                            what: "time of a mass release, s (it must come once the rocket has left \
1602                                   the rail)",
1603                            value: t,
1604                        });
1605                    }
1606                    let sample = self.sample(stack, phase, window, t, &y, area)?;
1607                    record(&mut events, observer, EventKind::MassRelease(index), sample);
1608                    rising.get_or_insert(sample.vertical_speed_m_s);
1609                    // Its center, and the velocity that point had: `v_O + ω × c` in `L` (the body
1610                    // rates are zero in the descent, whose attitude is frozen).
1611                    let part = self.release_parts.part(index);
1612                    let state = State::from_array(&y);
1613                    let omega = if phase == Phase::Free {
1614                        state.body_rate_rad_s
1615                    } else {
1616                        DVec3::ZERO
1617                    };
1618                    let velocity_enu_m_s = state.velocity_enu_m_s
1619                        + state.unit_attitude().mul_vec3(omega.cross(part.cg_m));
1620                    released_flights.push(self.fly_released(
1621                        index,
1622                        t,
1623                        (state.point_enu_m(part.cg_m), velocity_enu_m_s),
1624                        part.mass_kg,
1625                    )?);
1626                    lighten(
1627                        &mut lightened
1628                            .get_or_insert_with(|| self.vehicle.clone())
1629                            .assembly,
1630                        part,
1631                    );
1632                    *gone = true;
1633                }
1634                let (Some(rising), Some(stack)) = (rising, lightened.as_ref()) else {
1635                    break;
1636                };
1637                // The mass steps here, so the integrator starts afresh from the same state: the
1638                // nose tip's, which the stack keeps.
1639                integrator.reset(t, y)?;
1640                let window = (t, next_stop(&stops, t, cap));
1641                let area = self.ascent_drag_area_m2(&run, t);
1642                let sample = self.sample(stack, phase, window, t, &y, area)?;
1643                if sample.height_above_ground_m <= 0.0 {
1644                    if sample.vertical_speed_m_s > 0.0 {
1645                        // Below the ground and climbing: not a landing.
1646                        return Err(SimError::Domain {
1647                            what: "height of the rest's center of mass above the ground after a \
1648                                   mass release, m (it steps below the ground while climbing)",
1649                            value: sample.height_above_ground_m,
1650                        });
1651                    }
1652                    // The rest's center stepped to the ground or below it, which the ground
1653                    // event, a crossing from above, would never see: it has landed.
1654                    landed = Some(sample);
1655                    break;
1656                }
1657                // The rest's center moves at `v_O + ω × cg'`, not as the rocket's did: a part let
1658                // go just before the apogee can leave it already falling, and the apogee the
1659                // flight watches for, its vertical speed falling through zero, is then here.
1660                if rising > 0.0
1661                    && sample.vertical_speed_m_s <= 0.0
1662                    && events.iter().all(|event| event.kind != EventKind::Apogee)
1663                {
1664                    self.reach_apogee(sample, &mut run, (&mut stops, cap), &mut events, observer);
1665                }
1666            }
1667            if let Some(sample) = landed {
1668                record(&mut events, observer, EventKind::GroundHit, sample);
1669                break Termination::GroundHit;
1670            }
1671            let vehicle = sustainer
1672                .as_ref()
1673                .or(shifted.as_ref())
1674                .or(lightened.as_ref())
1675                .unwrap_or(&self.vehicle);
1676
1677            // Motors lit after launch: their ignitions are stop times, so each is found here at
1678            // its own time.
1679            for index in 0..ignition_s.len() {
1680                if !ignited[index] && ignition_s[index].is_some_and(|time| t >= time) {
1681                    ignited[index] = true;
1682                    let window = (t, next_stop(&stops, t, cap));
1683                    let area = self.ascent_drag_area_m2(&run, t);
1684                    let sample = self.sample(vehicle, phase, window, t, &y, area)?;
1685                    record(&mut events, observer, EventKind::Ignition(index), sample);
1686                }
1687            }
1688
1689            // Recovery: fire the charges whose time or height has come, then deploy the devices
1690            // whose lag has run out. A lag of zero deploys in the same pass, and a deployment can
1691            // release another device, so this repeats until nothing more happens. Only the
1692            // devices of body 0 act before a separation: a device meant for another body has a
1693            // drag area computed for that body (a booster's tumbling area, say), which is not a
1694            // model of the whole stack.
1695            while !self.recovery_held
1696                && !self.devices.is_empty()
1697                && matches!(phase, Phase::Free | Phase::Descent)
1698            {
1699                let mut again = false;
1700                let window = (t, next_stop(&stops, t, cap));
1701                let area = self.ascent_drag_area_m2(&run, t);
1702                // One evaluation serves every device in the pass: they all ask about the same
1703                // `(t, y)`. It is only made when a pending device needs the flight's state, and
1704                // it is not one of the integrator's, so `Stats` doesn't count it.
1705                let mut here: Option<Evaluation> = None;
1706                for index in 0..self.devices.len() {
1707                    if !run.pending(index) || !self.acts_before_separation(index) {
1708                        continue;
1709                    }
1710                    // `plan` gives `trigger_times_s` one entry per device, in order.
1711                    let fires = match self.devices[index].trigger {
1712                        Trigger::Time { .. }
1713                        | Trigger::MotorDelay { .. }
1714                        | Trigger::Burnout { .. } => trigger_times_s
1715                            .get(index)
1716                            .copied()
1717                            .flatten()
1718                            .is_some_and(|time| t >= time),
1719                        // At or past the apogee, which is RocketPy's own trigger (`y[5] < 0`)
1720                        // widened to include a flight that starts exactly at its apogee: with
1721                        // `< 0` such a flight has no crossing for the apogee event to find
1722                        // either, and would wait for the next interval. The apogee event fires
1723                        // this too, and whichever comes first wins.
1724                        Trigger::Apogee => {
1725                            let e = cached(&mut here, || {
1726                                self.evaluate(vehicle, phase, window, t, &y, area)
1727                            })?;
1728                            e.vertical_speed_m_s <= 0.0
1729                        }
1730                        Trigger::Altitude {
1731                            height_above_ground_m,
1732                        } => {
1733                            // An altimeter's main setting: descending, at or below the height.
1734                            // A rocket already below it at apogee fires there, as the event on
1735                            // the height never crosses it (RocketPy's numeric trigger). Past the
1736                            // recorded apogee the rocket is descending, even while a part let go
1737                            // there has the rest's center rising for a moment (beyond RocketPy,
1738                            // which has no releases; without one the rule is the same).
1739                            let e = cached(&mut here, || {
1740                                self.evaluate(vehicle, phase, window, t, &y, area)
1741                            })?;
1742                            (e.vertical_speed_m_s < 0.0
1743                                || events.iter().any(|event| event.kind == EventKind::Apogee))
1744                                && e.height_above_ground_m <= height_above_ground_m
1745                        }
1746                    };
1747                    if fires {
1748                        let deploy_s = run.trigger(&self.devices, index, t);
1749                        insert_stop(&mut stops, deploy_s, cap);
1750                        let sample = self.sample(vehicle, phase, window, t, &y, area)?;
1751                        record(&mut events, observer, EventKind::Trigger(index), sample);
1752                        again = true;
1753                    }
1754                }
1755                for index in 0..self.devices.len() {
1756                    if !run.waiting(index)
1757                        || run.deploy_s(index) > t
1758                        || !self.acts_before_separation(index)
1759                    {
1760                        continue;
1761                    }
1762                    if run.released_s(index).is_some_and(|released| released <= t) {
1763                        // Cut away before its own charge fired: the canopy never flies.
1764                        run.abandon(index);
1765                        again = true;
1766                        continue;
1767                    }
1768                    let evaluation = cached(&mut here, || {
1769                        self.evaluate(vehicle, phase, window, t, &y, area)
1770                    })?;
1771                    let full_s = run.deploy(&self.devices, index, t, evaluation.airspeed_m_s);
1772                    insert_stop(&mut stops, full_s, cap);
1773                    if phase != Phase::Descent {
1774                        // The descent is a point mass: the attitude freezes where it deployed and
1775                        // the body rates go, snubbed by the lines and the canopy. The state's
1776                        // velocity is the nose tip's, so it is shifted to keep the center of mass
1777                        // moving as it was: dropping `ω` while holding `v_O` would change the
1778                        // center of mass's momentum with nothing to do it (found in review).
1779                        // Written this way it reads as what it is; the `ṙ_cg` terms cancel, so
1780                        // the net shift is `q(ω × r_cg)`.
1781                        phase = Phase::Descent;
1782                        frozen_at_s = Some(t);
1783                        let mut frozen = State::from_array(&y);
1784                        frozen.velocity_enu_m_s = evaluation.cg_velocity_enu_m_s
1785                            - frozen.unit_attitude().mul_vec3(evaluation.mass.cg_rate_m_s);
1786                        frozen.body_rate_rad_s = DVec3::ZERO;
1787                        y = frozen.to_array();
1788                        integrator.reset(t, y)?;
1789                        here = None;
1790                    }
1791                    let area = self.ascent_drag_area_m2(&run, t);
1792                    let sample = self.sample(vehicle, phase, window, t, &y, area)?;
1793                    record(&mut events, observer, EventKind::Deployment(index), sample);
1794                    again = true;
1795                }
1796                // A release happens when the device that releases it is fully open, which is a
1797                // stop time, so the drag area never dips between a drogue and a filling main.
1798                for index in 0..self.devices.len() {
1799                    if run.release_due(index, t) && self.acts_before_separation(index) {
1800                        run.release(index);
1801                        let area = self.ascent_drag_area_m2(&run, t);
1802                        let sample = self.sample(vehicle, phase, window, t, &y, area)?;
1803                        record(&mut events, observer, EventKind::Release(index), sample);
1804                        again = true;
1805                    }
1806                }
1807                if !again {
1808                    break;
1809                }
1810            }
1811
1812            // A separation or an ejection known to come while the rocket is still on the pad or
1813            // the rail: the stack can't come apart there, and firing it at the rail exit instead
1814            // would be a different flight (#231). Refused, as an early shift or release is.
1815            if self.parts() && !separation_held && matches!(phase, Phase::Pad | Phase::Rail) {
1816                let separations = self.separation_times_s.iter().skip(staged).map(|time_s| {
1817                    (
1818                        time_s,
1819                        "time of a separation, s (it must come once the rocket has left the rail)",
1820                    )
1821                });
1822                let ejections = self.ejection_times_s.iter().map(|time_s| {
1823                    (
1824                        time_s,
1825                        "time of an ejection, s (it must come once the rocket has left the rail)",
1826                    )
1827                });
1828                for (time_s, what) in separations.chain(ejections) {
1829                    if time_s.is_some_and(|time_s| t >= time_s) {
1830                        return Err(SimError::Domain { what, value: t });
1831                    }
1832                }
1833            }
1834
1835            // The separations and the ejections: the same triggers as a device's. When the next
1836            // separation fires with the nose's body still to burn, that body flies on as the
1837            // sustainer and the part behind it descends; otherwise the ascent ends and every body
1838            // flies on as a point mass.
1839            if self.parts()
1840                && (staged < self.separations.len() || (staged == 0 && !self.ejections.is_empty()))
1841                && !separation_held
1842                && matches!(phase, Phase::Free | Phase::Descent)
1843            {
1844                let window = (t, next_stop(&stops, t, cap));
1845                let area = self.ascent_drag_area_m2(&run, t);
1846                let mut here: Option<Evaluation> = None;
1847                let mut fires = |trigger: Trigger, time_s: Option<f64>| -> Result<bool, SimError> {
1848                    Ok(match trigger {
1849                        Trigger::Time { .. }
1850                        | Trigger::MotorDelay { .. }
1851                        | Trigger::Burnout { .. } => time_s.is_some_and(|time| t >= time),
1852                        // At or past the apogee, as a device's apogee trigger is.
1853                        Trigger::Apogee => {
1854                            cached(&mut here, || {
1855                                self.evaluate(vehicle, phase, window, t, &y, area)
1856                            })?
1857                            .vertical_speed_m_s
1858                                <= 0.0
1859                        }
1860                        Trigger::Altitude {
1861                            height_above_ground_m,
1862                        } => {
1863                            let e = cached(&mut here, || {
1864                                self.evaluate(vehicle, phase, window, t, &y, area)
1865                            })?;
1866                            e.vertical_speed_m_s < 0.0
1867                                && e.height_above_ground_m <= height_above_ground_m
1868                        }
1869                    })
1870                };
1871                let pending = self.separations.get(staged).copied();
1872                let separates = match pending {
1873                    Some(separation) => fires(separation.trigger, self.separation_times_s[staged])?,
1874                    None => false,
1875                };
1876                if !separates {
1877                    // One further down the list can't come first: the stages it drops are still
1878                    // held by the ones behind them.
1879                    for later in staged + 1..self.separations.len() {
1880                        if fires(
1881                            self.separations[later].trigger,
1882                            self.separation_times_s[later],
1883                        )? {
1884                            return Err(SimError::Domain {
1885                                what: "time of a separation, s (it fires before the separation \
1886                                       ahead of it in the list, which drops the stages behind it)",
1887                                value: t,
1888                            });
1889                        }
1890                    }
1891                }
1892                let mut ejected = Vec::new();
1893                for (index, ejection) in self.ejections.iter().enumerate() {
1894                    if fires(ejection.trigger, self.ejection_times_s[index])? {
1895                        ejected.push(index);
1896                    }
1897                }
1898                if separates || !ejected.is_empty() {
1899                    // The stack's motors as the separation lights them: one lit by it counts from
1900                    // now. A body's mass is held constant through its descent, so the booster's
1901                    // motors must be spent.
1902                    let mut lit = ignition_s.clone();
1903                    // The separation, when it fires with a motor ahead of it still to burn.
1904                    let mut powered: Option<Separation> = None;
1905                    if let Some(separation) = pending.filter(|_| separates) {
1906                        // A motor lit by an earlier separation counts from that one.
1907                        lit = self.vehicle.assembly.ignition_times_s(|stage| {
1908                            (stage == separation.after_stage).then_some(t).or_else(|| {
1909                                self.separations
1910                                    .iter()
1911                                    .zip(&staged_at_s)
1912                                    .find(|(earlier, _)| earlier.after_stage == stage)
1913                                    .map(|(_, &at_s)| at_s)
1914                            })
1915                        });
1916                        let burning = self.vehicle.assembly.motors.iter().zip(&lit).any(
1917                            |(placed, ignition)| {
1918                                placed.stage <= separation.after_stage
1919                                    && ignition.is_some_and(|ignition| {
1920                                        ignition + placed.mounted.motor.burnout_time_s() > t
1921                                    })
1922                            },
1923                        );
1924                        powered = burning.then_some(separation);
1925                        // Checked before a held flight holds it, so that the delay's flight
1926                        // refuses what the flown one would.
1927                        self.check_aft_body_spent(separation, &lit, t, burning)?;
1928                        if !burning && self.separations.len() > 1 {
1929                            // Once every body is a point mass there is no sustainer left to part
1930                            // again, and the bodies a sustainer already dropped have flown.
1931                            return Err(SimError::Domain {
1932                                what: "time of a separation that leaves nothing ahead of it to \
1933                                       burn, in a flight with more than one separation (each \
1934                                       must hand the flight on to a sustainer)",
1935                                value: t,
1936                            });
1937                        }
1938                    }
1939                    if powered.is_some() && !self.ejections.is_empty() {
1940                        // The sustainer flies on a cut design whose components aren't the
1941                        // pieces the ejections were given for.
1942                        return Err(SimError::Domain {
1943                            what: "time of a powered separation in a flight with ejections (the \
1944                                   pieces of a sustainer aren't tracked)",
1945                            value: t,
1946                        });
1947                    }
1948                    if !ejected.is_empty() {
1949                        // Every body is a point mass of constant mass once the airframe parts.
1950                        self.check_spent(&lit, t)?;
1951                        if let Some(separation) = pending.filter(|_| !separates)
1952                            && self
1953                                .vehicle
1954                                .assembly
1955                                .ignition_times_s(|stage| {
1956                                    (stage == separation.after_stage).then_some(t)
1957                                })
1958                                .iter()
1959                                .zip(&lit)
1960                                .any(|(would, is)| would.is_some() && is.is_none())
1961                        {
1962                            return Err(SimError::Domain {
1963                                what: "time of an ejection before the separation that lights a \
1964                                       motor (the pieces would never light it)",
1965                                value: t,
1966                            });
1967                        }
1968                    }
1969                    if self.recovery_held && powered.is_none() {
1970                        // With nothing ahead of it left to burn it is part of the recovery, so it
1971                        // is held with the charges.
1972                        separation_held = true;
1973                        continue;
1974                    }
1975                    let sample = self.sample(vehicle, phase, window, t, &y, area)?;
1976                    if separates {
1977                        record(&mut events, observer, EventKind::Separation, sample);
1978                    }
1979                    for &index in &ejected {
1980                        record(&mut events, observer, EventKind::Ejection(index), sample);
1981                    }
1982                    let Some(separation) = powered else {
1983                        if separates {
1984                            opened[staged] = true;
1985                        }
1986                        climbing = events.iter().all(|event| event.kind != EventKind::Apogee)
1987                            && State::from_array(&y).velocity_enu_m_s.z > 0.0;
1988                        let first = self.separations.len();
1989                        for &index in &ejected {
1990                            opened[first + index] = true;
1991                        }
1992                        ignition_s = lit;
1993                        separated = true;
1994                        break Termination::Separated;
1995                    };
1996                    if phase == Phase::Descent {
1997                        // The descent is a point mass under canopies; a sustainer under thrust
1998                        // is not something it can fly.
1999                        return Err(SimError::Domain {
2000                            what: "time of a separation whose nose body still has a motor to \
2001                                   burn (it comes after a recovery device opened on that body)",
2002                            value: t,
2003                        });
2004                    }
2005                    if self.aero_overridden {
2006                        return Err(SimError::Domain {
2007                            what: "time of a powered separation (a drag table or drag model, \
2008                                   or a normal-force table, is the whole stack's, and the \
2009                                   sustainer has none)",
2010                            value: t,
2011                        });
2012                    }
2013                    // Built only now, so an unpowered separation never needs the cut design.
2014                    let model = Sustainer::of(
2015                        &self.rocket,
2016                        &self.configuration_id,
2017                        &self.vehicle.assembly,
2018                        separation,
2019                    )?;
2020                    let lit_here = model.motors.iter().map(|&index| lit[index]).collect();
2021                    let aero = if self.vehicle.aero.full_base_drag_under_power() {
2022                        model.aero.with_full_base_drag_under_power()
2023                    } else {
2024                        model.aero
2025                    }
2026                    .with_drag_scale(self.vehicle.aero.drag_scale())?;
2027                    let flown = Vehicle::lit(model.assembly, aero, lit_here)?;
2028                    // The part it drops is the next body, flown alone: the nose's flies on, and those
2029                    // dropped before have flown.
2030                    opened[staged] = true;
2031                    booster.extend(self.fly_bodies(
2032                        t,
2033                        &State::from_array(&y),
2034                        &mut run,
2035                        &lit,
2036                        (
2037                            &opened,
2038                            Some(staged + 1),
2039                            frozen_at_s.is_some_and(|frozen_s| frozen_s < t),
2040                        ),
2041                    )?);
2042                    // The booster's descent has no airframe drag, and a powered separation comes
2043                    // near the top speed, so a coast to its device would climb as if in a vacuum
2044                    // (twice the sustainer's apogee in review). Its device must open at once.
2045                    let open_at_split = self.devices.iter().enumerate().any(|(index, device)| {
2046                        device.body == staged + 1
2047                            && run.devices[index]
2048                                .deployed_s
2049                                .is_some_and(|deployed_s| deployed_s <= t)
2050                    });
2051                    if !open_at_split {
2052                        return Err(SimError::Domain {
2053                            what: "time of a powered separation (the booster falls with no \
2054                                   airframe drag, so a device on it must open at the separation: \
2055                                   one triggered at `Time { time_s: 0.0 }` with no lag does, as \
2056                                   a booster's devices act only once it flies)",
2057                            value: t,
2058                        });
2059                    }
2060                    trigger_times_s =
2061                        recovery::plan(&self.devices, &self.vehicle.assembly.motors, &lit)?;
2062                    for time in trigger_times_s
2063                        .iter()
2064                        .flatten()
2065                        .copied()
2066                        .chain(flown.thrust_knots_s())
2067                    {
2068                        if time > t {
2069                            insert_stop(&mut stops, time, cap);
2070                        }
2071                    }
2072                    if flown.burnout_s() > t {
2073                        burnout_recorded = false;
2074                    }
2075                    burnout_s = flown.burnout_s();
2076                    ignition_s = lit;
2077                    staged += 1;
2078                    staged_at_s.push(t);
2079                    sustainer = Some(flown);
2080                    // The mass steps here, so the integrator starts afresh from the same state:
2081                    // the nose tip's, which the sustainer keeps.
2082                    integrator.reset(t, y)?;
2083                    continue;
2084                }
2085            }
2086
2087            let next = next_stop(&stops, t, cap);
2088            let window = (t, next);
2089            if phase == Phase::Pad {
2090                if self
2091                    .evaluate(vehicle, Phase::Pad, window, t, &y, 0.0)?
2092                    .rail_force_n
2093                    > 0.0
2094                {
2095                    phase = Phase::Rail;
2096                    lifted = true;
2097                    let sample = self.sample(vehicle, phase, window, t, &y, 0.0)?;
2098                    record(&mut events, observer, EventKind::Liftoff, sample);
2099                } else if t >= burnout_s {
2100                    break if lifted {
2101                        Termination::StalledOnRail
2102                    } else {
2103                        Termination::NoLiftoff
2104                    };
2105                }
2106            }
2107            // Once a part has left, the rest's center sits apart from where the rocket's was, and
2108            // can rise again for a moment after the rocket's apogee: the flight has one.
2109            let apogee_pending = !(released.contains(&true)
2110                && events.iter().any(|event| event.kind == EventKind::Apogee));
2111            let watches = self.watches(
2112                phase,
2113                &run,
2114                (
2115                    (!separation_held
2116                        && (staged < self.separations.len() || (staged == 0 && self.parts())))
2117                    .then_some(staged),
2118                    apogee_pending,
2119                ),
2120                (&shift_started, &released),
2121            );
2122            let mut system = PhaseSystem {
2123                simulation: self,
2124                vehicle,
2125                phase,
2126                window,
2127                rail_origin,
2128                exit_travel_m,
2129                canopies: Canopies {
2130                    devices: &self.devices,
2131                    run: &run,
2132                    body: self.parts().then_some(0),
2133                },
2134                watches: &watches,
2135                observer: &mut *observer,
2136                failure: None,
2137                cache: None,
2138            };
2139            let outcome = integrator.advance(&mut system, next);
2140            if let Some(error) = system.failure.take() {
2141                return Err(error);
2142            }
2143            let outcome = match outcome {
2144                Ok(outcome) => outcome,
2145                Err(IntegrationError::StepLimit { .. }) => break Termination::StepLimit,
2146                Err(IntegrationError::Derivative { source, .. }) => return Err(source),
2147                Err(error) => return Err(SimError::Integration(Box::new(error))),
2148            };
2149            let t = integrator.time_s();
2150            let y = *integrator.state();
2151            let area = self.ascent_drag_area_m2(&run, t);
2152            // Burnout is a stop time, but an event can end the step on it first.
2153            if !burnout_recorded && t >= burnout_s {
2154                burnout_recorded = true;
2155                let sample = self.sample(vehicle, phase, window, t, &y, area)?;
2156                record(&mut events, observer, EventKind::Burnout, sample);
2157            }
2158            match outcome {
2159                Advance::Reached => {}
2160                Advance::Events => {
2161                    let fired = integrator.fired_events().to_vec();
2162                    let mut ground = false;
2163                    let mut next_phase = phase;
2164                    for index in fired {
2165                        // `event_count` is `watches.len()`, and the integrator numbers its events
2166                        // by it, so a fired index is always in range.
2167                        match watches[index] {
2168                            Watch::RailExit => {
2169                                next_phase = Phase::Free;
2170                                let sample =
2171                                    self.sample(vehicle, Phase::Free, window, t, &y, area)?;
2172                                record(&mut events, observer, EventKind::RailExit, sample);
2173                            }
2174                            Watch::RailStall => next_phase = Phase::Pad,
2175                            Watch::RailForce => {
2176                                next_phase = Phase::Rail;
2177                                lifted = true;
2178                                let sample =
2179                                    self.sample(vehicle, Phase::Rail, window, t, &y, area)?;
2180                                record(&mut events, observer, EventKind::Liftoff, sample);
2181                            }
2182                            // The watch stays armed past the apogee until a part leaves, and can
2183                            // find the same root again a step later (#242): the flight has one.
2184                            Watch::Apogee
2185                                if events.iter().any(|event| event.kind == EventKind::Apogee) => {}
2186                            Watch::Apogee => {
2187                                let sample = self.sample(vehicle, phase, window, t, &y, area)?;
2188                                self.reach_apogee(
2189                                    sample,
2190                                    &mut run,
2191                                    (&mut stops, cap),
2192                                    &mut events,
2193                                    observer,
2194                                );
2195                            }
2196                            Watch::Ground => {
2197                                let sample = self.sample(vehicle, phase, window, t, &y, area)?;
2198                                record(&mut events, observer, EventKind::GroundHit, sample);
2199                                ground = true;
2200                            }
2201                            Watch::Altitude(device) => {
2202                                if !self.recovery_held && run.pending(device) {
2203                                    let deploy_s = run.trigger(&self.devices, device, t);
2204                                    insert_stop(&mut stops, deploy_s, cap);
2205                                    let sample =
2206                                        self.sample(vehicle, phase, window, t, &y, area)?;
2207                                    record(
2208                                        &mut events,
2209                                        observer,
2210                                        EventKind::Trigger(device),
2211                                        sample,
2212                                    );
2213                                }
2214                            }
2215                            Watch::SeparationHeight(_)
2216                            | Watch::EjectionHeight(_)
2217                            | Watch::ShiftHeight(_)
2218                            | Watch::ReleaseHeight(_) => {
2219                                // The separation, ejection, shift or release itself fires at the
2220                                // top of the next pass, which is where its burnout check and its
2221                                // bodies live, where a shift's start is set, and where a part
2222                                // leaves.
2223                            }
2224                            Watch::User(user) => {
2225                                let sample = self.sample(vehicle, phase, window, t, &y, area)?;
2226                                record(&mut events, observer, EventKind::User(user), sample);
2227                            }
2228                        }
2229                    }
2230                    if ground {
2231                        break Termination::GroundHit;
2232                    }
2233                    if next_phase == Phase::Pad && phase == Phase::Rail {
2234                        // Stopped on the rail: at rest where it stopped.
2235                        let mut at_rest = State::from_array(&y);
2236                        at_rest.velocity_enu_m_s = DVec3::ZERO;
2237                        integrator.reset(t, at_rest.to_array())?;
2238                    }
2239                    phase = next_phase;
2240                }
2241                _ => {}
2242            }
2243        };
2244
2245        let t = integrator.time_s();
2246        let y = *integrator.state();
2247        let vehicle = sustainer
2248            .as_ref()
2249            .or(shifted.as_ref())
2250            .or(lightened.as_ref())
2251            .unwrap_or(&self.vehicle);
2252        let next = next_stop(&stops, t, f64::INFINITY);
2253        let area = self.ascent_drag_area_m2(&run, t);
2254        let final_sample = self.sample(vehicle, phase, (t, next.max(t)), t, &y, area)?;
2255        let bodies = if separated {
2256            let bodies = self.fly_bodies(
2257                t,
2258                &State::from_array(&y),
2259                &mut run,
2260                &ignition_s,
2261                (
2262                    &opened,
2263                    None,
2264                    frozen_at_s.is_some_and(|frozen_s| frozen_s < t),
2265                ),
2266            )?;
2267            // Each part falls as a point with only its open devices' drag. One parted on the way
2268            // up whose device fired by the split but waits out its lag would climb through the
2269            // lag with no drag at all, as if in a vacuum, where the real part has its airframe's
2270            // (a deployment lag drawn by a Monte Carlo run did, in review): refused by name. A
2271            // device set for the part's own apogee leaves it climbing as before, which the
2272            // `.ork` reader refuses for the part that keeps the nose (ADR-165).
2273            if climbing {
2274                for body in bodies.iter().filter(|body| body.start_sample.time_s == t) {
2275                    let mine = || {
2276                        self.devices
2277                            .iter()
2278                            .enumerate()
2279                            .filter(|(_, device)| device.body == body.body)
2280                            .map(|(index, _)| &run.devices[index])
2281                    };
2282                    let open_at_split = mine()
2283                        .any(|device| device.deployed_s.is_some_and(|deployed_s| deployed_s <= t));
2284                    let lagging = mine().any(|device| {
2285                        device
2286                            .triggered_s
2287                            .is_some_and(|triggered_s| triggered_s <= t)
2288                    });
2289                    if lagging && !open_at_split {
2290                        return Err(SimError::Domain {
2291                            what: "time of a split with nothing left to burn, before \
2292                                   apogee (a part's device fired by then but opens after its \
2293                                   lag, and the part would climb through the lag with no \
2294                                   airframe drag)",
2295                            value: t,
2296                        });
2297                    }
2298                }
2299            }
2300            bodies
2301        } else {
2302            booster
2303        };
2304        Ok(FlightResult {
2305            termination,
2306            events,
2307            final_sample,
2308            stats: integrator.stats(),
2309            bodies,
2310            released: released_flights,
2311        })
2312    }
2313
2314    /// Flies the bodies of a stack that came apart at `t` to their landings, with the stack's
2315    /// motors lit at `ignition_s`: `open[split]` says which splits happened (the separations, then
2316    /// the ejections), `only` flies that body alone, the one a powered separation drops while the
2317    /// rest fly on or have already flown, and `frozen`
2318    /// says a device froze the stack's attitude before `t` (one that opens as the stack parts
2319    /// leaves the axis it had then).
2320    ///
2321    /// Each body is a point mass with its own pieces' and motors' mass, starting where its own
2322    /// center of mass was with the velocity that point already had, plus the push of each
2323    /// ejection's impulse on its side. The bodies share the flight's devices and their progress: a
2324    /// device that had already opened stays open on whichever body carries it. A body that parts
2325    /// again on the way down hands the piece that leaves to a body of its own, flown after it.
2326    fn fly_bodies(
2327        &self,
2328        t: f64,
2329        state: &State,
2330        run: &mut Run,
2331        ignition_s: &[Option<f64>],
2332        (open, only, frozen): (&[bool], Option<usize>, bool),
2333    ) -> Result<Vec<BodyFlight>, SimError> {
2334        if !self.parts() {
2335            return Ok(Vec::new());
2336        }
2337        let pieces = Pieces::new(
2338            &self.rocket,
2339            &self.vehicle.assembly,
2340            &self.separations,
2341            &self.ejections,
2342        )?;
2343        let mut open = open.to_vec();
2344        open.resize(pieces.count() - 1, false);
2345        let attitude = state.unit_attitude();
2346        // A trigger on a motor the separation lit has its time only now.
2347        let trigger_times_s =
2348            recovery::plan(&self.devices, &self.vehicle.assembly.motors, ignition_s)?;
2349        let leaders = pieces.leaders(&open);
2350        let mut starts = Vec::new();
2351        for body in 0..pieces.count() {
2352            if leaders[body] != body || only.is_some_and(|only| only != body) {
2353                continue;
2354            }
2355            let mass = pieces.mass_properties(
2356                |piece| leaders[piece] == body,
2357                &self.vehicle.assembly,
2358                t,
2359                ignition_s,
2360            );
2361            // Its own center of mass, and the velocity that point had: `v_O + ω × r` in `L`.
2362            let cg_enu_m = state.point_enu_m(mass.cg_m);
2363            let velocity_enu_m_s =
2364                state.velocity_enu_m_s + attitude.mul_vec3(state.body_rate_rad_s.cross(mass.cg_m));
2365            starts.push(Start {
2366                body,
2367                t_s: t,
2368                cg_enu_m,
2369                velocity_enu_m_s,
2370                mass_kg: checked_body_mass(mass.mass_kg)?,
2371            });
2372        }
2373        // Each split that parts the stack here pushes its two sides apart: along the airframe's
2374        // axis while it flies with nothing open, and by its velocity through the air once a
2375        // device has frozen its attitude at some earlier time (ADR-086).
2376        let firing: Vec<usize> = (0..open.len()).filter(|&index| open[index]).collect();
2377        if firing
2378            .iter()
2379            .any(|&index| self.split_impulse_n_s(index) > 0.0)
2380        {
2381            let nose_ward = if frozen {
2382                let (mut kg, mut momentum, mut moment) = (0.0, DVec3::ZERO, DVec3::ZERO);
2383                for start in &starts {
2384                    kg += start.mass_kg;
2385                    momentum += start.velocity_enu_m_s * start.mass_kg;
2386                    moment += start.cg_enu_m * start.mass_kg;
2387                }
2388                let hanging =
2389                    run.hung_before(&self.devices, |index| self.acts_before_separation(index), t);
2390                self.nose_ward_enu(moment / kg, momentum / kg, hanging)?
2391            } else {
2392                attitude.mul_vec3(DVec3::Z)
2393            };
2394            self.push_apart(
2395                (&firing, &pieces, &open, &leaders),
2396                nose_ward,
2397                &mut starts,
2398                t,
2399            )?;
2400        }
2401        let mut queue: std::collections::VecDeque<Start> = starts.into();
2402        let mut bodies = Vec::new();
2403        while let Some(start) = queue.pop_front() {
2404            let mut split = Split {
2405                pieces: &pieces,
2406                open: &mut open,
2407                ignition_s,
2408                queue: &mut queue,
2409            };
2410            bodies.push(self.fly_body(start, &mut split, run, &trigger_times_s)?);
2411        }
2412        bodies.sort_by_key(|body| body.body);
2413        Ok(bodies)
2414    }
2415
2416    /// Flies one body as a point mass from its `start` to its landing, parting it on the way
2417    /// down at each of its splits that fires.
2418    fn fly_body(
2419        &self,
2420        start: Start,
2421        split: &mut Split<'_>,
2422        run: &mut Run,
2423        trigger_times_s: &[Option<f64>],
2424    ) -> Result<BodyFlight, SimError> {
2425        let Start {
2426            body,
2427            t_s: t0,
2428            cg_enu_m,
2429            velocity_enu_m_s,
2430            mut mass_kg,
2431        } = start;
2432        let impulse_left_n_s = {
2433            let leaders = split.pieces.leaders(split.open);
2434            split.pieces.impulse_left_n_s(
2435                |piece| leaders[piece] == body,
2436                &self.vehicle.assembly,
2437                t0,
2438                split.ignition_s,
2439            )
2440        };
2441        let cap = self.settings.max_time_s;
2442        let start = [
2443            cg_enu_m.x,
2444            cg_enu_m.y,
2445            cg_enu_m.z,
2446            velocity_enu_m_s.x,
2447            velocity_enu_m_s.y,
2448            velocity_enu_m_s.z,
2449        ];
2450        let mut integrator = Integrator::new(self.settings.method, t0, start)?
2451            .with_step_limit(self.settings.step_limit);
2452        // Every time this body's devices already have: their trigger times, and (for a device
2453        // that was triggered, deployed or released before the separation) its deployment, the
2454        // end of its filling and its release. Without these the descent would step straight past
2455        // them (found in review).
2456        let mut stops: Vec<f64> = Vec::new();
2457        for index in 0..self.devices.len() {
2458            if self.devices[index].body != body {
2459                continue;
2460            }
2461            stops.extend(trigger_times_s.get(index).copied().flatten());
2462            stops.extend(run.times_of(index));
2463        }
2464        // And its splits' known times.
2465        let mut leaders = split.pieces.leaders(split.open);
2466        for index in split.pending(&leaders, body) {
2467            stops.extend(self.split(index).1);
2468        }
2469        stops.retain(|time| *time > t0 && *time <= cap);
2470        stops.push(cap);
2471        stops.sort_by(f64::total_cmp);
2472        stops.dedup();
2473        let mut events: Vec<BodyEvent> = Vec::new();
2474        let start_sample = self.body_sample(body, mass_kg, t0, &start, run)?;
2475        if start_sample.height_above_ground_m <= 0.0 {
2476            // The ground event is a falling crossing, so a body that starts below the site would
2477            // integrate underground to the time cap.
2478            return Err(SimError::Domain {
2479                what: "starting height of a separated body's center of mass above the ground",
2480                value: start_sample.height_above_ground_m,
2481            });
2482        }
2483        let mine: Vec<usize> = (0..self.devices.len())
2484            .filter(|index| self.devices[*index].body == body)
2485            .collect();
2486
2487        let termination = loop {
2488            let t = integrator.time_s();
2489            let mut y = *integrator.state();
2490            if t >= cap {
2491                break Termination::TimeCap;
2492            }
2493            // Charges, deployments and releases, as in the main loop.
2494            loop {
2495                let mut again = false;
2496                let sample = self.body_sample(body, mass_kg, t, &y, run)?;
2497                for &index in &mine {
2498                    if !run.pending(index) {
2499                        continue;
2500                    }
2501                    let fires = match self.devices[index].trigger {
2502                        Trigger::Time { .. }
2503                        | Trigger::MotorDelay { .. }
2504                        | Trigger::Burnout { .. } => trigger_times_s
2505                            .get(index)
2506                            .copied()
2507                            .flatten()
2508                            .is_some_and(|time| t >= time),
2509                        Trigger::Apogee => sample.vertical_speed_m_s <= 0.0,
2510                        Trigger::Altitude {
2511                            height_above_ground_m,
2512                        } => {
2513                            sample.vertical_speed_m_s < 0.0
2514                                && sample.height_above_ground_m <= height_above_ground_m
2515                        }
2516                    };
2517                    if fires {
2518                        let deploy_s = run.trigger(&self.devices, index, t);
2519                        insert_stop(&mut stops, deploy_s, cap);
2520                        events.push(BodyEvent {
2521                            kind: EventKind::Trigger(index),
2522                            sample,
2523                            after: None,
2524                        });
2525                        again = true;
2526                    }
2527                }
2528                for &index in &mine {
2529                    if !run.waiting(index) || run.deploy_s(index) > t {
2530                        continue;
2531                    }
2532                    if run.released_s(index).is_some_and(|released| released <= t) {
2533                        run.abandon(index);
2534                        again = true;
2535                        continue;
2536                    }
2537                    let full_s = run.deploy(&self.devices, index, t, sample.airspeed_m_s);
2538                    insert_stop(&mut stops, full_s, cap);
2539                    events.push(BodyEvent {
2540                        kind: EventKind::Deployment(index),
2541                        sample: self.body_sample(body, mass_kg, t, &y, run)?,
2542                        after: None,
2543                    });
2544                    again = true;
2545                }
2546                for &index in &mine {
2547                    if run.release_due(index, t) {
2548                        run.release(index);
2549                        events.push(BodyEvent {
2550                            kind: EventKind::Release(index),
2551                            sample: self.body_sample(body, mass_kg, t, &y, run)?,
2552                            after: None,
2553                        });
2554                        again = true;
2555                    }
2556                }
2557                if !again {
2558                    break;
2559                }
2560            }
2561
2562            // Its splits whose trigger has come part it at once, as the stack parts at the first
2563            // parting: each piece they make starts here, at this body's point and velocity plus
2564            // the pushes on its sides, and is flown after it. Which fire, and the way the pushes
2565            // point, are decided on the body as the pass starts, so neither depends on the order
2566            // the splits were given in (found in review: taking them one at a time let a push
2567            // delay a split, or share one charge's push with pieces another then parted). A split
2568            // that hasn't fired is asked again at the same instant, on the pushed body.
2569            let pending = split.pending(&leaders, body);
2570            let fires = |index: usize, sample: &BodySample| {
2571                let (trigger, time_s, _) = self.split(index);
2572                match trigger {
2573                    Trigger::Time { .. } | Trigger::MotorDelay { .. } | Trigger::Burnout { .. } => {
2574                        time_s.is_some_and(|time| t >= time)
2575                    }
2576                    Trigger::Apogee => sample.vertical_speed_m_s <= 0.0,
2577                    Trigger::Altitude {
2578                        height_above_ground_m,
2579                    } => {
2580                        sample.vertical_speed_m_s < 0.0
2581                            && sample.height_above_ground_m <= height_above_ground_m
2582                    }
2583                }
2584            };
2585            let before = if pending.is_empty() {
2586                None
2587            } else {
2588                Some(self.body_sample(body, mass_kg, t, &y, run)?)
2589            };
2590            let firing: Vec<usize> = before.map_or_else(Vec::new, |before| {
2591                pending
2592                    .iter()
2593                    .copied()
2594                    .filter(|&index| fires(index, &before))
2595                    .collect()
2596            });
2597            if let (Some(before), false) = (before, firing.is_empty()) {
2598                // A separation still to come here lights no motor: an ejection ahead of one that
2599                // would is refused in the ascent.
2600                for &index in &firing {
2601                    split.open[index] = true;
2602                }
2603                leaders = split.pieces.leaders(split.open);
2604                let mass_of = |lead: usize| {
2605                    checked_body_mass(
2606                        split
2607                            .pieces
2608                            .mass_properties(
2609                                |piece| leaders[piece] == lead,
2610                                &self.vehicle.assembly,
2611                                t,
2612                                split.ignition_s,
2613                            )
2614                            .mass_kg,
2615                    )
2616                };
2617                // This body, then the body each split makes, led by the split's piece.
2618                let mut parts = vec![Start {
2619                    body,
2620                    t_s: t,
2621                    cg_enu_m: before.cg_enu_m,
2622                    velocity_enu_m_s: before.cg_velocity_enu_m_s,
2623                    mass_kg: mass_of(body)?,
2624                }];
2625                for &index in &firing {
2626                    parts.push(Start {
2627                        body: index + 1,
2628                        mass_kg: mass_of(index + 1)?,
2629                        ..parts[0]
2630                    });
2631                }
2632                if firing
2633                    .iter()
2634                    .any(|&index| self.split_impulse_n_s(index) > 0.0)
2635                {
2636                    let hanging =
2637                        run.hung_before(&self.devices, |index| self.devices[index].body == body, t);
2638                    let nose_ward =
2639                        self.nose_ward_enu(before.cg_enu_m, before.cg_velocity_enu_m_s, hanging)?;
2640                    let open = &*split.open;
2641                    self.push_apart(
2642                        (&firing, split.pieces, open, &leaders),
2643                        nose_ward,
2644                        &mut parts,
2645                        t,
2646                    )?;
2647                }
2648                mass_kg = parts[0].mass_kg;
2649                y[3] = parts[0].velocity_enu_m_s.x;
2650                y[4] = parts[0].velocity_enu_m_s.y;
2651                y[5] = parts[0].velocity_enu_m_s.z;
2652                let after = self.body_sample(body, mass_kg, t, &y, run)?;
2653                for &index in &firing {
2654                    events.push(BodyEvent {
2655                        kind: self.split(index).2,
2656                        sample: before,
2657                        after: Some(after),
2658                    });
2659                }
2660                split.queue.extend(parts.into_iter().skip(1));
2661                // The mass steps here, and a push steps the velocity, so the integrator starts
2662                // afresh from the new state.
2663                integrator.reset(t, y)?;
2664                continue;
2665            }
2666
2667            let next = next_stop(&stops, t, cap);
2668            // The heights this body watches for: its devices' and its splits'.
2669            let mut watches: Vec<f64> = mine
2670                .iter()
2671                .filter(|index| run.pending(**index))
2672                .filter_map(|index| match self.devices[*index].trigger {
2673                    Trigger::Altitude {
2674                        height_above_ground_m,
2675                    } => Some(height_above_ground_m),
2676                    _ => None,
2677                })
2678                .collect();
2679            for &index in &pending {
2680                if let Trigger::Altitude {
2681                    height_above_ground_m,
2682                } = self.split(index).0
2683                {
2684                    watches.push(height_above_ground_m);
2685                }
2686            }
2687            // Every body watches for its own apogee: an apogee charge on a body separated while
2688            // climbing would never fire without it (found in review), and since the ascent ends
2689            // at the separation this is the only place a staged flight can record a peak.
2690            let apogee = events.iter().all(|event| event.kind != EventKind::Apogee);
2691            let mut system = BodySystem {
2692                simulation: self,
2693                body,
2694                mass_kg,
2695                run,
2696                apogee,
2697                watches: &watches,
2698                failure: None,
2699            };
2700            let outcome = integrator.advance(&mut system, next);
2701            if let Some(error) = system.failure.take() {
2702                return Err(error);
2703            }
2704            let outcome = match outcome {
2705                Ok(outcome) => outcome,
2706                Err(IntegrationError::StepLimit { .. }) => break Termination::StepLimit,
2707                Err(IntegrationError::Derivative { source, .. }) => return Err(source),
2708                Err(error) => return Err(SimError::Integration(Box::new(error))),
2709            };
2710            if let Advance::Events = outcome {
2711                let fired = integrator.fired_events().to_vec();
2712                let t = integrator.time_s();
2713                let y = *integrator.state();
2714                if apogee && fired.contains(&1) {
2715                    // The body's own apogee: the ascent's ended at the separation, so this is the
2716                    // only place a staged flight can record one.
2717                    events.push(BodyEvent {
2718                        kind: EventKind::Apogee,
2719                        sample: self.body_sample(body, mass_kg, t, &y, run)?,
2720                        after: None,
2721                    });
2722                }
2723                if fired.contains(&0) {
2724                    events.push(BodyEvent {
2725                        kind: EventKind::GroundHit,
2726                        sample: self.body_sample(body, mass_kg, t, &y, run)?,
2727                        after: None,
2728                    });
2729                    break Termination::GroundHit;
2730                }
2731            }
2732        };
2733
2734        let t = integrator.time_s();
2735        let y = *integrator.state();
2736        let final_sample = self.body_sample(body, mass_kg, t, &y, run)?;
2737        if termination == Termination::GroundHit
2738            && !mine
2739                .iter()
2740                .any(|&index| run.devices[index].deployed_s.is_some())
2741        {
2742            // Every body must carry a device, and its device must actually open: a trigger that
2743            // never becomes true (a timer set after the body lands, say) would
2744            // otherwise drop the body with no drag at all, which is a wrong number rather than a
2745            // missing feature (found in review). A device that opened on the stack before the
2746            // separation counts: its deployment is in the flight's events, not the body's.
2747            return Err(SimError::Domain {
2748                what: "a separated body reached the ground with no device open (its triggers \
2749                       never fired); the body",
2750                value: body as f64,
2751            });
2752        }
2753        let pieces: Vec<usize> = (0..leaders.len())
2754            .filter(|piece| leaders[*piece] == body)
2755            .collect();
2756        Ok(BodyFlight {
2757            body,
2758            stages: split.pieces.stages(|piece| leaders[piece] == body),
2759            pieces,
2760            mass_kg,
2761            start_sample,
2762            termination,
2763            events,
2764            final_sample,
2765            stats: integrator.stats(),
2766            impulse_left_n_s,
2767        })
2768    }
2769
2770    /// One separated body at `(t, y)`, where `y` is its center of mass and that point's velocity.
2771    fn body_sample(
2772        &self,
2773        body: usize,
2774        mass_kg: f64,
2775        t: f64,
2776        y: &[f64; 6],
2777        run: &Run,
2778    ) -> Result<BodySample, SimError> {
2779        self.point_sample(t, y, mass_kg, run.body_drag_area_m2(&self.devices, body, t))
2780    }
2781
2782    /// A point mass of `mass_kg` under drag area `drag_area_m2` at `(t, y)`, where `y` is its
2783    /// position and velocity in the launch frame: a separated body, or a released part.
2784    pub(crate) fn point_sample(
2785        &self,
2786        t: f64,
2787        y: &[f64; 6],
2788        mass_kg: f64,
2789        drag_area_m2: f64,
2790    ) -> Result<BodySample, SimError> {
2791        let cg_enu_m = DVec3::new(y[0], y[1], y[2]);
2792        let velocity_enu_m_s = DVec3::new(y[3], y[4], y[5]);
2793        let frame = self.environment.earth.frame();
2794        let height_above_ground_m =
2795            frame.geodetic_from_enu(cg_enu_m)?.height_m - frame.origin().height_m;
2796        let (up_enu, wind_enu) = self.up_and_wind_enu(cg_enu_m)?;
2797        Ok(BodySample {
2798            time_s: t,
2799            cg_enu_m,
2800            cg_velocity_enu_m_s: velocity_enu_m_s,
2801            height_above_ground_m,
2802            vertical_speed_m_s: up_enu.dot(velocity_enu_m_s),
2803            airspeed_m_s: (velocity_enu_m_s - wind_enu).length(),
2804            recovery_drag_area_m2: drag_area_m2,
2805            mass_kg,
2806        })
2807    }
2808
2809    /// The rates of a point mass of `mass_kg` under drag area `drag_area_m2`, whose position and
2810    /// velocity in the launch frame are `y`: a separated body, or a released part. The equations
2811    /// are the descent phase's (`docs/physics/recovery.md`) with no thrust and no airframe:
2812    /// `m a = −½ ρ (C_D S) |v − w| (v − w) + m (g + a_Coriolis)`.
2813    pub(crate) fn point_mass_derivative(
2814        &self,
2815        y: &[f64; 6],
2816        mass_kg: f64,
2817        drag_area_m2: f64,
2818    ) -> Result<[f64; 6], SimError> {
2819        let cg_enu_m = DVec3::new(y[0], y[1], y[2]);
2820        let velocity_enu_m_s = DVec3::new(y[3], y[4], y[5]);
2821        let environment = &self.environment;
2822        let frame = environment.earth.frame();
2823        let geodetic = frame.geodetic_from_enu(cg_enu_m)?;
2824        let height_msl_m = geodetic.height_m - environment.geoid_undulation_m;
2825        let air = environment.air_at(height_msl_m)?;
2826        let wind_enu = environment.wind_enu_m_s(height_msl_m)?;
2827        let gravity_enu = environment.earth.gravity_enu_mps2(cg_enu_m)?;
2828        let coriolis_enu = environment
2829            .earth
2830            .rotation_acceleration_enu_mps2(velocity_enu_m_s);
2831        let air_velocity = velocity_enu_m_s - wind_enu;
2832        let speed = air_velocity.length();
2833        let drag_enu = if air.density_kg_m3 > 0.0 && speed > 0.0 && drag_area_m2 > 0.0 {
2834            air_velocity * (-0.5 * air.density_kg_m3 * drag_area_m2 * speed / mass_kg)
2835        } else {
2836            DVec3::ZERO
2837        };
2838        let acceleration = drag_enu + gravity_enu + coriolis_enu;
2839        Ok([
2840            velocity_enu_m_s.x,
2841            velocity_enu_m_s.y,
2842            velocity_enu_m_s.z,
2843            acceleration.x,
2844            acceleration.y,
2845            acceleration.z,
2846        ])
2847    }
2848
2849    /// The way the nose of a body with no attitude of its own is taken to point, as a unit vector
2850    /// in the launch frame, for the push of an ejection (ADR-086): a body `hanging` from a device
2851    /// points its forward end against its velocity through the air, toward the device, assumed to
2852    /// have left through that end; any other points its nose along that velocity, as a stable
2853    /// airframe does. Below [`STILL_AIR_M_S`] that velocity is drift or round-off rather
2854    /// than a flight path, and the nose is taken to point up.
2855    fn nose_ward_enu(
2856        &self,
2857        cg_enu_m: DVec3,
2858        velocity_enu_m_s: DVec3,
2859        hanging: bool,
2860    ) -> Result<DVec3, SimError> {
2861        let (up_enu, wind_enu) = self.up_and_wind_enu(cg_enu_m)?;
2862        let through_air = velocity_enu_m_s - wind_enu;
2863        let speed_m_s = through_air.length();
2864        Ok(if speed_m_s < STILL_AIR_M_S {
2865            up_enu
2866        } else if hanging {
2867            -through_air / speed_m_s
2868        } else {
2869            through_air / speed_m_s
2870        })
2871    }
2872
2873    /// The local vertical and the wind at the point `cg_enu_m` of the launch frame.
2874    fn up_and_wind_enu(&self, cg_enu_m: DVec3) -> Result<(DVec3, DVec3), SimError> {
2875        let frame = self.environment.earth.frame();
2876        let geodetic = frame.geodetic_from_enu(cg_enu_m)?;
2877        let up_ecef = DVec3::new(
2878            geodetic.latitude_rad.cos() * geodetic.longitude_rad.cos(),
2879            geodetic.latitude_rad.cos() * geodetic.longitude_rad.sin(),
2880            geodetic.latitude_rad.sin(),
2881        );
2882        let up_enu = frame.ecef_from_enu_rotation().transpose() * up_ecef;
2883        let height_msl_m = geodetic.height_m - self.environment.geoid_undulation_m;
2884        let wind_enu = self.environment.wind_enu_m_s(height_msl_m)?;
2885        Ok((up_enu, wind_enu))
2886    }
2887
2888    /// Whether the airframe can come apart: it has a separation or an ejection.
2889    fn parts(&self) -> bool {
2890        !self.separations.is_empty() || !self.ejections.is_empty()
2891    }
2892
2893    /// How many bodies the airframe can come apart into: one per piece.
2894    fn body_count(&self) -> usize {
2895        1 + self.separations.len() + self.ejections.len()
2896    }
2897
2898    /// Pushes apart the bodies in `parts` (each a body's start, by its lead piece) at the splits
2899    /// `firing`, all parting at `t`: each split's own piece's body takes `±J` along `nose_ward`,
2900    /// `+J` if the piece is forward of the split, and the body on its other side the opposite,
2901    /// each over its own mass, so the momentum is unchanged (ADR-086). `open` says which splits
2902    /// have happened, these among them, and `leaders` gives each piece's body after them.
2903    ///
2904    /// # Errors
2905    ///
2906    /// [`SimError::Domain`] for a pushed payload whose section's forward joint hasn't parted, so
2907    /// that it has no way out forward.
2908    fn push_apart(
2909        &self,
2910        (firing, pieces, open, leaders): (&[usize], &Pieces, &[bool], &[usize]),
2911        nose_ward: DVec3,
2912        parts: &mut [Start],
2913        t: f64,
2914    ) -> Result<(), SimError> {
2915        for &index in firing {
2916            let impulse_n_s = self.split_impulse_n_s(index);
2917            if impulse_n_s == 0.0 {
2918                continue;
2919            }
2920            let piece = index + 1;
2921            if let Some(host) = pieces.host(piece)
2922                && !(host > 0 && open[host - 1])
2923            {
2924                return Err(SimError::Domain {
2925                    what: "time of a pushed payload's ejection (it leaves forward, and the joint \
2926                           forward of the section that carries it has not parted by then)",
2927                    value: t,
2928                });
2929            }
2930            let (other, forward) = pieces.across(piece);
2931            let push = nose_ward * if forward { impulse_n_s } else { -impulse_n_s };
2932            for (lead, push) in [(leaders[piece], push), (leaders[other], -push)] {
2933                let part = parts.iter_mut().find(|part| part.body == lead);
2934                // Both sides of a split that fires here fly: only a sustainer's body is left out,
2935                // and a powered separation is refused with ejections, whose splits alone push.
2936                debug_assert!(part.is_some(), "a pushed body {lead} doesn't fly");
2937                if let Some(part) = part {
2938                    part.velocity_enu_m_s += push / part.mass_kg;
2939                }
2940            }
2941        }
2942        Ok(())
2943    }
2944
2945    /// The impulse of split `split`, in piece order, N·s: an ejection's own, and none for a
2946    /// separation.
2947    fn split_impulse_n_s(&self, split: usize) -> f64 {
2948        match split.checked_sub(self.separations.len()) {
2949            Some(ejection) => self.ejections[ejection].impulse_n_s,
2950            None => 0.0,
2951        }
2952    }
2953
2954    /// Split `split` of the flight, in piece order (the separations first, then the ejections):
2955    /// its trigger, its time when that is known before the flight, and its event.
2956    fn split(&self, split: usize) -> (Trigger, Option<f64>, EventKind) {
2957        match self.separations.get(split) {
2958            Some(separation) => (
2959                separation.trigger,
2960                self.separation_times_s[split],
2961                EventKind::Separation,
2962            ),
2963            None => {
2964                let ejection = split - self.separations.len();
2965                (
2966                    self.ejections[ejection].trigger,
2967                    self.ejection_times_s[ejection],
2968                    EventKind::Ejection(ejection),
2969                )
2970            }
2971        }
2972    }
2973
2974    /// The drag area acting on the stack before it comes apart, m²: body 0's devices, which with
2975    /// no separation or ejection is all of them.
2976    fn ascent_drag_area_m2(&self, run: &Run, t: f64) -> f64 {
2977        if self.parts() {
2978            run.body_drag_area_m2(&self.devices, 0, t)
2979        } else {
2980            run.drag_area_m2(&self.devices, t)
2981        }
2982    }
2983
2984    /// Whether device `index` acts on the stack before it comes apart: only body 0's do.
2985    fn acts_before_separation(&self, index: usize) -> bool {
2986        !self.parts() || self.devices[index].body == 0
2987    }
2988
2989    /// What the integrator watches for in `phase`, in event order.
2990    fn watches(
2991        &self,
2992        phase: Phase,
2993        run: &Run,
2994        (separation_pending, apogee_pending): (Option<usize>, bool),
2995        (shift_started, released): (&[bool], &[bool]),
2996    ) -> Vec<Watch> {
2997        match phase {
2998            Phase::Pad => vec![Watch::RailForce],
2999            Phase::Rail => vec![Watch::RailExit, Watch::RailStall],
3000            Phase::Free | Phase::Descent => {
3001                let mut watches = if apogee_pending {
3002                    vec![Watch::Apogee, Watch::Ground]
3003                } else {
3004                    vec![Watch::Ground]
3005                };
3006                for (index, device) in self.devices.iter().enumerate() {
3007                    if matches!(device.trigger, Trigger::Altitude { .. })
3008                        && run.pending(index)
3009                        && self.acts_before_separation(index)
3010                    {
3011                        watches.push(Watch::Altitude(index));
3012                    }
3013                }
3014                // The next separation's own height, so it is located rather than polled at the
3015                // next boundary that happens to exist.
3016                if let Some((next, Trigger::Altitude { .. })) = separation_pending
3017                    .and_then(|next| Some((next, self.separations.get(next)?.trigger)))
3018                {
3019                    watches.push(Watch::SeparationHeight(next));
3020                }
3021                if separation_pending == Some(0) {
3022                    for (index, ejection) in self.ejections.iter().enumerate() {
3023                        if matches!(ejection.trigger, Trigger::Altitude { .. }) {
3024                            watches.push(Watch::EjectionHeight(index));
3025                        }
3026                    }
3027                }
3028                for (index, shift) in self.shifts.iter().enumerate() {
3029                    if matches!(shift.trigger, Trigger::Altitude { .. })
3030                        && !shift_started.get(index).copied().unwrap_or(true)
3031                    {
3032                        watches.push(Watch::ShiftHeight(index));
3033                    }
3034                }
3035                for (index, release) in self.releases.iter().enumerate() {
3036                    if matches!(release.trigger, Trigger::Altitude { .. })
3037                        && !released.get(index).copied().unwrap_or(true)
3038                    {
3039                        watches.push(Watch::ReleaseHeight(index));
3040                    }
3041                }
3042                watches.extend((0..self.user_events.len()).map(Watch::User));
3043                watches
3044            }
3045        }
3046    }
3047}
3048
3049/// Whether `stages[first..=last]` hang together as one dropped body: each after the first hangs on
3050/// one of them, a parallel stage on the stage it is hung on ([`hpr_design::PlacedStage::hung_on`]),
3051/// an axial stage on the axial stage ahead of it. A separation's boundary drops every stage after
3052/// it, so a parallel stage hung on a stage the nose keeps must not lie behind a stage it drops
3053/// with it (the decision record on parallel stages, [ADR-171][adr-171]).
3054///
3055/// [adr-171]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-171-a-parallel-stage-is-a-pod-set-that-separates-2026-10-05
3056fn hangs_together(stages: &[hpr_design::PlacedStage], first: usize, last: usize) -> bool {
3057    // The axial stage ahead of each stage, found in one pass.
3058    let mut axial_ahead = None;
3059    for (index, stage) in stages.iter().enumerate().take(last + 1) {
3060        let carrier = stage.hung_on.or(axial_ahead);
3061        if index > first && !carrier.is_some_and(|carrier| (first..=last).contains(&carrier)) {
3062            return false;
3063        }
3064        if stage.hung_on.is_none() {
3065            axial_ahead = Some(index);
3066        }
3067    }
3068    true
3069}
3070
3071/// Checks the devices against the `bodies` a separation and ejections make, whichever builder ran
3072/// last: one when there is neither.
3073///
3074/// When the airframe can part, every body needs at least one device, and no device may name a
3075/// body that doesn't exist. Without a parting there is only body 0, so a device that names another
3076/// would have its drag area counted on the whole rocket and never be flown on a body of its own.
3077/// A builder checks only the first (`complete` false): a separation or ejection given after it
3078/// can still make the body a device names, and an ejection's number counts the separation.
3079fn check_bodies(devices: &[Device], bodies: usize, complete: bool) -> Result<(), SimError> {
3080    if let Some(device) = devices
3081        .iter()
3082        .find(|device| complete && device.body >= bodies)
3083    {
3084        return Err(SimError::Domain {
3085            what: if bodies > 1 {
3086                "body a device is attached to (the separation and ejections don't make it)"
3087            } else {
3088                "body a device is attached to (there is no separation or ejection, so there is \
3089                 only body 0)"
3090            },
3091            value: device.body as f64,
3092        });
3093    }
3094    if bodies > 1 {
3095        for body in 0..bodies {
3096            if !devices.iter().any(|device| device.body == body) {
3097                return Err(SimError::Domain {
3098                    what: "recovery devices on a separated body (every body needs one: a descent \
3099                           has no airframe drag)",
3100                    value: body as f64,
3101                });
3102            }
3103        }
3104    }
3105    Ok(())
3106}
3107
3108/// The first stop after `t`, or `cap`.
3109fn next_stop(stops: &[f64], t: f64, cap: f64) -> f64 {
3110    stops.iter().copied().find(|stop| *stop > t).unwrap_or(cap)
3111}
3112
3113/// The evaluation for the recovery scan, made once per pass and reused.
3114fn cached(
3115    cache: &mut Option<Evaluation>,
3116    evaluate: impl FnOnce() -> Result<Evaluation, SimError>,
3117) -> Result<Evaluation, SimError> {
3118    if let Some(evaluation) = cache {
3119        return Ok(*evaluation);
3120    }
3121    let evaluation = evaluate()?;
3122    *cache = Some(evaluation);
3123    Ok(evaluation)
3124}
3125
3126/// Adds `t` to the sorted stop times, unless it is past `cap` or already there.
3127fn insert_stop(stops: &mut Vec<f64>, t: f64, cap: f64) {
3128    if !t.is_finite() || t > cap {
3129        return;
3130    }
3131    match stops.binary_search_by(|stop| stop.total_cmp(&t)) {
3132        Ok(_) => {}
3133        Err(index) => stops.insert(index, t),
3134    }
3135}
3136
3137/// What the integrator watches for, in the order the events are numbered.
3138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3139enum Watch {
3140    /// The pad's force margin rising through zero: liftoff.
3141    RailForce,
3142    /// The travel along the rail reaching the exit travel.
3143    RailExit,
3144    /// The speed along the rail falling through zero: a stall.
3145    RailStall,
3146    /// The center of mass's height rate falling through zero: apogee.
3147    Apogee,
3148    /// The center of mass reaching the ground, descending.
3149    Ground,
3150    /// A device's deployment height, descending.
3151    Altitude(usize),
3152    /// A separation's height, descending, by its index.
3153    SeparationHeight(usize),
3154    /// An ejection's height, descending, by its index.
3155    EjectionHeight(usize),
3156    /// A mass shift's height, descending, by its index.
3157    ShiftHeight(usize),
3158    /// A mass release's height, descending, by its index.
3159    ReleaseHeight(usize),
3160    /// A user event.
3161    User(usize),
3162}
3163
3164/// Where a body's own flight starts: its number, the time, its center of mass and that point's
3165/// velocity, and its mass.
3166#[derive(Debug, Clone, Copy)]
3167struct Start {
3168    body: usize,
3169    t_s: f64,
3170    cg_enu_m: DVec3,
3171    velocity_enu_m_s: DVec3,
3172    mass_kg: f64,
3173}
3174
3175/// What the bodies' flights share about the airframe's parting: its pieces, which splits have
3176/// happened, the motors' ignitions, and the bodies still to fly.
3177struct Split<'a> {
3178    pieces: &'a Pieces,
3179    open: &'a mut Vec<bool>,
3180    ignition_s: &'a [Option<f64>],
3181    queue: &'a mut std::collections::VecDeque<Start>,
3182}
3183
3184impl Split<'_> {
3185    /// The splits still to happen inside `body`, given each piece's body in `leaders`: those whose
3186    /// piece is still joined to it.
3187    fn pending(&self, leaders: &[usize], body: usize) -> Vec<usize> {
3188        (0..self.open.len())
3189            .filter(|&index| !self.open[index] && leaders[index + 1] == body)
3190            .collect()
3191    }
3192}
3193
3194/// Takes `part` out of `assembly`'s structure: a part released in flight.
3195fn lighten(assembly: &mut hpr_design::Assembly, part: &hpr_design::MassProperties) {
3196    assembly.layout.structure = assembly.layout.structure.without_part(part);
3197}
3198
3199/// A body's mass, refused unless it is finite and positive.
3200fn checked_body_mass(mass_kg: f64) -> Result<f64, SimError> {
3201    if mass_kg.is_finite() && mass_kg > 0.0 {
3202        Ok(mass_kg)
3203    } else {
3204        Err(SimError::Domain {
3205            what: "mass of a separated body, kg",
3206            value: mass_kg,
3207        })
3208    }
3209}
3210
3211/// One separated body as the integrator sees it: a point mass under its open devices' drag area,
3212/// with its center of mass and that point's velocity as the state.
3213///
3214/// The equations are the descent phase's (`docs/physics/recovery.md`) with no thrust and no
3215/// airframe: `m a = −½ ρ (C_D S)(t) |v − w| (v − w) + m (g + a_Coriolis)`.
3216struct BodySystem<'a> {
3217    simulation: &'a Simulation,
3218    body: usize,
3219    mass_kg: f64,
3220    run: &'a Run,
3221    /// Whether the body is watching for its own apogee, which is event 1 when it is.
3222    apogee: bool,
3223    /// The heights this body is watching for, after the apogee: its devices' deployment heights
3224    /// and its splits'.
3225    watches: &'a [f64],
3226    failure: Option<SimError>,
3227}
3228
3229impl BodySystem<'_> {
3230    /// The height above the site and the drag acceleration at `(t, y)`.
3231    fn sample(&self, t_s: f64, y: &[f64; 6]) -> Result<BodySample, SimError> {
3232        self.simulation
3233            .body_sample(self.body, self.mass_kg, t_s, y, self.run)
3234    }
3235}
3236
3237impl OdeSystem<6> for BodySystem<'_> {
3238    type Error = SimError;
3239
3240    fn derivative(&mut self, t_s: f64, y: &[f64; 6]) -> Result<[f64; 6], SimError> {
3241        let drag_area_m2 = self
3242            .run
3243            .body_drag_area_m2(&self.simulation.devices, self.body, t_s);
3244        self.simulation
3245            .point_mass_derivative(y, self.mass_kg, drag_area_m2)
3246    }
3247
3248    fn event_count(&self) -> usize {
3249        1 + usize::from(self.apogee) + self.watches.len()
3250    }
3251
3252    fn event_direction(&self, _index: usize) -> Direction {
3253        Direction::Falling
3254    }
3255
3256    fn event_value(&mut self, index: usize, t_s: f64, y: &[f64; 6]) -> f64 {
3257        let sample = match self.sample(t_s, y) {
3258            Ok(sample) => sample,
3259            Err(error) => {
3260                self.failure.get_or_insert(error);
3261                return f64::NAN;
3262            }
3263        };
3264        // The ground, then this body's apogee if it is looking for one, then each watched
3265        // device's deployment height. Every one is a falling crossing, so a device fires on the
3266        // way down only.
3267        if index == 0 {
3268            return sample.height_above_ground_m;
3269        }
3270        if self.apogee && index == 1 {
3271            return sample.vertical_speed_m_s;
3272        }
3273        let watch = index - 1 - usize::from(self.apogee);
3274        self.watches
3275            .get(watch)
3276            .map_or(f64::NAN, |height_m| sample.height_above_ground_m - height_m)
3277    }
3278}
3279
3280/// The recovery devices of a flight and their progress through it.
3281#[derive(Debug, Clone, Copy)]
3282struct Canopies<'a> {
3283    devices: &'a [Device],
3284    run: &'a Run,
3285    /// The body whose devices act, when a separation means only some of them do. `None` is all
3286    /// of them, which is what a flight without a separation has.
3287    body: Option<usize>,
3288}
3289
3290impl Canopies<'_> {
3291    /// The drag area at `t` of the devices that act on what is being flown, m². It has to match
3292    /// the loop's `ascent_drag_area_m2`, because this is what the equations and the recorded rows
3293    /// see.
3294    fn drag_area_m2(&self, t: f64) -> f64 {
3295        match self.body {
3296            Some(body) => self.run.body_drag_area_m2(self.devices, body, t),
3297            None => self.run.drag_area_m2(self.devices, t),
3298        }
3299    }
3300}
3301
3302fn sample_of(phase: Phase, t: f64, y: &[f64; STATE_LEN], e: &Evaluation) -> Sample {
3303    Sample {
3304        time_s: t,
3305        phase,
3306        state: State::from_array(y),
3307        cg_enu_m: e.cg_enu_m,
3308        cg_velocity_enu_m_s: e.cg_velocity_enu_m_s,
3309        height_above_ground_m: e.height_above_ground_m,
3310        vertical_speed_m_s: e.vertical_speed_m_s,
3311        acceleration_enu_m_s2: e.acceleration_enu_m_s2,
3312        airspeed_m_s: e.airspeed_m_s,
3313        mach: e.mach,
3314        angle_of_attack_rad: e.angle_of_attack_rad,
3315        dynamic_pressure_pa: e.dynamic_pressure_pa,
3316        axial_coefficient: e.axial_coefficient,
3317        thrust_n: e.thrust_n,
3318        mass_kg: e.mass.mass_kg,
3319        recovery_drag_area_m2: e.recovery_drag_area_m2,
3320    }
3321}
3322
3323/// One phase over one interval, as the integrator sees it.
3324struct PhaseSystem<'a> {
3325    simulation: &'a Simulation,
3326    vehicle: &'a Vehicle,
3327    phase: Phase,
3328    window: (f64, f64),
3329    rail_origin: DVec3,
3330    exit_travel_m: f64,
3331    canopies: Canopies<'a>,
3332    watches: &'a [Watch],
3333    observer: &'a mut dyn Observer,
3334    /// An error from an event function or the observer, returned after the integrator stops.
3335    failure: Option<SimError>,
3336    /// The last evaluation: the integrator evaluates the derivative at a step's end before the
3337    /// events there.
3338    cache: Option<(f64, [f64; STATE_LEN], Evaluation)>,
3339}
3340
3341impl PhaseSystem<'_> {
3342    /// The evaluation at `(t, y)`, from the cache when it holds that state. A reference, not a
3343    /// copy: an evaluation is a few hundred bytes, and the integrator asks for one at every
3344    /// stage and event check.
3345    fn evaluation(&mut self, t: f64, y: &[f64; STATE_LEN]) -> Result<&Evaluation, SimError> {
3346        if !matches!(&self.cache, Some((ct, cy, _)) if *ct == t && cy == y) {
3347            self.cache = None;
3348        }
3349        let (_, _, evaluation) = match &mut self.cache {
3350            Some(entry) => entry,
3351            slot @ None => slot.insert((
3352                t,
3353                *y,
3354                self.simulation.evaluate(
3355                    self.vehicle,
3356                    self.phase,
3357                    self.window,
3358                    t,
3359                    y,
3360                    self.canopies.drag_area_m2(t),
3361                )?,
3362            )),
3363        };
3364        Ok(evaluation)
3365    }
3366
3367    /// An event value, or NaN with the error kept for the caller.
3368    fn or_fail(&mut self, value: Result<f64, SimError>) -> f64 {
3369        match value {
3370            Ok(value) => value,
3371            Err(error) => {
3372                self.failure.get_or_insert(error);
3373                f64::NAN
3374            }
3375        }
3376    }
3377}
3378
3379impl OdeSystem<STATE_LEN> for PhaseSystem<'_> {
3380    type Error = SimError;
3381
3382    fn derivative(&mut self, t_s: f64, y: &[f64; STATE_LEN]) -> Result<[f64; STATE_LEN], SimError> {
3383        self.evaluation(t_s, y).map(|e| e.derivative)
3384    }
3385
3386    fn event_count(&self) -> usize {
3387        self.watches.len()
3388    }
3389
3390    fn event_direction(&self, index: usize) -> Direction {
3391        match self.watches.get(index) {
3392            Some(Watch::RailForce | Watch::RailExit) => Direction::Rising,
3393            Some(
3394                Watch::RailStall
3395                | Watch::Apogee
3396                | Watch::Ground
3397                | Watch::Altitude(_)
3398                | Watch::SeparationHeight(_)
3399                | Watch::EjectionHeight(_)
3400                | Watch::ShiftHeight(_)
3401                | Watch::ReleaseHeight(_),
3402            ) => Direction::Falling,
3403            Some(Watch::User(user)) => self
3404                .simulation
3405                .user_events
3406                .get(*user)
3407                .map_or(Direction::Either, |event| event.direction),
3408            None => Direction::Either,
3409        }
3410    }
3411
3412    fn event_value(&mut self, index: usize, t_s: f64, y: &[f64; STATE_LEN]) -> f64 {
3413        let state = State::from_array(y);
3414        let Some(watch) = self.watches.get(index).copied() else {
3415            return f64::NAN;
3416        };
3417        match watch {
3418            Watch::RailExit => {
3419                let along = self.simulation.rail.direction_enu();
3420                (state.position_enu_m - self.rail_origin).dot(along) - self.exit_travel_m
3421            }
3422            Watch::RailStall => state
3423                .velocity_enu_m_s
3424                .dot(self.simulation.rail.direction_enu()),
3425            Watch::Apogee => {
3426                let value = self.evaluation(t_s, y).map(|e| e.vertical_speed_m_s);
3427                self.or_fail(value)
3428            }
3429            Watch::Ground => {
3430                let value = self.evaluation(t_s, y).map(|e| e.height_above_ground_m);
3431                self.or_fail(value)
3432            }
3433            Watch::Altitude(device) => {
3434                let height_m = match self.simulation.devices.get(device).map(|d| d.trigger) {
3435                    Some(Trigger::Altitude {
3436                        height_above_ground_m,
3437                    }) => height_above_ground_m,
3438                    _ => return f64::NAN,
3439                };
3440                let value = self
3441                    .evaluation(t_s, y)
3442                    .map(|e| e.height_above_ground_m - height_m);
3443                self.or_fail(value)
3444            }
3445            Watch::SeparationHeight(_)
3446            | Watch::EjectionHeight(_)
3447            | Watch::ShiftHeight(_)
3448            | Watch::ReleaseHeight(_) => {
3449                let trigger = match watch {
3450                    Watch::EjectionHeight(index) => {
3451                        self.simulation.ejections.get(index).map(|e| e.trigger)
3452                    }
3453                    Watch::ShiftHeight(index) => {
3454                        self.simulation.shifts.get(index).map(|s| s.trigger)
3455                    }
3456                    Watch::ReleaseHeight(index) => {
3457                        self.simulation.releases.get(index).map(|r| r.trigger)
3458                    }
3459                    Watch::SeparationHeight(index) => {
3460                        self.simulation.separations.get(index).map(|s| s.trigger)
3461                    }
3462                    _ => None,
3463                };
3464                let height_m = match trigger {
3465                    Some(Trigger::Altitude {
3466                        height_above_ground_m,
3467                    }) => height_above_ground_m,
3468                    _ => return f64::NAN,
3469                };
3470                let value = self
3471                    .evaluation(t_s, y)
3472                    .map(|e| e.height_above_ground_m - height_m);
3473                self.or_fail(value)
3474            }
3475            Watch::User(user) => {
3476                let phase = self.phase;
3477                let value = self.evaluation(t_s, y).map(|e| sample_of(phase, t_s, y, e));
3478                match value {
3479                    Ok(sample) => self
3480                        .simulation
3481                        .user_events
3482                        .get(user)
3483                        .map_or(f64::NAN, |event| (event.function)(&sample)),
3484                    Err(error) => self.or_fail(Err(error)),
3485                }
3486            }
3487            Watch::RailForce => {
3488                let value = self.evaluation(t_s, y).map(|e| e.rail_force_n);
3489                self.or_fail(value)
3490            }
3491        }
3492    }
3493
3494    fn accept_step(&mut self, step: &Step<STATE_LEN>) -> ControlFlow<()> {
3495        let view = StepView {
3496            simulation: self.simulation,
3497            vehicle: self.vehicle,
3498            phase: self.phase,
3499            window: self.window,
3500            canopies: self.canopies,
3501            step,
3502        };
3503        match self.observer.step(&view) {
3504            Ok(()) => ControlFlow::Continue(()),
3505            Err(error) => {
3506                self.failure.get_or_insert(error);
3507                ControlFlow::Break(())
3508            }
3509        }
3510    }
3511}
3512
3513/// An accepted step as the observer sees it.
3514struct StepView<'a> {
3515    simulation: &'a Simulation,
3516    vehicle: &'a Vehicle,
3517    phase: Phase,
3518    window: (f64, f64),
3519    canopies: Canopies<'a>,
3520    step: &'a Step<STATE_LEN>,
3521}
3522
3523impl FlightStep for StepView<'_> {
3524    fn phase(&self) -> Phase {
3525        self.phase
3526    }
3527
3528    fn start_s(&self) -> f64 {
3529        self.step.start_s()
3530    }
3531
3532    fn end_s(&self) -> f64 {
3533        self.step.end_s()
3534    }
3535
3536    fn state_at(&self, t_s: f64) -> State {
3537        State::from_array(&self.step.state_at(t_s))
3538    }
3539
3540    fn sample(&self, t_s: f64) -> Result<Sample, SimError> {
3541        self.simulation.sample(
3542            self.vehicle,
3543            self.phase,
3544            self.window,
3545            t_s,
3546            &self.step.state_at(t_s),
3547            self.canopies.drag_area_m2(t_s),
3548        )
3549    }
3550
3551    fn stability(&self, t_s: f64) -> Result<Stability, SimError> {
3552        let e = self.simulation.evaluate(
3553            self.vehicle,
3554            self.phase,
3555            self.window,
3556            t_s,
3557            &self.step.state_at(t_s),
3558            self.canopies.drag_area_m2(t_s),
3559        )?;
3560        crate::metrics::stability(
3561            &self.vehicle.aero,
3562            t_s,
3563            e.height_above_ground_m,
3564            e.dynamic_pressure_pa,
3565            -e.mass.cg_m.z,
3566            e.mach,
3567        )
3568    }
3569}