Skip to main content

hpr_sim/
metrics.rs

1//! What a flight comes to: its peaks, its apogee, its stability margins, the ejection delay that
2//! would fire at apogee, and where each body landed ([the flight-metrics milestone][m1-10a] and
3//! [its decision record][adr-077]; the guide's [Flight metrics][page] page).
4//!
5//! - [`FlightMetrics`] is an [`Observer`]: fly a [`Simulation`] with it, then ask it for a
6//!   [`FlightSummary`]. It finds each peak on the integrator's dense output, not in a recorded
7//!   table, so a peak between two rows is not missed ([Loft lesson L34][l34]).
8//! - [`stability`] gives the static margin and the margin at the flight's Mach number at one
9//!   instant, and [`margin`] the margin of any flow. A margin that would be a ratio of nearly cancelling
10//!   slopes is `None`, with the pitch-moment slope beside it ([L33][l33]).
11//! - [`optimum_delays`] flies the rocket with its charges held and gives, for each motor, the delay
12//!   from its burnout to that apogee, so it doesn't depend on the delay flown ([L94][l94]).
13//! - Anything that did not happen is `None`, never a zero, and every height names its datum
14//!   ([L35][l35]).
15//!
16//! **Heights.** A height here is the center of mass's ellipsoidal height above the launch site's,
17//! [`Sample::height_above_ground_m`]. The center of mass starts above the site (the rocket stands
18//! on the rail), at [`FlightSummary::launch_height_m`]; [`Apogee::gain_m`] counts from there, as
19//! OpenRocket's altitude does.
20//!
21//! [l33]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l33
22//! [l34]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l34
23//! [l35]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l35
24//! [l94]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l94
25//! [m1-10a]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-10a
26//! [adr-077]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-077-flight-metrics-peaks-on-the-dense-output-margins-only-where-they-mean-something-and-none-for-what-didnt-happen-2026-09-26
27//! [page]: https://nrdptel.github.io/hpr-sim/physics/metrics.html
28
29use hpr_aero::{AeroModel, Flow};
30use serde::{Deserialize, Serialize};
31
32use crate::dynamics::Phase;
33use crate::envelope::{self, EnvelopeFlag, HIGH_ANGLE_GRACE_S, HIGH_ANGLE_OF_ATTACK_RAD};
34use crate::environment::Environment;
35use crate::error::SimError;
36use crate::flight::{EventKind, FlightEvent, FlightResult, Simulation, Termination};
37use crate::recorder::{FlightStep, Observer, Sample};
38use crate::recovery::BodySample;
39
40/// The largest ratio `κ = Σ |C_Nα,i| / C_Nα` at which a margin is given: `√10`.
41///
42/// The center of pressure is `x_cp = Σ C_Nα,i x_i / C_Nα`, over the parts the model adds up, with
43/// each part's station `x_i` on the rocket, in `[0, L]`. Since `x_cp − x_j = Σ C_Nα,i (x_i − x_j) / C_Nα`, it lies within `κ L` of every
44/// station. An error `ε C_Nα,j` in one component's slope moves it by
45/// `ε C_Nα,j (x_j − x_cp) / C_Nα`, so by at most `ε κ² L`. A rocket whose slopes all push the same
46/// way has `κ = 1`, and a 1% error in one slope moves its center of pressure by at most 1% of its
47/// length. As the net slope goes to zero, `κ` runs away: the loads become a pure couple with no
48/// line of action, and the quotient is noise (Loft published ±12 to 15 calibres so,
49/// [lesson L33][l33]). At `κ = √10` a 1% error in one slope can move the center of pressure by a
50/// tenth of the rocket's length; past it hpr gives no margin, only the pitch-moment slope, which
51/// stays finite. The limit is a chosen bound on that sensitivity, not a measurement. The bound
52/// holds for parts that each carry a force at a station: a part that is a pure couple (a step and
53/// a flare of equal slopes) has none, and `κ` doesn't count it.
54///
55/// [l33]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l33
56pub const MARGIN_CONDITION_LIMIT: f64 = 3.162_277_660_168_379_5;
57
58/// The Mach number of the static margin: the air at rest, as RocketPy's `static_margin` takes it.
59pub const STATIC_MARGIN_MACH: f64 = 0.0;
60
61/// A peak over the flight, with where and when it came.
62#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
63pub struct Peak {
64    /// The peak value, in the quantity's own unit.
65    pub value: f64,
66    /// When it came, s after launch.
67    pub time_s: f64,
68    /// The center of mass's height above the launch site then, m.
69    pub height_above_ground_m: f64,
70}
71
72/// The stability margin of one flow.
73#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
74pub struct Margin {
75    /// The Mach number.
76    pub mach: f64,
77    /// The total angle of attack, rad.
78    pub angle_of_attack_rad: f64,
79    /// The direction the air crosses the rocket, rad from `x_B` toward `y_B`
80    /// ([`hpr_aero::Flow::roll_rad`]): for [`weakest_margin`], its weakest plane's.
81    #[serde(default)]
82    pub roll_rad: f64,
83    /// The rocket's normal-force slope `C_Nα` on the reference area, per radian (at an angle of
84    /// attack, the secant `C_N/α`, [`hpr_aero::NormalForce::slope_per_rad`]).
85    pub normal_force_slope_per_rad: f64,
86    /// `Σ |C_Nα,i|` over the components, per radian: the scale against which the net slope is
87    /// judged ([`MARGIN_CONDITION_LIMIT`]). With a normal-force table it is the table's own slope's
88    /// magnitude.
89    pub slope_magnitude_sum_per_rad: f64,
90    /// The pitch-moment slope about the center of mass on the reference area and diameter, per
91    /// radian: `C_mα = −(Σ C_Nα,i x_i − C_Nα x_cg) / d`, stations `x` aft of the nose tip, from
92    /// [`hpr_aero::NormalForce::moment_slope_m`]. Negative restores. It is finite when the margin
93    /// is not, and with a margin it is `−C_Nα · margin`.
94    pub pitch_moment_slope_per_rad: f64,
95    /// The center of pressure, m aft of the nose tip; `None` when the margin is.
96    pub cp_station_m: Option<f64>,
97    /// The margin `(x_cp − x_cg) / d`, calibres: positive with the center of pressure aft of the
98    /// center of mass. `None` when the net slope is not positive or is below
99    /// `Σ |C_Nα,i| / MARGIN_CONDITION_LIMIT`, where the quotient would be noise.
100    pub margin_cal: Option<f64>,
101}
102
103/// The rocket's stability at one instant.
104#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
105pub struct Stability {
106    /// When, s after launch.
107    pub time_s: f64,
108    /// The center of mass's height above the launch site then, m.
109    pub height_above_ground_m: f64,
110    /// The dynamic pressure then, Pa: how hard the air presses on the margin's moment.
111    pub dynamic_pressure_pa: f64,
112    /// The center of mass, m aft of the nose tip.
113    pub cg_station_m: f64,
114    /// The reference diameter `d` the margins are counted in, m (the sustainer's after a powered
115    /// separation).
116    pub reference_diameter_m: f64,
117    /// The static margin: the air along the axis at [`STATIC_MARGIN_MACH`], with the center of
118    /// mass of this instant (RocketPy's `static_margin`), in the rocket's weakest plane
119    /// ([`weakest_margin`]).
120    pub static_margin: Margin,
121    /// The flight margin: the air along the axis at the flight's Mach number, with the center of
122    /// mass of this instant (RocketPy's `stability_margin`), in the rocket's weakest plane
123    /// ([`weakest_margin`]). The angle of attack is left out: near
124    /// apogee it swings toward 90° as the rocket slows and tips over, and a least margin that
125    /// followed it would land wherever large angles stopped being counted.
126    pub flight_margin: Margin,
127}
128
129/// The margin of `aero` in `flow` with the center of mass at `cg_station_m` (m aft of the nose
130/// tip). See [`Margin`] and [`MARGIN_CONDITION_LIMIT`] for when it is `None`.
131///
132/// # Errors
133///
134/// The aerodynamic model's, for a flow outside its range.
135pub fn margin(aero: &AeroModel, flow: &Flow, cg_station_m: f64) -> Result<Margin, SimError> {
136    let normal = aero.normal_force(flow)?;
137    let d = aero.reference_diameter_m();
138    let slope = normal.slope_per_rad;
139    let scale = if aero.normal_force_table().is_some() {
140        // The table gives one force at one station.
141        slope.abs()
142    } else {
143        aero.components(flow)?
144            .iter()
145            .map(|c| c.normal_force.slope_per_rad.abs())
146            .sum()
147    };
148    let conditioned = slope > 0.0 && slope * MARGIN_CONDITION_LIMIT >= scale;
149    let cp_station_m = normal.cp_station_m.filter(|_| conditioned);
150    Ok(Margin {
151        mach: flow.mach,
152        angle_of_attack_rad: flow.alpha_rad,
153        roll_rad: flow.roll_rad,
154        normal_force_slope_per_rad: slope,
155        slope_magnitude_sum_per_rad: scale,
156        pitch_moment_slope_per_rad: -(normal.moment_slope_m - slope * cg_station_m) / d,
157        cp_station_m,
158        margin_cal: cp_station_m.map(|cp| (cp - cg_station_m) / d),
159    })
160}
161
162/// The least margin of `aero` at Mach `mach`, the air along the axis, over the direction it
163/// crosses the rocket, with the center of mass at `cg_station_m`: [`margin`] in the weakest plane,
164/// whose direction it gives ([`Margin::roll_rad`]); or a plane where no margin can be given, if
165/// there is one (#329).
166///
167/// A fin set of one or two fins carries `Σ sin²(φ − θ_k)` of its force in the plane at `φ`
168/// (Niskanen 2009 eq. 3.51, [`hpr_aero::roll_sum`]), so a rocket with one ([`AeroModel::rolls`])
169/// has a margin in each plane. Each of its terms is then `a + b cos 2φ + c sin 2φ`, and so are the
170/// net slope `S(φ)`, its moment `M(φ) = Σ C_Nα,i x_i`, and the sum of the slopes' magnitudes
171/// `Σ(φ)` while each fin's slope is positive: three planes, `φ = 0, π/3, 2π/3`, give each sum's
172/// `a`, `b` and `c`. A quotient `N/D` of two such sums is stationary where
173/// `P sin 2φ + Q cos 2φ + R = 0`, with `P = n₀d₁ − n₁d₀`, `Q = n₂d₀ − n₀d₂` and
174/// `R = n₂d₁ − n₁d₂` (its derivative's numerator, `N′D − ND′`, expanded). The center of pressure
175/// `M/S` is foremost at one of its two roots, and the margin's conditioning `S/Σ`
176/// ([`MARGIN_CONDITION_LIMIT`]) worst at one of its. Each root, and the axial plane, is evaluated
177/// through the model: a plane with no margin wins, else the least margin. A rocket whose fin sets
178/// are each of three or more fins, or that flies a normal-force table, has one margin, the axial
179/// plane's, returned bit for bit.
180///
181/// # Errors
182///
183/// As [`margin`].
184pub fn weakest_margin(aero: &AeroModel, mach: f64, cg_station_m: f64) -> Result<Margin, SimError> {
185    let axial = margin(aero, &Flow::axial(mach), cg_station_m)?;
186    if !aero.rolls() {
187        return Ok(axial);
188    }
189    // Each sum's `[a, b, c]` in `a + b cos u + c sin u`, `u = 2φ`, from `u = 0, 2π/3, 4π/3`.
190    let mut samples = [[0.0; 3]; 3];
191    for (k, column) in [0.0, 1.0, 2.0].into_iter().enumerate() {
192        let flow = Flow::new(mach, 0.0, column * std::f64::consts::FRAC_PI_3);
193        let normal = aero.normal_force(&flow)?;
194        let scale: f64 = aero
195            .components(&flow)?
196            .iter()
197            .map(|c| c.normal_force.slope_per_rad.abs())
198            .sum();
199        samples[0][k] = normal.slope_per_rad;
200        samples[1][k] = normal.moment_slope_m;
201        samples[2][k] = scale;
202    }
203    let fit = |[f0, f1, f2]: [f64; 3]| {
204        [
205            (f0 + f1 + f2) / 3.0,
206            (2.0 * f0 - f1 - f2) / 3.0,
207            (f1 - f2) / 3.0_f64.sqrt(),
208        ]
209    };
210    let [slope, moment, scale] = samples.map(fit);
211    let mut least = axial;
212    for numerator_denominator in [(moment, slope), (slope, scale)] {
213        for u in stationary_angles(numerator_denominator) {
214            let candidate = margin(aero, &Flow::new(mach, 0.0, 0.5 * u), cg_station_m)?;
215            least = match (least.margin_cal, candidate.margin_cal) {
216                (None, _) => least,
217                (Some(_), None) => candidate,
218                (Some(a), Some(b)) if b < a => candidate,
219                _ => least,
220            };
221        }
222    }
223    Ok(least)
224}
225
226/// The angles `u` in `[0, 2π)` where `N(u)/D(u)` is stationary, `N = n₀ + n₁ cos u + n₂ sin u`
227/// and `D` alike: the roots of `P sin u + Q cos u + R = 0` ([`weakest_margin`]). `P sin u + Q cos u`
228/// is `A sin(u + ψ)` with `A = √(P² + Q²)` and `ψ = atan2(Q, P)`. None when the quotient is
229/// constant (`A` is zero); a root just past `|R| = A` by rounding is taken at the touch point.
230fn stationary_angles(([n0, n1, n2], [d0, d1, d2]): ([f64; 3], [f64; 3])) -> Vec<f64> {
231    let (p, q, r) = (n0 * d1 - n1 * d0, n2 * d0 - n0 * d2, n2 * d1 - n1 * d2);
232    let a = p.hypot(q);
233    if a.is_nan() || a <= 0.0 {
234        return Vec::new();
235    }
236    let ratio = -r / a;
237    if ratio.is_nan() || ratio.abs() > 1.0 + 1e-9 {
238        return Vec::new();
239    }
240    let (root, psi) = (ratio.clamp(-1.0, 1.0).asin(), q.atan2(p));
241    [root - psi, std::f64::consts::PI - root - psi]
242        .into_iter()
243        .map(|u| u.rem_euclid(std::f64::consts::TAU))
244        .collect()
245}
246
247/// The static margin and the flight margin of `aero` at `time_s`, with the center of mass
248/// `height_above_ground_m` above the launch site and at `cg_station_m` on the airframe, and the
249/// flight's air at Mach `mach` pressing with `dynamic_pressure_pa`.
250///
251/// # Errors
252///
253/// As [`margin`].
254pub fn stability(
255    aero: &AeroModel,
256    time_s: f64,
257    height_above_ground_m: f64,
258    dynamic_pressure_pa: f64,
259    cg_station_m: f64,
260    mach: f64,
261) -> Result<Stability, SimError> {
262    Ok(Stability {
263        time_s,
264        height_above_ground_m,
265        dynamic_pressure_pa,
266        cg_station_m,
267        reference_diameter_m: aero.reference_diameter_m(),
268        static_margin: weakest_margin(aero, STATIC_MARGIN_MACH, cg_station_m)?,
269        flight_margin: weakest_margin(aero, mach, cg_station_m)?,
270    })
271}
272
273/// The apogee of the flight's center of mass.
274#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
275pub struct Apogee {
276    /// When, s after launch.
277    pub time_s: f64,
278    /// The center of mass's ellipsoidal height above the launch site, m.
279    pub height_above_ground_m: f64,
280    /// The rise of the center of mass from where it stood at launch, m: the height above the site
281    /// less [`FlightSummary::launch_height_m`], as OpenRocket's altitude counts. `None` for a
282    /// flight started in the air ([`Simulation::run_free`]).
283    pub gain_m: Option<f64>,
284}
285
286/// Where something landed: the center of mass as it reached the launch site's ellipsoidal
287/// height.
288#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
289pub struct Landing {
290    /// Which separated body ([`crate::BodyFlight::body`]), or `None` for the flight itself: the
291    /// stack, or after a powered separation the sustainer.
292    pub body: Option<usize>,
293    /// When, s after launch.
294    pub time_s: f64,
295    /// Geodetic latitude, degrees north (WGS 84).
296    pub latitude_deg: f64,
297    /// Longitude, degrees east (WGS 84).
298    pub longitude_deg: f64,
299    /// East of the launch site in its local frame, m.
300    pub east_m: f64,
301    /// North of the launch site, m.
302    pub north_m: f64,
303    /// The horizontal distance from the launch site, m.
304    pub distance_m: f64,
305    /// The speed of the center of mass at the ground hit, relative to the ground, m/s.
306    pub ground_hit_speed_m_s: f64,
307    /// Its downward speed then, m/s.
308    pub descent_rate_m_s: f64,
309}
310
311impl Landing {
312    /// The landing at `sample`, for `body`, located on `environment`'s ellipsoid.
313    ///
314    /// # Errors
315    ///
316    /// [`SimError::Core`] if the position has no geodetic coordinates.
317    pub fn at(
318        body: Option<usize>,
319        sample: &BodySample,
320        environment: &Environment,
321    ) -> Result<Self, SimError> {
322        let place = environment
323            .earth
324            .frame()
325            .geodetic_from_enu(sample.cg_enu_m)?;
326        let (east_m, north_m) = (sample.cg_enu_m.x, sample.cg_enu_m.y);
327        Ok(Self {
328            body,
329            time_s: sample.time_s,
330            latitude_deg: place.latitude_rad.to_degrees(),
331            longitude_deg: place.longitude_rad.to_degrees(),
332            east_m,
333            north_m,
334            distance_m: east_m.hypot(north_m),
335            ground_hit_speed_m_s: sample.cg_velocity_enu_m_s.length(),
336            descent_rate_m_s: -sample.vertical_speed_m_s,
337        })
338    }
339}
340
341/// The metrics of one flight. Every field that may not have happened is an `Option`.
342#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
343pub struct FlightSummary {
344    /// Why the flight ended.
345    pub termination: Termination,
346    /// The center of mass's height above the launch site as the rocket stood at launch, m. `None`
347    /// for a flight started in the air ([`Simulation::run_free`]).
348    pub launch_height_m: Option<f64>,
349    /// The speed as the last rail guide left the rail, m/s, with its time and height.
350    pub rail_exit_speed_m_s: Option<Peak>,
351    /// The apogee of the center of mass. A stack that comes apart at a separation before its
352    /// apogee ([`Termination::Separated`]) has the apogee of the part that keeps the nose, body 0;
353    /// one that comes apart at an ejection ([`crate::Ejection`]) has none.
354    pub apogee: Option<Apogee>,
355    /// The largest speed of the center of mass relative to the ground after liftoff, m/s.
356    pub max_speed_m_s: Option<Peak>,
357    /// The largest Mach number after liftoff.
358    pub max_mach: Option<Peak>,
359    /// The largest dynamic pressure after liftoff ("max q"), Pa.
360    pub max_dynamic_pressure_pa: Option<Peak>,
361    /// The largest acceleration of the nose tip (the body origin) relative to the launch frame
362    /// from liftoff until a recovery device opens, m/s²: the boost and the coast, from the
363    /// equations of motion.
364    pub max_acceleration_m_s2: Option<Peak>,
365    /// The largest acceleration under the recovery devices, m/s²: the opening shock, which
366    /// follows the inflation model, kept apart from the boost's.
367    pub max_descent_acceleration_m_s2: Option<Peak>,
368    /// The smallest static margin from the rail exit to apogee or the first deployment,
369    /// calibres, where it is defined, found inside steps as a peak is. After a powered separation
370    /// the sustainer's margins count its own diameter.
371    pub min_static_margin_cal: Option<Peak>,
372    /// The smallest flight margin over the same span, calibres, found the same way. RocketPy's
373    /// `min_stability_margin` takes its least over the whole flight, so it can differ.
374    pub min_flight_margin_cal: Option<Peak>,
375    /// The smallest static margin over the same span while a motor burns, calibres, found the
376    /// same way: below zero, the rocket is unstable under power
377    /// ([`EnvelopeFlag::UnstableUnderPower`]; [#335][i335]). A step counts when the thrust at its
378    /// middle is positive. Every ignition, thrust-curve knot and burnout ends a step, so a motor's
379    /// thrust then holds through the step, and a step that ends at a burnout counts up to that
380    /// instant. A NaN margin at a step's start, middle or end while a motor burns is kept, and
381    /// then nothing replaces it, so it can't hide the flag; a NaN thrust counts as burning. `None`
382    /// when no motor burns in the span, the margin is nowhere defined while one does, and in a
383    /// summary written before this field was.
384    ///
385    /// [i335]: https://github.com/nrdptel/hpr-sim/issues/335
386    #[serde(default)]
387    pub min_powered_static_margin_cal: Option<Peak>,
388    /// The largest static pitch-moment slope `C_mα` per radian
389    /// ([`Margin::pitch_moment_slope_per_rad`], `C_mα = −(Σ C_Nα,i x_i − C_Nα x_cg) / d`) over the
390    /// same span while a motor burns, taken only at instants where the static margin is
391    /// undefined: above zero, the air turns the rocket away from its path, so it is unstable under
392    /// power although hpr can give no margin ([`EnvelopeFlag::UnstableWithoutMargin`]). The
393    /// margin is undefined where the net normal-force slope is not positive, or is too small
394    /// against `Σ |C_Nα,i|` for the quotient to mean anything ([`MARGIN_CONDITION_LIMIT`]); the
395    /// moment slope stays finite there and still says which way the air turns the rocket.
396    /// Taken at the start, middle and end of each step where a motor burns, counted as for
397    /// [`min_powered_static_margin_cal`](Self::min_powered_static_margin_cal), not searched
398    /// between. A NaN slope is kept, and then nothing replaces it. `None` when the margin is
399    /// defined at every such instant, no motor burns in the span, and in a summary written
400    /// before this field was.
401    #[serde(default)]
402    pub max_powered_moment_slope_per_rad: Option<Peak>,
403    /// The stability as the last rail guide left the rail.
404    pub rail_exit_stability: Option<Stability>,
405    /// The largest angle of attack more than [`HIGH_ANGLE_GRACE_S`] after the rail exit (or the
406    /// start of a flight begun in the air), until apogee or the first deployment, rad. Only
407    /// instants where a 15° angle would give a normal force of at least
408    /// [`HIGH_ANGLE_MIN_FORCE_SHARE`](envelope::HIGH_ANGLE_MIN_FORCE_SHARE) of the rocket's weight count: as the path turns over near
409    /// apogee the angle swings past 15° on an ordinary flight, with too little air to move it ([the envelope flags' decision
410    /// record][adr-179]). Taken at each step's start, middle and end, not searched between.
411    /// `None` when no instant counted, and in a summary written before this field was.
412    ///
413    /// [adr-179]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0179-the-envelope-flags.md
414    #[serde(default)]
415    pub max_angle_of_attack_rad: Option<Peak>,
416    /// Where the flight itself landed: the stack, or after a powered separation the sustainer.
417    pub landing: Option<Landing>,
418    /// Where each separated body landed, in body order; a body that didn't land is left out.
419    pub body_landings: Vec<Landing>,
420}
421
422impl FlightSummary {
423    /// The operating envelope's flags this flight raises, in [`EnvelopeFlag`]'s order.
424    #[must_use]
425    pub fn envelope_flags(&self) -> Vec<EnvelopeFlag> {
426        crate::envelope::flags(
427            self.max_mach,
428            self.max_angle_of_attack_rad,
429            self.min_powered_static_margin_cal,
430            self.max_powered_moment_slope_per_rad,
431        )
432    }
433
434    /// The flight's own ground-hit speed, m/s, if it landed.
435    #[must_use]
436    pub fn ground_hit_speed_m_s(&self) -> Option<f64> {
437        self.landing.map(|landing| landing.ground_hit_speed_m_s)
438    }
439
440    /// Where the part that keeps the nose landed, as [`Self::apogee`] is its apogee: the
441    /// flight's own landing ([`Self::landing`]), or, after the stack comes apart with nothing
442    /// left to burn ([`Termination::Separated`], which leaves [`Self::landing`] empty), part 0's
443    /// from [`Self::body_landings`].
444    #[must_use]
445    pub fn nose_landing(&self) -> Option<&Landing> {
446        self.landing
447            .as_ref()
448            .or_else(|| self.body_landings.iter().find(|l| l.body == Some(0)))
449    }
450}
451
452/// Watches a flight and keeps its peaks, and its stability from the rail exit to apogee or the
453/// first deployment.
454///
455/// Each step is sampled at its start, middle and end. When the parabola through the three has its
456/// top inside the step, a golden-section search on the dense output finds the peak there; the
457/// largest of what it finds and the three samples is kept. The least margins are found the same
458/// way, with the parabola turned over. Thrust-curve knots and events end steps, so a thrust
459/// spike's peak is a step's end. Nothing is kept on the pad.
460///
461/// A watcher keeps one flight: [`FlightMetrics::clear`] it before watching another, and
462/// [`FlightMetrics::summary`] refuses a flight whose steps it didn't all see, once each. Like
463/// [`crate::Recorder`], it serializes what it holds for inspection and is built with
464/// [`FlightMetrics::new`], not deserialized.
465#[derive(Debug, Clone, Default, PartialEq, Serialize)]
466pub struct FlightMetrics {
467    launch_height_m: Option<f64>,
468    max_speed: Option<Peak>,
469    max_mach: Option<Peak>,
470    max_q: Option<Peak>,
471    max_acceleration: Option<Peak>,
472    max_descent_acceleration: Option<Peak>,
473    /// Whether any step was seen, and the last one's end.
474    last_end_s: Option<f64>,
475    /// How many steps it saw: a flight's accepted steps, one each.
476    steps: u64,
477    on_rail: bool,
478    past_apogee: bool,
479    /// Whether a separation came since the last step: the next starts on another rocket.
480    separated: bool,
481    rail_exit: Option<Stability>,
482    stability: Vec<Stability>,
483    min_static_margin: Option<Peak>,
484    min_flight_margin: Option<Peak>,
485    /// The least static margin over the steps where a motor burns.
486    min_powered_static_margin: Option<Peak>,
487    /// The largest static pitch-moment slope over the steps where a motor burns, at instants
488    /// without a static margin.
489    max_powered_moment_slope: Option<Peak>,
490    /// When the free flight began (the rail exit, or the start of a flight begun in the air), s:
491    /// where the angle of attack's window opens.
492    free_start_s: Option<f64>,
493    max_angle_of_attack: Option<Peak>,
494}
495
496/// What a peak is of.
497#[derive(Debug, Clone, Copy)]
498enum Quantity {
499    Speed,
500    Mach,
501    DynamicPressure,
502    Acceleration,
503}
504
505impl Quantity {
506    const ALL: [Quantity; 4] = [
507        Quantity::Speed,
508        Quantity::Mach,
509        Quantity::DynamicPressure,
510        Quantity::Acceleration,
511    ];
512
513    fn of(self, sample: &Sample) -> f64 {
514        match self {
515            Quantity::Speed => sample.cg_velocity_enu_m_s.length(),
516            Quantity::Mach => sample.mach,
517            Quantity::DynamicPressure => sample.dynamic_pressure_pa,
518            Quantity::Acceleration => sample.acceleration_enu_m_s2.length(),
519        }
520    }
521}
522
523impl FlightMetrics {
524    /// A watcher with nothing seen.
525    #[must_use]
526    pub fn new() -> Self {
527        Self::default()
528    }
529
530    /// Forgets what it saw, ready for another flight.
531    pub fn clear(&mut self) {
532        *self = Self::default();
533    }
534
535    /// The stability from the rail exit (or the start of a flight begun in the air) to apogee or
536    /// the first deployment, whichever comes first: at the rail exit and at every step's end, in
537    /// time order. A powered separation adds the sustainer's own entry at the split, after the
538    /// stack's and at the same time, so the watcher needs the flight's events as well as its
539    /// steps.
540    #[must_use]
541    pub fn stability(&self) -> &[Stability] {
542        &self.stability
543    }
544
545    /// The summary of `result`, the flight this watched, flown in `environment`.
546    ///
547    /// # Errors
548    ///
549    /// [`SimError::Domain`] if it didn't see each of `result`'s accepted steps once, or its last
550    /// step ended after `result` did (it watched another flight, or wasn't cleared between two);
551    /// [`SimError::Core`] if a landing has no geodetic coordinates.
552    pub fn summary(
553        &self,
554        result: &FlightResult,
555        environment: &Environment,
556    ) -> Result<FlightSummary, SimError> {
557        // A flight can end without a step (before its first, or on a stop a few ulps ahead that
558        // the clock just moves to), so the steps are counted rather than its end matched.
559        if self.steps != result.stats.accepted_steps
560            || self
561                .last_end_s
562                .is_some_and(|end| end > result.final_sample.time_s)
563        {
564            return Err(SimError::Domain {
565                what: "steps this watcher saw (it must watch each step of the flight it sums up, \
566                       and be cleared before another)",
567                value: self.steps as f64,
568            });
569        }
570        // A stack that came apart at a separation on its way up has the apogee of the part that
571        // keeps the nose, body 0, which flies on as a point mass ([`Termination::Separated`]).
572        // An ejection's body 0 is its lead piece, often the nose cone alone, so a stack that
573        // came apart at one has none.
574        let stack = result
575            .event(EventKind::Apogee)
576            .map(|event| (event.sample.time_s, event.sample.height_above_ground_m));
577        let nose = || {
578            result
579                .bodies
580                .iter()
581                .find(|body| body.body == 0)
582                .and_then(|body| body.event(EventKind::Apogee))
583                .map(|event| (event.sample.time_s, event.sample.height_above_ground_m))
584        };
585        let apogee = stack
586            .or_else(|| {
587                let separated = result.termination == Termination::Separated
588                    && result.event(EventKind::Separation).is_some()
589                    && !result
590                        .events
591                        .iter()
592                        .any(|event| matches!(event.kind, EventKind::Ejection(_)));
593                separated.then(nose).flatten()
594            })
595            .map(|(time_s, height)| Apogee {
596                time_s,
597                height_above_ground_m: height,
598                gain_m: self.launch_height_m.map(|start| height - start),
599            });
600        let rail_exit = result.event(EventKind::RailExit).map(|event| event.sample);
601        let landing = if result.termination == Termination::GroundHit {
602            let s = &result.final_sample;
603            Some(Landing::at(
604                None,
605                &BodySample {
606                    time_s: s.time_s,
607                    cg_enu_m: s.cg_enu_m,
608                    cg_velocity_enu_m_s: s.cg_velocity_enu_m_s,
609                    height_above_ground_m: s.height_above_ground_m,
610                    vertical_speed_m_s: s.vertical_speed_m_s,
611                    airspeed_m_s: s.airspeed_m_s,
612                    recovery_drag_area_m2: s.recovery_drag_area_m2,
613                    mass_kg: s.mass_kg,
614                },
615                environment,
616            )?)
617        } else {
618            None
619        };
620        let body_landings = result
621            .bodies
622            .iter()
623            .filter(|body| body.termination == Termination::GroundHit)
624            .map(|body| Landing::at(Some(body.body), &body.final_sample, environment))
625            .collect::<Result<Vec<_>, _>>()?;
626        Ok(FlightSummary {
627            termination: result.termination,
628            launch_height_m: self.launch_height_m,
629            rail_exit_speed_m_s: rail_exit.map(|s| Peak {
630                value: s.cg_velocity_enu_m_s.length(),
631                time_s: s.time_s,
632                height_above_ground_m: s.height_above_ground_m,
633            }),
634            apogee,
635            max_speed_m_s: self.max_speed,
636            max_mach: self.max_mach,
637            max_dynamic_pressure_pa: self.max_q,
638            max_acceleration_m_s2: self.max_acceleration,
639            max_descent_acceleration_m_s2: self.max_descent_acceleration,
640            min_static_margin_cal: self.min_static_margin,
641            min_flight_margin_cal: self.min_flight_margin,
642            min_powered_static_margin_cal: self.min_powered_static_margin,
643            max_powered_moment_slope_per_rad: self.max_powered_moment_slope,
644            rail_exit_stability: self.rail_exit,
645            max_angle_of_attack_rad: self.max_angle_of_attack,
646            landing,
647            body_landings,
648        })
649    }
650
651    fn slot(&mut self, quantity: Quantity, phase: Phase) -> &mut Option<Peak> {
652        match quantity {
653            Quantity::Speed => &mut self.max_speed,
654            Quantity::Mach => &mut self.max_mach,
655            Quantity::DynamicPressure => &mut self.max_q,
656            Quantity::Acceleration if phase == Phase::Descent => &mut self.max_descent_acceleration,
657            Quantity::Acceleration => &mut self.max_acceleration,
658        }
659    }
660}
661
662impl Observer for FlightMetrics {
663    fn step(&mut self, step: &dyn FlightStep) -> Result<(), SimError> {
664        let phase = step.phase();
665        let (a, b) = (step.start_s(), step.end_s());
666        let start = step.sample(a)?;
667        if self.last_end_s.is_none() {
668            match phase {
669                Phase::Pad | Phase::Rail => {
670                    self.launch_height_m = Some(start.height_above_ground_m)
671                }
672                // A flight begun in the air on its way down has no apogee to come.
673                _ if start.vertical_speed_m_s <= 0.0 => self.past_apogee = true,
674                _ => {}
675            }
676        }
677        self.steps += 1;
678        self.last_end_s = Some(b);
679        if phase == Phase::Pad {
680            return Ok(());
681        }
682        let samples = [start, step.sample(0.5 * (a + b))?, step.sample(b)?];
683        for quantity in Quantity::ALL {
684            let best = step_peak(step, quantity, &samples)?;
685            let slot = self.slot(quantity, phase);
686            if slot.is_none_or(|peak| best.value > peak.value) {
687                *slot = Some(best);
688            }
689        }
690        if phase == Phase::Rail {
691            self.on_rail = true;
692        }
693        // On the rail the rail holds the rocket, so stability starts at the rail exit.
694        if !self.past_apogee && phase == Phase::Free {
695            let from_s = *self.free_start_s.get_or_insert(samples[0].time_s);
696            // A step starts where the last ended, on the same rocket, unless a separation came
697            // between: then the sustainer's own margins at the split start the step.
698            let first = match self.stability.last() {
699                Some(last) if !self.separated => *last,
700                _ => {
701                    let first = step.stability(a)?;
702                    if self.stability.is_empty() && self.on_rail {
703                        self.rail_exit = Some(first);
704                    }
705                    self.stability.push(first);
706                    first
707                }
708            };
709            let end = step.stability(b)?;
710            let entries = [first, step.stability(0.5 * (a + b))?, end];
711            for (sample, entry) in samples.iter().zip(&entries) {
712                // The normal force a 15° angle would give here: whether the air could turn the
713                // rocket at all.
714                let force_n = envelope::normal_force_n(
715                    sample.dynamic_pressure_pa,
716                    entry.reference_diameter_m,
717                    entry.flight_margin.normal_force_slope_per_rad,
718                    HIGH_ANGLE_OF_ATTACK_RAD,
719                );
720                // A NaN angle is kept, and then nothing replaces it: it can't hide a flag.
721                if sample.time_s - from_s > HIGH_ANGLE_GRACE_S
722                    && envelope::force_counts(force_n, sample.mass_kg)
723                    && self.max_angle_of_attack.is_none_or(|peak| {
724                        !peak.value.is_nan()
725                            && (sample.angle_of_attack_rad.is_nan()
726                                || sample.angle_of_attack_rad > peak.value)
727                    })
728                {
729                    self.max_angle_of_attack = Some(Peak {
730                        value: sample.angle_of_attack_rad,
731                        time_s: sample.time_s,
732                        height_above_ground_m: sample.height_above_ground_m,
733                    });
734                }
735            }
736            let least_static = least_margin(step, a, b, &entries, static_of)?;
737            for (least, slot) in [
738                (least_static, &mut self.min_static_margin),
739                (
740                    least_margin(step, a, b, &entries, flight_of)?,
741                    &mut self.min_flight_margin,
742                ),
743            ] {
744                if let Some(least) = least
745                    && slot.is_none_or(|peak| replaces(least.value, peak.value))
746                {
747                    *slot = Some(least);
748                }
749            }
750            // Every ignition, thrust-curve knot and burnout ends a step, so the thrust at the
751            // middle says whether a motor burns through the step. A NaN counts as burning.
752            let thrust_n = samples[1].thrust_n;
753            if thrust_n > 0.0 || thrust_n.is_nan() {
754                keep_powered(&mut self.min_powered_static_margin, &entries, least_static);
755                keep_moment_without_margin(&mut self.max_powered_moment_slope, &entries);
756            }
757            self.stability.push(end);
758        }
759        self.separated = false;
760        Ok(())
761    }
762
763    fn event(&mut self, event: &FlightEvent) {
764        match event.kind {
765            EventKind::Apogee => self.past_apogee = true,
766            EventKind::Separation => self.separated = true,
767            _ => {}
768        }
769    }
770}
771
772/// The golden ratio's inverse, `(√5 − 1)/2`.
773const INVERSE_PHI: f64 = 0.618_033_988_749_894_9;
774
775/// How finely a peak's time is found, relative to the flight's clock (at least 1 s): 1e-9. The
776/// value's error goes as the square of the time's, so it is far below the integration's.
777const PEAK_TIME_RESOLUTION: f64 = 1e-9;
778
779/// The largest of `value` on `[a, b]`, where it has one top: a golden-section search (Kiefer
780/// 1953) on a step's dense output, to [`PEAK_TIME_RESOLUTION`].
781fn golden_max(
782    mut a: f64,
783    mut b: f64,
784    mut value: impl FnMut(f64) -> Result<Peak, SimError>,
785) -> Result<Peak, SimError> {
786    let mut c = b - INVERSE_PHI * (b - a);
787    let mut d = a + INVERSE_PHI * (b - a);
788    let mut fc = value(c)?;
789    let mut fd = value(d)?;
790    while b - a > PEAK_TIME_RESOLUTION * b.abs().max(1.0) {
791        if fc.value >= fd.value {
792            b = d;
793            (d, fd) = (c, fc);
794            c = b - INVERSE_PHI * (b - a);
795            fc = value(c)?;
796        } else {
797            a = c;
798            (c, fc) = (d, fd);
799            d = a + INVERSE_PHI * (b - a);
800            fd = value(d)?;
801        }
802    }
803    Ok(if fc.value >= fd.value { fc } else { fd })
804}
805
806/// Where the parabola through `(−1, va)`, `(0, vm)` and `(1, vb)` has its top inside: it bends
807/// down, and its top `x = (va − vb) / (2 (va − 2 vm + vb))` has `|x| < 1`.
808fn top_inside(va: f64, vm: f64, vb: f64) -> bool {
809    let bend = va - 2.0 * vm + vb;
810    bend < 0.0 && (va - vb).abs() < -2.0 * bend
811}
812
813/// The peak of `quantity` on the step, from its start, middle and end samples and, when the
814/// parabola through them has its top inside, a search between.
815fn step_peak(
816    step: &dyn FlightStep,
817    quantity: Quantity,
818    samples: &[Sample; 3],
819) -> Result<Peak, SimError> {
820    let peak_of = |sample: &Sample| Peak {
821        value: quantity.of(sample),
822        time_s: sample.time_s,
823        height_above_ground_m: sample.height_above_ground_m,
824    };
825    let [start, middle, end] = samples.each_ref().map(peak_of);
826    let mut best = [middle, end]
827        .into_iter()
828        .fold(start, |x, y| if y.value > x.value { y } else { x });
829    if top_inside(start.value, middle.value, end.value) {
830        let found = golden_max(step.start_s(), step.end_s(), |t| {
831            step.sample(t).map(|sample| peak_of(&sample))
832        })?;
833        if found.value > best.value {
834            best = found;
835        }
836    }
837    Ok(best)
838}
839
840fn static_of(s: &Stability) -> &Margin {
841    &s.static_margin
842}
843
844fn flight_of(s: &Stability) -> &Margin {
845    &s.flight_margin
846}
847
848/// How far below a least margin another must be to replace it: this fraction of the least, or of
849/// 1 calibre below 1 calibre. A flat margin, which rounding can nudge by an ulp, keeps its first
850/// time.
851const MARGIN_TIE: f64 = 1e-12;
852
853/// Whether margin `a` is below `b` by more than [`MARGIN_TIE`].
854fn clearly_below(a: f64, b: f64) -> bool {
855    b - a > MARGIN_TIE * b.abs().max(1.0)
856}
857
858/// Whether margin `a` replaces the least so far, `b`: clearly below it, or below zero where `b`
859/// is not, so a tie can't keep a margin on the stable side of zero over one past it.
860fn replaces(a: f64, b: f64) -> bool {
861    clearly_below(a, b) || (a < 0.0 && b >= 0.0)
862}
863
864/// Keeps the least static margin of a step where a motor burns in `slot`: a NaN among the step's
865/// `entries` first, which then stays, so a broken number can't hide the flag; else `least`, the
866/// step's least, when it [`replaces`] the slot's.
867fn keep_powered(slot: &mut Option<Peak>, entries: &[Stability; 3], least: Option<Peak>) {
868    if slot.is_some_and(|peak| peak.value.is_nan()) {
869        return;
870    }
871    let broken = entries.iter().find_map(|s| {
872        s.static_margin
873            .margin_cal
874            .filter(|margin| margin.is_nan())
875            .map(|value| Peak {
876                value,
877                time_s: s.time_s,
878                height_above_ground_m: s.height_above_ground_m,
879            })
880    });
881    if let Some(broken) = broken {
882        *slot = Some(broken);
883    } else if let Some(least) = least
884        && slot.is_none_or(|peak| replaces(least.value, peak.value))
885    {
886        *slot = Some(least);
887    }
888}
889
890/// Keeps in `slot` the largest static pitch-moment slope among a powered step's `entries` where
891/// the static margin is undefined: a NaN first, which then stays, so a broken number can't hide
892/// the flag; else a larger slope.
893fn keep_moment_without_margin(slot: &mut Option<Peak>, entries: &[Stability; 3]) {
894    for entry in entries
895        .iter()
896        .filter(|s| s.static_margin.margin_cal.is_none())
897    {
898        let slope = entry.static_margin.pitch_moment_slope_per_rad;
899        if slot.is_none_or(|peak| !peak.value.is_nan() && (slope.is_nan() || slope > peak.value)) {
900            *slot = Some(Peak {
901                value: slope,
902                time_s: entry.time_s,
903                height_above_ground_m: entry.height_above_ground_m,
904            });
905        }
906    }
907}
908
909/// The least of one margin on the step `[a, b]`, from its `entries` at the start, middle and end
910/// and, when all three are defined and the parabola through them has its bottom inside, a search
911/// between; `None` if it is nowhere defined among them.
912fn least_margin(
913    step: &dyn FlightStep,
914    a: f64,
915    b: f64,
916    entries: &[Stability; 3],
917    pick: fn(&Stability) -> &Margin,
918) -> Result<Option<Peak>, SimError> {
919    let at = |s: &Stability| {
920        pick(s).margin_cal.map(|value| Peak {
921            value,
922            time_s: s.time_s,
923            height_above_ground_m: s.height_above_ground_m,
924        })
925    };
926    let mut best: Option<Peak> = None;
927    let mut keep = |candidate: Peak| {
928        if best.is_none_or(|peak| replaces(candidate.value, peak.value)) {
929            best = Some(candidate);
930        }
931    };
932    let points = entries.each_ref().map(at);
933    points.into_iter().flatten().for_each(&mut keep);
934    if let [Some(start), Some(middle), Some(end)] = points
935        && top_inside(-start.value, -middle.value, -end.value)
936    {
937        // Searched as the largest of its negative; an undefined margin is never the least.
938        let found = golden_max(a, b, |t| {
939            step.stability(t).map(|s| {
940                let peak = at(&s);
941                Peak {
942                    value: peak.map_or(f64::NEG_INFINITY, |p| -p.value),
943                    time_s: s.time_s,
944                    height_above_ground_m: s.height_above_ground_m,
945                }
946            })
947        })?;
948        if found.value.is_finite() {
949            keep(Peak {
950                value: -found.value,
951                ..found
952            });
953        }
954    }
955    Ok(best)
956}
957
958/// The ejection delay that would fire a motor's charge at apogee.
959#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
960pub struct OptimumDelay {
961    /// The motor, by its index in [`hpr_design::Assembly::motors`].
962    pub motor: usize,
963    /// When it burned out, s after launch.
964    pub burnout_s: f64,
965    /// When the rocket reached apogee with its charges held, s after launch.
966    pub apogee_s: f64,
967    /// How high its center of mass was then, m above the launch site.
968    pub apogee_height_above_ground_m: f64,
969    /// The delay from its burnout to that apogee, s.
970    pub delay_s: f64,
971}
972
973/// The optimum ejection delay of each motor that burns out before apogee: the time from its
974/// burnout to the apogee of the same flight with every recovery charge held, so that the answer
975/// is a property of the rocket, its motors and its air, and not of the delay flown
976/// ([Loft lesson L94][l94]).
977///
978/// - The held flight holds the stack's devices and any separation with nothing ahead of it left
979///   to burn, which is part of the recovery, and a mass shift or release fired by a motor's delay,
980///   whose charge is held. A powered separation still happens, and so does a shift or release
981///   on any other trigger.
982/// - A motor that burns out after that apogee, or never lights, has none. Nor does a motor in a
983///   body a powered separation drops: its charge fires in that body, which never reaches the
984///   stack's apogee.
985///
986/// It is `None` when the held flight has no apogee (it never lifted off, or hit its time cap).
987///
988/// # Errors
989///
990/// The flight's.
991///
992/// [l94]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l94
993pub fn optimum_delays(simulation: &Simulation) -> Result<Option<Vec<OptimumDelay>>, SimError> {
994    let held = simulation.with_recovery_held();
995    let result = held.run(&mut ())?;
996    let Some((apogee_s, apogee_height_above_ground_m)) = result
997        .event(EventKind::Apogee)
998        .map(|event| (event.sample.time_s, event.sample.height_above_ground_m))
999    else {
1000        return Ok(None);
1001    };
1002    let assembly = simulation.assembly();
1003    // A motor lit by a separation has its time only from the flight's ignition event.
1004    let known = assembly.ignition_times_s(|_| None);
1005    let dropped = |stage: usize| {
1006        result
1007            .bodies
1008            .iter()
1009            .any(|body| (body.stages.0..=body.stages.1).contains(&stage))
1010    };
1011    Ok(Some(
1012        assembly
1013            .motors
1014            .iter()
1015            .enumerate()
1016            .filter(|(_, placed)| !dropped(placed.stage))
1017            .filter_map(|(motor, placed)| {
1018                let ignition_s = result
1019                    .event(EventKind::Ignition(motor))
1020                    .map(|event| event.sample.time_s)
1021                    .or(known.get(motor).copied().flatten())?;
1022                let burnout_s = ignition_s + placed.mounted.motor.burnout_time_s();
1023                (burnout_s <= apogee_s).then_some(OptimumDelay {
1024                    motor,
1025                    burnout_s,
1026                    apogee_s,
1027                    apogee_height_above_ground_m,
1028                    delay_s: apogee_s - burnout_s,
1029                })
1030            })
1031            .collect(),
1032    ))
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037    use hpr_aero::{AeroModel, Flow};
1038    use hpr_atmos::ConstantWind;
1039    use hpr_design::Rocket;
1040    use serde_json::json;
1041
1042    use super::*;
1043    use crate::envelope::HIGH_ANGLE_MIN_FORCE_SHARE;
1044    use crate::flight::FlightSettings;
1045    use crate::integrator::{Adaptive, Method};
1046    use crate::rail::Rail;
1047    use crate::recorder::{Channel, Recorder};
1048    use crate::recovery::{Device, DeviceDrag, Trigger};
1049    use crate::testing::{UniformAir, analytic_environment, design, site, windy_environment};
1050
1051    const G: f64 = 9.806_65;
1052
1053    fn valetudo(environment: Environment, settings: FlightSettings) -> Simulation {
1054        Simulation::new(
1055            &design("rocketpy-valetudo"),
1056            "example",
1057            environment,
1058            Rail::vertical(3.0),
1059            settings,
1060        )
1061        .unwrap()
1062    }
1063
1064    fn capped(max_time_s: f64) -> FlightSettings {
1065        FlightSettings {
1066            max_time_s,
1067            ..FlightSettings::default()
1068        }
1069    }
1070
1071    fn fly(simulation: &Simulation) -> (FlightResult, FlightMetrics) {
1072        let mut metrics = FlightMetrics::new();
1073        let result = simulation.run(&mut metrics).unwrap();
1074        (result, metrics)
1075    }
1076
1077    /// A conical nose `0.3` m long of radius `R = 0.05` m, a `0.5` m tube, and a conical boattail
1078    /// `0.4` m long down to `r_m`, with no fins: Barrowman gives the nose a slope of 2 at
1079    /// `2/3` of its length and the boattail `2((r/R)² − 1)` at `L/3 · (1 + 1/(1 + R/r))` aft of
1080    /// its fore end (Barrowman's eq. 44, `hpr_aero`'s hand values, L89).
1081    fn nose_and_boattail(r_m: f64) -> AeroModel {
1082        let material = json!({"name": "test", "density": {"kind": "bulk", "kg_m3": 1000.0}});
1083        let rocket: Rocket = serde_json::from_value(json!({
1084            "name": "",
1085            "stages": [{
1086                "id": "stage",
1087                "name": "",
1088                "components": [
1089                    {"id": "nose", "name": "", "part": {"nose_cone": {
1090                        "shape": {"kind": "conical"}, "length_m": 0.3, "base_radius_m": 0.05,
1091                        "wall": {"kind": "filled"}, "shoulder": null, "material": material}}},
1092                    {"id": "tube", "name": "", "part": {"body_tube": {
1093                        "length_m": 0.5, "outer_radius_m": 0.05, "thickness_m": 0.005,
1094                        "material": material}}},
1095                    {"id": "boattail", "name": "", "part": {"transition": {
1096                        "shape": {"kind": "conical"}, "length_m": 0.4, "fore_radius_m": 0.05,
1097                        "aft_radius_m": r_m, "wall": {"kind": "filled"}, "material": material}}}
1098                ]
1099            }],
1100            "reference_diameter": {"kind": "maximum"},
1101            "configurations": []
1102        }))
1103        .unwrap();
1104        AeroModel::new(&rocket.layout().unwrap()).unwrap()
1105    }
1106
1107    /// The hand values of [`nose_and_boattail`]: `(slope, Σ |slopes|, C_mα about x_cg, x_cp)`.
1108    fn nose_and_boattail_by_hand(r_m: f64, cg_m: f64) -> (f64, f64, f64, f64) {
1109        let (big_r, d) = (0.05, 0.1);
1110        let (nose_slope, nose_x) = (2.0, 0.3 * 2.0 / 3.0);
1111        let tail_slope = 2.0 * ((r_m / big_r).powi(2) - 1.0);
1112        let tail_x = 0.8 + 0.4 / 3.0 * (1.0 + 1.0 / (1.0 + big_r / r_m));
1113        let slope = nose_slope + tail_slope;
1114        let moment = -(nose_slope * (nose_x - cg_m) + tail_slope * (tail_x - cg_m)) / d;
1115        let cp = (nose_slope * nose_x + tail_slope * tail_x) / slope;
1116        (slope, nose_slope + tail_slope.abs(), moment, cp)
1117    }
1118
1119    fn close(got: f64, want: f64, rel: f64, what: &str) {
1120        let err = ((got - want) / want).abs();
1121        assert!(
1122            err <= rel,
1123            "{what}: got {got}, want {want}, rel err {err:e}"
1124        );
1125    }
1126
1127    #[test]
1128    fn static_margin_undefined_when_cn_alpha_near_zero() {
1129        // L33: a boattail down to a tenth of the radius all but cancels the nose's slope
1130        // (2 − 1.98 = 0.02 against a sum of 3.98). The quotient still exists, and it is the kind
1131        // of number Loft published: hundreds of calibres.
1132        let cg_m = 0.5;
1133        let aero = nose_and_boattail(0.005);
1134        let (slope, sum, moment, cp) = nose_and_boattail_by_hand(0.005, cg_m);
1135        let raw = aero.normal_force(&Flow::axial(0.0)).unwrap();
1136        close(raw.slope_per_rad, slope, 1e-9, "net slope");
1137        close(raw.cp_station_m.unwrap(), cp, 1e-9, "the quotient");
1138        assert!(
1139            ((cp - cg_m) / 0.1).abs() > 100.0,
1140            "a margin of {} cal",
1141            (cp - cg_m) / 0.1
1142        );
1143        let static_margin = stability(&aero, 0.0, 0.0, 0.0, cg_m, 0.3)
1144            .unwrap()
1145            .static_margin;
1146        assert_eq!(static_margin.margin_cal, None);
1147        assert_eq!(static_margin.cp_station_m, None);
1148        close(
1149            static_margin.slope_magnitude_sum_per_rad,
1150            sum,
1151            1e-12,
1152            "Σ |slopes|",
1153        );
1154        // The couple stays: finite, and destabilising (positive).
1155        close(
1156            static_margin.pitch_moment_slope_per_rad,
1157            moment,
1158            1e-9,
1159            "C_mα",
1160        );
1161        assert!(moment > 0.0);
1162
1163        // Both sides of the limit: κ = Σ|C_Nα,i| / C_Nα = 2/ρ² − 1 with ρ = r/R, so the margin
1164        // is given from κ = √10, ρ = √(2/(1 + √10)) = 0.6932, up.
1165        for (r_m, defined) in [
1166            (0.035, true),
1167            (0.0345, false),
1168            (0.04, true),
1169            (0.0215, false),
1170        ] {
1171            let (slope, sum, moment, cp) = nose_and_boattail_by_hand(r_m, cg_m);
1172            assert_eq!(sum / slope <= MARGIN_CONDITION_LIMIT, defined, "r = {r_m}");
1173            let m = margin(&nose_and_boattail(r_m), &Flow::axial(0.0), cg_m).unwrap();
1174            close(m.pitch_moment_slope_per_rad, moment, 1e-9, "C_mα");
1175            if defined {
1176                close(m.margin_cal.unwrap(), (cp - cg_m) / 0.1, 1e-9, "margin");
1177                // C_mα = −C_Nα · margin.
1178                close(
1179                    m.pitch_moment_slope_per_rad,
1180                    -slope * m.margin_cal.unwrap(),
1181                    1e-9,
1182                    "C_mα",
1183                );
1184            } else {
1185                assert_eq!(m.margin_cal, None, "r = {r_m}");
1186            }
1187        }
1188    }
1189
1190    /// A rocket with one fin at 0° and a pair at `pair_rad`: the pair is edge-on to air crossing
1191    /// in its own plane, and the lone fin to air crossing in its. A fin set of one or two fins
1192    /// carries `Σ sin²(φ − θ_k)` of its force in the plane at `φ` (Niskanen 2009 eq. 3.51).
1193    fn uneven_fins(single_m: f64, pair_m: f64, pair_rad: f64) -> AeroModel {
1194        let material = json!({"name": "test", "density": {"kind": "bulk", "kg_m3": 1000.0}});
1195        let fins = |id: &str, count: u32, angle_rad: f64, span_m: f64| {
1196            json!({"id": id, "name": "", "part": {"fin_set": {
1197                "count": count, "base_angle_rad": angle_rad,
1198                "planform": {"kind": "trapezoidal", "root_chord_m": 0.12, "tip_chord_m": 0.06,
1199                    "span_m": span_m, "sweep_m": 0.05},
1200                "thickness_m": 0.003, "cross_section": "rounded", "tab": null,
1201                "cant_rad": 0.0, "material": material}},
1202                "position": {"from": "bottom", "aft_offset_m": 0.0}})
1203        };
1204        let rocket: Rocket = serde_json::from_value(json!({
1205            "name": "",
1206            "stages": [{"id": "stage", "name": "", "components": [
1207                {"id": "nose", "name": "", "part": {"nose_cone": {
1208                    "shape": {"kind": "conical"}, "length_m": 0.2, "base_radius_m": 0.025,
1209                    "wall": {"kind": "filled"}, "shoulder": null, "material": material}}},
1210                {"id": "tube", "name": "", "part": {"body_tube": {"length_m": 0.6,
1211                    "outer_radius_m": 0.025, "thickness_m": 0.001, "material": material}},
1212                    "children": [
1213                        fins("single", 1, 0.0, single_m),
1214                        fins("pair", 2, pair_rad, pair_m)
1215                    ]}
1216            ]}],
1217            "reference_diameter": {"kind": "maximum"},
1218            "configurations": []
1219        }))
1220        .unwrap();
1221        AeroModel::new(&rocket.layout().unwrap()).unwrap()
1222    }
1223
1224    /// The least margin over the direction the air crosses the rocket (#329): never above any
1225    /// plane's, met in a plane a fine scan can't beat, and found in closed form wherever it is.
1226    /// A rocket whose fin sets are each of three or more keeps its axial margin bit for bit.
1227    #[test]
1228    fn the_least_margin_is_the_weakest_plane_s() {
1229        let scan = |aero: &AeroModel, mach: f64, cg_m: f64| {
1230            (0..3600)
1231                .map(|k| {
1232                    let roll = std::f64::consts::PI * f64::from(k) / 3600.0;
1233                    margin(aero, &Flow::new(mach, 0.0, roll), cg_m).unwrap()
1234                })
1235                .collect::<Vec<_>>()
1236        };
1237        // Spans either way round, and centers of mass either side of a margin that vanishes in
1238        // some planes. With the pair at right angles to the lone fin every sum is even in `φ`
1239        // (`c = 0`), and the weak plane lies on a fin's plane; with it at 0.6 rad the weak plane
1240        // lies between them, where a wrong sign on the `sin 2φ` terms would land on a strong one.
1241        let right = std::f64::consts::FRAC_PI_2;
1242        let mut off_fins = 0;
1243        for (single_m, pair_m, pair_rad) in [
1244            (0.06, 0.03, right),
1245            (0.03, 0.06, right),
1246            (0.05, 0.05, right),
1247            (0.07, 0.02, right),
1248            (0.06, 0.03, 0.6),
1249            (0.03, 0.06, 0.6),
1250        ] {
1251            let aero = uneven_fins(single_m, pair_m, pair_rad);
1252            assert!(aero.rolls());
1253            for mach in [0.1, 0.6, 1.5] {
1254                for cg_m in [0.35, 0.45, 0.55] {
1255                    let least = weakest_margin(&aero, mach, cg_m).unwrap();
1256                    let planes = scan(&aero, mach, cg_m);
1257                    let at = margin(&aero, &Flow::new(mach, 0.0, least.roll_rad), cg_m).unwrap();
1258                    assert_eq!(at, least, "the least margin is its plane's");
1259                    // Distance, in the margin's period of π, to the nearest plane of a fin or
1260                    // square to one.
1261                    let off = [0.0, right, pair_rad, pair_rad + right]
1262                        .iter()
1263                        .map(|plane| {
1264                            let d = (least.roll_rad - plane).rem_euclid(std::f64::consts::PI);
1265                            d.min(std::f64::consts::PI - d)
1266                        })
1267                        .fold(f64::INFINITY, f64::min);
1268                    if off > 0.05 {
1269                        off_fins += 1;
1270                    }
1271                    match least.margin_cal {
1272                        None => assert!(
1273                            planes.iter().any(|m| m.margin_cal.is_none()) || {
1274                                // The plane where no margin can be given lies between two scan lines.
1275                                let near = |m: &Margin| {
1276                                    m.slope_magnitude_sum_per_rad
1277                                        / m.normal_force_slope_per_rad.max(f64::MIN_POSITIVE)
1278                                };
1279                                planes.iter().map(near).fold(0.0, f64::max)
1280                                    > 0.99 * MARGIN_CONDITION_LIMIT
1281                            }
1282                        ),
1283                        Some(cal) => {
1284                            for m in &planes {
1285                                let plane = m.margin_cal.expect("a margin in every plane");
1286                                assert!(
1287                                    cal <= plane + 1e-12,
1288                                    "{cal} above {plane} at {}",
1289                                    m.roll_rad
1290                                );
1291                            }
1292                            let scanned = planes
1293                                .iter()
1294                                .filter_map(|m| m.margin_cal)
1295                                .fold(f64::INFINITY, f64::min);
1296                            assert!(scanned - cal < 1e-5, "{cal} against the scan's {scanned}");
1297                        }
1298                    }
1299                }
1300            }
1301        }
1302        assert!(off_fins > 0, "no weak plane off the fins' planes");
1303        // With the pair the larger, the axial plane, where it works whole, is the strong one: the
1304        // reported margin is well below it.
1305        let aero = uneven_fins(0.03, 0.06, right);
1306        let axial = margin(&aero, &Flow::axial(0.3), 0.45)
1307            .unwrap()
1308            .margin_cal
1309            .unwrap();
1310        let least = weakest_margin(&aero, 0.3, 0.45)
1311            .unwrap()
1312            .margin_cal
1313            .unwrap();
1314        assert!(least < axial - 0.5, "{least} against {axial}");
1315        // Even fins: the axial margin, bit for bit.
1316        for name in [
1317            "synthetic-54mm-three-fin",
1318            "rocketpy-valetudo",
1319            "rocketpy-juno-iii",
1320        ] {
1321            let aero = AeroModel::new(&design(name).layout().unwrap()).unwrap();
1322            assert!(!aero.rolls(), "{name}");
1323            for mach in [0.0, 0.3, 1.2] {
1324                assert_eq!(
1325                    weakest_margin(&aero, mach, 1.0).unwrap(),
1326                    margin(&aero, &Flow::axial(mach), 1.0).unwrap(),
1327                    "{name}"
1328                );
1329            }
1330        }
1331    }
1332
1333    #[test]
1334    fn ordinary_rockets_keep_their_margin() {
1335        // The other side of the limit: every design in `validation/designs/`, at Mach 0 to 2 and
1336        // angles of attack of 0° to 20° (the points below), is far from it. Its slopes nearly all push one way.
1337        let mut worst: f64 = 0.0;
1338        for name in [
1339            "synthetic-54mm-three-fin",
1340            "rocketpy-valetudo",
1341            "rocketpy-calisto-tests-motor-at-minus-1.373",
1342            "rocketpy-ndrt-2020-nose-to-tail",
1343            "rocketpy-prometheus-2022-generic-motor",
1344            "rocketpy-juno-iii",
1345            "synthetic-two-stage-75mm-54mm",
1346            "mil-hdbk-762-sample-rocket",
1347            "rocketpy-bella-lui",
1348            "rocketpy-calisto-getting-started-motor-at-minus-1.255",
1349            "rocketpy-cavour",
1350            "wind-tunnel-arcas-robin-long",
1351            "wind-tunnel-arcas-robin-short",
1352        ] {
1353            let aero = AeroModel::new(&design(name).layout().unwrap()).unwrap();
1354            for mach in [0.0, 0.3, 0.8, 1.2, 2.0] {
1355                for alpha_deg in [0.0, 5.0, 10.0, 20.0] {
1356                    let flow = Flow::new(mach, f64::to_radians(alpha_deg), 0.0);
1357                    let m = margin(&aero, &flow, 0.0).unwrap();
1358                    assert!(m.margin_cal.is_some(), "{name} at {mach}, {alpha_deg}°");
1359                    worst = worst.max(m.slope_magnitude_sum_per_rad / m.normal_force_slope_per_rad);
1360                }
1361            }
1362        }
1363        // 1.35 at worst.
1364        assert!(worst < 1.5, "{worst}");
1365    }
1366
1367    #[test]
1368    fn a_pure_couple_keeps_its_moment() {
1369        // A nose (slope 2 at 0.2 m), then a step down from R = 0.05 m to 0.03 m at 0.8 m and a
1370        // conical flare back to 0.05 m over 0.2 m: the step's −2(1 − 0.36) = −1.28 at 0.8 m and
1371        // the flare's +1.28 at 0.8 + (0.2/3)(1 + 1/1.6) m cancel, leaving that component a pure
1372        // couple. It still turns the rocket.
1373        let material = json!({"name": "test", "density": {"kind": "bulk", "kg_m3": 1000.0}});
1374        let tube = |length_m: f64| {
1375            json!({"body_tube": {"length_m": length_m, "outer_radius_m": 0.05,
1376                "thickness_m": 0.005, "material": material}})
1377        };
1378        let rocket: Rocket = serde_json::from_value(json!({
1379            "name": "",
1380            "stages": [{"id": "stage", "name": "", "components": [
1381                {"id": "nose", "name": "", "part": {"nose_cone": {
1382                    "shape": {"kind": "conical"}, "length_m": 0.3, "base_radius_m": 0.05,
1383                    "wall": {"kind": "filled"}, "shoulder": null, "material": material}}},
1384                {"id": "tube", "name": "", "part": tube(0.5)},
1385                {"id": "flare", "name": "", "part": {"transition": {
1386                    "shape": {"kind": "conical"}, "length_m": 0.2, "fore_radius_m": 0.03,
1387                    "aft_radius_m": 0.05, "wall": {"kind": "filled"}, "material": material}}},
1388                {"id": "tail", "name": "", "part": tube(0.3)}
1389            ]}],
1390            "reference_diameter": {"kind": "maximum"},
1391            "configurations": []
1392        }))
1393        .unwrap();
1394        let aero = AeroModel::new(&rocket.layout().unwrap()).unwrap();
1395        let cg_m = 0.6;
1396        let couple = -1.28 * 0.8 + 1.28 * (0.8 + 0.2 / 3.0 * (1.0 + 1.0 / 1.6));
1397        let m = margin(&aero, &Flow::axial(0.0), cg_m).unwrap();
1398        close(m.normal_force_slope_per_rad, 2.0, 1e-12, "net slope");
1399        let cp = (2.0 * 0.2 + couple) / 2.0;
1400        close(m.margin_cal.unwrap(), (cp - cg_m) / 0.1, 1e-9, "margin");
1401        let moment = -(2.0 * 0.2 + couple - 2.0 * cg_m) / 0.1;
1402        close(m.pitch_moment_slope_per_rad, moment, 1e-9, "C_mα");
1403        close(
1404            m.pitch_moment_slope_per_rad,
1405            -2.0 * m.margin_cal.unwrap(),
1406            1e-12,
1407            "−C_Nα · margin",
1408        );
1409    }
1410
1411    #[test]
1412    fn a_normal_force_table_gives_its_own_margin() {
1413        // A table of one column: C_Nα = 10 on the rocket's reference area, at 1.2 m at every Mach
1414        // number. The margin is the table's, and κ is 1.
1415        use hpr_aero::{NormalForceColumn, NormalForceTable};
1416        use hpr_core::interp::{Extrapolation, Interpolation, Table1D};
1417        let constant = |y: f64| {
1418            Table1D::new(
1419                vec![0.0, 2.0],
1420                vec![y, y],
1421                Interpolation::Linear,
1422                Extrapolation::Clamp,
1423            )
1424            .unwrap()
1425        };
1426        let table = NormalForceTable::new(vec![NormalForceColumn::new(
1427            0.0,
1428            constant(10.0),
1429            constant(1.2),
1430        )])
1431        .unwrap();
1432        let sim = valetudo(Environment::standard(site()).unwrap(), capped(60.0))
1433            .with_normal_force_table(table)
1434            .unwrap();
1435        let d = sim.aero().reference_diameter_m();
1436        let m = margin(sim.aero(), &Flow::axial(0.3), 0.8).unwrap();
1437        close(m.normal_force_slope_per_rad, 10.0, 1e-12, "slope");
1438        assert_eq!(m.slope_magnitude_sum_per_rad, m.normal_force_slope_per_rad);
1439        close(m.margin_cal.unwrap(), (1.2 - 0.8) / d, 1e-12, "margin");
1440        close(
1441            m.pitch_moment_slope_per_rad,
1442            -10.0 * (1.2 - 0.8) / d,
1443            1e-12,
1444            "C_mα",
1445        );
1446    }
1447
1448    #[test]
1449    fn peak_acceleration_is_analytic_and_excludes_opening_shock() {
1450        // L34, first half: a vertical boost in a vacuum under uniform gravity, with no rotation.
1451        // The nose tip's acceleration is (T − m r″ − 2ṁ r′ + m̈(n − r))/m − g along the axis, from
1452        // the motor and the assembly directly (as the powered-climb test integrates it). Its
1453        // largest value on a fine scan, and at every thrust-curve knot from both sides, is the
1454        // peak.
1455        let sim = valetudo(analytic_environment(UniformAir::vacuum(), G), capped(4.0));
1456        let (_, metrics) = fly(&sim);
1457        let assembly = sim.assembly();
1458        let placed = &assembly.motors[0];
1459        let motor = &placed.mounted.motor;
1460        let burnout_s = motor.burnout_time_s();
1461        let h = 1e-5;
1462        let mut knots: Vec<f64> = motor
1463            .curve()
1464            .times_s()
1465            .iter()
1466            .copied()
1467            .filter(|t| *t > 0.0 && *t < burnout_s)
1468            .collect();
1469        knots.insert(0, 0.0);
1470        knots.push(burnout_s);
1471        let props = |t: f64| assembly.mass_properties(t);
1472        // On the knot interval [a, b], derivatives taken inside it and extended to t.
1473        let by_hand = |t: f64, a: f64, b: f64| {
1474            let c = t.clamp(a + h, b - h);
1475            let (m, r) = (props(t).mass_kg, props(t).cg_m.z);
1476            let r_mid = props(c).cg_m.z;
1477            let r2 = (props(c + h).cg_m.z - 2.0 * r_mid + props(c - h).cg_m.z) / (h * h);
1478            let r1 = (props(c + h).cg_m.z - props(c - h).cg_m.z) / (2.0 * h) + (t - c) * r2;
1479            let mdot = -motor.state(t).mass_flow_kg_s;
1480            let mddot = -(motor.state(c + h).mass_flow_kg_s - motor.state(c - h).mass_flow_kg_s)
1481                / (2.0 * h);
1482            let thrust = motor.thrust_at_pressure_n(t, 0.0);
1483            (thrust - m * r2 - 2.0 * mdot * r1 + mddot * (placed.nozzle_m.z - r)) / m - G
1484        };
1485        let mut expected: f64 = 0.0;
1486        for pair in knots.windows(2) {
1487            let (a, b) = (pair[0], pair[1]);
1488            let n = 2000;
1489            for i in 0..=n {
1490                let t = a + (b - a) * f64::from(i) / f64::from(n);
1491                expected = expected.max(by_hand(t, a, b));
1492            }
1493        }
1494        let peak = metrics.max_acceleration.unwrap();
1495        close(peak.value, expected, 1e-6, "peak acceleration");
1496        assert!(peak.time_s < burnout_s);
1497        // What Loft did: a finite difference of the speed recorded at 100 Hz, which averages the
1498        // acceleration over each interval and reads the thrust spike low.
1499        let mut recorder =
1500            Recorder::new(vec![Channel::Time, Channel::Velocity], Some(0.01)).unwrap();
1501        sim.run(&mut recorder).unwrap();
1502        let rows = recorder.rows();
1503        let differenced = rows
1504            .windows(2)
1505            .map(|w| (w[1][3] - w[0][3]) / (w[1][0] - w[0][0]))
1506            .fold(0.0, f64::max);
1507        // 1.3% low on this motor's first ramp.
1508        assert!(
1509            differenced < 0.99 * peak.value,
1510            "{differenced} vs {}",
1511            peak.value
1512        );
1513
1514        // Second half: the same boost in air, with a large canopy opening at once while the rocket
1515        // still climbs fast. The boost's peak is the flight's without the canopy, and the opening
1516        // shock, several times larger, is kept apart.
1517        let air = || analytic_environment(UniformAir::sea_level(), G);
1518        let (_, plain) = fly(&valetudo(air(), capped(60.0)));
1519        let canopy = Device::new(
1520            "main",
1521            DeviceDrag::DragArea { cd_s_m2: 4.0 },
1522            Trigger::Time { time_s: 4.0 },
1523        );
1524        let early = valetudo(air(), capped(60.0))
1525            .with_recovery(vec![canopy])
1526            .unwrap();
1527        let (result, shocked) = fly(&early);
1528        let deployed = result.event(EventKind::Deployment(0)).unwrap().sample;
1529        assert!(deployed.airspeed_m_s > 100.0, "{}", deployed.airspeed_m_s);
1530        let boost = shocked.max_acceleration.unwrap();
1531        close(
1532            boost.value,
1533            plain.max_acceleration.unwrap().value,
1534            1e-12,
1535            "boost peak",
1536        );
1537        let shock = shocked.max_descent_acceleration.unwrap();
1538        assert!(
1539            shock.value > 3.0 * boost.value,
1540            "{} vs {}",
1541            shock.value,
1542            boost.value
1543        );
1544        assert_eq!(shock.time_s, deployed.time_s);
1545        assert_eq!(plain.max_descent_acceleration, None);
1546    }
1547
1548    #[test]
1549    fn unlanded_flight_has_no_ground_hit_speed_and_outputs_name_datum() {
1550        // L35: a flight stopped by its time cap at 5 s has no landing, no ground-hit speed and no
1551        // apogee, and says so with `None`, not a zero.
1552        let environment = || Environment::standard(site()).unwrap();
1553        let sim = valetudo(environment(), capped(5.0));
1554        let (result, metrics) = fly(&sim);
1555        assert_eq!(result.termination, Termination::TimeCap);
1556        let summary = metrics.summary(&result, sim.environment()).unwrap();
1557        assert_eq!(summary.landing, None);
1558        assert_eq!(summary.ground_hit_speed_m_s(), None);
1559        assert_eq!(summary.apogee, None);
1560        assert_eq!(summary.max_descent_acceleration_m_s2, None);
1561        let json = serde_json::to_value(&summary).unwrap();
1562        assert_eq!(json["landing"], serde_json::Value::Null);
1563        assert_eq!(json["apogee"], serde_json::Value::Null);
1564
1565        // The whole flight: the heights name their datum. The center of mass starts above the
1566        // site, the apogee's gain counts from there, and the landing is on the site's ellipsoidal
1567        // height, where the flight stops.
1568        let sim = valetudo(environment(), capped(600.0));
1569        let (result, metrics) = fly(&sim);
1570        let summary = metrics.summary(&result, sim.environment()).unwrap();
1571        // On a vertical rail the nose tip starts at the aft guide's station above the site, and
1572        // the center of mass that far less its own station.
1573        let start = summary.launch_height_m.unwrap();
1574        let cg_station_m = -sim.assembly().mass_properties(0.0).cg_m.z;
1575        close(
1576            start,
1577            sim.guides().aft_station_m - cg_station_m,
1578            1e-9,
1579            "start",
1580        );
1581        let apogee = summary.apogee.unwrap();
1582        assert_eq!(apogee.gain_m, Some(apogee.height_above_ground_m - start));
1583        let landing = summary.landing.unwrap();
1584        assert_eq!(
1585            summary.ground_hit_speed_m_s(),
1586            Some(landing.ground_hit_speed_m_s)
1587        );
1588        assert!(result.final_sample.height_above_ground_m.abs() < 1e-6);
1589        let json = serde_json::to_value(&summary).unwrap();
1590        for field in ["launch_height_m", "apogee"] {
1591            assert!(
1592                json[field].is_number() || json[field].is_object(),
1593                "{field}"
1594            );
1595        }
1596        assert!(json["apogee"]["height_above_ground_m"].is_number());
1597        assert!(json["apogee"]["gain_m"].is_number());
1598    }
1599
1600    #[test]
1601    fn optimum_delay_independent_of_flown_delay() {
1602        // L94: a delay of 1 s opens the canopy while the rocket climbs; one of 20 s opens it long
1603        // after apogee. The optimum is the same for both, and is the time from burnout to the
1604        // apogee of a flight with no recovery at all.
1605        let environment = || Environment::standard(site()).unwrap();
1606        let flown = |delay_s: f64| {
1607            valetudo(environment(), capped(600.0))
1608                .with_recovery(vec![Device::new(
1609                    "main",
1610                    DeviceDrag::DragArea { cd_s_m2: 1.0 },
1611                    Trigger::Burnout { motor: 0, delay_s },
1612                )])
1613                .unwrap()
1614        };
1615        let (early, late) = (flown(1.0), flown(20.0));
1616        let bare = valetudo(environment(), capped(600.0));
1617        let bare_apogee_s = bare
1618            .run(&mut ())
1619            .unwrap()
1620            .event(EventKind::Apogee)
1621            .unwrap()
1622            .sample
1623            .time_s;
1624        let early_result = early.run(&mut ()).unwrap();
1625        let opened_s = early_result
1626            .event(EventKind::Deployment(0))
1627            .unwrap()
1628            .sample
1629            .time_s;
1630        assert!(
1631            opened_s < bare_apogee_s - 5.0,
1632            "{opened_s} vs {bare_apogee_s}"
1633        );
1634
1635        let a = optimum_delays(&early).unwrap().unwrap();
1636        let b = optimum_delays(&late).unwrap().unwrap();
1637        assert_eq!(a, b);
1638        assert_eq!(a.len(), 1);
1639        let burnout_s = bare.assembly().motors[0].mounted.motor.burnout_time_s();
1640        assert_eq!(a[0].burnout_s, burnout_s);
1641        close(a[0].apogee_s, bare_apogee_s, 1e-9, "apogee");
1642        close(a[0].delay_s, bare_apogee_s - burnout_s, 1e-9, "delay");
1643        // It doesn't touch the simulation it was given.
1644        assert_eq!(early.run(&mut ()).unwrap(), early_result);
1645    }
1646
1647    #[test]
1648    fn peaks_are_refined_inside_steps() {
1649        // Max q and max Mach come between the integrator's steps, some in a step's outer quarter,
1650        // where its middle sample is below an end. On each rocket every peak is at least every
1651        // sample of a millisecond record, and within the record's spacing of the best.
1652        for (name, configuration) in [
1653            ("rocketpy-valetudo", "example"),
1654            ("rocketpy-juno-iii", "example"),
1655            ("rocketpy-calisto-tests-motor-at-minus-1.373", "example"),
1656        ] {
1657            let sim = Simulation::new(
1658                &design(name),
1659                configuration,
1660                Environment::standard(site()).unwrap(),
1661                Rail::vertical(5.0),
1662                capped(20.0),
1663            )
1664            .unwrap();
1665            let mut recorder = Recorder::new(
1666                vec![Channel::Time, Channel::DynamicPressure, Channel::Mach],
1667                Some(1e-3),
1668            )
1669            .unwrap();
1670            sim.run(&mut recorder).unwrap();
1671            let (result, metrics) = fly(&sim);
1672            let summary = metrics.summary(&result, sim.environment()).unwrap();
1673            for (column, peak) in [
1674                (1, summary.max_dynamic_pressure_pa.unwrap()),
1675                (2, summary.max_mach.unwrap()),
1676            ] {
1677                let recorded = recorder
1678                    .rows()
1679                    .iter()
1680                    .map(|row| row[column])
1681                    .fold(0.0, f64::max);
1682                assert!(
1683                    peak.value >= recorded,
1684                    "{name}: {} < {recorded}",
1685                    peak.value
1686                );
1687                assert!(
1688                    peak.value - recorded < 1e-4 * peak.value,
1689                    "{name}: {}",
1690                    peak.value
1691                );
1692            }
1693            if name == "rocketpy-valetudo" {
1694                // At the speed's peak the acceleration is zero, so q = ½ρv² falls there with the
1695                // density (dq/dt = ½v² dρ/dt < 0) and peaked earlier; the Mach number still rises
1696                // with the falling speed of sound, and peaks later.
1697                let (q, mach) = (
1698                    summary.max_dynamic_pressure_pa.unwrap(),
1699                    summary.max_mach.unwrap(),
1700                );
1701                let speed = summary.max_speed_m_s.unwrap();
1702                assert!(q.time_s < speed.time_s, "{} vs {}", q.time_s, speed.time_s);
1703                assert!(
1704                    speed.time_s < mach.time_s,
1705                    "{} vs {}",
1706                    speed.time_s,
1707                    mach.time_s
1708                );
1709                assert!(q.height_above_ground_m > 0.0);
1710            }
1711        }
1712    }
1713
1714    #[test]
1715    fn landings_are_placed_on_the_ellipsoid() {
1716        // A 6 m/s wind from the west carries the rocket east. Over a few hundred meters the
1717        // landing's latitude and longitude are the site's plus the local displacement over the
1718        // radii of curvature M and N at the site's height, to second order in distance/radius.
1719        let wind = ConstantWind::new(6.0, 1.5 * std::f64::consts::PI).unwrap();
1720        let canopy = Device::new(
1721            "main",
1722            DeviceDrag::DragArea { cd_s_m2: 0.5 },
1723            Trigger::Apogee,
1724        );
1725        let sim = Simulation::new(
1726            &design("rocketpy-valetudo"),
1727            "example",
1728            windy_environment(wind),
1729            Rail::vertical(3.0),
1730            capped(600.0),
1731        )
1732        .unwrap()
1733        .with_recovery(vec![canopy])
1734        .unwrap();
1735        let (result, metrics) = fly(&sim);
1736        let summary = metrics.summary(&result, sim.environment()).unwrap();
1737        let landing = summary.landing.unwrap();
1738        assert!(landing.east_m > 100.0, "{}", landing.east_m);
1739        let place = site();
1740        let (a, f) = (6_378_137.0, 1.0 / 298.257_223_563);
1741        let e2 = f * (2.0 - f);
1742        let s = place.latitude_rad.sin();
1743        let w = (1.0 - e2 * s * s).sqrt();
1744        let n = a / w + place.height_m;
1745        let m = a * (1.0 - e2) / (w * w * w) + place.height_m;
1746        let lat = place.latitude_rad + landing.north_m / m;
1747        let lon = place.longitude_rad + landing.east_m / (n * place.latitude_rad.cos());
1748        let second_order = (landing.distance_m / a).powi(2);
1749        assert!((landing.latitude_deg.to_radians() - lat).abs() < 10.0 * second_order);
1750        assert!((landing.longitude_deg.to_radians() - lon).abs() < 10.0 * second_order);
1751        close(
1752            landing.distance_m,
1753            landing.east_m.hypot(landing.north_m),
1754            1e-15,
1755            "distance",
1756        );
1757        assert_eq!(landing.body, None);
1758        assert!(landing.descent_rate_m_s > 0.0);
1759    }
1760
1761    #[test]
1762    fn stability_is_kept_from_rail_exit_to_apogee() {
1763        let sim = valetudo(Environment::standard(site()).unwrap(), capped(600.0));
1764        let (result, metrics) = fly(&sim);
1765        let summary = metrics.summary(&result, sim.environment()).unwrap();
1766        let series = metrics.stability();
1767        let exit_s = result.event(EventKind::RailExit).unwrap().sample.time_s;
1768        let apogee_s = summary.apogee.unwrap().time_s;
1769        assert_eq!(series.first().unwrap().time_s, exit_s);
1770        assert_eq!(series.last().unwrap().time_s, apogee_s);
1771        assert!(series.windows(2).all(|w| w[0].time_s < w[1].time_s));
1772        // The static margin at the rail exit: the center of pressure at Mach 0 against the center
1773        // of mass then, from the assembly and the aerodynamic model directly.
1774        let first = series[0];
1775        let lit = sim.assembly().ignition_times_s(|_| None);
1776        let cg_m = -sim.assembly().mass_properties_lit(exit_s, &lit).cg_m.z;
1777        close(first.cg_station_m, cg_m, 1e-12, "center of mass");
1778        let cp_m = sim
1779            .aero()
1780            .normal_force(&Flow::axial(0.0))
1781            .unwrap()
1782            .cp_station_m
1783            .unwrap();
1784        let d = sim.aero().reference_diameter_m();
1785        close(
1786            first.static_margin.margin_cal.unwrap(),
1787            (cp_m - cg_m) / d,
1788            1e-12,
1789            "static margin",
1790        );
1791        assert_eq!(summary.rail_exit_stability, Some(first));
1792        let min = summary.min_static_margin_cal.unwrap();
1793        assert!(
1794            series
1795                .iter()
1796                .all(|s| s.static_margin.margin_cal.unwrap() >= min.value)
1797        );
1798        // The least margins are found inside steps, so they are at or below every entry.
1799        let least = summary.min_flight_margin_cal.unwrap();
1800        assert!(
1801            series
1802                .iter()
1803                .all(|s| s.flight_margin.margin_cal.unwrap() >= least.value)
1804        );
1805        assert!(least.value > 3.0, "{least:?}");
1806
1807        // The flight margin is the model's at the flight's own Mach number with the air along the
1808        // axis, whatever the angle of attack: at the rail exit in a crosswind the rocket meets the
1809        // air more than 0.2 rad off its axis, and the margin leaves that out.
1810        let windy = valetudo(
1811            windy_environment(ConstantWind::new(5.0, 1.5 * std::f64::consts::PI).unwrap()),
1812            capped(600.0),
1813        );
1814        let (result, metrics) = fly(&windy);
1815        let summary = metrics.summary(&result, windy.environment()).unwrap();
1816        let exit = result.event(EventKind::RailExit).unwrap().sample;
1817        assert!(
1818            exit.angle_of_attack_rad > 0.2,
1819            "{}",
1820            exit.angle_of_attack_rad
1821        );
1822        let cg_m = -windy
1823            .assembly()
1824            .mass_properties_lit(exit.time_s, &lit)
1825            .cg_m
1826            .z;
1827        let expected = margin(windy.aero(), &Flow::axial(exit.mach), cg_m).unwrap();
1828        let got = summary.rail_exit_stability.unwrap().flight_margin;
1829        assert_eq!(got.angle_of_attack_rad, 0.0);
1830        close(got.mach, exit.mach, 1e-12, "Mach number");
1831        close(
1832            got.margin_cal.unwrap(),
1833            expected.margin_cal.unwrap(),
1834            1e-12,
1835            "flight margin",
1836        );
1837    }
1838
1839    #[test]
1840    fn least_margins_do_not_depend_on_where_steps_end() {
1841        // The same flight flown in steps of at most 1 ms gives the same least margins, to a
1842        // millionth of a calibre: Valetudo in calm air off a vertical and an 84° rail and in a
1843        // crosswind, and Prometheus. On all four both leasts are at the rail exit, where the rocket
1844        // is heaviest and its center of mass furthest aft: the search inside steps is checked on a
1845        // two-stage flight (`staging::tests::metrics_follow_a_powered_separation`) instead.
1846        let fine = |settings: FlightSettings| FlightSettings {
1847            method: Method::DormandPrince54(Adaptive {
1848                max_step_s: Some(1e-3),
1849                ..Adaptive::default()
1850            }),
1851            ..settings
1852        };
1853        let tilted = Rail {
1854            elevation_rad: 84.0_f64.to_radians(),
1855            ..Rail::vertical(2.0)
1856        };
1857        let calm = || Environment::standard(site()).unwrap();
1858        let wind =
1859            || windy_environment(ConstantWind::new(5.0, 1.5 * std::f64::consts::PI).unwrap());
1860        let cases = [
1861            ("rocketpy-valetudo", calm(), Rail::vertical(3.0), 15.0),
1862            ("rocketpy-valetudo", calm(), tilted, 15.0),
1863            ("rocketpy-valetudo", wind(), Rail::vertical(3.0), 15.0),
1864            (
1865                "rocketpy-prometheus-2022-generic-motor",
1866                calm(),
1867                Rail::vertical(5.0),
1868                60.0,
1869            ),
1870        ];
1871        for (name, environment, rail, max_time_s) in cases {
1872            let least = |settings: FlightSettings| {
1873                let sim = Simulation::new(
1874                    &design(name),
1875                    "example",
1876                    environment.clone(),
1877                    rail,
1878                    settings,
1879                )
1880                .unwrap();
1881                let (result, metrics) = fly(&sim);
1882                let summary = metrics.summary(&result, sim.environment()).unwrap();
1883                let series = metrics.stability().to_vec();
1884                (
1885                    summary.min_static_margin_cal.unwrap(),
1886                    summary.min_flight_margin_cal.unwrap(),
1887                    series,
1888                )
1889            };
1890            let (coarse_static, coarse_flight, series) = least(capped(max_time_s));
1891            assert_eq!(coarse_flight.time_s, series[0].time_s, "{name}");
1892            assert_eq!(coarse_static.time_s, series[0].time_s, "{name}");
1893            let (fine_static, fine_flight, _) = least(fine(capped(max_time_s)));
1894            for (coarse, fine, what) in [
1895                (coarse_static, fine_static, "static"),
1896                (coarse_flight, fine_flight, "flight"),
1897            ] {
1898                assert!(
1899                    (coarse.value - fine.value).abs() < 1e-6,
1900                    "{name} {what}: {coarse:?} against {fine:?}"
1901                );
1902            }
1903        }
1904    }
1905
1906    /// A step whose only use is its margins: both are `margin_cal(t)`.
1907    struct MarginStep {
1908        margin_cal: fn(f64) -> Option<f64>,
1909    }
1910
1911    impl FlightStep for MarginStep {
1912        fn phase(&self) -> Phase {
1913            Phase::Free
1914        }
1915        fn start_s(&self) -> f64 {
1916            0.0
1917        }
1918        fn end_s(&self) -> f64 {
1919            1.0
1920        }
1921        fn state_at(&self, _t_s: f64) -> crate::state::State {
1922            unreachable!("the margin search reads only the stability")
1923        }
1924        fn sample(&self, _t_s: f64) -> Result<Sample, SimError> {
1925            unreachable!("the margin search reads only the stability")
1926        }
1927        fn stability(&self, t_s: f64) -> Result<Stability, SimError> {
1928            let margin_cal = (self.margin_cal)(t_s);
1929            let margin = Margin {
1930                mach: 0.0,
1931                angle_of_attack_rad: 0.0,
1932                roll_rad: 0.0,
1933                normal_force_slope_per_rad: 1.0,
1934                slope_magnitude_sum_per_rad: 1.0,
1935                pitch_moment_slope_per_rad: -margin_cal.unwrap_or(0.0),
1936                cp_station_m: margin_cal,
1937                margin_cal,
1938            };
1939            Ok(Stability {
1940                time_s: t_s,
1941                height_above_ground_m: 100.0 * t_s,
1942                dynamic_pressure_pa: 0.0,
1943                cg_station_m: 0.0,
1944                reference_diameter_m: 1.0,
1945                static_margin: margin,
1946                flight_margin: margin,
1947            })
1948        }
1949    }
1950
1951    fn least_of(margin_cal: fn(f64) -> Option<f64>) -> Option<Peak> {
1952        let step = MarginStep { margin_cal };
1953        let entries = [0.0, 0.5, 1.0].map(|t| step.stability(t).unwrap());
1954        least_margin(&step, 0.0, 1.0, &entries, flight_of).unwrap()
1955    }
1956
1957    #[test]
1958    fn a_least_margin_between_step_ends_is_found() {
1959        // 2 + (t − 0.37)² reads 2.137, 2.017 and 2.397 at the step's start, middle and end; the
1960        // parabola through them has its bottom inside, and the search finds 2 at 0.37 s.
1961        let least = least_of(|t| Some(2.0 + (t - 0.37).powi(2))).unwrap();
1962        close(least.value, 2.0, 1e-15, "least");
1963        assert!((least.time_s - 0.37).abs() < 1e-7, "{least:?}");
1964        close(least.height_above_ground_m, 37.0, 1e-5, "height");
1965        // Falling across the step, it is least at the end, with no search.
1966        let least = least_of(|t| Some(3.0 - t)).unwrap();
1967        assert_eq!((least.value, least.time_s), (2.0, 1.0));
1968        // Undefined at the middle: the least of the defined ends, and no search through the gap.
1969        let least = least_of(|t| (t != 0.5).then_some(2.0 + (t - 0.37).powi(2))).unwrap();
1970        assert_eq!(least.time_s, 0.0);
1971        assert_eq!(least_of(|_| None), None);
1972    }
1973
1974    #[test]
1975    fn a_summary_needs_the_flight_it_watched() {
1976        // A watcher that saw nothing, or saw another flight too, refuses to sum one up; cleared, it
1977        // sums up the next. A flight started in the air has no launch height, and no climb.
1978        // The refusal names the steps the watcher saw.
1979        let refused = |summary: Result<FlightSummary, SimError>, steps: u64| match summary {
1980            Err(SimError::Domain { what, value }) => {
1981                assert!(what.starts_with("steps this watcher saw"), "{what}");
1982                assert_eq!(value, steps as f64);
1983            }
1984            other => panic!("{other:?}"),
1985        };
1986        let sim = valetudo(Environment::standard(site()).unwrap(), capped(20.0));
1987        let (result, mut metrics) = fly(&sim);
1988        let steps = result.stats.accepted_steps;
1989        refused(FlightMetrics::new().summary(&result, sim.environment()), 0);
1990        // The same steps, but a flight that ended before the watcher's last step did.
1991        let mut early = result.clone();
1992        early.final_sample.time_s -= 1.0;
1993        refused(metrics.summary(&early, sim.environment()), steps);
1994        let other = valetudo(Environment::standard(site()).unwrap(), capped(15.0));
1995        let other_result = other.run(&mut metrics).unwrap();
1996        refused(
1997            metrics.summary(&other_result, other.environment()),
1998            steps + other_result.stats.accepted_steps,
1999        );
2000        metrics.clear();
2001        let other_result = other.run(&mut metrics).unwrap();
2002        let summary = metrics.summary(&other_result, other.environment()).unwrap();
2003        assert!(summary.rail_exit_stability.is_some());
2004
2005        let burnout = result.event(EventKind::Burnout).unwrap().sample;
2006        let mut aloft = FlightMetrics::new();
2007        let free = sim
2008            .run_free(burnout.time_s, burnout.state, &mut aloft)
2009            .unwrap();
2010        let summary = aloft.summary(&free, sim.environment()).unwrap();
2011        assert_eq!(summary.launch_height_m, None);
2012        assert_eq!(summary.apogee.unwrap().gain_m, None);
2013        assert_eq!(summary.rail_exit_stability, None);
2014
2015        // Reused forward in time, not cleared: the first flight's steps are still counted.
2016        let short = valetudo(Environment::standard(site()).unwrap(), capped(5.0));
2017        let (short_result, mut reused) = fly(&short);
2018        let apogee = result.event(EventKind::Apogee).unwrap().sample;
2019        let free = sim
2020            .run_free(apogee.time_s, apogee.state, &mut reused)
2021            .unwrap();
2022        refused(
2023            reused.summary(&free, sim.environment()),
2024            short_result.stats.accepted_steps + free.stats.accepted_steps,
2025        );
2026
2027        // Begun in the air on its way down, it keeps no stability: its apogee is behind it.
2028        let mut falling = FlightMetrics::new();
2029        let mut state = apogee.state;
2030        state.velocity_enu_m_s.z = -1.0;
2031        let free = sim.run_free(apogee.time_s, state, &mut falling).unwrap();
2032        falling.summary(&free, sim.environment()).unwrap();
2033        assert!(falling.stability().is_empty());
2034
2035        // A flight can end without a step, or on a stop so close ahead that the clock just moves
2036        // there; it is still summed up.
2037        let stepless = valetudo(
2038            Environment::standard(site()).unwrap(),
2039            FlightSettings {
2040                step_limit: 0,
2041                ..capped(20.0)
2042            },
2043        );
2044        let (result, metrics) = fly(&stepless);
2045        assert_eq!(result.stats.accepted_steps, 0);
2046        let summary = metrics.summary(&result, stepless.environment()).unwrap();
2047        assert_eq!(summary.termination, Termination::StepLimit);
2048        assert_eq!(summary.max_speed_m_s, None);
2049        let cap_s = f64::from_bits(5.0_f64.to_bits() + 3);
2050        let at_cap = valetudo(Environment::standard(site()).unwrap(), capped(cap_s))
2051            .with_recovery(vec![Device::new(
2052                "main",
2053                DeviceDrag::canopy(crate::recovery::CanopyType::FlatCircular, 1.0),
2054                Trigger::Time { time_s: 5.0 },
2055            )])
2056            .unwrap();
2057        let (result, metrics) = fly(&at_cap);
2058        assert_eq!(result.termination, Termination::TimeCap);
2059        assert_eq!(result.final_sample.time_s, cap_s);
2060        metrics.summary(&result, at_cap.environment()).unwrap();
2061    }
2062
2063    /// A free-flight step from `start_s` to `end_s` whose samples copy `template` but for the
2064    /// time, the dynamic pressure and the angle of attack, both functions of time.
2065    struct AngleStep {
2066        template: Sample,
2067        span: (f64, f64),
2068        phase: Phase,
2069        dynamic_pressure_pa: fn(f64) -> f64,
2070        angle_of_attack_rad: fn(f64) -> f64,
2071    }
2072
2073    impl FlightStep for AngleStep {
2074        fn phase(&self) -> Phase {
2075            self.phase
2076        }
2077        fn start_s(&self) -> f64 {
2078            self.span.0
2079        }
2080        fn end_s(&self) -> f64 {
2081            self.span.1
2082        }
2083        fn state_at(&self, _t_s: f64) -> crate::state::State {
2084            unreachable!("the watcher reads samples and stability")
2085        }
2086        fn sample(&self, t_s: f64) -> Result<Sample, SimError> {
2087            Ok(Sample {
2088                time_s: t_s,
2089                dynamic_pressure_pa: (self.dynamic_pressure_pa)(t_s),
2090                angle_of_attack_rad: (self.angle_of_attack_rad)(t_s),
2091                ..self.template
2092            })
2093        }
2094        fn stability(&self, t_s: f64) -> Result<Stability, SimError> {
2095            MarginStep {
2096                margin_cal: |_| Some(2.0),
2097            }
2098            .stability(t_s)
2099        }
2100    }
2101
2102    /// The largest counted angle of attack, rad, after watching free-flight steps that end at
2103    /// each of `ends` in turn (the first starting at 0 s), then an apogee and a step after it.
2104    fn largest_angle(
2105        ends: &[f64],
2106        dynamic_pressure_pa: fn(f64) -> f64,
2107        angle_of_attack_rad: fn(f64) -> f64,
2108    ) -> Option<Peak> {
2109        let sim = valetudo(Environment::standard(site()).unwrap(), capped(2.0));
2110        let template = fly(&sim).0.final_sample;
2111        let mut metrics = FlightMetrics::new();
2112        let mut start = 0.0;
2113        for &end in ends {
2114            let step = AngleStep {
2115                template,
2116                span: (start, end),
2117                phase: Phase::Free,
2118                dynamic_pressure_pa,
2119                angle_of_attack_rad,
2120            };
2121            metrics.step(&step).unwrap();
2122            start = end;
2123        }
2124        // Nothing after apogee counts, however steep.
2125        metrics.event(&FlightEvent {
2126            kind: EventKind::Apogee,
2127            sample: template,
2128        });
2129        let after = AngleStep {
2130            template,
2131            span: (start, start + 1.0),
2132            phase: Phase::Free,
2133            dynamic_pressure_pa: |_| 1e5,
2134            angle_of_attack_rad: |_| 3.0,
2135        };
2136        metrics.step(&after).unwrap();
2137        metrics.max_angle_of_attack
2138    }
2139
2140    #[test]
2141    fn the_angle_of_attack_counts_from_just_past_one_second_after_the_rail() {
2142        // The free flight starts at 0 s. 40° exactly 1 s after it is the transient and doesn't
2143        // count; 20° at 1.5 s, the next step's middle, does.
2144        let spike = |t: f64| if t == 1.0 { 40_f64 } else { 20.0 }.to_radians();
2145        let peak = largest_angle(&[0.5, 1.0, 2.0], |_| 1e3, spike).unwrap();
2146        assert_eq!((peak.value, peak.time_s), (20_f64.to_radians(), 1.5));
2147        // The same spike a step end later, at the next time after 1 s, counts.
2148        let spike = |t: f64| if t == 1.0_f64.next_up() { 40_f64 } else { 20.0 }.to_radians();
2149        let ends = [0.5, 1.0_f64.next_up(), 2.0];
2150        let peak = largest_angle(&ends, |_| 1e3, spike).unwrap();
2151        assert_eq!(
2152            (peak.value, peak.time_s),
2153            (40_f64.to_radians(), 1.0_f64.next_up())
2154        );
2155        // Nothing past 1 s: no peak.
2156        assert_eq!(largest_angle(&[0.5, 1.0], |_| 1e3, spike), None);
2157    }
2158
2159    #[test]
2160    fn the_angle_of_attack_counts_only_with_a_fifth_of_the_weight_in_normal_force() {
2161        // The step's normal-force slope is 1 on a diameter of 1 m: at 1000 Pa a 15° angle gives
2162        // 206 N, far past a fifth of the rocket's weight; at 1 mPa, far short of it. The edge
2163        // itself is `envelope::force_counts`'s, tested there.
2164        let angle = |t: f64| t / 10.0;
2165        let peak = largest_angle(&[1.0, 2.0, 3.0], |_| 1000.0, angle).unwrap();
2166        assert_eq!(peak.time_s, 3.0);
2167        let fading = |t: f64| if t > 2.0 { 1e-3 } else { 1000.0 };
2168        let peak = largest_angle(&[1.0, 2.0, 3.0], fading, angle).unwrap();
2169        assert_eq!(peak.time_s, 2.0);
2170        // A NaN angle that counts stays the largest, so it can't hide a flag.
2171        let broken = |t: f64| if t == 1.5 { f64::NAN } else { t / 10.0 };
2172        let peak = largest_angle(&[1.0, 2.0, 3.0], |_| 1000.0, broken).unwrap();
2173        assert!(peak.value.is_nan() && peak.time_s == 1.5, "{peak:?}");
2174    }
2175
2176    /// No wind below `height_msl_m`, and `speed_m_s` from the north above it.
2177    #[derive(Debug)]
2178    struct Layer {
2179        height_msl_m: f64,
2180        speed_m_s: f64,
2181    }
2182
2183    impl hpr_atmos::Wind for Layer {
2184        fn wind(&self, height_msl_m: f64) -> Result<hpr_atmos::WindSample, hpr_atmos::AtmosError> {
2185            let speed = if height_msl_m < self.height_msl_m {
2186                0.0
2187            } else {
2188                self.speed_m_s
2189            };
2190            Ok(hpr_atmos::WindSample {
2191                velocity_enu_m_s: hpr_atmos::wind::velocity_from_speed_direction(speed, 0.0),
2192                extrapolated: None,
2193            })
2194        }
2195    }
2196
2197    /// Every step end's angle of attack before apogee, with its time and the normal force a 15°
2198    /// angle would give then, as a share of the rocket's weight.
2199    #[derive(Default)]
2200    struct Angles {
2201        climbing: Vec<(f64, f64, f64)>,
2202        past_apogee: bool,
2203    }
2204
2205    impl Observer for Angles {
2206        fn step(&mut self, step: &dyn FlightStep) -> Result<(), SimError> {
2207            let sample = step.sample(step.end_s())?;
2208            if !self.past_apogee && step.phase() == Phase::Free {
2209                let stability = step.stability(step.end_s())?;
2210                let force_n = envelope::normal_force_n(
2211                    sample.dynamic_pressure_pa,
2212                    stability.reference_diameter_m,
2213                    stability.flight_margin.normal_force_slope_per_rad,
2214                    HIGH_ANGLE_OF_ATTACK_RAD,
2215                );
2216                let weight_n = sample.mass_kg * hpr_core::gravity::STANDARD_GRAVITY_MPS2;
2217                self.climbing.push((
2218                    sample.time_s,
2219                    sample.angle_of_attack_rad,
2220                    force_n / weight_n,
2221                ));
2222            }
2223            Ok(())
2224        }
2225        fn event(&mut self, event: &FlightEvent) {
2226            self.past_apogee |= event.kind == EventKind::Apogee;
2227        }
2228    }
2229
2230    #[test]
2231    fn a_tilted_calm_climb_passes_15_degrees_only_with_too_little_force() {
2232        // Off an 84° rail in calm air the path turns over near apogee faster than the slowing
2233        // rocket can follow, and its angle of attack passes 15° there, at about the rail exit's
2234        // airspeed; a 15° angle then gives a normal force of 1.0% to 1.5% of its weight, far short of
2235        // a fifth, so the flight raises no flag.
2236        let tilted = Rail {
2237            elevation_rad: 84.0_f64.to_radians(),
2238            ..Rail::vertical(3.0)
2239        };
2240        let sim = Simulation::new(
2241            &design("rocketpy-valetudo"),
2242            "example",
2243            Environment::standard(site()).unwrap(),
2244            tilted,
2245            FlightSettings::default(),
2246        )
2247        .unwrap();
2248        let mut watchers = (FlightMetrics::new(), Angles::default());
2249        let result = sim.run(&mut watchers).unwrap();
2250        let (metrics, angles) = watchers;
2251        let summary = metrics.summary(&result, sim.environment()).unwrap();
2252        let steep: Vec<_> = angles
2253            .climbing
2254            .iter()
2255            .filter(|(_, angle, _)| *angle > HIGH_ANGLE_OF_ATTACK_RAD)
2256            .collect();
2257        assert!(!steep.is_empty(), "the climb passes 15° before apogee");
2258        for (_, _, share) in &steep {
2259            assert!(*share < 0.15 * HIGH_ANGLE_MIN_FORCE_SHARE, "{steep:?}");
2260        }
2261        let counted = summary.max_angle_of_attack_rad.unwrap();
2262        assert!(counted.value < HIGH_ANGLE_OF_ATTACK_RAD, "{counted:?}");
2263        assert!(summary.envelope_flags().is_empty());
2264    }
2265
2266    #[test]
2267    fn a_wind_layer_met_at_speed_raises_the_high_angle_flag() {
2268        // Valetudo climbs into a 30 m/s wind 300 m up, fast, well after its first second: the
2269        // crosswind turns its angle of attack past 15° until it weathercocks.
2270        let environment = Environment::standard(site()).unwrap().with_wind(Layer {
2271            height_msl_m: site().height_m + 300.0,
2272            speed_m_s: 30.0,
2273        });
2274        let sim = valetudo(environment, FlightSettings::default());
2275        let (result, metrics) = fly(&sim);
2276        let summary = metrics.summary(&result, sim.environment()).unwrap();
2277        let flags = summary.envelope_flags();
2278        let exit = summary.rail_exit_speed_m_s.unwrap();
2279        let [
2280            EnvelopeFlag::HighAngleOfAttack {
2281                angle_of_attack_rad,
2282            },
2283        ] = flags[..]
2284        else {
2285            panic!("{flags:?}");
2286        };
2287        assert!(angle_of_attack_rad.value > HIGH_ANGLE_OF_ATTACK_RAD);
2288        assert!(angle_of_attack_rad.time_s > exit.time_s + HIGH_ANGLE_GRACE_S);
2289        assert!(
2290            (angle_of_attack_rad.height_above_ground_m - 300.0).abs() < 50.0,
2291            "{angle_of_attack_rad:?}"
2292        );
2293        // Calm, the same flight raises nothing.
2294        let calm = valetudo(
2295            Environment::standard(site()).unwrap(),
2296            FlightSettings::default(),
2297        );
2298        let (result, metrics) = fly(&calm);
2299        let summary = metrics.summary(&result, calm.environment()).unwrap();
2300        assert!(summary.envelope_flags().is_empty(), "{summary:?}");
2301    }
2302
2303    #[test]
2304    fn a_wind_layer_met_in_a_fast_coast_raises_the_high_angle_flag() {
2305        // The synthetic two-stage rocket, flown as one stack, burns out within 2 s and coasts at
2306        // several thousand pascals into a 40 m/s wind 2000 m up, long after its boost's peak:
2307        // the crosswind turns its angle of attack past 15° with a normal force larger than its
2308        // weight. A floor at a tenth of the boost's dynamic pressure missed it.
2309        let environment = Environment::standard(site()).unwrap().with_wind(Layer {
2310            height_msl_m: site().height_m + 2000.0,
2311            speed_m_s: 40.0,
2312        });
2313        let sim = Simulation::new(
2314            &design("synthetic-two-stage-75mm-54mm"),
2315            "j760-i175",
2316            environment,
2317            Rail::vertical(3.0),
2318            FlightSettings::default(),
2319        )
2320        .unwrap();
2321        let (result, metrics) = fly(&sim);
2322        let summary = metrics.summary(&result, sim.environment()).unwrap();
2323        let flags: Vec<_> = summary
2324            .envelope_flags()
2325            .iter()
2326            .map(EnvelopeFlag::name)
2327            .collect();
2328        assert_eq!(flags, ["beyond_validated_range", "high_angle_of_attack"]);
2329        let angle = summary.max_angle_of_attack_rad.unwrap();
2330        assert!(
2331            (angle.height_above_ground_m - 2000.0).abs() < 100.0,
2332            "{angle:?}"
2333        );
2334    }
2335
2336    /// A free-flight step from `span.0` to `span.1` whose samples copy `template` but for the
2337    /// time and the thrust, and whose margins are both `margin_cal(t)`, with their pitch-moment
2338    /// slopes `moment_slope_per_rad(t)` when given (else [`MarginStep`]'s).
2339    struct PoweredStep {
2340        template: Sample,
2341        span: (f64, f64),
2342        thrust_n: fn(f64) -> f64,
2343        margin_cal: fn(f64) -> Option<f64>,
2344        moment_slope_per_rad: Option<fn(f64) -> f64>,
2345    }
2346
2347    impl FlightStep for PoweredStep {
2348        fn phase(&self) -> Phase {
2349            Phase::Free
2350        }
2351        fn start_s(&self) -> f64 {
2352            self.span.0
2353        }
2354        fn end_s(&self) -> f64 {
2355            self.span.1
2356        }
2357        fn state_at(&self, _t_s: f64) -> crate::state::State {
2358            unreachable!("the watcher reads samples and stability")
2359        }
2360        fn sample(&self, t_s: f64) -> Result<Sample, SimError> {
2361            Ok(Sample {
2362                time_s: t_s,
2363                thrust_n: (self.thrust_n)(t_s),
2364                ..self.template
2365            })
2366        }
2367        fn stability(&self, t_s: f64) -> Result<Stability, SimError> {
2368            let mut stability = MarginStep {
2369                margin_cal: self.margin_cal,
2370            }
2371            .stability(t_s)?;
2372            if let Some(slope) = self.moment_slope_per_rad {
2373                stability.static_margin.pitch_moment_slope_per_rad = slope(t_s);
2374                stability.flight_margin.pitch_moment_slope_per_rad = slope(t_s);
2375            }
2376            Ok(stability)
2377        }
2378    }
2379
2380    /// The least static margin while a motor burns, and the least over the whole span, after
2381    /// watching free-flight steps from 0 s that end at 0.5, 1 and 2 s, with a motor that burns
2382    /// out at 1 s, a step's end.
2383    fn least_powered(margin_cal: fn(f64) -> Option<f64>) -> (Option<Peak>, Option<Peak>) {
2384        let metrics = watch_powered(margin_cal, None);
2385        (metrics.min_powered_static_margin, metrics.min_static_margin)
2386    }
2387
2388    /// The watcher after [`least_powered`]'s steps, with margins `margin_cal(t)` and, when given,
2389    /// pitch-moment slopes `moment_slope_per_rad(t)`.
2390    fn watch_powered(
2391        margin_cal: fn(f64) -> Option<f64>,
2392        moment_slope_per_rad: Option<fn(f64) -> f64>,
2393    ) -> FlightMetrics {
2394        let sim = valetudo(Environment::standard(site()).unwrap(), capped(2.0));
2395        let template = fly(&sim).0.final_sample;
2396        let mut metrics = FlightMetrics::new();
2397        let mut start = 0.0;
2398        for end in [0.5, 1.0, 2.0] {
2399            let step = PoweredStep {
2400                template,
2401                span: (start, end),
2402                thrust_n: |t| if t < 1.0 { 50.0 } else { 0.0 },
2403                margin_cal,
2404                moment_slope_per_rad,
2405            };
2406            metrics.step(&step).unwrap();
2407            start = end;
2408        }
2409        metrics
2410    }
2411
2412    #[test]
2413    fn the_powered_margin_counts_only_while_a_motor_burns() {
2414        let tiny = 2_f64.powi(-30);
2415        let names = |least: Option<Peak>| {
2416            envelope::flags(None, None, least, None)
2417                .iter()
2418                .map(EnvelopeFlag::name)
2419                .collect::<Vec<_>>()
2420        };
2421        // The margin falls by 1 calibre a second. From 1 − 2⁻³⁰ it is −2⁻³⁰ at the burnout:
2422        // unstable under power, however little.
2423        let (powered, least) = least_powered(|t| Some(1.0 - 2_f64.powi(-30) - t));
2424        let powered = powered.unwrap();
2425        assert_eq!((powered.value, powered.time_s), (-tiny, 1.0));
2426        assert_eq!(names(Some(powered)), ["unstable_under_power"]);
2427        assert_eq!(least.unwrap().time_s, 2.0);
2428        // From 1 + 2⁻³⁰ it is +2⁻³⁰ at the burnout, and falls to −1 calibre in the coast after:
2429        // no flag, as only the margin under power counts.
2430        let (powered, least) = least_powered(|t| Some(1.0 + 2_f64.powi(-30) - t));
2431        let powered = powered.unwrap();
2432        assert_eq!((powered.value, powered.time_s), (tiny, 1.0));
2433        assert!(names(Some(powered)).is_empty());
2434        assert!(least.unwrap().value < -0.99, "{least:?}");
2435        // Least inside a powered step, below zero, found by the search there.
2436        let (powered, _) = least_powered(|t| Some((t - 0.37).powi(2) - 0.01));
2437        let powered = powered.unwrap();
2438        assert!((powered.value + 0.01).abs() < 1e-15, "{powered:?}");
2439        assert!((powered.time_s - 0.37).abs() < 1e-7, "{powered:?}");
2440        // A NaN margin under power is kept, so it raises the flag; one in the coast doesn't count.
2441        let (powered, _) = least_powered(|t| Some(if t == 0.25 { f64::NAN } else { 2.0 - t }));
2442        let powered = powered.unwrap();
2443        assert!(
2444            powered.value.is_nan() && powered.time_s == 0.25,
2445            "{powered:?}"
2446        );
2447        assert_eq!(names(Some(powered)), ["unstable_under_power"]);
2448        let (powered, _) = least_powered(|t| Some(if t == 1.5 { f64::NAN } else { 2.0 - t }));
2449        assert_eq!(powered.unwrap().value, 1.0);
2450        // A margin defined nowhere under power: none.
2451        let (powered, _) = least_powered(|t| (t > 1.0).then_some(-1.0));
2452        assert_eq!(powered, None);
2453        // A tie can't keep a margin on the stable side of zero: +10⁻¹³ at the start, then
2454        // −10⁻¹³ (closer than the tie of 10⁻¹² calibres) from 0.25 s on, all under power.
2455        let (powered, _) = least_powered(|t| Some(if t < 0.2 { 1e-13 } else { -1e-13 }));
2456        let powered = powered.unwrap();
2457        assert_eq!((powered.value, powered.time_s), (-1e-13, 0.25));
2458        assert_eq!(names(Some(powered)), ["unstable_under_power"]);
2459    }
2460
2461    #[test]
2462    fn a_moment_slope_without_a_margin_counts_only_while_a_motor_burns() {
2463        // 2⁻³⁰, a const so the closures below stay fn pointers.
2464        const TINY: f64 = 1.0 / 1_073_741_824.0;
2465        let flag_names = |metrics: &FlightMetrics| {
2466            envelope::flags(
2467                None,
2468                None,
2469                metrics.min_powered_static_margin,
2470                metrics.max_powered_moment_slope,
2471            )
2472            .iter()
2473            .map(EnvelopeFlag::name)
2474            .collect::<Vec<_>>()
2475        };
2476        // No margin anywhere; C_mα is +2⁻³⁰ per radian at 0.75 s, while the motor burns, and
2477        // −1 elsewhere: unstable under power, however little.
2478        let metrics = watch_powered(|_| None, Some(|t| if t == 0.75 { TINY } else { -1.0 }));
2479        let peak = metrics.max_powered_moment_slope.unwrap();
2480        assert_eq!((peak.value, peak.time_s), (TINY, 0.75));
2481        assert_eq!(flag_names(&metrics), ["unstable_without_margin"]);
2482        // −2⁻³⁰ there instead: the air turns it back, no flag.
2483        let metrics = watch_powered(|_| None, Some(|t| if t == 0.75 { -TINY } else { -1.0 }));
2484        let peak = metrics.max_powered_moment_slope.unwrap();
2485        assert_eq!((peak.value, peak.time_s), (-TINY, 0.75));
2486        assert!(flag_names(&metrics).is_empty());
2487        // +2⁻³⁰ only after the burnout at 1 s: not under power, no flag.
2488        let metrics = watch_powered(|_| None, Some(|t| if t > 1.0 { TINY } else { -1.0 }));
2489        assert_eq!(metrics.max_powered_moment_slope.unwrap().value, -1.0);
2490        assert!(flag_names(&metrics).is_empty());
2491        // A NaN slope under power is kept, so it raises the flag; nothing larger replaces it, nor
2492        // does a later NaN.
2493        let metrics = watch_powered(
2494            |_| None,
2495            Some(|t| {
2496                if t == 0.25 || t == 0.75 {
2497                    f64::NAN
2498                } else {
2499                    5.0 * t
2500                }
2501            }),
2502        );
2503        let peak = metrics.max_powered_moment_slope.unwrap();
2504        assert!(peak.value.is_nan() && peak.time_s == 0.25, "{peak:?}");
2505        assert_eq!(flag_names(&metrics), ["unstable_without_margin"]);
2506        // Where the margin is defined, its slope doesn't count, even above zero: the margin says
2507        // it all there.
2508        let metrics = watch_powered(|t| (t != 0.75).then_some(1.0), Some(|_| 3.0));
2509        let peak = metrics.max_powered_moment_slope.unwrap();
2510        assert_eq!((peak.value, peak.time_s), (3.0, 0.75));
2511        let metrics = watch_powered(|_| Some(1.0), Some(|_| 3.0));
2512        assert_eq!(metrics.max_powered_moment_slope, None);
2513        assert!(flag_names(&metrics).is_empty());
2514        // A margin below zero under power and a slope above zero where it is undefined: one flag.
2515        let metrics = watch_powered(|t| (t != 0.75).then_some(-1.0), Some(|_| 3.0));
2516        assert_eq!(flag_names(&metrics), ["unstable_under_power"]);
2517    }
2518
2519    #[test]
2520    fn a_rocket_without_fins_is_unstable_under_power() {
2521        // Valetudo, its fins taken off, flown for 2 s: its nose alone carries the normal force,
2522        // ahead of the center of mass, so the margin is below zero while the motor burns. With
2523        // its fins it raises nothing (above, and in the angle-of-attack tests).
2524        let mut rocket = serde_json::to_value(design("rocketpy-valetudo")).unwrap();
2525        let children = rocket["stages"][0]["components"][1]["children"]
2526            .as_array_mut()
2527            .unwrap();
2528        let before = children.len();
2529        children.retain(|child| child["id"] != "fins-1");
2530        assert_eq!(children.len(), before - 1);
2531        let rocket: Rocket = serde_json::from_value(rocket).unwrap();
2532        let sim = Simulation::new(
2533            &rocket,
2534            "example",
2535            Environment::standard(site()).unwrap(),
2536            Rail::vertical(3.0),
2537            capped(2.0),
2538        )
2539        .unwrap();
2540        let (result, metrics) = fly(&sim);
2541        let summary = metrics.summary(&result, sim.environment()).unwrap();
2542        let powered = summary.min_powered_static_margin_cal.unwrap();
2543        assert!(powered.value < -1.0, "{powered:?}");
2544        let burnout_s = sim.assembly().motors[0].mounted.motor.burnout_time_s();
2545        assert!(powered.time_s <= burnout_s, "{powered:?}");
2546        let flags = summary.envelope_flags();
2547        assert_eq!(
2548            flags[0],
2549            EnvelopeFlag::UnstableUnderPower {
2550                static_margin_cal: powered
2551            }
2552        );
2553        // Stock, its least margin under power is the least of all, and above zero.
2554        let sim = valetudo(Environment::standard(site()).unwrap(), capped(2.0));
2555        let (result, metrics) = fly(&sim);
2556        let summary = metrics.summary(&result, sim.environment()).unwrap();
2557        let powered = summary.min_powered_static_margin_cal.unwrap();
2558        assert_eq!(Some(powered), summary.min_static_margin_cal);
2559        assert!(powered.value > 1.0, "{powered:?}");
2560        assert!(summary.envelope_flags().is_empty());
2561    }
2562}