Skip to main content

hpr_sim/
recovery.rs

1//! Recovery devices: what opens, when it opens, and the drag area it presents.
2//!
3//! A [`Device`] is a drag area ([`DeviceDrag`]) with a [`Trigger`], a lag from the trigger to line
4//! stretch, and an [`Inflation`] law. A flight carries a list of them ([`crate::Simulation`]); the
5//! first one to open starts the descent phase ([`crate::Phase::Descent`]), where the rocket flies
6//! as a point mass under the sum of the open devices' drag areas (`docs/physics/recovery.md`).
7//!
8//! Streamers and tumbling bodies are drag areas too, from their own sources
9//! ([`StreamerModel`], [`DeviceDrag::tumbling`]).
10//!
11//! Canopy data comes from T. W. Knacke, *Parachute Recovery Systems Design Manual*, NWC TP 6575
12//! (1991): drag coefficients on the nominal area `S₀` from Tables 5-1 and 5-2, canopy fill
13//! constants from Table 5-6, the drag-area growth exponents of Pflanz's method (Figure 5-51) and
14//! the infinite-mass opening-force coefficients `C_x` from the same tables. Every number is cited
15//! at its accessor, with the printed page.
16
17use hpr_core::DVec3;
18use serde::{Deserialize, Serialize};
19
20use crate::error::SimError;
21
22/// The smallest aspect ratio `l/w` a streamer may have. A strip wider than it is long is not a
23/// streamer, and both correlations run away there: Carruthers and Filippone's `C_D → ∞` as
24/// `AR → 0`, and appendix C notes its own form "obtains maximum drag for a fixed surface area at
25/// the limit `l → 0`, `w → ∞`" (printed page 117).
26pub const MIN_STREAMER_ASPECT_RATIO: f64 = 1.0;
27
28/// The largest `C_D0` a canopy may be given. Knacke's printed values run from 0.30 to 0.96 on the
29/// nominal area; anything above this is a drag area mistaken for a coefficient.
30const MAX_CANOPY_DRAG_COEFFICIENT: f64 = 2.0;
31
32/// A canopy type with printed data in Knacke's tables.
33///
34/// Solid textile canopies come from Table 5-1 (printed page 5-3), slotted ones from Table 5-2
35/// (5-4).
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38#[non_exhaustive]
39pub enum CanopyType {
40    /// Flat circular (solid textile).
41    FlatCircular,
42    /// Conical (solid textile).
43    Conical,
44    /// Biconical (solid textile).
45    Biconical,
46    /// Triconical or polyconical (solid textile).
47    Triconical,
48    /// Extended skirt, 10% flat (solid textile).
49    ExtendedSkirt10Flat,
50    /// Extended skirt, 14.3% full (solid textile).
51    ExtendedSkirt14Full,
52    /// Hemispherical (solid textile).
53    Hemispherical,
54    /// Annular (solid textile).
55    Annular,
56    /// Cross, or cruciform (solid textile).
57    Cross,
58    /// Flat (FIST) ribbon (slotted).
59    FlatRibbon,
60    /// Conical ribbon (slotted).
61    ConicalRibbon,
62    /// Ringslot (slotted).
63    Ringslot,
64    /// Ringsail (slotted).
65    Ringsail,
66}
67
68impl CanopyType {
69    /// Knacke's printed range of `C_D0`, the drag coefficient on the nominal area `S₀`
70    /// (Tables 5-1 and 5-2, printed pages 5-3 and 5-4).
71    #[must_use]
72    pub const fn drag_coefficient_range(self) -> (f64, f64) {
73        match self {
74            Self::FlatCircular => (0.75, 0.80),
75            Self::Conical => (0.75, 0.90),
76            Self::Biconical => (0.75, 0.92),
77            Self::Triconical => (0.80, 0.96),
78            Self::ExtendedSkirt10Flat => (0.78, 0.87),
79            Self::ExtendedSkirt14Full => (0.75, 0.90),
80            Self::Hemispherical => (0.62, 0.77),
81            Self::Annular => (0.85, 0.95),
82            Self::Cross => (0.60, 0.85),
83            Self::FlatRibbon => (0.45, 0.50),
84            Self::ConicalRibbon => (0.50, 0.55),
85            Self::Ringslot => (0.56, 0.65),
86            Self::Ringsail => (0.75, 0.85),
87        }
88    }
89
90    /// The middle of [`Self::drag_coefficient_range`], which is what hpr uses when the user gives
91    /// no `C_D0`. Knacke prints a range for every type and no single value; the middle is hpr's
92    /// choice, not his (the decision record on recovery, [ADR-012][adr-012]).
93    ///
94    /// [adr-012]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-012-recovery-drag-areas-triggers-inflation-and-the-descent-phase-2026-09-17
95    #[must_use]
96    pub const fn drag_coefficient(self) -> f64 {
97        let (low, high) = self.drag_coefficient_range();
98        0.5 * (low + high)
99    }
100
101    /// The canopy fill constant `n` of `t_f = n D₀/v` (Table 5-6, printed page 5-44, unreefed
102    /// column), where Knacke prints one. `None` where the table has no unreefed value for the
103    /// type, which for five of these is because it has no row there at all.
104    ///
105    /// Knacke's rows cover types, not every variant: the ribbon row serves both ribbon entries.
106    /// (The ringsail row prints 7 unreefed; its 7 to 8 is the reefed column.)
107    #[must_use]
108    pub const fn fill_constant(self) -> Option<f64> {
109        match self {
110            Self::FlatCircular => Some(8.0),
111            Self::ExtendedSkirt10Flat => Some(10.0),
112            Self::ExtendedSkirt14Full => Some(12.0),
113            Self::Cross => Some(11.7),
114            Self::FlatRibbon | Self::ConicalRibbon => Some(14.0),
115            Self::Ringslot => Some(14.0),
116            Self::Ringsail => Some(7.0),
117            Self::Conical
118            | Self::Biconical
119            | Self::Triconical
120            | Self::Hemispherical
121            | Self::Annular => None,
122        }
123    }
124
125    /// The drag-area growth exponent `j` of `(C_D S)(t) = (C_D S)₀ (t/t_f)^j`, for the types
126    /// Pflanz's method names (Figure 5-51, printed page 5-59): `j = 2` for solid cloth (flat
127    /// circular, conical, extended skirt, triconical) and `j = 1` for ribbon and ringslot.
128    /// `None` for the types he doesn't name, which need a measured exponent.
129    #[must_use]
130    pub const fn growth_exponent(self) -> Option<f64> {
131        match self {
132            Self::FlatCircular
133            | Self::Conical
134            | Self::Triconical
135            | Self::ExtendedSkirt10Flat
136            | Self::ExtendedSkirt14Full => Some(2.0),
137            Self::FlatRibbon | Self::ConicalRibbon | Self::Ringslot => Some(1.0),
138            Self::Biconical
139            | Self::Hemispherical
140            | Self::Annular
141            | Self::Cross
142            | Self::Ringsail => None,
143        }
144    }
145
146    /// The infinite-mass opening-force coefficient `C_x = F_x/F_c` (Tables 5-1 and 5-2; the cross
147    /// canopy's printed 1.1 to 1.2 is taken at its middle). hpr reports it; it is not used in the
148    /// equations, which integrate the opening force instead.
149    #[must_use]
150    pub const fn opening_force_coefficient(self) -> f64 {
151        match self {
152            Self::FlatCircular => 1.7,
153            Self::Conical | Self::Biconical | Self::Triconical => 1.8,
154            Self::ExtendedSkirt10Flat | Self::ExtendedSkirt14Full | Self::Annular => 1.4,
155            Self::Hemispherical => 1.6,
156            Self::Cross => 1.15,
157            Self::FlatRibbon | Self::ConicalRibbon | Self::Ringslot => 1.05,
158            Self::Ringsail => 1.10,
159        }
160    }
161
162    /// Where the numbers come from, for reports.
163    #[must_use]
164    pub const fn source(self) -> &'static str {
165        "Knacke, Parachute Recovery Systems Design Manual, NWC TP 6575 (1991): Table 5-1 (solid \
166         textile canopies), Table 5-2 (slotted), Table 5-6 (fill constants) and Figure 5-51 \
167         (drag-area growth)"
168    }
169}
170
171/// How a streamer's drag area is estimated. A streamer of length `l` and width `w` has a
172/// planform (one-side) area `S = l w` and an aspect ratio `AR = l/w`.
173///
174/// The two models disagree by a factor of about four in drag area, so
175/// `docs/physics/recovery.md` sets out what each is fitted to and how each compares with the only
176/// free-drop data in hand (the decision record on streamer and tumble drag, [ADR-013][adr-013]).
177///
178/// [adr-013]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-013-streamer-and-tumble-drag-2026-09-17
179#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181#[non_exhaustive]
182pub enum StreamerModel {
183    /// J. Carruthers and A. Filippone, "Aerodynamic Drag of Streamers and Flags", *Journal of
184    /// Aircraft* 42(4), 2005, pp. 976-982, on the planform area, from wind-tunnel tests of cotton
185    /// streamers at `AR` 3.3 to 30 and 6 to 18.9 m/s. It fits one power curve per planform area:
186    ///
187    /// - `C_D = 0.561 AR^−0.480` at `S = 0.025 m²` (eq. 2; Figure 2's trend line reads
188    ///   `0.561 AR^−0.4795`),
189    /// - `C_D = 0.6514 AR^−0.6075` at `S = 0.05 m²` (the trend line on Figure 3, which the text
190    ///   does not repeat as an equation),
191    /// - `C_D = 0.405 AR^−0.494` at `S = 0.075 m²` (eq. 1; Figure 4 reads `0.4046 AR^−0.494`).
192    ///
193    /// hpr interpolates between **neighbouring** curves linearly in `ln S` and holds the end
194    /// curve outside the fitted range. That interpolation is hpr's, not the paper's. All three
195    /// are needed because `C_D` is far from linear in `ln S`: at `AR = 3.3` the middle curve sits
196    /// 0.3% *below* the smallest area's rather than 63% of the way to the largest's, so blending
197    /// only the extremes reads 18% low there.
198    ///
199    /// The correlations are for streamers clamped at the luff; the paper measures more drag when
200    /// the luff is free, which is what a descending streamer has.
201    ///
202    /// This is the default: on the one free-drop measurement in hand that a flat correlation
203    /// should fit (Kidwell's unpleated streamer) it is 9% fast where [`Self::OpenRocket`] is 88%
204    /// fast. Both are fast on his pleated streamers, which neither models
205    /// (`docs/physics/recovery.md`).
206    #[default]
207    Filippone,
208    /// The OpenRocket technical documentation v13.05, Appendix C (printed pages 113-118):
209    /// `C_Dm = 0.034 ((ρ_m + 25 g/m²)/(105 g/m²)) ((l + 1 m)/l)` on the planform area, fitted to
210    /// wind-tunnel tests of model-rocket streamers (`w` 0.01 to 0.09 m, `l` 0.2 to 1.0 m, surface
211    /// density 10 to 80 g/m², 6 to 12 m/s), with a stated 12 to 27% error on an independent set.
212    ///
213    /// Use it to compare with OpenRocket. Against Kidwell's free drops it is low in drag area by
214    /// 4.2 times on his flat crêpe streamer and 7.8 times on his pleated Micafilm one, which is
215    /// 88% and 154% fast in descent rate (`docs/physics/recovery.md`).
216    OpenRocket,
217}
218
219impl StreamerModel {
220    /// Carruthers and Filippone's three fitted curves, as `(planform area m², coefficient,
221    /// exponent)` with `C_D = coefficient · AR^exponent`, in order of area.
222    const FILIPPONE_CURVES: [(f64, f64, f64); 3] = [
223        (0.025, 0.561, -0.480),
224        (0.05, 0.6514, -0.6075),
225        (0.075, 0.405, -0.494),
226    ];
227
228    /// The aspect ratios `AR = l/w` the correlations were fitted over. Outside it they only
229    /// extrapolate, and as `AR → 0` the power law runs away (`C_D → ∞`), which is why a flight
230    /// refuses a strip wider than it is long ([`MIN_STREAMER_ASPECT_RATIO`]).
231    pub const FILIPPONE_ASPECT_RATIO_RANGE: (f64, f64) = (3.3, 30.0);
232
233    /// The drag area `C_D S` of a streamer, m².
234    ///
235    /// `surface_density_kg_m2` is the fabric's, which only [`Self::OpenRocket`] uses. Outside the
236    /// ranges each correlation was fitted over this extrapolates; a flight refuses the shapes
237    /// that make it meaningless ([`crate::Simulation::with_recovery`]).
238    #[must_use]
239    pub fn drag_area_m2(self, length_m: f64, width_m: f64, surface_density_kg_m2: f64) -> f64 {
240        let planform_m2 = length_m * width_m;
241        match self {
242            Self::Filippone => {
243                let aspect_ratio = length_m / width_m;
244                let curve = |(_, coefficient, exponent): (f64, f64, f64)| {
245                    coefficient * aspect_ratio.powf(exponent)
246                };
247                let curves = Self::FILIPPONE_CURVES;
248                // Between two neighbouring curves, linearly in `ln S`; outside, the end curve.
249                let coefficient = if planform_m2 <= curves[0].0 {
250                    curve(curves[0])
251                } else if planform_m2 >= curves[2].0 {
252                    curve(curves[2])
253                } else {
254                    let upper = usize::from(planform_m2 > curves[1].0) + 1;
255                    let (low_m2, high_m2) = (curves[upper - 1].0, curves[upper].0);
256                    let fraction = (planform_m2 / low_m2).ln() / (high_m2 / low_m2).ln();
257                    let (low, high) = (curve(curves[upper - 1]), curve(curves[upper]));
258                    low + fraction * (high - low)
259                };
260                coefficient * planform_m2
261            }
262            Self::OpenRocket => {
263                0.034
264                    * ((surface_density_kg_m2 + 0.025) / 0.105)
265                    * ((length_m + 1.0) / length_m)
266                    * planform_m2
267            }
268        }
269    }
270}
271
272/// What gives a device its drag area `C_D S`.
273#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
274#[serde(rename_all = "snake_case", deny_unknown_fields)]
275#[non_exhaustive]
276pub enum DeviceDrag {
277    /// A drag area given directly, m² (RocketPy's `cd_s`).
278    DragArea {
279        /// `C_D S`, m².
280        cd_s_m2: f64,
281    },
282    /// A streamer: a strip of fabric `length_m` by `width_m`, whose drag area comes from
283    /// `model` ([`StreamerModel`]).
284    Streamer {
285        /// Its length `l`, m.
286        length_m: f64,
287        /// Its width `w`, m.
288        width_m: f64,
289        /// The fabric's surface density, kg/m² ([`StreamerModel::OpenRocket`] uses it).
290        surface_density_kg_m2: f64,
291        /// Which correlation gives its drag area.
292        #[serde(default)]
293        model: StreamerModel,
294    },
295    /// A body descending broadside, tumbling, with no device open: the drag area of its body tubes
296    /// and fins. Build it with [`DeviceDrag::tumbling`], which computes it from the airframe.
297    Tumble {
298        /// The drag area `C_D S`, m².
299        drag_area_m2: f64,
300        /// The body's side profile area, m² (for reports; it and the fin area have to add up to
301        /// the drag area through the model's two coefficients, so a file that leaves one out is
302        /// refused by serde rather than silently defaulting to an inconsistent device).
303        body_profile_m2: f64,
304        /// The effective fin area, m² (for reports).
305        fin_area_m2: f64,
306    },
307    /// A canopy of nominal diameter `D₀` with `C_D0` on the nominal area `S₀ = π D₀²/4`
308    /// (Knacke's convention, printed page 5-2).
309    Canopy {
310        /// The nominal diameter `D₀`, m.
311        nominal_diameter_m: f64,
312        /// `C_D0` on `S₀`.
313        drag_coefficient: f64,
314        /// The canopy type, where it is one of Knacke's.
315        #[serde(default, skip_serializing_if = "Option::is_none")]
316        kind: Option<CanopyType>,
317    },
318}
319
320/// The drag coefficient of a tumbling body tube, on its side profile area (the OpenRocket
321/// technical documentation v13.05, §3.5, printed page 54: fitted to 22 m drop tests of five
322/// models, and half the 1.12 of a circular cylinder in crossflow, as expected of a cylinder
323/// falling at a random angle).
324pub const TUMBLE_BODY_DRAG_COEFFICIENT: f64 = 0.56;
325
326/// The drag coefficient of a tumbling fin set, on its effective fin area (the same source; it
327/// sits between a flat plate's 1.17 and an open hemispherical cup's 1.42, and the documentation
328/// says it is the less reliable of the two).
329pub const TUMBLE_FIN_DRAG_COEFFICIENT: f64 = 1.42;
330
331/// The effective fin area of a tumbling set is one fin's area times this factor, by fin count
332/// (the same source, Table 3.4, printed page 55, for 1 to 8 fins). It is a fit, not a model: it
333/// is not `n` times one fin, and it is not monotonic.
334pub const TUMBLE_FIN_EFFICIENCY: [f64; 8] = [0.50, 1.00, 1.50, 1.41, 1.81, 1.73, 1.90, 1.85];
335
336/// A body component's side profile area `∫ d dx`, m²: a tube's diameter times its length, and a
337/// nose cone's or transition's own profile integrated ([`hpr_design::revolve`]'s planform area).
338/// Other parts add no body profile (fins are counted apart).
339fn side_profile_m2(component: &hpr_design::PlacedComponent) -> Result<f64, SimError> {
340    let profile = match &component.part {
341        hpr_design::Part::BodyTube(tube) => {
342            return Ok(2.0 * tube.outer_radius_m * component.length_m);
343        }
344        hpr_design::Part::NoseCone(nose) => nose.profile()?,
345        hpr_design::Part::Transition(transition) => transition.profile()?,
346        _ => return Ok(0.0),
347    };
348    Ok(hpr_design::revolve(&profile, hpr_design::Wall::Filled {})?.planform_area_m2)
349}
350
351impl DeviceDrag {
352    /// The drag area of `assembly` tumbling: `C_D,f A_f + C_D,bt A_bt` (the OpenRocket technical
353    /// documentation v13.05, §3.5, eq. 3.98 and 3.99, printed pages 53 to 55).
354    ///
355    /// - `A_bt` is the body's side profile area, the integral of its outer diameter along the
356    ///   axis, `∫ d dx`, taken over each body component's own profile (a curved nose's or
357    ///   transition's by quadrature, [`hpr_design::revolve`]). Shoulders are inside the airframe
358    ///   and add nothing.
359    /// - `A_f` is, for each fin set, one fin's planform area times the efficiency factor for its
360    ///   fin count ([`TUMBLE_FIN_EFFICIENCY`]). Launch lugs and rail buttons add nothing, and an
361    ///   airframe with **tube fins** is refused: they are a large part of its broadside area and
362    ///   the model has no factor for them.
363    /// - A **pod**'s body components and fins count as the airframe's do, once per pod: the
364    ///   documentation's model has no term for them, and none for one part shading another.
365    ///
366    /// It sums **every** stage, so it is the whole stack tumbling. For a spent booster on its
367    /// own, which is what the documentation's model was written for, use
368    /// [`Self::tumbling_stages`] with that body's stages.
369    ///
370    /// The constants were fitted to 22 m drop tests of five models 44 to 103 mm across and 6.8 to
371    /// 160 g, descending at 5.0 to 6.6 m/s, and predict those terminal velocities within 3 to 14%.
372    /// Bodies much larger or faster than that are outside the fit: above a Reynolds number of
373    /// about 3e5 a cylinder's crossflow drag falls by roughly half (`docs/physics/recovery.md`).
374    ///
375    /// # Errors
376    ///
377    /// [`SimError::Design`] if a fin planform's area or a nose cone's or transition's profile can't
378    /// be computed, and [`SimError::Domain`] if the airframe presents no area at all, carries tube
379    /// fins, or has a fin set of more than the eight fins Table 3.4 covers.
380    pub fn tumbling(assembly: &hpr_design::Assembly) -> Result<Self, SimError> {
381        Self::tumbling_stages(
382            assembly,
383            (0, assembly.layout.stages.len().saturating_sub(1)),
384        )
385    }
386
387    /// The drag area of the stages `first..=last` of `assembly` tumbling on their own, which is
388    /// what a separated body does ([`Separation`]). [`Self::tumbling`] is this over every stage.
389    /// It covers whole stages only: for an ejected piece that is part of a stage
390    /// ([`crate::Ejection`]) use [`crate::Simulation::tumbling_piece`], which sums the piece's
391    /// own components.
392    ///
393    /// # Errors
394    ///
395    /// As [`Self::tumbling`].
396    pub fn tumbling_stages(
397        assembly: &hpr_design::Assembly,
398        (first, last): (usize, usize),
399    ) -> Result<Self, SimError> {
400        let stages = assembly.layout.stages.len();
401        if first > last || last >= stages {
402            return Err(SimError::Domain {
403                what: "stage range of a tumbling body (first must not pass last, and last must \
404                       be a stage of the design); the last given",
405                value: last as f64,
406            });
407        }
408        Self::tumbling_where(assembly, |_, component| {
409            (first..=last).contains(&component.stage)
410        })
411    }
412
413    /// The drag area of the components of `assembly` for which `member` (given each one's index
414    /// in the layout) is true, tumbling: the sum [`Self::tumbling`] describes, over them alone.
415    pub(crate) fn tumbling_where(
416        assembly: &hpr_design::Assembly,
417        member: impl Fn(usize, &hpr_design::PlacedComponent) -> bool,
418    ) -> Result<Self, SimError> {
419        let mut body_profile_m2 = 0.0;
420        let mut fin_area_m2 = 0.0;
421        for (index, component) in assembly.layout.components.iter().enumerate() {
422            if !member(index, component) {
423                continue;
424            }
425            // A part in a pod counts once per pod: each pod's body components and fins are
426            // broadside to the air as the airframe's are.
427            let copies = component.copies.len() as f64;
428            body_profile_m2 += copies * side_profile_m2(component)?;
429            if matches!(component.part, hpr_design::Part::TubeFinSet(_)) {
430                // Tube fins are a large part of such a rocket's broadside area and the model has
431                // no factor for them, so hpr refuses rather than crediting a bare tube's drag.
432                return Err(SimError::Domain {
433                    what: "tumbling an airframe with tube fins (the model covers body tubes and \
434                           fin sets only)",
435                    value: 0.0,
436                });
437            }
438            if let hpr_design::Part::FinSet(fins) = &component.part {
439                let count = fins.count as usize;
440                let efficiency =
441                    TUMBLE_FIN_EFFICIENCY
442                        .get(count.wrapping_sub(1))
443                        .ok_or(SimError::Domain {
444                            what: "number of fins in a tumbling set (the fitted efficiency factors \
445                               cover 1 to 8)",
446                            value: fins.count.into(),
447                        })?;
448                fin_area_m2 += copies * fins.planform.geometry()?.area_m2 * efficiency;
449            }
450        }
451        let drag_area_m2 = TUMBLE_FIN_DRAG_COEFFICIENT * fin_area_m2
452            + TUMBLE_BODY_DRAG_COEFFICIENT * body_profile_m2;
453        if !(drag_area_m2.is_finite() && drag_area_m2 > 0.0) {
454            return Err(SimError::Domain {
455                what: "drag area of the tumbling airframe, m²",
456                value: drag_area_m2,
457            });
458        }
459        Ok(Self::Tumble {
460            drag_area_m2,
461            body_profile_m2,
462            fin_area_m2,
463        })
464    }
465
466    /// A streamer `length_m` by `width_m` of a fabric of `surface_density_kg_m2`, by the default
467    /// model ([`StreamerModel::Filippone`]).
468    #[must_use]
469    pub const fn streamer(length_m: f64, width_m: f64, surface_density_kg_m2: f64) -> Self {
470        Self::Streamer {
471            length_m,
472            width_m,
473            surface_density_kg_m2,
474            model: StreamerModel::Filippone,
475        }
476    }
477
478    /// A canopy of `nominal_diameter_m` with its type's default `C_D0`
479    /// ([`CanopyType::drag_coefficient`]).
480    #[must_use]
481    pub const fn canopy(kind: CanopyType, nominal_diameter_m: f64) -> Self {
482        Self::Canopy {
483            nominal_diameter_m,
484            drag_coefficient: kind.drag_coefficient(),
485            kind: Some(kind),
486        }
487    }
488
489    /// The fully open drag area `C_D S`, m².
490    #[must_use]
491    pub fn drag_area_m2(&self) -> f64 {
492        match *self {
493            Self::DragArea { cd_s_m2 } => cd_s_m2,
494            Self::Streamer {
495                length_m,
496                width_m,
497                surface_density_kg_m2,
498                model,
499            } => model.drag_area_m2(length_m, width_m, surface_density_kg_m2),
500            Self::Tumble { drag_area_m2, .. } => drag_area_m2,
501            Self::Canopy {
502                nominal_diameter_m,
503                drag_coefficient,
504                ..
505            } => {
506                drag_coefficient * std::f64::consts::PI * nominal_diameter_m * nominal_diameter_m
507                    / 4.0
508            }
509        }
510    }
511
512    /// The nominal diameter `D₀`, m, where the device has one.
513    #[must_use]
514    pub const fn nominal_diameter_m(&self) -> Option<f64> {
515        match *self {
516            Self::DragArea { .. } | Self::Streamer { .. } | Self::Tumble { .. } => None,
517            Self::Canopy {
518                nominal_diameter_m, ..
519            } => Some(nominal_diameter_m),
520        }
521    }
522
523    /// The canopy type, where the device has one.
524    #[must_use]
525    pub const fn canopy_type(&self) -> Option<CanopyType> {
526        match *self {
527            Self::DragArea { .. } | Self::Streamer { .. } | Self::Tumble { .. } => None,
528            Self::Canopy { kind, .. } => kind,
529        }
530    }
531}
532
533/// When a device's charge fires.
534#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
535#[serde(rename_all = "snake_case")]
536#[non_exhaustive]
537pub enum Trigger {
538    /// At apogee.
539    Apogee,
540    /// The first time from apogee that the center of mass is at or below this height above the
541    /// launch site, m: an altimeter's main setting. A rocket whose apogee is already below it
542    /// fires at apogee.
543    Altitude {
544        /// The height above the launch site, m.
545        height_above_ground_m: f64,
546    },
547    /// At a time after launch, s. Charges are checked in free flight and during the descent, so a
548    /// time that passes on the pad or the rail fires at rail exit.
549    Time {
550        /// The time after launch, s.
551        time_s: f64,
552    },
553    /// A motor's ejection delay after that motor's burnout. The motor is its index in
554    /// [`hpr_design::Assembly::motors`], and it must have a [`hpr_motor::Delay::Seconds`] delay.
555    /// As [`Self::Time`], a delay that expires before the rail exit fires there. A motor that
556    /// never lights never fires it, so on a cluster with a motor out
557    /// ([`hpr_design::MountedMotor::failed_tubes`]) name a tube that lights.
558    MotorDelay {
559        /// The motor's index.
560        motor: usize,
561    },
562    /// A delay after a motor's burnout, whatever its ejection delay: a stage separation timed
563    /// from the booster's burnout. The motor is its index in [`hpr_design::Assembly::motors`].
564    /// As [`Self::MotorDelay`], it fires no earlier than the rail exit, and never if the motor
565    /// never lights.
566    Burnout {
567        /// The motor's index.
568        motor: usize,
569        /// The delay after its burnout, s.
570        delay_s: f64,
571    },
572}
573
574impl Trigger {
575    /// When it fires on a flight of `assembly`, if that is known before the flight: a
576    /// [`Self::Time`], or a delay after the burnout of a motor `assembly` lights at a known time
577    /// (one lit by a separation has none yet). `None` for [`Self::Apogee`] and
578    /// [`Self::Altitude`], which the flight watches for. A time that passes on the pad or the
579    /// rail fires at the rail exit in flight, which this does not know.
580    ///
581    /// # Errors
582    ///
583    /// [`SimError::Domain`] as the flight refuses the trigger: a time before launch, a motor that
584    /// isn't there, a [`Self::MotorDelay`] on a motor with no ejection delay in seconds, or a
585    /// delay that is negative or not finite.
586    pub fn known_time_s(self, assembly: &hpr_design::Assembly) -> Result<Option<f64>, SimError> {
587        trigger_time_s(self, &assembly.motors, &assembly.ignition_times_s(|_| None))
588    }
589}
590
591/// How a device's drag area grows once it is deployed.
592///
593/// Knacke's measurements (Figure 5-40, printed page 5-47) overshoot the steady drag area by 10 to
594/// 80% near the end of filling. These laws don't: they rise to the steady value and stay there.
595#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
596#[serde(rename_all = "snake_case")]
597#[non_exhaustive]
598pub enum Inflation {
599    /// The full drag area from the moment the device deploys.
600    Instant,
601    /// `(C_D S)(t) = (C_D S)₀ (t/t_f)^j` over a filling time `t_f` fixed in advance.
602    FillingTime {
603        /// The filling time `t_f`, s.
604        time_s: f64,
605        /// The growth exponent `j` (Pflanz: 1 for ribbon and ringslot, 2 for solid cloth).
606        exponent: f64,
607    },
608    /// The same growth law with Knacke's filling time `t_f = n D₀/v` (printed page 5-43), where
609    /// `v` is the airspeed at deployment and `n` the canopy fill constant. Needs a device with a
610    /// nominal diameter.
611    ///
612    /// Knacke states this linear form "gives satisfactory results in the medium-velocity range of
613    /// about 150 to 500 ft/s" (45.7 to 152.4 m/s, printed page 5-44). A hobby main opening at 20
614    /// to 30 m/s is below that range, and his alternative there
615    /// (`t_f = n D₀/v^0.85`, `n = 4.0` for solid flat circular canopies) is dimensional, so it
616    /// cannot be used in SI as printed. [`Inflation::FILL_CONSTANT_RANGE_M_S`] holds the range,
617    /// and `docs/physics/recovery.md` says what using it outside costs.
618    FillConstant {
619        /// The fill constant `n` (Table 5-6).
620        constant: f64,
621        /// The growth exponent `j`.
622        exponent: f64,
623    },
624}
625
626impl Inflation {
627    /// The airspeeds at line stretch where Knacke's linear filling time `t_f = n D₀/v` is stated
628    /// to hold, m/s (150 to 500 ft/s, printed page 5-44).
629    pub const FILL_CONSTANT_RANGE_M_S: (f64, f64) = (45.72, 152.4);
630
631    /// Knacke's filling time and growth exponent for `kind`, where his tables print both.
632    #[must_use]
633    pub const fn knacke(kind: CanopyType) -> Option<Self> {
634        match (kind.fill_constant(), kind.growth_exponent()) {
635            (Some(constant), Some(exponent)) => Some(Self::FillConstant { constant, exponent }),
636            _ => None,
637        }
638    }
639
640    /// The filling time, s, for a device of nominal diameter `diameter_m` deployed at airspeed
641    /// `airspeed_m_s`. Zero means the drag area appears at once.
642    fn filling_time_s(&self, diameter_m: Option<f64>, airspeed_m_s: f64) -> f64 {
643        match *self {
644            Self::Instant => 0.0,
645            Self::FillingTime { time_s, .. } => time_s,
646            Self::FillConstant { constant, .. } => match diameter_m {
647                // A deployment at rest has no filling time: there is no flow to fill the canopy,
648                // and `n D₀/v` diverges. The canopy opens as the rocket picks up speed instead.
649                Some(diameter_m) if airspeed_m_s > 0.0 => constant * diameter_m / airspeed_m_s,
650                _ => 0.0,
651            },
652        }
653    }
654
655    /// The growth exponent `j`.
656    const fn exponent(&self) -> f64 {
657        match *self {
658            Self::Instant => 1.0,
659            Self::FillingTime { exponent, .. } | Self::FillConstant { exponent, .. } => exponent,
660        }
661    }
662}
663
664/// A recovery device: a drag area, when it opens, and how it fills.
665///
666/// Build one with [`Device::new`] and the builders: the struct is `#[non_exhaustive]` so that
667/// streamers ([M1.7b][m1-7b]) and separation ([M1.7c][m1-7c]) can add fields without breaking
668/// callers.
669///
670/// [m1-7b]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-7b
671/// [m1-7c]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-7c
672#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
673#[serde(deny_unknown_fields)]
674#[non_exhaustive]
675pub struct Device {
676    /// A name for reports.
677    pub name: String,
678    /// What gives it its drag area.
679    pub drag: DeviceDrag,
680    /// When its charge fires.
681    pub trigger: Trigger,
682    /// Seconds from the trigger to line stretch, when the canopy starts to fill (RocketPy's
683    /// `lag`).
684    #[serde(default)]
685    pub lag_s: f64,
686    /// How its drag area grows from line stretch.
687    #[serde(default = "instant")]
688    pub inflation: Inflation,
689    /// The device whose opening releases this one, by its index in the flight's list: a drogue cut
690    /// away once the main is fully open. A device released before its own charge fires never
691    /// deploys.
692    #[serde(default, skip_serializing_if = "Option::is_none")]
693    pub released_by: Option<usize>,
694    /// Which body it is attached to, after a separation ([`Separation`]) or an ejection
695    /// ([`crate::Ejection`]): body 0 keeps the nose, body 1 is the separation's aft stages, and each
696    /// ejection's piece the next number, in the order given, so with no separation the first
697    /// ejection makes body 1. Without either there is only body 0.
698    #[serde(default)]
699    pub body: usize,
700}
701
702fn instant() -> Inflation {
703    Inflation::Instant
704}
705
706impl Device {
707    /// A device with no lag, opening at once and never released.
708    #[must_use]
709    pub fn new(name: impl Into<String>, drag: DeviceDrag, trigger: Trigger) -> Self {
710        Self {
711            name: name.into(),
712            drag,
713            trigger,
714            lag_s: 0.0,
715            inflation: Inflation::Instant,
716            released_by: None,
717            body: 0,
718        }
719    }
720
721    /// The same device with `lag_s` seconds from the trigger to line stretch.
722    #[must_use]
723    pub fn with_lag_s(mut self, lag_s: f64) -> Self {
724        self.lag_s = lag_s;
725        self
726    }
727
728    /// The same device with an inflation law.
729    #[must_use]
730    pub fn with_inflation(mut self, inflation: Inflation) -> Self {
731        self.inflation = inflation;
732        self
733    }
734
735    /// The same device, released when device `index` is fully open.
736    #[must_use]
737    pub fn with_release_by(mut self, index: usize) -> Self {
738        self.released_by = Some(index);
739        self
740    }
741
742    /// The same device, carried by body `index` after a separation ([`Separation`]) or an
743    /// ejection ([`crate::Ejection`]); [`Self::body`] has the numbering. It acts only once that
744    /// body flies on its own.
745    #[must_use]
746    pub fn on_body(mut self, index: usize) -> Self {
747        self.body = index;
748        self
749    }
750
751    /// Checks the device's numbers.
752    fn validate(&self, count: usize, index: usize) -> Result<(), SimError> {
753        match self.drag {
754            DeviceDrag::Streamer {
755                length_m,
756                width_m,
757                surface_density_kg_m2,
758                model,
759            } => {
760                for (what, value) in [
761                    ("streamer length, m", length_m),
762                    ("streamer width, m", width_m),
763                ] {
764                    if !(value.is_finite() && value > 0.0) {
765                        return Err(SimError::Domain { what, value });
766                    }
767                }
768                if !(surface_density_kg_m2.is_finite() && surface_density_kg_m2 >= 0.0) {
769                    return Err(SimError::Domain {
770                        what: "streamer fabric surface density, kg/m²",
771                        value: surface_density_kg_m2,
772                    });
773                }
774                // A strip wider than it is long is not a streamer, and both correlations run
775                // away there. Above the fitted range they only extrapolate, which is allowed.
776                let _ = model;
777                if length_m / width_m < MIN_STREAMER_ASPECT_RATIO {
778                    return Err(SimError::Domain {
779                        what: "streamer aspect ratio, length over width (Carruthers and \
780                               Filippone fit 3.3 to 30, and both correlations are meaningless \
781                               below 1)",
782                        value: length_m / width_m,
783                    });
784                }
785            }
786            DeviceDrag::Tumble {
787                drag_area_m2,
788                body_profile_m2,
789                fin_area_m2,
790            } => {
791                for (what, value) in [
792                    ("tumbling body side profile area, m²", body_profile_m2),
793                    ("tumbling effective fin area, m²", fin_area_m2),
794                ] {
795                    if !(value.is_finite() && value >= 0.0) {
796                        return Err(SimError::Domain { what, value });
797                    }
798                }
799                let parts = TUMBLE_FIN_DRAG_COEFFICIENT * fin_area_m2
800                    + TUMBLE_BODY_DRAG_COEFFICIENT * body_profile_m2;
801                if (drag_area_m2 - parts).abs() > 1e-9 * drag_area_m2.abs().max(1.0) {
802                    return Err(SimError::Domain {
803                        what: "tumbling drag area against its body and fin areas (build one with \
804                               DeviceDrag::tumbling)",
805                        value: drag_area_m2,
806                    });
807                }
808            }
809            DeviceDrag::DragArea { .. } | DeviceDrag::Canopy { .. } => {}
810        }
811        if let DeviceDrag::Canopy {
812            drag_coefficient, ..
813        } = self.drag
814            // Knacke's tables run from 0.30 (hemisflo ribbon) to 0.96 (triconical) on the nominal
815            // area. The bound is loose enough for a coefficient measured on another reference
816            // area, and tight enough to catch a drag area passed as a coefficient.
817            && !(drag_coefficient.is_finite()
818                && drag_coefficient > 0.0
819                && drag_coefficient <= MAX_CANOPY_DRAG_COEFFICIENT)
820        {
821            return Err(SimError::Domain {
822                what: "canopy drag coefficient on the nominal area (0 to 2]",
823                value: drag_coefficient,
824            });
825        }
826        let area = self.drag.drag_area_m2();
827        if !(area.is_finite() && area > 0.0) {
828            return Err(SimError::Domain {
829                what: "recovery device drag area, m²",
830                value: area,
831            });
832        }
833        if let Some(diameter) = self.drag.nominal_diameter_m()
834            && !(diameter.is_finite() && diameter > 0.0)
835        {
836            return Err(SimError::Domain {
837                what: "canopy nominal diameter, m",
838                value: diameter,
839            });
840        }
841        if !(self.lag_s.is_finite() && self.lag_s >= 0.0) {
842            return Err(SimError::Domain {
843                what: "recovery device lag, s",
844                value: self.lag_s,
845            });
846        }
847        match self.inflation {
848            Inflation::Instant => {}
849            Inflation::FillingTime { time_s, exponent } => {
850                if !(time_s.is_finite() && time_s >= 0.0) {
851                    return Err(SimError::Domain {
852                        what: "canopy filling time, s",
853                        value: time_s,
854                    });
855                }
856                check_exponent(exponent)?;
857            }
858            Inflation::FillConstant { constant, exponent } => {
859                if !(constant.is_finite() && constant > 0.0) {
860                    return Err(SimError::Domain {
861                        what: "canopy fill constant",
862                        value: constant,
863                    });
864                }
865                if self.drag.nominal_diameter_m().is_none() {
866                    return Err(SimError::Domain {
867                        what: "canopy fill constant without a nominal diameter (give a canopy, \
868                               or a filling time)",
869                        value: constant,
870                    });
871                }
872                check_exponent(exponent)?;
873            }
874        }
875        match self.trigger {
876            Trigger::Apogee | Trigger::MotorDelay { .. } | Trigger::Burnout { .. } => {}
877            Trigger::Altitude {
878                height_above_ground_m,
879            } => {
880                if !(height_above_ground_m.is_finite() && height_above_ground_m > 0.0) {
881                    return Err(SimError::Domain {
882                        what: "deployment height above the launch site, m",
883                        value: height_above_ground_m,
884                    });
885                }
886            }
887            Trigger::Time { time_s } => {
888                if !(time_s.is_finite() && time_s >= 0.0) {
889                    return Err(SimError::Domain {
890                        what: "deployment time after launch, s",
891                        value: time_s,
892                    });
893                }
894            }
895        }
896        if let Some(other) = self.released_by
897            && (other >= count || other == index)
898        {
899            return Err(SimError::Domain {
900                what: "index of the device that releases this one",
901                value: other as f64,
902            });
903        }
904        Ok(())
905    }
906}
907
908fn check_exponent(exponent: f64) -> Result<(), SimError> {
909    if exponent.is_finite() && exponent > 0.0 {
910        Ok(())
911    } else {
912        Err(SimError::Domain {
913            what: "canopy drag-area growth exponent",
914            value: exponent,
915        })
916    }
917}
918
919/// A separation: the stack comes apart at a stage boundary and every body descends under its own
920/// devices (`docs/physics/recovery.md`, and the decision record on separation, [ADR-014][adr-014]).
921///
922/// Bodies are contiguous runs of stages. Stages `0..=after_stage` keep the nose and are body 0;
923/// the stages aft of the split are body 1. Each body flies as a point mass from the separation,
924/// with the mass properties of its own stages and motors, so every body must carry at least one
925/// device: the descent phase has no airframe drag (the decision record on recovery,
926/// [ADR-012][adr-012]), and a body with nothing open would fall as if in a vacuum.
927///
928/// A separation is an ideal one: no impulse, so each body leaves with the velocity its own center
929/// of mass already had. The aft body's motors must have burned out by then, because a body's
930/// mass is taken as constant through its descent, unless the separation may drop one burning
931/// ([`Separation::drops_burning`]). When the nose's body still has a motor to burn,
932/// it is a sustainer: it flies on as a rigid body on its own stages' aerodynamics, and only the aft
933/// body descends as a point mass (the decision record on staging, [ADR-074][adr-074]).
934///
935/// [adr-012]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-012-recovery-drag-areas-triggers-inflation-and-the-descent-phase-2026-09-17
936/// [adr-014]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-014-separation-bodies-their-masses-and-their-descents-2026-09-17
937/// [adr-074]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-074-ignition-times-and-powered-staging-the-sustainer-flies-on-as-a-rigid-body-2026-09-25
938#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
939#[serde(deny_unknown_fields)]
940pub struct Separation {
941    /// When the stack comes apart.
942    pub trigger: Trigger,
943    /// The last stage that stays with the nose. Stages after it form the aft body.
944    pub after_stage: usize,
945    /// Whether, when it is powered ([`Self::powered_at`]), it may drop a motor that still burns,
946    /// as OpenRocket's first burnout of a stage drops the stage's other motors (the decision
947    /// record on a stage's first burnout, [ADR-172](https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0172-a-stage-s-first-burnout.md)). The dropped body's descent is a point
948    /// mass with no thrust, so it leaves out the impulse that motor had left
949    /// ([`BodyFlight::impulse_left_n_s`]). Off by default: then the aft body's motors must have
950    /// burned out by the separation. A motor still to light is refused either way, and so is any
951    /// in an unpowered separation's aft body.
952    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
953    pub drops_burning: bool,
954}
955
956impl Separation {
957    /// A separation at the boundary after `after_stage`, on `trigger`.
958    #[must_use]
959    pub const fn new(trigger: Trigger, after_stage: usize) -> Self {
960        Self {
961            trigger,
962            after_stage,
963            drops_burning: false,
964        }
965    }
966
967    /// The same separation, let drop a motor that still burns when it is powered
968    /// ([`Self::drops_burning`]).
969    #[must_use]
970    pub const fn dropping_burning(mut self) -> Self {
971        self.drops_burning = true;
972        self
973    }
974
975    /// The stages of body `index`: body 0 keeps the nose, body 1 is the rest. `None` when there
976    /// is no such body, including when the boundary is at or past the last stage.
977    #[must_use]
978    pub const fn stages_of(&self, index: usize, stage_count: usize) -> Option<(usize, usize)> {
979        let Some(aft) = self.after_stage.checked_add(1) else {
980            return None;
981        };
982        if aft >= stage_count {
983            return None;
984        }
985        match index {
986            0 => Some((0, self.after_stage)),
987            1 => Some((aft, stage_count - 1)),
988            _ => None,
989        }
990    }
991
992    /// Whether it is powered when it fires at `time_s`, as the flight decides for the first
993    /// separation it flies: a motor of the nose's body (stages `0..=after_stage`) burns then, or
994    /// lights later at a known time, counting one this separation lights. A powered separation's
995    /// nose body flies on as a rigid body on its own stages' aerodynamics (the decision record on
996    /// staging, [ADR-074][adr-074]); an unpowered one's descends as a point mass with only its
997    /// devices' drag, as every body does (the decision record on separation, [ADR-014][adr-014]).
998    ///
999    /// [adr-014]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-014-separation-bodies-their-masses-and-their-descents-2026-09-17
1000    /// [adr-074]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-074-ignition-times-and-powered-staging-the-sustainer-flies-on-as-a-rigid-body-2026-09-25
1001    #[must_use]
1002    pub fn powered_at(&self, assembly: &hpr_design::Assembly, time_s: f64) -> bool {
1003        let lit = assembly.ignition_times_s(|stage| (stage == self.after_stage).then_some(time_s));
1004        assembly.motors.iter().zip(&lit).any(|(placed, ignition)| {
1005            placed.stage <= self.after_stage
1006                && ignition.is_some_and(|ignition| {
1007                    ignition + placed.mounted.motor.burnout_time_s() > time_s
1008                })
1009        })
1010    }
1011
1012    /// How many bodies it makes: two.
1013    pub const BODIES: usize = 2;
1014
1015    /// The body stage `stage` rides once every one of `separations` (in the order they fire, as
1016    /// [`crate::Simulation::with_separations`] takes them) has fired: body `k + 1` for the first
1017    /// separation `k` it is aft of, the part that separation drops; body 0, the nose's, for a
1018    /// stage aft of none.
1019    #[must_use]
1020    pub fn body_of(separations: &[Self], stage: usize) -> usize {
1021        separations
1022            .iter()
1023            .position(|separation| stage > separation.after_stage)
1024            .map_or(0, |k| k + 1)
1025    }
1026
1027    /// The stages of body `body` once every one of `separations` has fired, in a design of
1028    /// `stage_count` stages: body 0 keeps the nose, and body `k + 1` is the stages separation `k`
1029    /// drops, from its boundary back to the one behind it (or the tail). `None` when there is no
1030    /// such body, including when a boundary is at or past the last stage, or the boundaries don't
1031    /// move forward in the order given.
1032    #[must_use]
1033    pub fn stages_of_body(
1034        separations: &[Self],
1035        body: usize,
1036        stage_count: usize,
1037    ) -> Option<(usize, usize)> {
1038        let mut last = stage_count.checked_sub(1)?;
1039        for separation in separations {
1040            if separation.after_stage >= last {
1041                return None;
1042            }
1043            last = separation.after_stage;
1044        }
1045        match body {
1046            0 => Some((0, last)),
1047            _ => {
1048                let separation = separations.get(body - 1)?;
1049                let end = match body - 1 {
1050                    0 => stage_count - 1,
1051                    k => separations[k - 1].after_stage,
1052                };
1053                Some((separation.after_stage + 1, end))
1054            }
1055        }
1056    }
1057}
1058
1059/// The mass properties of the stages `first..=last` of `assembly`, with their motors, at flight
1060/// time `t_s`, each motor lit at its `ignition_s`. Summing over every stage gives
1061/// [`hpr_design::Assembly::mass_properties_lit`]. The flights sum pieces instead
1062/// (`crate::pieces`); the tests keep this as a reference that sums stages.
1063#[cfg(test)]
1064pub(crate) fn body_mass_properties(
1065    assembly: &hpr_design::Assembly,
1066    (first, last): (usize, usize),
1067    t_s: f64,
1068    ignition_s: &[Option<f64>],
1069) -> hpr_design::MassProperties {
1070    let mut parts = vec![];
1071    for (index, stage) in assembly.layout.stages.iter().enumerate() {
1072        if (first..=last).contains(&index) {
1073            parts.push(stage.mass);
1074        }
1075    }
1076    let motors: Vec<_> = assembly
1077        .motors
1078        .iter()
1079        .zip(ignition_s)
1080        .filter(|(motor, _)| (first..=last).contains(&motor.stage))
1081        .map(|(motor, ignition)| motor.mass_properties_lit(t_s, *ignition))
1082        .collect();
1083    parts.extend(motors);
1084    hpr_design::MassProperties::combine(parts.iter())
1085}
1086
1087/// The time a trigger fires, when it is one that is known before the flight: a time after
1088/// launch, or a delay after a motor's burnout, with the motors lit at `ignition_s`. A trigger on
1089/// a motor with no known ignition time has none either, and doesn't fire.
1090///
1091/// # Errors
1092///
1093/// [`SimError::Domain`] for a time before launch, a motor that isn't there, a motor with no
1094/// ejection delay in seconds, or a delay that is negative or not finite.
1095pub(crate) fn trigger_time_s(
1096    trigger: Trigger,
1097    motors: &[hpr_design::PlacedMotor],
1098    ignition_s: &[Option<f64>],
1099) -> Result<Option<f64>, SimError> {
1100    let burnout_s = |motor: usize| {
1101        ignition_s
1102            .get(motor)
1103            .copied()
1104            .flatten()
1105            .zip(motors.get(motor))
1106            .map(|(ignition_s, placed)| ignition_s + placed.mounted.motor.burnout_time_s())
1107    };
1108    match trigger {
1109        Trigger::Time { time_s } => {
1110            if !(time_s.is_finite() && time_s >= 0.0) {
1111                return Err(SimError::Domain {
1112                    what: "deployment time after launch, s",
1113                    value: time_s,
1114                });
1115            }
1116            Ok(Some(time_s))
1117        }
1118        Trigger::MotorDelay { motor } => {
1119            let placed = motors.get(motor).ok_or(SimError::Domain {
1120                what: "index of the motor whose delay fires a device",
1121                value: motor as f64,
1122            })?;
1123            let delay_s = match placed.mounted.delay {
1124                Some(hpr_motor::Delay::Seconds(delay_s)) => delay_s,
1125                _ => {
1126                    return Err(SimError::Domain {
1127                        what: "the motor firing a device has no ejection delay in seconds (it is \
1128                               plugged, or its delay is unset)",
1129                        value: motor as f64,
1130                    });
1131                }
1132            };
1133            if !(delay_s.is_finite() && delay_s >= 0.0) {
1134                return Err(SimError::Domain {
1135                    what: "motor ejection delay, s",
1136                    value: delay_s,
1137                });
1138            }
1139            Ok(burnout_s(motor).map(|t| t + delay_s))
1140        }
1141        Trigger::Burnout { motor, delay_s } => {
1142            if motor >= motors.len() {
1143                return Err(SimError::Domain {
1144                    what: "index of the motor whose burnout a trigger counts from",
1145                    value: motor as f64,
1146                });
1147            }
1148            if !(delay_s.is_finite() && delay_s >= 0.0) {
1149                return Err(SimError::Domain {
1150                    what: "delay after a motor's burnout, s",
1151                    value: delay_s,
1152                });
1153            }
1154            Ok(burnout_s(motor).map(|t| t + delay_s))
1155        }
1156        Trigger::Apogee | Trigger::Altitude { .. } => Ok(None),
1157    }
1158}
1159
1160/// Checks a flight's devices, and finds the trigger times that are known before it flies.
1161///
1162/// The result has one entry per device: `Some(t)` for a [`Trigger::Time`], and for a
1163/// [`Trigger::MotorDelay`] or [`Trigger::Burnout`] on a motor lit at a known time (of
1164/// `ignition_s`); `None` for the triggers the flight has to watch for, and for one on a motor that
1165/// hasn't a known ignition.
1166pub(crate) fn plan(
1167    devices: &[Device],
1168    motors: &[hpr_design::PlacedMotor],
1169    ignition_s: &[Option<f64>],
1170) -> Result<Vec<Option<f64>>, SimError> {
1171    // A device is cut away by a line on its own body; a release across a separation has nothing
1172    // to act through.
1173    for (index, device) in devices.iter().enumerate() {
1174        if let Some(other) = device.released_by
1175            && devices.get(other).is_some_and(|by| by.body != device.body)
1176        {
1177            return Err(SimError::Domain {
1178                what: "release across a separation (a device can only be released by one on its \
1179                       own body); index of the released device",
1180                value: index as f64,
1181            });
1182        }
1183    }
1184    // A cycle of releases (A released by B, B released by A) can leave every device released and
1185    // the rocket falling under nothing at all, which the descent phase would fly as a vacuum drop
1186    // (found in review). Each chain has to end.
1187    for start in 0..devices.len() {
1188        let mut at = start;
1189        for _ in 0..devices.len() {
1190            match devices[at].released_by {
1191                Some(next) if next < devices.len() => at = next,
1192                _ => break,
1193            }
1194            if at == start {
1195                return Err(SimError::Domain {
1196                    what: "recovery device releases run in a cycle, so every one of them could \
1197                           be released at once; index of a device in the cycle",
1198                    value: start as f64,
1199                });
1200            }
1201        }
1202    }
1203    let mut times = Vec::with_capacity(devices.len());
1204    for (index, device) in devices.iter().enumerate() {
1205        device.validate(devices.len(), index)?;
1206        let time = trigger_time_s(device.trigger, motors, ignition_s)?;
1207        times.push(time);
1208    }
1209    Ok(times)
1210}
1211
1212/// One device's progress through a flight.
1213#[derive(Debug, Clone, Copy, Default, PartialEq)]
1214pub(crate) struct DeviceRun {
1215    /// When its charge fired, s after launch.
1216    pub(crate) triggered_s: Option<f64>,
1217    /// When it will deploy (line stretch), s: the trigger plus the lag.
1218    pub(crate) deploy_s: Option<f64>,
1219    /// When it deployed (line stretch), s.
1220    pub(crate) deployed_s: Option<f64>,
1221    /// Its filling time, s, fixed at deployment.
1222    pub(crate) filling_time_s: f64,
1223    /// When it is released, s: the time the device that releases it is fully open.
1224    pub(crate) released_s: Option<f64>,
1225    /// Whether that release has been recorded.
1226    pub(crate) release_recorded: bool,
1227    /// Whether it was cut away before its own charge fired, so it never deploys.
1228    pub(crate) abandoned: bool,
1229}
1230
1231/// Every device's progress through one flight. The [`crate::Simulation`] is not mutated by a run
1232/// (Loft lesson L24), so this lives with the flight.
1233///
1234/// Every method here indexes `devices` by a device's position in the flight's list, which is the
1235/// invariant [`Run::new`] establishes: a run is built with one entry per device and is only ever
1236/// passed the same slice. Indexing therefore cannot be out of range, and a caller that broke that
1237/// (a future per-body run, M1.7b) would panic here rather than silently pull the wrong canopy.
1238#[derive(Debug, Clone, Default)]
1239pub(crate) struct Run {
1240    pub(crate) devices: Vec<DeviceRun>,
1241}
1242
1243impl Run {
1244    pub(crate) fn new(count: usize) -> Self {
1245        Self {
1246            devices: vec![DeviceRun::default(); count],
1247        }
1248    }
1249
1250    /// Fires device `index`'s charge at `t`, and returns when it will deploy.
1251    pub(crate) fn trigger(&mut self, devices: &[Device], index: usize, t: f64) -> f64 {
1252        let deploy_s = t + devices[index].lag_s;
1253        self.devices[index].triggered_s = Some(t);
1254        self.devices[index].deploy_s = Some(deploy_s);
1255        deploy_s
1256    }
1257
1258    /// Deploys device `index` at `t` with airspeed `airspeed_m_s`, schedules the release of
1259    /// whatever it releases for the moment its own canopy is full, and returns that time.
1260    pub(crate) fn deploy(
1261        &mut self,
1262        devices: &[Device],
1263        index: usize,
1264        t: f64,
1265        airspeed_m_s: f64,
1266    ) -> f64 {
1267        let device = &devices[index];
1268        let filling_time_s = device
1269            .inflation
1270            .filling_time_s(device.drag.nominal_diameter_m(), airspeed_m_s);
1271        self.devices[index].deployed_s = Some(t);
1272        self.devices[index].filling_time_s = filling_time_s;
1273        let full_s = t + filling_time_s;
1274        for (other, run) in devices.iter().zip(&mut self.devices) {
1275            if other.released_by == Some(index) && run.released_s.is_none() {
1276                run.released_s = Some(full_s);
1277            }
1278        }
1279        full_s
1280    }
1281
1282    /// Every time device `index` already has that a descent must not step past: its deployment,
1283    /// the end of its filling and its release.
1284    pub(crate) fn times_of(&self, index: usize) -> Vec<f64> {
1285        let run = self.devices[index];
1286        let mut times = Vec::new();
1287        times.extend(run.deploy_s);
1288        if let Some(deployed_s) = run.deployed_s {
1289            times.push(deployed_s + run.filling_time_s);
1290        }
1291        times.extend(run.released_s);
1292        times.retain(|time| time.is_finite());
1293        times
1294    }
1295
1296    /// When device `index` is released, s.
1297    pub(crate) fn released_s(&self, index: usize) -> Option<f64> {
1298        self.devices[index].released_s
1299    }
1300
1301    /// Whether a device among those for which `member` is true was open just before `t`: deployed
1302    /// before `t` and not released before it. That is something a body hangs from as it falls,
1303    /// any device but a tumble. So what happens at `t` itself, a deployment or a release, doesn't
1304    /// count yet, and the answer doesn't depend on how fast a device opening at `t` fills.
1305    pub(crate) fn hung_before(
1306        &self,
1307        devices: &[Device],
1308        member: impl Fn(usize) -> bool,
1309        t: f64,
1310    ) -> bool {
1311        devices.iter().enumerate().any(|(index, device)| {
1312            member(index)
1313                && !matches!(device.drag, DeviceDrag::Tumble { .. })
1314                && self.devices[index]
1315                    .deployed_s
1316                    .is_some_and(|deployed_s| deployed_s < t)
1317                && !self.devices[index]
1318                    .released_s
1319                    .is_some_and(|released_s| released_s < t)
1320        })
1321    }
1322
1323    /// Whether device `index`'s release has come and has not been recorded.
1324    pub(crate) fn release_due(&self, index: usize, t: f64) -> bool {
1325        let run = self.devices[index];
1326        !run.release_recorded && run.released_s.is_some_and(|released| t >= released)
1327    }
1328
1329    /// Marks device `index`'s release as recorded.
1330    pub(crate) fn release(&mut self, index: usize) {
1331        self.devices[index].release_recorded = true;
1332    }
1333
1334    /// Gives up on device `index`, which was cut away before its charge fired.
1335    pub(crate) fn abandon(&mut self, index: usize) {
1336        self.devices[index].abandoned = true;
1337    }
1338
1339    /// Whether device `index` has been triggered but has not deployed or been abandoned.
1340    pub(crate) fn waiting(&self, index: usize) -> bool {
1341        let run = self.devices[index];
1342        run.triggered_s.is_some() && run.deployed_s.is_none() && !run.abandoned
1343    }
1344
1345    /// When device `index` deploys, s: infinite until its charge fires.
1346    pub(crate) fn deploy_s(&self, index: usize) -> f64 {
1347        self.devices[index].deploy_s.unwrap_or(f64::INFINITY)
1348    }
1349
1350    /// Whether device `index` is still waiting for its trigger.
1351    pub(crate) fn pending(&self, index: usize) -> bool {
1352        self.devices[index].triggered_s.is_none()
1353    }
1354
1355    /// The total drag area of body `body`'s open devices at `t`, m².
1356    pub(crate) fn body_drag_area_m2(&self, devices: &[Device], body: usize, t: f64) -> f64 {
1357        devices
1358            .iter()
1359            .enumerate()
1360            .filter(|(_, device)| device.body == body)
1361            .map(|(index, _)| self.one_drag_area_m2(devices, index, t))
1362            .sum()
1363    }
1364
1365    /// The total drag area of the open devices at `t`, m².
1366    ///
1367    /// A device deployed at `t_d` with filling time `t_f` and growth exponent `j` contributes
1368    /// `(C_D S)₀ min(1, (t − t_d)/t_f)^j`, and nothing once it is released.
1369    pub(crate) fn drag_area_m2(&self, devices: &[Device], t: f64) -> f64 {
1370        (0..devices.len())
1371            .map(|index| self.one_drag_area_m2(devices, index, t))
1372            .sum()
1373    }
1374
1375    /// The drag area of device `index` at `t`, m²: nothing before it deploys or after it is
1376    /// released, and its inflation law in between.
1377    fn one_drag_area_m2(&self, devices: &[Device], index: usize, t: f64) -> f64 {
1378        let (device, run) = (&devices[index], self.devices[index]);
1379        let Some(deployed_s) = run.deployed_s else {
1380            return 0.0;
1381        };
1382        if run.released_s.is_some_and(|released| t >= released) {
1383            return 0.0;
1384        }
1385        let full = device.drag.drag_area_m2();
1386        let elapsed = t - deployed_s;
1387        if elapsed < 0.0 {
1388            return 0.0;
1389        }
1390        if run.filling_time_s <= 0.0 {
1391            return full;
1392        }
1393        let fraction = (elapsed / run.filling_time_s).min(1.0);
1394        // `j` is 1 (ribbon and ringslot) or 2 (solid cloth) for every canopy Knacke's method
1395        // names, so the common cases avoid `powf`.
1396        let growth = match device.inflation.exponent() {
1397            1.0 => fraction,
1398            2.0 => fraction * fraction,
1399            exponent => fraction.powf(exponent),
1400        };
1401        full * growth
1402    }
1403}
1404
1405/// One separated body at an instant of its descent.
1406#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1407pub struct BodySample {
1408    /// Time since launch, s.
1409    pub time_s: f64,
1410    /// Its center of mass in the launch frame, m.
1411    pub cg_enu_m: DVec3,
1412    /// That point's velocity relative to the launch frame, m/s.
1413    pub cg_velocity_enu_m_s: DVec3,
1414    /// Its center of mass's ellipsoidal height above the launch site, m.
1415    pub height_above_ground_m: f64,
1416    /// The rate of that height, m/s.
1417    pub vertical_speed_m_s: f64,
1418    /// Its airspeed, m/s.
1419    pub airspeed_m_s: f64,
1420    /// The drag area `C_D S` of its open devices, m².
1421    pub recovery_drag_area_m2: f64,
1422    /// Its mass, kg.
1423    pub mass_kg: f64,
1424}
1425
1426/// An event during a separated body's descent, with the body at that instant.
1427#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1428pub struct BodyEvent {
1429    /// What happened: a device's [`crate::EventKind::Trigger`], [`crate::EventKind::Deployment`],
1430    /// [`crate::EventKind::Release`], the body's [`crate::EventKind::Apogee`] or
1431    /// [`crate::EventKind::GroundHit`], or a piece leaving it at a
1432    /// [`crate::EventKind::Separation`] or [`crate::EventKind::Ejection`], whose sample has the
1433    /// body's mass from before the piece left.
1434    pub kind: crate::EventKind,
1435    /// The body at that instant.
1436    pub sample: BodySample,
1437    /// For a piece leaving it, the body just after: its mass without the piece, and its velocity
1438    /// once the ejections' impulses have pushed it ([`crate::Ejection::with_impulse`]). Partings
1439    /// at one instant share it: it is the body after all of them. `None` for every other event.
1440    #[serde(default, skip_serializing_if = "Option::is_none")]
1441    pub after: Option<BodySample>,
1442}
1443
1444/// One separated body's descent, from the moment it flies on its own to its landing.
1445#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1446pub struct BodyFlight {
1447    /// Which body: 0 keeps the nose. A body is numbered by its lead piece ([`crate::Ejection`]).
1448    pub body: usize,
1449    /// The first and last stage it has a component in when it lands.
1450    pub stages: (usize, usize),
1451    /// The pieces it lands with: its own, and any joined to it whose split never fired. Piece 0
1452    /// is the nose's, the separation makes piece 1, and each ejection the next.
1453    #[serde(default)]
1454    pub pieces: Vec<usize>,
1455    /// Its mass when it lands, kg. It is constant between splits, and steps down when a piece
1456    /// leaves it on the way down (an [`crate::EventKind::Ejection`] among its events).
1457    pub mass_kg: f64,
1458    /// Where it started flying on its own: at the airframe's first parting, its own center of mass
1459    /// and that point's velocity; at a later one, on the way down, the point and velocity of the
1460    /// body it left. Either way plus the pushes of the pushed ejections on its sides
1461    /// ([`crate::Ejection::with_impulse`]).
1462    pub start_sample: BodySample,
1463    /// Why its descent ended.
1464    pub termination: crate::Termination,
1465    /// Its events, in order.
1466    pub events: Vec<BodyEvent>,
1467    /// Where it ended.
1468    pub final_sample: BodySample,
1469    /// The integrator's work on it.
1470    pub stats: crate::Stats,
1471    /// The impulse its motors still had to give when it started flying on its own, N·s, which
1472    /// its descent leaves out: a point mass has no thrust. It is 0 but for a body a powered
1473    /// separation dropped with a motor still burning, as OpenRocket's first burnout of a stage
1474    /// drops its other motors ([ADR-172](https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0172-a-stage-s-first-burnout.md)). That thrust could have changed its speed by
1475    /// about this over its mass, a little more as the propellant left burns away.
1476    #[serde(default)]
1477    pub impulse_left_n_s: f64,
1478}
1479
1480impl BodyFlight {
1481    /// The first event of `kind`.
1482    #[must_use]
1483    pub fn event(&self, kind: crate::EventKind) -> Option<&BodyEvent> {
1484        self.events.iter().find(|event| event.kind == kind)
1485    }
1486}
1487
1488/// The equilibrium descent speed `v_e = √(2 m g/(ρ C_D S))`, m/s (Knacke, printed page 5-128).
1489///
1490/// It is the speed at which the drag area's drag balances the weight, so it is also the speed a
1491/// long descent settles at.
1492///
1493/// The formula is evaluated as written, with no domain checks: a zero drag area or density gives
1494/// infinity, and a negative mass, density, drag area or gravity gives NaN. Flights validate their
1495/// devices instead ([`crate::Simulation::with_recovery`]).
1496#[must_use]
1497pub fn terminal_speed_m_s(
1498    mass_kg: f64,
1499    drag_area_m2: f64,
1500    density_kg_m3: f64,
1501    gravity_m_s2: f64,
1502) -> f64 {
1503    (2.0 * mass_kg * gravity_m_s2 / (density_kg_m3 * drag_area_m2)).sqrt()
1504}
1505
1506#[cfg(test)]
1507mod tests {
1508    use std::f64::consts::PI;
1509
1510    use hpr_atmos::ConstantWind;
1511    use hpr_core::DVec3;
1512
1513    use super::*;
1514    use crate::environment::Environment;
1515    use crate::flight::{EventKind, FlightResult, FlightSettings, Simulation, Termination};
1516    use crate::rail::Rail;
1517    use crate::recorder::{Channel, Recorder};
1518    use crate::state::State;
1519    use crate::testing::{
1520        QuadraticDragFall, UniformAir, analytic_environment, analytic_wind_environment,
1521        closed_form_quadratic_drag, design, rotating_analytic_environment,
1522    };
1523
1524    const G: f64 = 9.806_65;
1525    /// Valetudo's motor burns out at 3.26 s; the descents start well after that.
1526    const START_S: f64 = 10.0;
1527
1528    /// Valetudo with `devices`, an hour's cap and a rail it never uses.
1529    fn flight(environment: Environment, devices: Vec<Device>, max_time_s: f64) -> Simulation {
1530        Simulation::new(
1531            &design("rocketpy-valetudo"),
1532            "example",
1533            environment,
1534            Rail::vertical(3.0),
1535            FlightSettings {
1536                max_time_s,
1537                ..FlightSettings::default()
1538            },
1539        )
1540        .unwrap()
1541        .with_recovery(devices)
1542        .unwrap()
1543    }
1544
1545    /// The same rocket with an ejection delay of `delay_s` on every motor of every configuration.
1546    fn with_delay(mut rocket: hpr_design::Rocket, delay_s: f64) -> hpr_design::Rocket {
1547        for configuration in &mut rocket.configurations {
1548            for motor in &mut configuration.motors {
1549                motor.delay = Some(hpr_motor::Delay::Seconds(delay_s));
1550            }
1551        }
1552        rocket
1553    }
1554
1555    /// A device open from the moment the descent starts.
1556    fn open_at_start(drag: DeviceDrag) -> Device {
1557        Device::new("test", drag, Trigger::Time { time_s: START_S })
1558    }
1559
1560    /// The state at rest (`velocity_enu_m_s`) with the center of mass `height_m` above the site,
1561    /// nose up.
1562    fn dropped(sim: &Simulation, height_m: f64, velocity_enu_m_s: DVec3) -> State {
1563        let attitude = Rail::vertical(3.0).attitude();
1564        let cg_m = sim.assembly().mass_properties(START_S).cg_m;
1565        State {
1566            position_enu_m: DVec3::new(0.0, 0.0, height_m) - attitude.mul_vec3(cg_m),
1567            velocity_enu_m_s,
1568            attitude,
1569            body_rate_rad_s: DVec3::ZERO,
1570        }
1571    }
1572
1573    /// The column `name` of every row.
1574    fn column(recorder: &Recorder, name: &str) -> Vec<f64> {
1575        let index = recorder
1576            .columns()
1577            .iter()
1578            .position(|c| c == name)
1579            .unwrap_or_else(|| panic!("no column {name}"));
1580        recorder.rows().iter().map(|row| row[index]).collect()
1581    }
1582
1583    /// The largest canopy drag force of a flight, N, over the event samples and the recorded
1584    /// steps: `q (C_D S)`.
1585    fn peak_load_n(result: &FlightResult, recorder: &Recorder) -> f64 {
1586        let from_events = result
1587            .events
1588            .iter()
1589            .map(|event| event.sample.dynamic_pressure_pa * event.sample.recovery_drag_area_m2);
1590        let pressure = column(recorder, "dynamic_pressure_pa");
1591        let area = column(recorder, "recovery_drag_area_m2");
1592        let from_rows = pressure.iter().zip(&area).map(|(q, s)| q * s);
1593        from_events.chain(from_rows).fold(0.0, f64::max)
1594    }
1595
1596    #[test]
1597    fn default_canopy_cd_carries_its_citation() {
1598        // Loft lesson L29. Loft's parachute C_D 0.8 was copied out of OpenRocket's GPL source.
1599        // hpr's default is the middle of Knacke's printed range for the type, on the nominal area
1600        // S₀ = π D₀²/4, and says where it comes from. RocketPy's 1.4 is not a C_D0 at all: it is a
1601        // hemispherical canopy's coefficient on the projected area, and Knacke's hemispherical
1602        // range on S₀ is 0.62 to 0.77.
1603        let flat = CanopyType::FlatCircular;
1604        assert_eq!(flat.drag_coefficient_range(), (0.75, 0.80));
1605        assert_eq!(flat.drag_coefficient(), 0.775);
1606        let (low, high) = flat.drag_coefficient_range();
1607        assert!(low <= flat.drag_coefficient() && flat.drag_coefficient() <= high);
1608        let source = flat.source();
1609        for cited in ["Knacke", "NWC TP 6575", "Table 5-1"] {
1610            assert!(source.contains(cited), "{source} does not cite {cited}");
1611        }
1612        assert_eq!(
1613            CanopyType::Hemispherical.drag_coefficient_range(),
1614            (0.62, 0.77)
1615        );
1616        assert!(CanopyType::Hemispherical.drag_coefficient() < 1.4);
1617        // The drag area is C_D0 on the nominal area, not on the projected area.
1618        let canopy = DeviceDrag::canopy(flat, 2.0);
1619        assert!((canopy.drag_area_m2() - 0.775 * PI).abs() < 1e-15);
1620        assert_eq!(canopy.nominal_diameter_m(), Some(2.0));
1621        assert_eq!(canopy.canopy_type(), Some(flat));
1622        // Knacke's tables as transcribed, entry by entry: the `C_D0` range (Tables 5-1 and 5-2),
1623        // the unreefed fill constant (Table 5-6, `None` where the table prints "insufficient
1624        // data"), the drag-area growth exponent (Pflanz, Figure 5-51, `None` for the types he
1625        // does not name) and the infinite-mass opening-force coefficient `C_x`. A typo in any of
1626        // these changes a user's descent rate, so they are pinned literally.
1627        let table = [
1628            (
1629                CanopyType::FlatCircular,
1630                0.75,
1631                0.80,
1632                Some(8.0),
1633                Some(2.0),
1634                1.7,
1635            ),
1636            (CanopyType::Conical, 0.75, 0.90, None, Some(2.0), 1.8),
1637            (CanopyType::Biconical, 0.75, 0.92, None, None, 1.8),
1638            (CanopyType::Triconical, 0.80, 0.96, None, Some(2.0), 1.8),
1639            (
1640                CanopyType::ExtendedSkirt10Flat,
1641                0.78,
1642                0.87,
1643                Some(10.0),
1644                Some(2.0),
1645                1.4,
1646            ),
1647            (
1648                CanopyType::ExtendedSkirt14Full,
1649                0.75,
1650                0.90,
1651                Some(12.0),
1652                Some(2.0),
1653                1.4,
1654            ),
1655            (CanopyType::Hemispherical, 0.62, 0.77, None, None, 1.6),
1656            (CanopyType::Annular, 0.85, 0.95, None, None, 1.4),
1657            (CanopyType::Cross, 0.60, 0.85, Some(11.7), None, 1.15),
1658            (
1659                CanopyType::FlatRibbon,
1660                0.45,
1661                0.50,
1662                Some(14.0),
1663                Some(1.0),
1664                1.05,
1665            ),
1666            (
1667                CanopyType::ConicalRibbon,
1668                0.50,
1669                0.55,
1670                Some(14.0),
1671                Some(1.0),
1672                1.05,
1673            ),
1674            (
1675                CanopyType::Ringslot,
1676                0.56,
1677                0.65,
1678                Some(14.0),
1679                Some(1.0),
1680                1.05,
1681            ),
1682            (CanopyType::Ringsail, 0.75, 0.85, Some(7.0), None, 1.10),
1683        ];
1684        for (kind, low, high, fill_constant, growth_exponent, opening) in table {
1685            assert_eq!(kind.drag_coefficient_range(), (low, high), "{kind:?}");
1686            assert_eq!(kind.drag_coefficient(), 0.5 * (low + high), "{kind:?}");
1687            assert_eq!(kind.fill_constant(), fill_constant, "{kind:?}");
1688            assert_eq!(kind.growth_exponent(), growth_exponent, "{kind:?}");
1689            assert_eq!(kind.opening_force_coefficient(), opening, "{kind:?}");
1690            assert!(
1691                source.contains("Knacke") && kind.source() == source,
1692                "{kind:?}"
1693            );
1694        }
1695
1696        assert_eq!(Inflation::knacke(CanopyType::Hemispherical), None);
1697        assert_eq!(
1698            Inflation::knacke(CanopyType::FlatCircular),
1699            Some(Inflation::FillConstant {
1700                constant: 8.0,
1701                exponent: 2.0
1702            })
1703        );
1704    }
1705
1706    #[test]
1707    fn descent_rate_equals_terminal_velocity() {
1708        // Loft lesson L92. Loft's own case, recomputed from Knacke's equilibrium descent speed
1709        // v_e = √(2 W/(ρ C_D0 S₀)) (printed page 5-128): 1.1 kg under a 1 m flat canopy of C_D 0.8
1710        // at ρ = 1.225 comes to 5.294 m/s. Loft allowed ±30% against it; hpr's formula has to
1711        // print it.
1712        let cd_s = 0.8 * PI * 1.0 * 1.0 / 4.0;
1713        let loft = terminal_speed_m_s(1.1, cd_s, 1.225, G);
1714        assert!((loft - 5.294).abs() < 5e-4, "{loft}");
1715
1716        // A flight under the same law: dropped from rest with the canopy already open, in uniform
1717        // air under constant gravity, the descent is the closed-form fall under quadratic drag,
1718        // v = −v_t tanh(g t/v_t), and it lands at the speed v_t that the formula gives.
1719        let air = UniformAir::sea_level();
1720        let rho = air.0.density_kg_m3;
1721        let device = open_at_start(DeviceDrag::canopy(CanopyType::FlatCircular, 1.5));
1722        let drag_area_m2 = device.drag.drag_area_m2();
1723        let sim = flight(analytic_environment(air, G), vec![device], 3600.0);
1724        let mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
1725        let terminal_m_s = terminal_speed_m_s(mass_kg, drag_area_m2, rho, G);
1726        let height_m = 2_000.0;
1727        let closed = closed_form_quadratic_drag(
1728            &QuadraticDragFall {
1729                gravity_mps2: G,
1730                k_per_m: rho * drag_area_m2 / (2.0 * mass_kg),
1731            },
1732            0.0,
1733        );
1734        assert!((closed.apogee_s).abs() < 1e-15);
1735
1736        let mut recorder = Recorder::new(
1737            vec![
1738                Channel::Time,
1739                Channel::VerticalSpeed,
1740                Channel::HeightAboveGround,
1741            ],
1742            Some(5.0),
1743        )
1744        .unwrap();
1745        let result = sim
1746            .run_free(START_S, dropped(&sim, height_m, DVec3::ZERO), &mut recorder)
1747            .unwrap();
1748        assert_eq!(result.termination, Termination::GroundHit);
1749        assert_eq!(result.final_sample.phase, crate::Phase::Descent);
1750
1751        // The whole descent follows the closed form, and the impact speed is the terminal speed
1752        // (reached to 1e-9 of it after 2 km).
1753        let times = column(&recorder, "time_s");
1754        let speeds = column(&recorder, "vertical_speed_m_s");
1755        let mut worst: f64 = 0.0;
1756        for (t, v) in times.iter().zip(&speeds) {
1757            let expected = closed.state(t - START_S)[1];
1758            worst = worst.max((v - expected).abs() / terminal_m_s);
1759        }
1760        // Measured: 2.1e-8 of v_t, the integrator's own error at rtol = atol = 1e-8.
1761        assert!(worst < 1e-7, "{worst} of v_t");
1762        let landing = result.event(EventKind::GroundHit).unwrap().sample;
1763        assert!(
1764            (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 1e-7,
1765            "{} vs {terminal_m_s}",
1766            landing.vertical_speed_m_s
1767        );
1768        // And it lands when the closed form says, to 1e-5 s of a 200 s descent.
1769        let expected_s = START_S + closed.time_at_descending_height_s(-height_m);
1770        assert!(
1771            (landing.time_s - expected_s).abs() < 1e-5,
1772            "{} vs {expected_s}",
1773            landing.time_s
1774        );
1775    }
1776
1777    #[test]
1778    fn drift_equals_the_wind_times_the_descent_time() {
1779        // Dropped into a steady wind with the same horizontal velocity as the air, the rocket has
1780        // no crossflow: the horizontal equation holds v = w for the whole descent, so the drift is
1781        // exactly the wind times the time of flight, and the vertical fall is unchanged.
1782        let air = UniformAir::sea_level();
1783        let device = open_at_start(DeviceDrag::canopy(CanopyType::FlatCircular, 1.5));
1784        let drag_area_m2 = device.drag.drag_area_m2();
1785        let sim = flight(
1786            analytic_wind_environment(air, G, ConstantWind::new(6.5, 0.7).unwrap()),
1787            vec![device],
1788            3600.0,
1789        );
1790        let wind_enu = sim.environment().wind.wind(0.0).unwrap().velocity_enu_m_s;
1791        assert!(wind_enu.z == 0.0 && wind_enu.length() > 6.4, "{wind_enu}");
1792        let mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
1793        let closed = closed_form_quadratic_drag(
1794            &QuadraticDragFall {
1795                gravity_mps2: G,
1796                k_per_m: air.0.density_kg_m3 * drag_area_m2 / (2.0 * mass_kg),
1797            },
1798            0.0,
1799        );
1800        let height_m = 1_000.0;
1801        let start = dropped(&sim, height_m, wind_enu);
1802        let result = sim.run_free(START_S, start, &mut ()).unwrap();
1803        assert_eq!(result.termination, Termination::GroundHit);
1804        let landing = result.event(EventKind::GroundHit).unwrap().sample;
1805
1806        let flown_s = landing.time_s - START_S;
1807        let drift =
1808            landing.cg_enu_m - start.point_enu_m(sim.assembly().mass_properties(START_S).cg_m);
1809        let expected = wind_enu * flown_s;
1810        assert!(
1811            (drift.x - expected.x).abs() < 1e-8 * expected.x.abs()
1812                && (drift.y - expected.y).abs() < 1e-8 * expected.y.abs(),
1813            "{drift} vs {expected}"
1814        );
1815        // The wind doesn't change the fall: the descent takes what the closed form says, to 1e-4
1816        // of it (the drift of 1.5 km costs 0.13 m of ellipsoidal height, which the closed form
1817        // over a flat Earth doesn't have).
1818        let expected_s = closed.time_at_descending_height_s(-height_m);
1819        assert!(
1820            (flown_s - expected_s).abs() < 1e-4 * expected_s,
1821            "{flown_s} vs {expected_s}"
1822        );
1823        assert!(drift.length() > 600.0, "{drift}");
1824    }
1825
1826    #[test]
1827    fn coriolis_drifts_a_descent_east_by_the_analytic_amount() {
1828        // Every other analytic descent here runs with Earth's rotation off. With it on, a body
1829        // falling at `v_t` feels `−2Ω × v = 2Ω v_t cos φ` to the east, which the canopy's drag
1830        // balances at `v_east = 2Ω cos φ · m/(½ρ C_D S) = 2Ω cos φ · v_t²/g`. The drift is that
1831        // times the time of flight, once the fall has settled.
1832        let air = UniformAir::sea_level();
1833        let device = open_at_start(DeviceDrag::canopy(CanopyType::FlatCircular, 1.5));
1834        let drag_area_m2 = device.drag.drag_area_m2();
1835        let sim = flight(rotating_analytic_environment(air, G), vec![device], 3600.0);
1836        let mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
1837        let terminal_m_s = terminal_speed_m_s(mass_kg, drag_area_m2, air.0.density_kg_m3, G);
1838        let latitude_rad = sim.environment().site().latitude_rad;
1839        let rate_rad_s = 7.292_115e-5;
1840        let east_m_s = 2.0 * rate_rad_s * latitude_rad.cos() * terminal_m_s * terminal_m_s / G;
1841        assert!(east_m_s > 1e-3, "{east_m_s} m/s is too small to measure");
1842
1843        let height_m = 3_000.0;
1844        let start = dropped(&sim, height_m, DVec3::ZERO);
1845        let result = sim.run_free(START_S, start, &mut ()).unwrap();
1846        assert_eq!(result.termination, Termination::GroundHit);
1847        let landing = result.event(EventKind::GroundHit).unwrap().sample;
1848        let flown_s = landing.time_s - START_S;
1849        let drift =
1850            landing.cg_enu_m - start.point_enu_m(sim.assembly().mass_properties(START_S).cg_m);
1851        let expected_m = east_m_s * flown_s;
1852        // The drift builds up over the first few seconds, while the fall settles, so the
1853        // prediction is the steady one. Measured: 0.3666 m east against the steady prediction's
1854        // 0.3685 m over 306.0 s, and 29 µm north.
1855        assert!(
1856            (drift.x - expected_m).abs() < 0.05 * expected_m,
1857            "{} m east against {expected_m} m in {flown_s} s",
1858            drift.x
1859        );
1860        assert!(
1861            drift.y.abs() < 0.02 * expected_m,
1862            "{} m north, which should be second order",
1863            drift.y
1864        );
1865        // The fall itself is the same as without rotation, to the size of the drift's effect.
1866        let without = terminal_speed_m_s(mass_kg, drag_area_m2, air.0.density_kg_m3, G);
1867        assert!(
1868            (-landing.vertical_speed_m_s / without - 1.0).abs() < 1e-5,
1869            "{} vs {without}",
1870            landing.vertical_speed_m_s
1871        );
1872    }
1873
1874    #[test]
1875    fn inflation_time_limits_peak_opening_load() {
1876        // Loft lesson L27. Loft's canopies opened instantly, so its peak load was the whole
1877        // steady drag at deployment speed. Knacke's filling time t_f = n D₀/v (printed page 5-43)
1878        // with the drag area growing as (t/t_f)^j (Pflanz, Figure 5-51) spreads the opening out:
1879        // the canopy builds its area while the rocket is already slowing down.
1880        //
1881        // hpr does not model Knacke's measured overshoot (C_x = 1.7 for a flat circular canopy at
1882        // infinite mass). For a deployment well above the canopy's terminal speed, as here, the
1883        // instant opening gives hpr's highest load; near terminal speed a filling time can give a
1884        // higher one, because the rocket speeds up while the canopy fills.
1885        let air = UniformAir::sea_level();
1886        let rho = air.0.density_kg_m3;
1887        let diameter_m = 1.5;
1888        let fall = DVec3::new(0.0, 0.0, -60.0);
1889        let mut loads = Vec::new();
1890        let mut mass_kg = 0.0;
1891        for inflation in [
1892            Inflation::Instant,
1893            Inflation::knacke(CanopyType::FlatCircular).unwrap(),
1894        ] {
1895            let device = open_at_start(DeviceDrag::canopy(CanopyType::FlatCircular, diameter_m))
1896                .with_inflation(inflation);
1897            let drag_area_m2 = device.drag.drag_area_m2();
1898            let sim = flight(analytic_environment(air, G), vec![device], 3600.0);
1899            let mut recorder = Recorder::new(
1900                vec![
1901                    Channel::Time,
1902                    Channel::DynamicPressure,
1903                    Channel::RecoveryDragArea,
1904                    Channel::VerticalSpeed,
1905                ],
1906                None,
1907            )
1908            .unwrap();
1909            let result = sim
1910                .run_free(START_S, dropped(&sim, 2_000.0, fall), &mut recorder)
1911                .unwrap();
1912            assert_eq!(result.termination, Termination::GroundHit);
1913            mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
1914            let terminal_m_s = terminal_speed_m_s(mass_kg, drag_area_m2, rho, G);
1915            let landing = result.event(EventKind::GroundHit).unwrap().sample;
1916            assert!(
1917                (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 1e-6,
1918                "{}",
1919                landing.vertical_speed_m_s
1920            );
1921            loads.push((peak_load_n(&result, &recorder), drag_area_m2));
1922        }
1923        let (instant_n, drag_area_m2) = loads[0];
1924        let (filled_n, _) = loads[1];
1925        // The instant opening's peak is the steady drag at the deployment speed, at deployment.
1926        let steady_n = 0.5 * rho * drag_area_m2 * fall.z * fall.z;
1927        assert!(
1928            (instant_n - steady_n).abs() < 1e-6 * steady_n,
1929            "{instant_n} vs {steady_n}"
1930        );
1931        // Filling in t_f = n D₀/v = 8 · 1.5/60 = 0.2 s cuts the peak to 1,615 N, 0.53 of the
1932        // instant opening. That is pinned against the closed form rather than a fraction, because
1933        // a fraction cannot tell the growth exponents apart (`j = 1` would peak near 0.42).
1934        //
1935        // Ignoring gravity, `m dv/dt = −½ρ(C_D S)(t/t_f)² v²` separates to
1936        // `1/v = 1/v₀ + k t³/(3 t_f²)` with `k = ρ(C_D S)/2m`, so at the end of filling
1937        // `v = v₀/(1 + k v₀ t_f/3)` and the peak load is `½ρ(C_D S) v²` there (the load rises
1938        // while the canopy grows and falls once it is full). Gravity adds at most `g t_f` to that
1939        // speed, which is where the upper bound comes from.
1940        let k_per_m = rho * drag_area_m2 / (2.0 * mass_kg);
1941        let filling_s = CanopyType::FlatCircular.fill_constant().unwrap() * diameter_m / -fall.z;
1942        let end_m_s = -fall.z / (1.0 + k_per_m * -fall.z * filling_s / 3.0);
1943        let closed_n = 0.5 * rho * drag_area_m2 * end_m_s * end_m_s;
1944        let with_gravity_n = closed_n * (1.0 + G * filling_s / end_m_s).powi(2);
1945        assert!(
1946            (closed_n..=with_gravity_n).contains(&filled_n),
1947            "{filled_n} N is outside {closed_n} to {with_gravity_n} N (t_f {filling_s} s)"
1948        );
1949        assert!(filled_n < 0.6 * instant_n, "{filled_n} against {instant_n}");
1950    }
1951
1952    #[test]
1953    fn a_whole_flight_deploys_a_drogue_at_apogee_and_a_main_that_releases_it() {
1954        // A flight from the pad: the drogue's charge fires at apogee and opens 1 s later, the main
1955        // fires at 300 m above the site and opens 1.5 s later, and the main's opening releases the
1956        // drogue. The events have to come in that order, the drag area has to follow, and each
1957        // stage of the descent has to settle at its own terminal speed.
1958        let devices = vec![
1959            Device::new(
1960                "drogue",
1961                DeviceDrag::DragArea { cd_s_m2: 0.45 },
1962                Trigger::Apogee,
1963            )
1964            .with_lag_s(1.0)
1965            .with_release_by(1),
1966            Device::new(
1967                "main",
1968                DeviceDrag::canopy(CanopyType::FlatCircular, 2.5),
1969                Trigger::Altitude {
1970                    height_above_ground_m: 300.0,
1971                },
1972            )
1973            .with_lag_s(1.5),
1974        ];
1975        let drogue_m2 = devices[0].drag.drag_area_m2();
1976        let main_m2 = devices[1].drag.drag_area_m2();
1977        let sim = flight(
1978            analytic_wind_environment(
1979                UniformAir::sea_level(),
1980                G,
1981                ConstantWind::new(4.0, 0.0).unwrap(),
1982            ),
1983            devices,
1984            3600.0,
1985        );
1986        let mut recorder = Recorder::new(
1987            vec![
1988                Channel::Time,
1989                Channel::HeightAboveGround,
1990                Channel::VerticalSpeed,
1991                Channel::RecoveryDragArea,
1992            ],
1993            Some(0.25),
1994        )
1995        .unwrap();
1996        let result = sim.run(&mut recorder).unwrap();
1997        assert_eq!(result.termination, Termination::GroundHit);
1998
1999        let kinds: Vec<EventKind> = result.events.iter().map(|event| event.kind).collect();
2000        assert_eq!(
2001            kinds,
2002            vec![
2003                EventKind::Liftoff,
2004                EventKind::RailExit,
2005                EventKind::Burnout,
2006                EventKind::Apogee,
2007                EventKind::Trigger(0),
2008                EventKind::Deployment(0),
2009                EventKind::Trigger(1),
2010                EventKind::Deployment(1),
2011                EventKind::Release(0),
2012                EventKind::GroundHit,
2013            ]
2014        );
2015        let at = |kind| result.event(kind).unwrap().sample;
2016        // Each charge opens its device after its lag, and the descent starts at the first opening.
2017        assert!(
2018            (at(EventKind::Deployment(0)).time_s - at(EventKind::Trigger(0)).time_s - 1.0).abs()
2019                < 1e-12
2020        );
2021        assert!(
2022            (at(EventKind::Deployment(1)).time_s - at(EventKind::Trigger(1)).time_s - 1.5).abs()
2023                < 1e-12
2024        );
2025        assert_eq!(at(EventKind::Apogee).phase, crate::Phase::Free);
2026        assert_eq!(at(EventKind::Deployment(0)).phase, crate::Phase::Descent);
2027        // The main fires as the center of mass passes 300 m, descending under the drogue.
2028        let trigger = at(EventKind::Trigger(1));
2029        assert!(
2030            (trigger.height_above_ground_m - 300.0).abs() < 1e-6,
2031            "{trigger:?}"
2032        );
2033        assert!(trigger.vertical_speed_m_s < 0.0);
2034        // The drag area: the drogue alone, then the main alone once it releases the drogue.
2035        assert!((at(EventKind::Deployment(0)).recovery_drag_area_m2 - drogue_m2).abs() < 1e-12);
2036        assert!((at(EventKind::Trigger(1)).recovery_drag_area_m2 - drogue_m2).abs() < 1e-12);
2037        assert!((at(EventKind::Deployment(1)).recovery_drag_area_m2 - main_m2).abs() < 1e-12);
2038        assert!((at(EventKind::GroundHit).recovery_drag_area_m2 - main_m2).abs() < 1e-12);
2039
2040        // Both stages settle at their own terminal speeds, so the descent rate falls when the
2041        // main opens. The drogue's stage is the speed just before the main's charge fires.
2042        let mass_kg = sim
2043            .assembly()
2044            .mass_properties(at(EventKind::Apogee).time_s)
2045            .mass_kg;
2046        let rho = UniformAir::sea_level().0.density_kg_m3;
2047        let under_drogue = terminal_speed_m_s(mass_kg, drogue_m2, rho, G);
2048        let under_main = terminal_speed_m_s(mass_kg, main_m2, rho, G);
2049        assert!(
2050            (-trigger.vertical_speed_m_s / under_drogue - 1.0).abs() < 0.02,
2051            "{} vs {under_drogue}",
2052            trigger.vertical_speed_m_s
2053        );
2054        let landing = at(EventKind::GroundHit);
2055        assert!(
2056            (-landing.vertical_speed_m_s / under_main - 1.0).abs() < 0.02,
2057            "{} vs {under_main}",
2058            landing.vertical_speed_m_s
2059        );
2060        assert!(
2061            under_main < 0.5 * under_drogue,
2062            "{under_main} {under_drogue}"
2063        );
2064        // The recorder's drag-area channel never exceeds one device's area: they never add up.
2065        let area = column(&recorder, "recovery_drag_area_m2");
2066        assert!(area.iter().all(|a| *a <= main_m2 + 1e-12));
2067        assert!(area.iter().any(|a| (*a - drogue_m2).abs() < 1e-12));
2068    }
2069
2070    #[test]
2071    fn a_motor_delay_fires_a_device_and_a_canopy_fills_by_knackes_law() {
2072        // The ejection charge of Valetudo's motor (a 2 s delay after its 3.26 s burn) fires the
2073        // canopy, which then fills over t_f = n D₀/v from the airspeed at line stretch, its drag
2074        // area growing as (t/t_f)^j.
2075        let kind = CanopyType::FlatCircular;
2076        let diameter_m = 1.2;
2077        // Valetudo's example gives its motor no ejection charge, so the test picks one.
2078        let sim = Simulation::new(
2079            &with_delay(design("rocketpy-valetudo"), 2.0),
2080            "example",
2081            analytic_environment(UniformAir::sea_level(), G),
2082            Rail::vertical(3.0),
2083            FlightSettings::default(),
2084        )
2085        .unwrap()
2086        .with_recovery(vec![
2087            Device::new(
2088                "ejection",
2089                DeviceDrag::canopy(kind, diameter_m),
2090                Trigger::MotorDelay { motor: 0 },
2091            )
2092            .with_inflation(Inflation::knacke(kind).unwrap()),
2093        ])
2094        .unwrap();
2095        let delay_s = match sim.assembly().motors[0].mounted.delay {
2096            Some(hpr_motor::Delay::Seconds(delay_s)) => delay_s,
2097            other => panic!("Valetudo's motor has no delay in seconds: {other:?}"),
2098        };
2099        let burnout_s = sim.assembly().motors[0].mounted.motor.burnout_time_s();
2100        let mut recorder =
2101            Recorder::new(vec![Channel::Time, Channel::RecoveryDragArea], None).unwrap();
2102        let result = sim.run(&mut recorder).unwrap();
2103        assert_eq!(result.termination, Termination::GroundHit);
2104        let trigger = result.event(EventKind::Trigger(0)).unwrap().sample;
2105        let deployment = result.event(EventKind::Deployment(0)).unwrap().sample;
2106        assert!(
2107            (trigger.time_s - (burnout_s + delay_s)).abs() < 1e-12,
2108            "{} vs {}",
2109            trigger.time_s,
2110            burnout_s + delay_s
2111        );
2112        assert_eq!(deployment.time_s, trigger.time_s, "no lag was given");
2113        assert_eq!(deployment.recovery_drag_area_m2, 0.0, "it starts empty");
2114
2115        // Knacke's filling time from the airspeed at line stretch, and the growth law between.
2116        let filling_s = kind.fill_constant().unwrap() * diameter_m / deployment.airspeed_m_s;
2117        assert!(filling_s > 0.05 && filling_s < 0.5, "{filling_s} s");
2118        let full_m2 = sim.recovery()[0].drag.drag_area_m2();
2119        let times = column(&recorder, "time_s");
2120        let areas = column(&recorder, "recovery_drag_area_m2");
2121        let mut checked = 0;
2122        for (t, area) in times.iter().zip(&areas) {
2123            let elapsed = t - deployment.time_s;
2124            if elapsed <= 0.0 {
2125                assert_eq!(*area, 0.0, "area before line stretch at {t}");
2126                continue;
2127            }
2128            let fraction = (elapsed / filling_s).min(1.0);
2129            let expected = full_m2 * fraction.powf(kind.growth_exponent().unwrap());
2130            assert!(
2131                (area - expected).abs() < 1e-9 * full_m2,
2132                "{area} vs {expected} at {t}"
2133            );
2134            if elapsed < filling_s {
2135                checked += 1;
2136            }
2137        }
2138        assert!(checked >= 3, "only {checked} rows inside the filling time");
2139    }
2140
2141    /// The comparison against RocketPy's own parachute phase
2142    /// (`validation/oracles/rocketpy/recovery.py`, M1.7a): the same declared state, devices, wind
2143    /// and site, and the descent that follows.
2144    #[test]
2145    fn descent_matches_rocketpy_examples() {
2146        let fixture = include_str!("../../../validation/fixtures/recovery/rocketpy-descent.json");
2147        let document: serde_json::Value = serde_json::from_str(fixture).unwrap();
2148        assert_eq!(document["oracle"], "rocketpy 1.13.0");
2149        let cases = document["cases"].as_array().unwrap();
2150        assert!(cases.len() >= 3, "{} cases", cases.len());
2151        let number = |value: &serde_json::Value| value.as_f64().unwrap();
2152        let mut rows = Vec::new();
2153
2154        for case in cases {
2155            let name = case["name"].as_str().unwrap();
2156            let environment = case["environment"].clone();
2157
2158            // The site: RocketPy's elevation above sea level, taken as the ellipsoidal height with
2159            // no geoid undulation (hpr has no geoid model, and the oracle's atmosphere and wind
2160            // are functions of that same height).
2161            let site = hpr_core::geodesy::Geodetic::from_degrees(
2162                number(&environment["latitude_deg"]),
2163                number(&environment["longitude_deg"]),
2164                number(&environment["elevation_m"]),
2165            )
2166            .unwrap();
2167            let wind = wind_of(&environment);
2168            // Gravity: RocketPy applies it to the vertical axis alone (`Flight.u_dot_parachute`,
2169            // `flight.py:2777`, where only `az` carries a gravity term), so this flies hpr's
2170            // model of the same shape. hpr's default is the full normal-gravity vector, which
2171            // differs from that in two ways: it turns with the vertical downrange (`g d/R`,
2172            // 2.1e-3 m/s² at Calisto's 1.4 km of drift, and the larger effect wherever a rocket
2173            // drifts), and above the ellipsoid it leans a few parts in 10^6 toward the equator
2174            // (3e-5 m/s²). The lean is what issue #27 chased: over Valetudo's 800 m of still-air
2175            // descent it is 5.2e-4 m of drift, 26 times what the Coriolis term produces there.
2176            let earth = hpr_core::earth::Earth::new(
2177                hpr_core::gravity::NormalGravity::wgs84(),
2178                site,
2179                hpr_core::earth::GravityModel::VerticalTaylor,
2180                hpr_core::earth::EarthRotation::Coriolis,
2181            )
2182            .unwrap();
2183            let sim = Simulation::new(
2184                &design(&format!("rocketpy-{name}")),
2185                "example",
2186                Environment {
2187                    wind,
2188                    ..Environment::new(
2189                        earth,
2190                        hpr_atmos::AtmosphereModel::default(),
2191                        hpr_atmos::ConstantWind::calm(),
2192                    )
2193                },
2194                // The rail is never used: these flights start in the air. It only has to be
2195                // long enough for the design's guides.
2196                Rail::vertical(6.0),
2197                FlightSettings {
2198                    max_time_s: 6000.0,
2199                    ..FlightSettings::default()
2200                },
2201            )
2202            .unwrap();
2203
2204            // First the environments: a descent compared against an oracle whose air, gravity or
2205            // wind differs is not comparing recovery.
2206            for sample in environment["samples"].as_array().unwrap() {
2207                let height_msl_m = number(&sample["height_msl_m"]);
2208                let air = sim.environment().atmosphere.air(height_msl_m).unwrap().air;
2209                let density = air.density_kg_m3;
2210                let oracle = number(&sample["density_kg_m3"]);
2211                // RocketPy reads its standard atmosphere off a 100-point linear pressure table
2212                // over 0 to 80 km, so it differs from the exact 1976 atmosphere. Measured worst
2213                // over all the samples: 3.7e-4 relative (NDRT at 206 m), which is 1.9e-4 in a
2214                // descent rate, below every difference this test goes on to report.
2215                assert!(
2216                    (density - oracle).abs() < 5e-4 * oracle,
2217                    "{name}: density {density} vs {oracle} at {height_msl_m} m"
2218                );
2219                // Gravity: RocketPy's "Somigliana" model is the same WGS 84 normal gravity hpr
2220                // uses, so these have to agree, not merely be close. They have to agree as a
2221                // *vector*, because agreeing in magnitude is exactly what let a model difference
2222                // hide here for a milestone. Under the model this test flies, gravity is vertical,
2223                // as RocketPy's is; under hpr's default it would not be, and the next assertion
2224                // measures what that would have added.
2225                let up = DVec3::new(0.0, 0.0, height_msl_m - number(&environment["elevation_m"]));
2226                let gravity = sim.environment().earth.gravity_enu_mps2(up).unwrap();
2227                let oracle_gravity = number(&sample["gravity_m_s2"]);
2228                // 1e-8, not the 1e-6 this used to allow: the worst residual over the 23 samples
2229                // is 4.7e-9 relative, and 1e-6 of g is 9.8e-6 m/s², which is larger than the
2230                // deflection issue #27 turned on. A bound has to be tighter than the effects it
2231                // is meant to see.
2232                assert!(
2233                    (gravity.length() - oracle_gravity).abs() < 1e-8 * oracle_gravity,
2234                    "{name}: gravity {gravity:?} vs {oracle_gravity} at {height_msl_m} m"
2235                );
2236                assert_eq!(
2237                    (gravity.x, gravity.y),
2238                    (0.0, 0.0),
2239                    "{name}: gravity leans off the vertical where RocketPy's cannot"
2240                );
2241                // What hpr's own model would have added, so the difference is a number in this
2242                // file rather than a surprise in a validation report. To first order the
2243                // meridional component of normal gravity above the ellipsoid is
2244                //
2245                //     γ_φ ≈ −C h sin 2φ,   C = 8.15e-9 s⁻²
2246                //
2247                // which is proportional to height above the ellipsoid, points toward the equator
2248                // in both hemispheres, and is zero on it (`docs/physics/gravity.md`). Over these
2249                // five cases it runs from +6.9e-6 m/s² at Valetudo's topmost sample (1,168 m, 23°S,
2250                // so northward) to −3.3e-5 m/s² at Calisto's 4,400 m (33°N, so southward).
2251                // Checking the form rather than a bound is the point: "less than 5e-5" would pass
2252                // an implementation whose size was wrong by half.
2253                let latitude_rad = number(&environment["latitude_deg"]).to_radians();
2254                let expected = -8.15e-9 * height_msl_m * (2.0 * latitude_rad).sin();
2255                let ellipsoidal = hpr_core::earth::Earth::new(
2256                    hpr_core::gravity::NormalGravity::wgs84(),
2257                    site,
2258                    hpr_core::earth::GravityModel::Ellipsoidal,
2259                    hpr_core::earth::EarthRotation::Coriolis,
2260                )
2261                .unwrap()
2262                .gravity_enu_mps2(up)
2263                .unwrap();
2264                assert!(
2265                    ellipsoidal.x.abs() < 1e-12,
2266                    "{name}: normal gravity has no east component, but this one is {}",
2267                    ellipsoidal.x
2268                );
2269                assert!(
2270                    (ellipsoidal.y - expected).abs() <= 0.02 * expected.abs().max(1e-9),
2271                    "{name}: hpr's own gravity leans {} m/s² at {height_msl_m} m, where the \
2272                     first-order normal-gravity term is {expected} m/s² (issue #27)",
2273                    ellipsoidal.y
2274                );
2275                let wind = sim
2276                    .environment()
2277                    .wind
2278                    .wind(height_msl_m)
2279                    .unwrap()
2280                    .velocity_enu_m_s;
2281                for (component, key) in [(wind.x, "wind_east_m_s"), (wind.y, "wind_north_m_s")] {
2282                    let oracle = number(&sample[key]);
2283                    assert!(
2284                        (component - oracle).abs() < 1e-9,
2285                        "{name}: {key} {component} vs {oracle} at {height_msl_m} m"
2286                    );
2287                }
2288                assert_eq!(wind.z, 0.0);
2289            }
2290
2291            // The devices: the oracle's drag areas and triggers, with the first open from the
2292            // start and each one released by the next, which is how RocketPy's single `cd_s`
2293            // behaves when a main replaces a drogue.
2294            let start = case["start"].clone();
2295            let start_s = number(&start["time_s"]);
2296            let oracle_devices = case["devices"].as_array().unwrap();
2297            let mut devices = Vec::new();
2298            for (index, device) in oracle_devices.iter().enumerate() {
2299                let drag = DeviceDrag::DragArea {
2300                    cd_s_m2: number(&device["cd_s_m2"]),
2301                };
2302                let trigger = if index == 0 {
2303                    assert_eq!(device["trigger"]["kind"], "apogee");
2304                    assert_eq!(
2305                        number(&device["lag_s"]),
2306                        0.0,
2307                        "{name}: the first lag is zero"
2308                    );
2309                    Trigger::Time { time_s: start_s }
2310                } else {
2311                    assert_eq!(device["trigger"]["kind"], "descending_below_height_agl");
2312                    Trigger::Altitude {
2313                        height_above_ground_m: number(&device["trigger"]["height_m"]),
2314                    }
2315                };
2316                let mut next = Device::new(device["name"].as_str().unwrap(), drag, trigger)
2317                    .with_lag_s(number(&device["lag_s"]));
2318                if index + 1 < oracle_devices.len() {
2319                    next = next.with_release_by(index + 1);
2320                }
2321                devices.push(next);
2322            }
2323            let sim = sim.with_recovery(devices).unwrap();
2324
2325            // The mass: RocketPy's parachute phase uses the rocket's dry mass, and hpr the
2326            // assembly's mass once the propellant is gone. The design is generated from the same
2327            // example, so they have to agree.
2328            let mass_kg = sim.assembly().mass_properties(start_s).mass_kg;
2329            let dry_mass_kg = number(&case["dry_mass_kg"]);
2330            assert!(
2331                (mass_kg - dry_mass_kg).abs() < 1e-9 * dry_mass_kg,
2332                "{name}: mass {mass_kg} vs the oracle's dry mass {dry_mass_kg}"
2333            );
2334
2335            // The declared start, with the center of mass where the oracle put it.
2336            let position = start["position_msl_m"].as_array().unwrap();
2337            let velocity = start["velocity_m_s"].as_array().unwrap();
2338            let cg_enu_m = DVec3::new(
2339                number(&position[0]),
2340                number(&position[1]),
2341                number(&position[2]) - number(&environment["elevation_m"]),
2342            );
2343            let attitude = Rail::vertical(6.0).attitude();
2344            let state = State {
2345                position_enu_m: cg_enu_m
2346                    - attitude.mul_vec3(sim.assembly().mass_properties(start_s).cg_m),
2347                velocity_enu_m_s: DVec3::new(
2348                    number(&velocity[0]),
2349                    number(&velocity[1]),
2350                    number(&velocity[2]),
2351                ),
2352                attitude,
2353                body_rate_rad_s: DVec3::ZERO,
2354            };
2355            let result = sim.run_free(start_s, state, &mut ()).unwrap();
2356            assert_eq!(result.termination, Termination::GroundHit, "{name}");
2357            let landing = result.event(EventKind::GroundHit).unwrap().sample;
2358
2359            // The first device opens where both models agree, to within RocketPy's own trigger
2360            // sampling: it checks its triggers on a grid of `1/sampling_rate` anchored at t = 0,
2361            // and only over the span after its first accepted step, so its deployment comes a
2362            // little after hpr's, which locates the crossing exactly. Measured: 2.5 ms for the
2363            // four 105 Hz cases and 13 ms for Prometheus's 100 Hz one, against descents of 46 to
2364            // 257 s, so under 0.01% of the descent either way.
2365            let first = &case["events"].as_array().unwrap()[0];
2366            let late_s = number(&first["deploy_s"]) - start_s;
2367            let descent_s = number(&case["metrics"]["descent_time_s"]);
2368            assert!(
2369                (0.0..=0.02).contains(&late_s) && late_s < 1e-4 * descent_s,
2370                "{name}: the oracle deployed {late_s} s after the start, of a {descent_s} s descent"
2371            );
2372
2373            // Every later device fired at its own setting, and the oracle's grid put its own
2374            // trigger a little below it: that difference is reported, not assumed away.
2375            for (index, device) in oracle_devices.iter().enumerate().skip(1) {
2376                let trigger = result
2377                    .event(EventKind::Trigger(index))
2378                    .unwrap_or_else(|| panic!("{name}: device {index} never fired"))
2379                    .sample;
2380                let height_m = number(&device["trigger"]["height_m"]);
2381                assert!(
2382                    (trigger.height_above_ground_m - height_m).abs() < 1e-3,
2383                    "{name}: device {index} fired at {} m, not its setting {height_m}",
2384                    trigger.height_above_ground_m
2385                );
2386                let oracle_event = &case["events"].as_array().unwrap()[index];
2387                // The oracle's own reported height at its trigger, which comes from RocketPy's
2388                // reporting spline over its stored samples, not from the dense output its
2389                // trigger was evaluated on. It can only have fired at or below the setting, by
2390                // at most one sample of fall (`v_z/sampling_rate`).
2391                rows.push((
2392                    format!(
2393                        "{name}: device {index} trigger height, hpr against the oracle's report"
2394                    ),
2395                    trigger.height_above_ground_m,
2396                    number(&oracle_event["height_above_ground_at_trigger_m"]),
2397                ));
2398                // The descent rate under the device before it, where the oracle reports its own.
2399                let oracle_speed = -number(&oracle_event["vertical_speed_at_trigger_m_s"]);
2400                let speed = -trigger.vertical_speed_m_s;
2401                rows.push((
2402                    format!("{name}: descent rate under device {}", index - 1),
2403                    speed,
2404                    oracle_speed,
2405                ));
2406            }
2407
2408            // An independent anchor on both simulators: by the time it lands, the rocket is
2409            // descending at Knacke's equilibrium speed under the last device to open, computed
2410            // here from hpr's own air and gravity at the site.
2411            let last = oracle_devices.last().unwrap();
2412            let air = sim
2413                .environment()
2414                .atmosphere
2415                .air(number(&environment["elevation_m"]))
2416                .unwrap()
2417                .air;
2418            let gravity_m_s2 = sim
2419                .environment()
2420                .earth
2421                .gravity_enu_mps2(DVec3::ZERO)
2422                .unwrap()
2423                .length();
2424            let equilibrium_m_s = terminal_speed_m_s(
2425                mass_kg,
2426                number(&last["cd_s_m2"]),
2427                air.density_kg_m3,
2428                gravity_m_s2,
2429            );
2430            for (who, speed) in [
2431                ("hpr", -landing.vertical_speed_m_s),
2432                ("rocketpy", number(&case["metrics"]["impact_speed_m_s"])),
2433            ] {
2434                assert!(
2435                    (speed - equilibrium_m_s).abs() < 0.01 * equilibrium_m_s,
2436                    "{name}: {who} lands at {speed} m/s, not the equilibrium {equilibrium_m_s}"
2437                );
2438            }
2439
2440            let metrics = case["metrics"].clone();
2441            let descent_time_s = landing.time_s - start_s;
2442            let drift = landing.cg_enu_m - cg_enu_m;
2443            rows.push((
2444                format!("{name}: descent time"),
2445                descent_time_s,
2446                number(&metrics["descent_time_s"]),
2447            ));
2448            rows.push((
2449                format!("{name}: impact descent rate"),
2450                -landing.vertical_speed_m_s,
2451                number(&metrics["impact_speed_m_s"]),
2452            ));
2453            rows.push((
2454                format!("{name}: drift"),
2455                drift.truncate().length(),
2456                number(&metrics["drift_m"]),
2457            ));
2458            if number(&metrics["drift_m"]) > 10.0 {
2459                rows.push((
2460                    format!("{name}: drift east"),
2461                    drift.x,
2462                    number(&metrics["drift_east_m"]),
2463                ));
2464                rows.push((
2465                    format!("{name}: drift north"),
2466                    drift.y,
2467                    number(&metrics["drift_north_m"]),
2468                ));
2469            }
2470        }
2471
2472        // The milestone's tolerance: descent rate and drift within 3% of RocketPy's.
2473        let mut worst: f64 = 0.0;
2474        let mut report = String::new();
2475        for (what, hpr, oracle) in &rows {
2476            let error = (hpr - oracle) / oracle;
2477            worst = worst.max(error.abs());
2478            report.push_str(&format!(
2479                "{what}: hpr {hpr:.4}, rocketpy {oracle:.4}, {:+.2}%\n",
2480                100.0 * error
2481            ));
2482        }
2483        eprintln!("{report}");
2484        assert!(worst < 0.03, "worst error {:.2}%:\n{report}", 100.0 * worst);
2485    }
2486
2487    /// The oracle's wind: a constant, or a profile in height above sea level. RocketPy
2488    /// interpolates its east and north components linearly, which is
2489    /// [`hpr_atmos::WindInterpolation::Components`].
2490    fn wind_of(environment: &serde_json::Value) -> std::sync::Arc<dyn hpr_atmos::Wind> {
2491        /// A level from east and north components, m/s: the speed and the direction the wind
2492        /// blows from, clockwise from north (`hpr_atmos::velocity_from_speed_direction`).
2493        fn level(height_msl_m: f64, east: f64, north: f64) -> hpr_atmos::WindLevel {
2494            hpr_atmos::WindLevel {
2495                height_msl_m,
2496                speed_m_s: east.hypot(north),
2497                direction_from_rad: (-east).atan2(-north).rem_euclid(std::f64::consts::TAU),
2498            }
2499        }
2500        let components = |key: &str| -> Option<Vec<(f64, f64)>> {
2501            environment[key].as_array().map(|rows| {
2502                rows.iter()
2503                    .map(|row| {
2504                        let row = row.as_array().unwrap();
2505                        (row[0].as_f64().unwrap(), row[1].as_f64().unwrap())
2506                    })
2507                    .collect()
2508            })
2509        };
2510        let levels = match (components("wind_u"), components("wind_v")) {
2511            (Some(east), Some(north)) => {
2512                assert_eq!(east.len(), north.len());
2513                east.iter()
2514                    .zip(&north)
2515                    .map(|((height_msl_m, east), (other, north))| {
2516                        assert_eq!(height_msl_m, other, "the profiles differ in height");
2517                        level(*height_msl_m, *east, *north)
2518                    })
2519                    .collect()
2520            }
2521            (None, None) => vec![level(
2522                0.0,
2523                environment["wind_u"].as_f64().unwrap(),
2524                environment["wind_v"].as_f64().unwrap(),
2525            )],
2526            _ => panic!("one wind component is a profile and the other is not"),
2527        };
2528        std::sync::Arc::new(
2529            hpr_atmos::LayeredWind::new(levels, hpr_atmos::WindInterpolation::Components).unwrap(),
2530        )
2531    }
2532
2533    #[test]
2534    fn a_deployment_keeps_the_center_of_mass_moving_as_it_was() {
2535        // Dropping the body rates at deployment must not move the center of mass's momentum:
2536        // the state carries the nose tip's velocity, so it has to be shifted by `ω × r_cg`
2537        // (found in review). With `r_cg` along the axis and `ω` across it the error is across the
2538        // axis too, so for this nose-up drop it is about 0.9 m/s of drift rate; it becomes a
2539        // descent-rate error once the rocket has pitched over.
2540        let air = UniformAir::sea_level();
2541        let device = open_at_start(DeviceDrag::DragArea { cd_s_m2: 1.5 });
2542        let sim = flight(analytic_environment(air, G), vec![device], 3600.0);
2543        let rate_rad_s = DVec3::new(0.0, 0.7, 0.0);
2544        let mut state = dropped(&sim, 1_500.0, DVec3::new(2.0, 0.0, -12.0));
2545        state.body_rate_rad_s = rate_rad_s;
2546        let cg_m = sim.assembly().mass_properties(START_S).cg_m;
2547        // The center of mass's velocity before the canopy opens: the nose tip's plus `ω × r_cg`
2548        // (the propellant is gone, so the center of mass doesn't move inside the body).
2549        let before =
2550            state.velocity_enu_m_s + state.unit_attitude().mul_vec3(rate_rad_s.cross(cg_m));
2551        assert!(
2552            (before - state.velocity_enu_m_s).length() > 0.5,
2553            "the test needs a lever arm: {before}"
2554        );
2555        let result = sim.run_free(START_S, state, &mut ()).unwrap();
2556        let deployment = result.event(EventKind::Deployment(0)).unwrap().sample;
2557        assert_eq!(deployment.time_s, START_S);
2558        assert!(
2559            (deployment.cg_velocity_enu_m_s - before).length() < 1e-12,
2560            "{} vs {before}",
2561            deployment.cg_velocity_enu_m_s
2562        );
2563        assert_eq!(deployment.state.body_rate_rad_s, DVec3::ZERO);
2564        // The descent then settles at the terminal speed, as before.
2565        let mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
2566        let terminal_m_s = terminal_speed_m_s(mass_kg, 1.5, air.0.density_kg_m3, G);
2567        let landing = result.event(EventKind::GroundHit).unwrap().sample;
2568        assert!(
2569            (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 1e-6,
2570            "{} vs {terminal_m_s}",
2571            landing.vertical_speed_m_s
2572        );
2573    }
2574
2575    #[test]
2576    fn a_deployment_during_a_burn_keeps_the_center_of_mass_moving_as_it_was() {
2577        // The same handover while propellant still burns, so the center of mass is moving inside
2578        // the body as well (`v_cg = v_O + q(ω × r_cg + ṙ_cg)`). The two `ṙ_cg` terms cancel, so
2579        // the net shift is `q(ω × r_cg)` whatever the motor is doing; this pins that, and that
2580        // the canopy opening under thrust (an off-nominal case the descent phase keeps the thrust
2581        // for) does not move the center of mass. Valetudo burns until 3.26 s, and the crosswind
2582        // gives it a body rate to shift by.
2583        let sim = flight(
2584            analytic_wind_environment(
2585                UniformAir::sea_level(),
2586                G,
2587                ConstantWind::new(8.0, 1.5).unwrap(),
2588            ),
2589            vec![Device::new(
2590                "early",
2591                DeviceDrag::DragArea { cd_s_m2: 0.2 },
2592                Trigger::Time { time_s: 2.0 },
2593            )],
2594            3600.0,
2595        );
2596        let result = sim.run(&mut ()).unwrap();
2597        let trigger = result.event(EventKind::Trigger(0)).unwrap().sample;
2598        let deployment = result.event(EventKind::Deployment(0)).unwrap().sample;
2599        assert_eq!(trigger.time_s, 2.0);
2600        assert_eq!(deployment.time_s, 2.0);
2601        assert!(
2602            trigger.thrust_n > 0.0,
2603            "the motor has to be burning: {trigger:?}"
2604        );
2605        assert!(
2606            trigger.state.body_rate_rad_s.length() > 1e-3,
2607            "the flight needs a body rate to shift by: {}",
2608            trigger.state.body_rate_rad_s
2609        );
2610        // The center of mass keeps its velocity, and the nose tip's moved by exactly `ω × r_cg`.
2611        assert!(
2612            (deployment.cg_velocity_enu_m_s - trigger.cg_velocity_enu_m_s).length() < 1e-12,
2613            "{} vs {}",
2614            deployment.cg_velocity_enu_m_s,
2615            trigger.cg_velocity_enu_m_s
2616        );
2617        let cg_m = sim.assembly().mass_properties(2.0).cg_m;
2618        let shift = trigger
2619            .state
2620            .unit_attitude()
2621            .mul_vec3(trigger.state.body_rate_rad_s.cross(cg_m));
2622        assert!(shift.length() > 1e-3, "{shift}");
2623        assert!(
2624            (deployment.state.velocity_enu_m_s - trigger.state.velocity_enu_m_s - shift).length()
2625                < 1e-12,
2626            "{} vs {} + {shift}",
2627            deployment.state.velocity_enu_m_s,
2628            trigger.state.velocity_enu_m_s
2629        );
2630        assert_eq!(deployment.phase, crate::Phase::Descent);
2631        assert_eq!(result.termination, Termination::GroundHit);
2632    }
2633
2634    #[test]
2635    fn devices_that_open_together_add_their_drag_areas() {
2636        // Two devices triggered at the same instant both open in the same pass, and the descent
2637        // runs under the sum of their drag areas.
2638        let air = UniformAir::sea_level();
2639        let devices = vec![
2640            open_at_start(DeviceDrag::DragArea { cd_s_m2: 1.0 }),
2641            open_at_start(DeviceDrag::DragArea { cd_s_m2: 2.0 }),
2642        ];
2643        let sim = flight(analytic_environment(air, G), devices, 3600.0);
2644        let result = sim
2645            .run_free(START_S, dropped(&sim, 1_000.0, DVec3::ZERO), &mut ())
2646            .unwrap();
2647        assert_eq!(result.termination, Termination::GroundHit);
2648        for index in 0..2 {
2649            let deployment = result.event(EventKind::Deployment(index)).unwrap().sample;
2650            assert_eq!(deployment.time_s, START_S, "device {index}");
2651        }
2652        let landing = result.event(EventKind::GroundHit).unwrap().sample;
2653        assert!((landing.recovery_drag_area_m2 - 3.0).abs() < 1e-12);
2654        let mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
2655        let terminal_m_s = terminal_speed_m_s(mass_kg, 3.0, air.0.density_kg_m3, G);
2656        assert!(
2657            (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 1e-6,
2658            "{} vs {terminal_m_s}",
2659            landing.vertical_speed_m_s
2660        );
2661    }
2662
2663    #[test]
2664    fn a_device_released_before_it_opens_never_pulls() {
2665        // The main opens first and releases the drogue; when the drogue's own charge fires later
2666        // there is nothing left to open, so it is recorded as triggered and never deploys.
2667        let air = UniformAir::sea_level();
2668        let devices = vec![
2669            Device::new(
2670                "drogue",
2671                DeviceDrag::DragArea { cd_s_m2: 4.0 },
2672                Trigger::Time {
2673                    time_s: START_S + 5.0,
2674                },
2675            )
2676            .with_release_by(1),
2677            open_at_start(DeviceDrag::DragArea { cd_s_m2: 1.0 }),
2678        ];
2679        let sim = flight(analytic_environment(air, G), devices, 3600.0);
2680        let result = sim
2681            .run_free(START_S, dropped(&sim, 1_000.0, DVec3::ZERO), &mut ())
2682            .unwrap();
2683        assert_eq!(result.termination, Termination::GroundHit);
2684        let release = result.event(EventKind::Release(0)).unwrap().sample;
2685        assert_eq!(
2686            release.time_s, START_S,
2687            "the main opens at once, so it releases at once"
2688        );
2689        let trigger = result.event(EventKind::Trigger(0)).unwrap().sample;
2690        assert_eq!(trigger.time_s, START_S + 5.0);
2691        assert!(
2692            result.event(EventKind::Deployment(0)).is_none(),
2693            "a released device must not deploy: {:?}",
2694            result.events.iter().map(|e| e.kind).collect::<Vec<_>>()
2695        );
2696        let landing = result.event(EventKind::GroundHit).unwrap().sample;
2697        assert!((landing.recovery_drag_area_m2 - 1.0).abs() < 1e-12);
2698        let mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
2699        let terminal_m_s = terminal_speed_m_s(mass_kg, 1.0, air.0.density_kg_m3, G);
2700        assert!(
2701            (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 1e-6,
2702            "{} vs {terminal_m_s}",
2703            landing.vertical_speed_m_s
2704        );
2705    }
2706
2707    #[test]
2708    fn a_release_waits_for_the_main_to_fill_so_the_drag_area_never_dips() {
2709        // A drogue cut away at the main's line stretch would leave the rocket under an empty
2710        // canopy: the drag area would collapse and the descent would speed up. The release waits
2711        // until the main is full, so the drag area only ever grows here (ADR-012).
2712        let air = UniformAir::sea_level();
2713        let drogue_m2 = 0.45;
2714        let main_m2 = 6.0;
2715        let filling_s = 2.0;
2716        let devices = vec![
2717            open_at_start(DeviceDrag::DragArea { cd_s_m2: drogue_m2 }).with_release_by(1),
2718            Device::new(
2719                "main",
2720                DeviceDrag::DragArea { cd_s_m2: main_m2 },
2721                Trigger::Time {
2722                    time_s: START_S + 20.0,
2723                },
2724            )
2725            .with_inflation(Inflation::FillingTime {
2726                time_s: filling_s,
2727                exponent: 2.0,
2728            }),
2729        ];
2730        let sim = flight(analytic_environment(air, G), devices, 3600.0);
2731        let mut recorder = Recorder::new(
2732            vec![
2733                Channel::Time,
2734                Channel::RecoveryDragArea,
2735                Channel::VerticalSpeed,
2736            ],
2737            None,
2738        )
2739        .unwrap();
2740        let result = sim
2741            .run_free(START_S, dropped(&sim, 2_000.0, DVec3::ZERO), &mut recorder)
2742            .unwrap();
2743        assert_eq!(result.termination, Termination::GroundHit);
2744        let main = result.event(EventKind::Deployment(1)).unwrap().sample;
2745        let release = result.event(EventKind::Release(0)).unwrap().sample;
2746        assert_eq!(main.time_s, START_S + 20.0);
2747        assert!(
2748            (release.time_s - (main.time_s + filling_s)).abs() < 1e-9,
2749            "released at {} m, not at the end of filling {}",
2750            release.time_s,
2751            main.time_s + filling_s
2752        );
2753        // The drag area never falls below the drogue's, and the descent never speeds up after the
2754        // main's charge fires.
2755        let areas = column(&recorder, "recovery_drag_area_m2");
2756        let times = column(&recorder, "time_s");
2757        let speeds = column(&recorder, "vertical_speed_m_s");
2758        let mut worst_area = f64::INFINITY;
2759        let mut fastest = 0.0_f64;
2760        for ((t, area), speed) in times.iter().zip(&areas).zip(&speeds) {
2761            if *t < main.time_s {
2762                continue;
2763            }
2764            worst_area = worst_area.min(*area);
2765            fastest = fastest.max(-speed);
2766        }
2767        assert!(
2768            worst_area >= drogue_m2 - 1e-12,
2769            "the drag area dipped to {worst_area} m², below the drogue's {drogue_m2}"
2770        );
2771        assert!(
2772            fastest <= -main.vertical_speed_m_s + 1e-9,
2773            "the descent sped up after the main fired: {fastest} against {}",
2774            -main.vertical_speed_m_s
2775        );
2776        // And by the end the main alone carries it.
2777        let landing = result.event(EventKind::GroundHit).unwrap().sample;
2778        assert!((landing.recovery_drag_area_m2 - main_m2).abs() < 1e-12);
2779    }
2780
2781    #[test]
2782    fn a_recovered_flight_repeats_bit_identically() {
2783        // Determinism, and Loft lesson L24: the devices' progress belongs to the flight, not to
2784        // the simulation, so flying the same simulation twice gives bit-identical rows and events.
2785        let sim = flight(
2786            analytic_wind_environment(
2787                UniformAir::sea_level(),
2788                G,
2789                ConstantWind::new(3.0, 1.2).unwrap(),
2790            ),
2791            vec![
2792                Device::new(
2793                    "drogue",
2794                    DeviceDrag::canopy(CanopyType::FlatCircular, 0.5),
2795                    Trigger::Apogee,
2796                )
2797                .with_lag_s(0.75)
2798                .with_inflation(Inflation::knacke(CanopyType::FlatCircular).unwrap())
2799                .with_release_by(1),
2800                Device::new(
2801                    "main",
2802                    DeviceDrag::canopy(CanopyType::FlatCircular, 2.0),
2803                    Trigger::Altitude {
2804                        height_above_ground_m: 200.0,
2805                    },
2806                )
2807                .with_lag_s(1.25)
2808                .with_inflation(Inflation::knacke(CanopyType::FlatCircular).unwrap()),
2809            ],
2810            3600.0,
2811        );
2812        let fly = || {
2813            let mut recorder = Recorder::new(Channel::ALL.to_vec(), Some(0.1)).unwrap();
2814            let result = sim.run(&mut recorder).unwrap();
2815            (recorder.rows().to_vec(), result)
2816        };
2817        let (first_rows, first) = fly();
2818        let (second_rows, second) = fly();
2819        assert_eq!(first.termination, Termination::GroundHit);
2820        assert!(first_rows.len() > 100, "{} rows", first_rows.len());
2821        assert_eq!(first_rows, second_rows, "the rows differ between runs");
2822        assert_eq!(
2823            first.events, second.events,
2824            "the events differ between runs"
2825        );
2826        assert_eq!(first.final_sample, second.final_sample);
2827        assert_eq!(first.stats, second.stats);
2828        // And the events are the full sequence, once each.
2829        let kinds: Vec<EventKind> = first.events.iter().map(|event| event.kind).collect();
2830        assert_eq!(
2831            kinds,
2832            vec![
2833                EventKind::Liftoff,
2834                EventKind::RailExit,
2835                EventKind::Burnout,
2836                EventKind::Apogee,
2837                EventKind::Trigger(0),
2838                EventKind::Deployment(0),
2839                EventKind::Trigger(1),
2840                EventKind::Deployment(1),
2841                EventKind::Release(0),
2842                EventKind::GroundHit,
2843            ]
2844        );
2845    }
2846
2847    #[test]
2848    fn an_apogee_charge_fires_on_a_flight_that_starts_descending() {
2849        // The apogee trigger is RocketPy's `y[5] < 0`, not only hpr's apogee event: a flight
2850        // restarted past its apogee (`run_free`, which M1.9's staging and flight-data replay use)
2851        // still deploys. Found in review: with an event-only trigger this flight fell ballistically
2852        // to the ground with no deployment and no error.
2853        let air = UniformAir::sea_level();
2854        let device = Device::new(
2855            "main",
2856            DeviceDrag::DragArea { cd_s_m2: 2.0 },
2857            Trigger::Apogee,
2858        );
2859        let drag_area_m2 = device.drag.drag_area_m2();
2860        let sim = flight(analytic_environment(air, G), vec![device], 3600.0);
2861        let result = sim
2862            .run_free(
2863                START_S,
2864                dropped(&sim, 1_000.0, DVec3::new(0.0, 0.0, -5.0)),
2865                &mut (),
2866            )
2867            .unwrap();
2868        assert_eq!(result.termination, Termination::GroundHit);
2869        let deployment = result.event(EventKind::Deployment(0)).unwrap().sample;
2870        assert_eq!(deployment.time_s, START_S, "it is already descending");
2871        assert_eq!(
2872            result.event(EventKind::Apogee),
2873            None,
2874            "there is no apogee to find"
2875        );
2876        let mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
2877        let terminal_m_s = terminal_speed_m_s(mass_kg, drag_area_m2, air.0.density_kg_m3, G);
2878        let landing = result.event(EventKind::GroundHit).unwrap().sample;
2879        assert!(
2880            (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 1e-6,
2881            "{} vs {terminal_m_s}",
2882            landing.vertical_speed_m_s
2883        );
2884        // A climbing flight does not fire it early: the charge waits for the apogee.
2885        let climbing = sim
2886            .run_free(
2887                START_S,
2888                dropped(&sim, 1_000.0, DVec3::new(0.0, 0.0, 30.0)),
2889                &mut (),
2890            )
2891            .unwrap();
2892        let apogee = climbing.event(EventKind::Apogee).unwrap().sample;
2893        let fired = climbing.event(EventKind::Trigger(0)).unwrap().sample;
2894        assert_eq!(fired.time_s, apogee.time_s);
2895        assert!(apogee.time_s > START_S + 1.0, "{}", apogee.time_s);
2896    }
2897
2898    #[test]
2899    fn user_events_keep_their_numbers_and_fire_during_the_descent() {
2900        // The event list is rebuilt per interval (`flight::Watch`), and the height triggers come
2901        // and go, so the user events sit after them. This pins that their indices don't move and
2902        // that they still fire once the descent has started.
2903        let air = UniformAir::sea_level();
2904        let devices = vec![
2905            open_at_start(DeviceDrag::DragArea { cd_s_m2: 0.5 }).with_release_by(1),
2906            Device::new(
2907                "main",
2908                DeviceDrag::DragArea { cd_s_m2: 4.0 },
2909                Trigger::Altitude {
2910                    height_above_ground_m: 400.0,
2911                },
2912            ),
2913        ];
2914        let sim = flight(analytic_environment(air, G), devices, 3600.0)
2915            .with_event(crate::flight::UserEvent {
2916                name: "through 700 m".to_owned(),
2917                direction: crate::events::Direction::Falling,
2918                function: Box::new(|sample| sample.height_above_ground_m - 700.0),
2919            })
2920            .with_event(crate::flight::UserEvent {
2921                name: "through 100 m".to_owned(),
2922                direction: crate::events::Direction::Falling,
2923                function: Box::new(|sample| sample.height_above_ground_m - 100.0),
2924            });
2925        let result = sim
2926            .run_free(START_S, dropped(&sim, 1_000.0, DVec3::ZERO), &mut ())
2927            .unwrap();
2928        assert_eq!(result.termination, Termination::GroundHit);
2929        for (user, height_m) in [(0usize, 700.0), (1, 100.0)] {
2930            let event = result
2931                .event(EventKind::User(user))
2932                .unwrap_or_else(|| panic!("user event {user} never fired"))
2933                .sample;
2934            assert!(
2935                (event.height_above_ground_m - height_m).abs() < 1e-6,
2936                "user event {user} fired at {} m",
2937                event.height_above_ground_m
2938            );
2939            assert_eq!(event.phase, crate::Phase::Descent);
2940        }
2941        // The user events fire between the main's trigger and the ground, in height order.
2942        let kinds: Vec<EventKind> = result.events.iter().map(|event| event.kind).collect();
2943        let position = |kind| kinds.iter().position(|other| *other == kind).unwrap();
2944        assert!(position(EventKind::User(0)) < position(EventKind::Trigger(1)));
2945        assert!(position(EventKind::Trigger(1)) < position(EventKind::User(1)));
2946        assert!(position(EventKind::User(1)) < position(EventKind::GroundHit));
2947    }
2948
2949    #[test]
2950    fn the_public_recovery_types_round_trip_through_json() {
2951        let devices = vec![
2952            Device::new(
2953                "drogue",
2954                DeviceDrag::DragArea { cd_s_m2: 0.45 },
2955                Trigger::Apogee,
2956            )
2957            .with_lag_s(1.5)
2958            .with_release_by(1),
2959            Device::new(
2960                "main",
2961                DeviceDrag::canopy(CanopyType::Ringsail, 3.0),
2962                Trigger::Altitude {
2963                    height_above_ground_m: 250.0,
2964                },
2965            )
2966            .with_inflation(Inflation::FillingTime {
2967                time_s: 1.5,
2968                exponent: 2.0,
2969            }),
2970            Device::new(
2971                "timer",
2972                DeviceDrag::canopy(CanopyType::FlatCircular, 1.0),
2973                Trigger::Time { time_s: 12.0 },
2974            )
2975            .with_inflation(Inflation::knacke(CanopyType::FlatCircular).unwrap()),
2976            Device::new(
2977                "ejection",
2978                DeviceDrag::DragArea { cd_s_m2: 1.0 },
2979                Trigger::MotorDelay { motor: 0 },
2980            ),
2981            Device::new(
2982                "streamer",
2983                DeviceDrag::streamer(1.2, 0.12, 0.032),
2984                Trigger::Apogee,
2985            ),
2986            Device::new(
2987                "appendix C streamer",
2988                DeviceDrag::Streamer {
2989                    length_m: 1.0,
2990                    width_m: 0.1,
2991                    surface_density_kg_m2: 0.04,
2992                    model: StreamerModel::OpenRocket,
2993                },
2994                Trigger::Apogee,
2995            ),
2996            Device::new(
2997                "tumble",
2998                DeviceDrag::tumbling(&design("rocketpy-valetudo").assemble("example").unwrap())
2999                    .unwrap(),
3000                Trigger::Apogee,
3001            ),
3002        ];
3003        let text = serde_json::to_string(&devices).unwrap();
3004        let back: Vec<Device> = serde_json::from_str(&text).unwrap();
3005        assert_eq!(devices, back);
3006        // The defaults are optional in a file, and unknown fields are refused.
3007        let terse: Device = serde_json::from_str(
3008            r#"{"name":"d","drag":{"drag_area":{"cd_s_m2":1.5}},"trigger":"apogee"}"#,
3009        )
3010        .unwrap();
3011        assert_eq!(terse.lag_s, 0.0);
3012        assert_eq!(terse.inflation, Inflation::Instant);
3013        assert_eq!(terse.released_by, None);
3014        assert!(
3015            serde_json::from_str::<Device>(
3016                r#"{"name":"d","drag":{"drag_area":{"cd_s_m2":1.5}},"trigger":"apogee","lg":1}"#
3017            )
3018            .is_err()
3019        );
3020        // A typo inside the drag is refused too: `model` defaults, and the two streamer models
3021        // differ by a factor of four in drag area, so a silent default would be a wrong number.
3022        let typo = r#"{"name":"s","drag":{"streamer":{"length_m":1.0,"width_m":0.1,
3023            "surface_density_kg_m2":0.04,"modle":"open_rocket"}},"trigger":"apogee"}"#;
3024        assert!(serde_json::from_str::<Device>(typo).is_err(), "{typo}");
3025        // And the model itself round-trips by name.
3026        let named: DeviceDrag = serde_json::from_str(
3027            r#"{"streamer":{"length_m":1.0,"width_m":0.1,"surface_density_kg_m2":0.04,
3028                "model":"open_rocket"}}"#,
3029        )
3030        .unwrap();
3031        assert_eq!(named.canopy_type(), None);
3032        assert!(
3033            (named.drag_area_m2() - StreamerModel::OpenRocket.drag_area_m2(1.0, 0.1, 0.04)).abs()
3034                < 1e-15
3035        );
3036    }
3037
3038    #[test]
3039    fn streamer_models_reproduce_their_printed_equations() {
3040        // Carruthers and Filippone's three printed curves, on the planform area, at the areas
3041        // they were fitted at: `0.405 AR^−0.494` at 0.075 m² (eq. 1), `0.561 AR^−0.480` at
3042        // 0.025 m² (eq. 2) and `0.6514 AR^−0.6075` at 0.05 m² (the trend line on Figure 3).
3043        let cases: [(f64, f64, f64); 6] = [
3044            (10.0, 0.075, 0.405 * 10.0_f64.powf(-0.494)),
3045            (30.0, 0.075, 0.405 * 30.0_f64.powf(-0.494)),
3046            (10.0, 0.025, 0.561 * 10.0_f64.powf(-0.480)),
3047            (3.3, 0.025, 0.561 * 3.3_f64.powf(-0.480)),
3048            // The trend line on Figure 3, which the text does not repeat as an equation.
3049            (3.3, 0.05, 0.6514 * 3.3_f64.powf(-0.6075)),
3050            (30.0, 0.05, 0.6514 * 30.0_f64.powf(-0.6075)),
3051        ];
3052        for (aspect_ratio, planform_m2, expected) in cases {
3053            // A planform area `S` at aspect ratio `l/w` means `l = √(S·AR)`, `w = √(S/AR)`.
3054            let length_m = (planform_m2 * aspect_ratio).sqrt();
3055            let width_m = (planform_m2 / aspect_ratio).sqrt();
3056            let drag_area_m2 = StreamerModel::Filippone.drag_area_m2(length_m, width_m, 0.05);
3057            let coefficient = drag_area_m2 / planform_m2;
3058            assert!(
3059                (coefficient - expected).abs() < 1e-12,
3060                "AR {aspect_ratio} at {planform_m2} m²: {coefficient} vs {expected}"
3061            );
3062        }
3063        // Between the two areas it interpolates, and outside them it holds the end curve. A
3064        // planform of 0.05 m² at `AR = 10` is `l = √0.5 m` by `w = l/10`.
3065        let middle_m = (0.5_f64).sqrt();
3066        let between = StreamerModel::Filippone.drag_area_m2(middle_m, middle_m / 10.0, 0.05) / 0.05;
3067        let small = 0.561 * 10.0_f64.powf(-0.480);
3068        let large = 0.405 * 10.0_f64.powf(-0.494);
3069        assert!(large < between && between < small, "{between}");
3070        let huge = StreamerModel::Filippone.drag_area_m2(3.0, 0.3, 0.05) / 0.9;
3071        assert!((huge - large).abs() < 1e-12, "{huge} vs {large}");
3072
3073        // The OpenRocket technical documentation's appendix C: its own reference material is
3074        // 80 g/m² polyethylene, where the material factor is exactly 1 and `C_Dm` is
3075        // `0.034 (l + 1)/l`.
3076        let reference = StreamerModel::OpenRocket.drag_area_m2(0.4, 0.04, 0.080) / (0.4 * 0.04);
3077        assert!(
3078            (reference - 0.034 * (0.4 + 1.0) / 0.4).abs() < 1e-12,
3079            "{reference}"
3080        );
3081        // And its material correction is linear in surface density about −25 g/m².
3082        let light = StreamerModel::OpenRocket.drag_area_m2(0.4, 0.04, 0.010);
3083        let heavy = StreamerModel::OpenRocket.drag_area_m2(0.4, 0.04, 0.080);
3084        assert!(
3085            (light / heavy - (0.010 + 0.025) / (0.080 + 0.025)).abs() < 1e-12,
3086            "{light} {heavy}"
3087        );
3088    }
3089
3090    /// The average speed of a drop from rest through `height_m` under a drag area, m/s: the
3091    /// closed-form fall `h = (v_t²/g) ln cosh(g t/v_t)` solved for the time. A drop test measures
3092    /// this, not the terminal speed it is approaching.
3093    fn drop_average_m_s(
3094        mass_kg: f64,
3095        drag_area_m2: f64,
3096        density_kg_m3: f64,
3097        height_m: f64,
3098    ) -> (f64, f64) {
3099        let terminal_m_s = terminal_speed_m_s(mass_kg, drag_area_m2, density_kg_m3, G);
3100        let time_s =
3101            (terminal_m_s / G) * (G * height_m / (terminal_m_s * terminal_m_s)).exp().acosh();
3102        (height_m / time_s, terminal_m_s)
3103    }
3104
3105    #[test]
3106    fn streamer_models_against_kidwells_drop_tests() {
3107        // The only free-drop streamer data in hand: C. Kidwell, "Streamer Duration Optimization",
3108        // NAR R&D, NARAM-43 (2001). Sixteen 4 in × 40 in streamers, each with a weight of about
3109        // 5 g at one corner, dropped 20.1 m from a stadium deck.
3110        //
3111        // Two details of his method decide how to compare (both found in review):
3112        //
3113        // - his descent rates are **distance over time**, so they are averages over the drop, not
3114        //   terminal speeds. A 20.1 m drop averages 0.97 of terminal at 2.8 m/s and 0.89 at
3115        //   5.9 m/s, so the correction matters most for the model that predicts the fastest fall.
3116        //   The prediction here is therefore the same average, from the closed-form fall.
3117        // - he **normalised** each rate "by dividing by the actual mass of the attached weight
3118        //   and multiplying by 5 g", so the rates belong to a notional 5.000 g weight, not to
3119        //   Table 1's actual one.
3120        let length_m = 40.0 * 0.0254;
3121        let width_m = 4.0 * 0.0254;
3122        let planform_m2 = length_m * width_m;
3123        let rho = 1.225;
3124        let drop_m = 20.1;
3125        let cases = [
3126            // (material, streamer mass from Table 1, normalised descent rate, pleated)
3127            ("crepe paper", 3.3206e-3, 2.80, false),
3128            ("Micafilm", 4.3412e-3, 2.04, true),
3129        ];
3130        let mut report = String::new();
3131        for (material, streamer_kg, measured_m_s, pleated) in cases {
3132            let surface_density_kg_m2 = streamer_kg / planform_m2;
3133            let mass_kg = streamer_kg + 5.0e-3;
3134            let predict = |model: StreamerModel| {
3135                let drag_area_m2 = model.drag_area_m2(length_m, width_m, surface_density_kg_m2);
3136                let (average_m_s, terminal_m_s) =
3137                    drop_average_m_s(mass_kg, drag_area_m2, rho, drop_m);
3138                (average_m_s, terminal_m_s, drag_area_m2)
3139            };
3140            let (filippone, _, filippone_m2) = predict(StreamerModel::Filippone);
3141            let (open_rocket, _, open_rocket_m2) = predict(StreamerModel::OpenRocket);
3142            // What the drop itself says the drag area was.
3143            let measured_m2 = {
3144                // Invert the closed-form average: search the drag area whose average matches.
3145                let mut low = 1e-4;
3146                let mut high = 1.0;
3147                for _ in 0..200 {
3148                    let middle = 0.5 * (low + high);
3149                    if drop_average_m_s(mass_kg, middle, rho, drop_m).0 > measured_m_s {
3150                        low = middle;
3151                    } else {
3152                        high = middle;
3153                    }
3154                }
3155                0.5 * (low + high)
3156            };
3157            report.push_str(&format!(
3158                "{material}: measured {measured_m_s:.2} m/s (C_D S {measured_m2:.5} m², C_D \
3159                 {:.3}), Filippone {filippone:.2} ({:+.0}%, C_D S {filippone_m2:.5}), OpenRocket \
3160                 {open_rocket:.2} ({:+.0}%, C_D S {open_rocket_m2:.5})\n",
3161                measured_m2 / planform_m2,
3162                100.0 * (filippone / measured_m_s - 1.0),
3163                100.0 * (open_rocket / measured_m_s - 1.0),
3164            ));
3165            // Both models predict a faster descent than the drop: neither knows about pleats, and
3166            // the correlations are for a streamer clamped at its leading edge, which the paper
3167            // measures as less draggy than a free one.
3168            assert!(filippone > measured_m_s, "{material}: {filippone}");
3169            assert!(open_rocket > filippone, "{material}: {open_rocket}");
3170            if pleated {
3171                // Pleats more than double the drag: measured C_D 0.341 against 0.161 flat.
3172                assert!(
3173                    (filippone / measured_m_s - 1.0) < 0.75,
3174                    "{material}: {filippone} against {measured_m_s}"
3175                );
3176            } else {
3177                // Flat streamer, flat correlation: within 10%, where appendix C is 88% fast.
3178                assert!(
3179                    (filippone / measured_m_s - 1.0) < 0.10,
3180                    "{material}: {filippone} against {measured_m_s}"
3181                );
3182                assert!(
3183                    (open_rocket / measured_m_s - 1.0) > 0.5,
3184                    "{material}: appendix C should be the slow one: {open_rocket}"
3185                );
3186            }
3187        }
3188        eprintln!("{report}");
3189    }
3190
3191    #[test]
3192    fn a_streamer_descends_at_its_cited_terminal_velocity() {
3193        // A streamer's drag area is its model's, and a descent under it settles at Knacke's
3194        // equilibrium speed for that area, the same as a canopy's.
3195        let air = UniformAir::sea_level();
3196        let drag = DeviceDrag::streamer(1.5, 0.15, 0.040);
3197        let drag_area_m2 = drag.drag_area_m2();
3198        // 1.5 m by 0.15 m is a planform of 0.225 m², above the largest area Carruthers and
3199        // Filippone fitted, so their 0.075 m² curve holds: `C_D = 0.405 · 10^−0.494 = 0.12984`
3200        // and `C_D S = 0.029216 m²`.
3201        let expected_m2 = 0.405 * 10.0_f64.powf(-0.494) * 0.225;
3202        assert!(
3203            (drag_area_m2 - expected_m2).abs() < 1e-12,
3204            "{drag_area_m2} vs {expected_m2}"
3205        );
3206        let sim = flight(
3207            analytic_environment(air, G),
3208            vec![open_at_start(drag)],
3209            3600.0,
3210        );
3211        let mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
3212        let terminal_m_s = terminal_speed_m_s(mass_kg, drag_area_m2, air.0.density_kg_m3, G);
3213        // A streamer is a feeble decelerator: Valetudo falls at 67 m/s under this one, so the
3214        // drop has to be long enough to settle (the time constant is `v_t/g`, near 7 s).
3215        assert!(terminal_m_s > 20.0, "{terminal_m_s}");
3216        let result = sim
3217            .run_free(START_S, dropped(&sim, 6_000.0, DVec3::ZERO), &mut ())
3218            .unwrap();
3219        assert_eq!(result.termination, Termination::GroundHit);
3220        let landing = result.event(EventKind::GroundHit).unwrap().sample;
3221        assert!(
3222            (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 1e-6,
3223            "{} vs {terminal_m_s}",
3224            landing.vertical_speed_m_s
3225        );
3226    }
3227
3228    #[test]
3229    fn the_tumble_model_against_its_own_drop_tests() {
3230        // The tumbling constants were fitted to the five models of the OpenRocket technical
3231        // documentation's Table 3.3 (printed page 54), dropped 22 m at ρ = 1.31 kg/m³ with the
3232        // terminal velocity read off the video to ±0.3 m/s. Replaying them through hpr's reading
3233        // of the model (`1.42 · eff(n) · A_1fin + 0.56 · d · l`, with one fin's area the
3234        // trapezoid `(C_r + C_t) s/2`) is the only independent check of it available, and it
3235        // does **not** reproduce the documentation's claim of 3 to 14%.
3236        let rho = 1.31;
3237        // (model, fins, root chord, tip chord, span, diameter, body length, mass, measured v0)
3238        let models = [
3239            (
3240                "#1", 3usize, 0.070, 0.040, 0.060, 0.044, 0.108, 18.0e-3, 5.6,
3241            ),
3242            ("#2", 4, 0.070, 0.040, 0.060, 0.044, 0.108, 22.0e-3, 6.3),
3243            ("#3", 3, 0.200, 0.140, 0.130, 0.103, 0.290, 160.0e-3, 6.6),
3244            ("#4", 0, 0.0, 0.0, 0.0, 0.044, 0.100, 6.8e-3, 5.4),
3245            ("#5", 4, 0.085, 0.085, 0.050, 0.0, 0.0, 11.5e-3, 5.0),
3246        ];
3247        let mut report = String::new();
3248        let mut worst: f64 = 0.0;
3249        let mut errors = Vec::new();
3250        for (name, fins, root_m, tip_m, span_m, diameter_m, body_m, mass_kg, measured_m_s) in models
3251        {
3252            let fin_area_m2 = if fins == 0 {
3253                0.0
3254            } else {
3255                let one_m2 = 0.5 * (root_m + tip_m) * span_m;
3256                one_m2 * TUMBLE_FIN_EFFICIENCY[fins - 1]
3257            };
3258            let body_profile_m2 = diameter_m * body_m;
3259            let drag_area_m2 = TUMBLE_FIN_DRAG_COEFFICIENT * fin_area_m2
3260                + TUMBLE_BODY_DRAG_COEFFICIENT * body_profile_m2;
3261            let predicted_m_s = terminal_speed_m_s(mass_kg, drag_area_m2, rho, G);
3262            let error = predicted_m_s / measured_m_s - 1.0;
3263            worst = worst.max(error.abs());
3264            errors.push(error);
3265            report.push_str(&format!(
3266                "{name}: measured {measured_m_s:.1} m/s, hpr {predicted_m_s:.2} ({:+.1}%)\n",
3267                100.0 * error
3268            ));
3269        }
3270        eprintln!("{report}");
3271        // The spread is −10 to +19%, not the 3 to 14% the documentation claims for its own fit,
3272        // and the finless model is the outlier: it wants a body coefficient near 0.79 where the
3273        // model says 0.56. Either hpr's reading of the two areas is not the one behind the
3274        // constants (the text pins neither convention) or the claim is not reproducible;
3275        // `docs/physics/recovery.md` prints this table rather than repeating the 3 to 14%.
3276        //
3277        // Every model's error is pinned, not just the worst: a wrong efficiency factor would
3278        // move one of the others while the extreme stayed put.
3279        assert_eq!(
3280            errors
3281                .iter()
3282                .map(|error| format!("{:+.1}", 100.0 * error))
3283                .collect::<Vec<_>>(),
3284            ["-5.8", "-5.4", "-7.2", "+19.0", "-10.0"],
3285            "{report}"
3286        );
3287        assert!(worst < 0.20, "{worst} spread:\n{report}");
3288    }
3289
3290    #[test]
3291    fn tumbling_refuses_an_airframe_the_model_cannot_represent() {
3292        // Tube fins are a large part of a tumbling rocket's broadside area and §3.5 has no factor
3293        // for them, so `tumbling` refuses rather than crediting a bare tube's drag.
3294        let mut rocket = design("rocketpy-valetudo");
3295        let (index, fins) = rocket.stages[0].components[1]
3296            .children
3297            .iter()
3298            .enumerate()
3299            .find_map(|(index, child)| match &child.part {
3300                hpr_design::Part::FinSet(fins) => Some((index, fins.clone())),
3301                _ => None,
3302            })
3303            .expect("Valetudo has a fin set");
3304        rocket.stages[0].components[1].children[index].part =
3305            hpr_design::Part::TubeFinSet(hpr_design::TubeFinSet {
3306                count: 3,
3307                length_m: 0.15,
3308                outer_radius_m: 0.02,
3309                thickness_m: 0.001,
3310                base_angle_rad: 0.0,
3311                material: fins.material.clone(),
3312            });
3313        let assembly = rocket.assemble("example").unwrap();
3314        let error = DeviceDrag::tumbling(&assembly).expect_err("tube fins");
3315        assert!(matches!(error, SimError::Domain { .. }), "{error:?}");
3316        // The unchanged design is fine, so the refusal is about the tube fins and nothing else.
3317        assert!(
3318            DeviceDrag::tumbling(&design("rocketpy-valetudo").assemble("example").unwrap()).is_ok()
3319        );
3320
3321        // Pods: each pod's tube and fins count as the airframe's do, once per pod.
3322        let mut rocket = design("rocketpy-valetudo");
3323        let airframe = &mut rocket.stages[0].components[1];
3324        let fin_set = airframe
3325            .children
3326            .iter()
3327            .find(|c| matches!(c.part, hpr_design::Part::FinSet(_)))
3328            .expect("Valetudo has a fin set")
3329            .clone();
3330        let mut pod_tube = airframe.clone();
3331        pod_tube.id = "pod-tube".to_owned();
3332        pod_tube.auto.clear();
3333        pod_tube.motor_mount = None;
3334        pod_tube.children.clear();
3335        let hpr_design::Part::BodyTube(tube) = &pod_tube.part else {
3336            panic!("Valetudo's airframe is a body tube");
3337        };
3338        let tube_profile_m2 = 2.0 * tube.outer_radius_m * tube.length_m;
3339        let hpr_design::Part::FinSet(fins) = &fin_set.part else {
3340            unreachable!("found as a fin set");
3341        };
3342        // Three fins: Table 3.4's factor 1.50.
3343        let fin_m2 = 1.5 * fins.planform.geometry().unwrap().area_m2;
3344        let mut pod_fins = fin_set.clone();
3345        pod_fins.id = "pod-fins".to_owned();
3346        pod_tube.children.push(pod_fins);
3347        let mut pods = pod_tube.clone();
3348        pods.id = "pods".to_owned();
3349        pods.part = hpr_design::Part::PodSet(hpr_design::PodSet {
3350            count: 2,
3351            radial_offset_m: 0.2,
3352            angle_rad: 0.0,
3353        });
3354        pods.position = Some(hpr_design::Position::Top { aft_offset_m: 0.0 });
3355        pods.children = vec![pod_tube];
3356        pods.overrides = hpr_design::Overrides::default();
3357        airframe.children.push(pods);
3358        let tumble = |rocket: &hpr_design::Rocket| match DeviceDrag::tumbling(
3359            &rocket.assemble("example").unwrap(),
3360        )
3361        .unwrap()
3362        {
3363            DeviceDrag::Tumble {
3364                body_profile_m2,
3365                fin_area_m2,
3366                ..
3367            } => (body_profile_m2, fin_area_m2),
3368            other => panic!("tumbling should build a Tumble: {other:?}"),
3369        };
3370        let (bare_body, bare_fins) = tumble(&design("rocketpy-valetudo"));
3371        let (body, fins) = tumble(&rocket);
3372        assert!(
3373            (body - bare_body - 2.0 * tube_profile_m2).abs() <= 1e-15,
3374            "{body}"
3375        );
3376        assert!((fins - bare_fins - 2.0 * fin_m2).abs() <= 1e-15, "{fins}");
3377        // A pod set that holds nothing adds no drag area.
3378        let pods = rocket.stages[0].components[1].children.last_mut().unwrap();
3379        pods.children.clear();
3380        assert_eq!(
3381            DeviceDrag::tumbling(&rocket.assemble("example").unwrap()).unwrap(),
3382            DeviceDrag::tumbling(&design("rocketpy-valetudo").assemble("example").unwrap())
3383                .unwrap()
3384        );
3385    }
3386
3387    #[test]
3388    fn a_tumbling_body_descends_at_its_cited_terminal_velocity() {
3389        // The OpenRocket technical documentation's tumbling model, §3.5: the drag area is
3390        // `1.42 A_f + 0.56 A_bt`, with `A_bt` the body's side profile and `A_f` one fin's area
3391        // times the efficiency factor for the fin count. Computed here from Valetudo's own
3392        // geometry, and checked by a flight.
3393        let air = UniformAir::sea_level();
3394        let rocket = design("rocketpy-valetudo");
3395        let assembly = rocket.assemble("example").unwrap();
3396        let drag = DeviceDrag::tumbling(&assembly).unwrap();
3397        let DeviceDrag::Tumble {
3398            drag_area_m2,
3399            body_profile_m2,
3400            fin_area_m2,
3401        } = drag
3402        else {
3403            panic!("tumbling should build a Tumble: {drag:?}");
3404        };
3405        // Valetudo: a 0.274 m tangent ogive nose of 40.45 mm base radius and 1.884 m of 80.9 mm
3406        // tube. The ogive's arc has radius `ρ = (R² + L²)/(2R)`, so its side area in closed form
3407        // is `L√(ρ² − L²) + ρ² asin(L/ρ) + 2(R − ρ)L` = 0.014842 m², a third more than the
3408        // triangle through its ends; the side profile is that plus 0.0809·1.884 = 0.1673 m².
3409        // Three fins of 0.058 m root, 0.018 m tip and 0.077 m span, so one fin is 2.93e-3 m² and
3410        // the three-fin factor is 1.50.
3411        let expected_fin_m2 = 1.5
3412            * match &assembly
3413                .layout
3414                .components
3415                .iter()
3416                .find(|c| matches!(c.part, hpr_design::Part::FinSet(_)))
3417                .unwrap()
3418                .part
3419            {
3420                hpr_design::Part::FinSet(fins) => fins.planform.geometry().unwrap().area_m2,
3421                _ => unreachable!(),
3422            };
3423        assert!(
3424            (fin_area_m2 - expected_fin_m2).abs() < 1e-12,
3425            "{fin_area_m2}"
3426        );
3427        assert!(
3428            (drag_area_m2 - (1.42 * fin_area_m2 + 0.56 * body_profile_m2)).abs() < 1e-15,
3429            "{drag_area_m2}"
3430        );
3431        let (nose_m, base_m) = (0.274_f64, 0.040_45_f64);
3432        let arc_m = (base_m * base_m + nose_m * nose_m) / (2.0 * base_m);
3433        let ogive_m2 = nose_m * (arc_m * arc_m - nose_m * nose_m).sqrt()
3434            + arc_m * arc_m * (nose_m / arc_m).asin()
3435            + 2.0 * (base_m - arc_m) * nose_m;
3436        assert!((ogive_m2 - 0.014_842).abs() < 5e-7, "{ogive_m2}");
3437        assert!(
3438            (body_profile_m2 - (ogive_m2 + 0.080_9 * 1.884)).abs() < 1e-12,
3439            "{body_profile_m2}"
3440        );
3441
3442        let sim = flight(
3443            analytic_environment(air, G),
3444            vec![open_at_start(drag)],
3445            3600.0,
3446        );
3447        let mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
3448        let terminal_m_s = terminal_speed_m_s(mass_kg, drag_area_m2, air.0.density_kg_m3, G);
3449        // Tumbling is slower than a ballistic dive but far faster than a canopy: 36.38 m/s for
3450        // this 8.3 kg rocket, well outside the 6.8 to 160 g the constants were fitted on. Its
3451        // nose's end diameters alone gave 36.77 m/s, before the side area was integrated.
3452        assert!((terminal_m_s - 36.38).abs() < 0.005, "{terminal_m_s}");
3453        let result = sim
3454            .run_free(START_S, dropped(&sim, 4_000.0, DVec3::ZERO), &mut ())
3455            .unwrap();
3456        assert_eq!(result.termination, Termination::GroundHit);
3457        let landing = result.event(EventKind::GroundHit).unwrap().sample;
3458        assert!(
3459            (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 1e-6,
3460            "{} vs {terminal_m_s}",
3461            landing.vertical_speed_m_s
3462        );
3463    }
3464
3465    /// The two-stage test design, whose booster and sustainer each carry a motor.
3466    fn two_stage() -> hpr_design::Rocket {
3467        design("synthetic-two-stage-75mm-54mm")
3468    }
3469
3470    /// That design flown with `devices` and a separation, from a vertical rail.
3471    fn staged_flight(
3472        environment: Environment,
3473        devices: Vec<Device>,
3474        separation: Separation,
3475    ) -> Simulation {
3476        Simulation::new(
3477            &two_stage(),
3478            "j760-i175",
3479            environment,
3480            Rail::vertical(6.0),
3481            FlightSettings {
3482                max_time_s: 3600.0,
3483                ..FlightSettings::default()
3484            },
3485        )
3486        .unwrap()
3487        .with_recovery(devices)
3488        .unwrap()
3489        .with_separation(separation)
3490        .unwrap()
3491    }
3492
3493    #[test]
3494    fn a_separation_lands_every_body_and_the_masses_add_up() {
3495        // The stack comes apart at apogee: the sustainer descends under a canopy, the booster
3496        // tumbles. Both have to reach the ground, each at its own terminal speed, and their
3497        // masses have to add up to the whole rocket's.
3498        let air = UniformAir::sea_level();
3499        let assembly = two_stage().assemble("j760-i175").unwrap();
3500        // The booster tumbles on its own stage's geometry, not the whole stack's.
3501        let tumble = DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap();
3502        let devices = vec![
3503            Device::new(
3504                "sustainer main",
3505                DeviceDrag::canopy(CanopyType::FlatCircular, 1.8),
3506                Trigger::Altitude {
3507                    height_above_ground_m: 1_500.0,
3508                },
3509            ),
3510            Device::new(
3511                "booster tumble",
3512                tumble,
3513                Trigger::Altitude {
3514                    height_above_ground_m: 1_500.0,
3515                },
3516            )
3517            .on_body(1),
3518        ];
3519        let sim = staged_flight(
3520            analytic_environment(air, G),
3521            devices,
3522            Separation::new(Trigger::Apogee, 0),
3523        );
3524        // From just past apogee: the ascent is not what this test is about.
3525        let start = dropped(&sim, 2_000.0, DVec3::new(0.0, 0.0, -0.5));
3526        let result = sim.run_free(START_S, start, &mut ()).unwrap();
3527        assert_eq!(result.termination, Termination::Separated);
3528        assert!(result.event(EventKind::Separation).is_some());
3529        assert_eq!(result.bodies.len(), 2, "{:?}", result.bodies.len());
3530
3531        // The bodies' masses add up to the rocket's at the separation, and each is a real share.
3532        let separation = result.event(EventKind::Separation).unwrap().sample;
3533        let whole_kg = sim.assembly().mass_properties(separation.time_s).mass_kg;
3534        let sum_kg: f64 = result.bodies.iter().map(|body| body.mass_kg).sum();
3535        assert!(
3536            (sum_kg - whole_kg).abs() < 1e-12 * whole_kg,
3537            "{sum_kg} vs {whole_kg}"
3538        );
3539        assert_eq!(result.bodies[0].stages, (0, 0));
3540        assert_eq!(result.bodies[1].stages, (1, 1));
3541        for body in &result.bodies {
3542            // Each body is its own stage's structure plus the motor mounted in it, so a swapped
3543            // split or a motor on the wrong stage fails here and not only in the sum.
3544            let layout = &sim.assembly().layout;
3545            let expected_kg = layout.stages[body.stages.0].mass.mass_kg
3546                + sim
3547                    .assembly()
3548                    .motors
3549                    .iter()
3550                    .filter(|motor| motor.stage == body.stages.0)
3551                    .map(|motor| motor.mass_properties(separation.time_s).mass_kg)
3552                    .sum::<f64>();
3553            assert!(
3554                (body.mass_kg - expected_kg).abs() < 1e-12 * expected_kg,
3555                "body {}: {} vs {expected_kg}",
3556                body.body,
3557                body.mass_kg
3558            );
3559        }
3560        // Measured: a 0.550 kg sustainer and a 1.125 kg booster of a 1.675 kg stack.
3561        assert!(
3562            (result.bodies[0].mass_kg - 0.550_344).abs() < 1e-5,
3563            "{}",
3564            result.bodies[0].mass_kg
3565        );
3566        assert!(
3567            (result.bodies[1].mass_kg - 1.124_834).abs() < 1e-5,
3568            "{}",
3569            result.bodies[1].mass_kg
3570        );
3571
3572        // Every body lands, under its own device, at its own terminal speed.
3573        let rho = air.0.density_kg_m3;
3574        for body in &result.bodies {
3575            assert_eq!(
3576                body.termination,
3577                Termination::GroundHit,
3578                "body {}",
3579                body.body
3580            );
3581            let landing = body.event(EventKind::GroundHit).unwrap().sample;
3582            assert!(landing.height_above_ground_m.abs() < 1e-6, "{landing:?}");
3583            assert!(landing.time_s > separation.time_s, "{landing:?}");
3584            let device = body.body;
3585            let drag_area_m2 = sim.recovery()[device].drag.drag_area_m2();
3586            let terminal_m_s = terminal_speed_m_s(body.mass_kg, drag_area_m2, rho, G);
3587            assert!(
3588                (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 1e-3,
3589                "body {}: {} vs {terminal_m_s}",
3590                body.body,
3591                landing.vertical_speed_m_s
3592            );
3593            assert!(
3594                (landing.recovery_drag_area_m2 - drag_area_m2).abs() < 1e-12,
3595                "body {}: {landing:?}",
3596                body.body
3597            );
3598            assert!(body.event(EventKind::Deployment(device)).is_some());
3599        }
3600        // The tumbling booster comes down much faster than the sustainer under its canopy, and
3601        // both descent times are pinned, not just the speeds. Measured: the sustainer lands at
3602        // 729.00 s at 2.114 m/s under its 1.8 m canopy, the booster at 107.51 s at 16.745 m/s.
3603        let landing_of = |body: usize| {
3604            result.bodies[body]
3605                .event(EventKind::GroundHit)
3606                .unwrap()
3607                .sample
3608        };
3609        let under_canopy = -landing_of(0).vertical_speed_m_s;
3610        let tumbling = -landing_of(1).vertical_speed_m_s;
3611        assert!(
3612            tumbling / under_canopy > 7.5,
3613            "{tumbling} vs {under_canopy}"
3614        );
3615        assert!(
3616            (landing_of(0).time_s - 729.00).abs() < 0.5,
3617            "{}",
3618            landing_of(0).time_s
3619        );
3620        assert!(
3621            (landing_of(1).time_s - 107.51).abs() < 0.2,
3622            "{}",
3623            landing_of(1).time_s
3624        );
3625        assert!(result.bodies_landed(), "{:?}", result.bodies.len());
3626        assert_eq!(result.landings().len(), 2);
3627    }
3628
3629    /// A west wind of `east_m_s` below `below_msl_m` above sea level, and calm above.
3630    #[derive(Debug)]
3631    struct WindBelow {
3632        below_msl_m: f64,
3633        east_m_s: f64,
3634    }
3635
3636    impl hpr_atmos::Wind for WindBelow {
3637        fn wind(&self, height_msl_m: f64) -> Result<hpr_atmos::WindSample, hpr_atmos::AtmosError> {
3638            let east_m_s = if height_msl_m < self.below_msl_m {
3639                self.east_m_s
3640            } else {
3641                0.0
3642            };
3643            Ok(hpr_atmos::WindSample {
3644                velocity_enu_m_s: DVec3::new(east_m_s, 0.0, 0.0),
3645                extrapolated: None,
3646            })
3647        }
3648    }
3649
3650    #[test]
3651    fn a_separated_body_refuses_a_wind_that_is_not_finite() {
3652        // Issue #237: the bodies flown apart after a separation read the wind through their own
3653        // point-mass rates and samples, not the rigid-body step. A wind that isn't finite below
3654        // 1,000 m over the site (2,400 m above sea level) is refused there, by name, with the
3655        // height, as the bodies come down through it under their devices.
3656        let assembly = two_stage().assemble("j760-i175").unwrap();
3657        let tumble = DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap();
3658        let devices = || {
3659            vec![
3660                Device::new(
3661                    "sustainer main",
3662                    DeviceDrag::canopy(CanopyType::FlatCircular, 1.8),
3663                    Trigger::Altitude {
3664                        height_above_ground_m: 1_500.0,
3665                    },
3666                ),
3667                Device::new(
3668                    "booster tumble",
3669                    tumble,
3670                    Trigger::Altitude {
3671                        height_above_ground_m: 1_500.0,
3672                    },
3673                )
3674                .on_body(1),
3675            ]
3676        };
3677        let below_msl_m = crate::testing::site().height_m + 1_000.0;
3678        let fly = |east_m_s: f64| {
3679            let environment =
3680                analytic_environment(UniformAir::sea_level(), G).with_wind(WindBelow {
3681                    below_msl_m,
3682                    east_m_s,
3683                });
3684            let sim = staged_flight(environment, devices(), Separation::new(Trigger::Apogee, 0));
3685            let start = dropped(&sim, 2_000.0, DVec3::new(0.0, 0.0, -0.5));
3686            sim.run_free(START_S, start, &mut ())
3687        };
3688        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
3689            match fly(bad) {
3690                Err(SimError::Domain { what, value }) => {
3691                    assert_eq!(what, crate::environment::WIND_NOT_FINITE, "{bad}");
3692                    // Within a step below the wind's edge.
3693                    assert!(
3694                        value < below_msl_m && value > below_msl_m - 50.0,
3695                        "{bad}: {value}"
3696                    );
3697                }
3698                other => panic!("{bad}: not the wind's refusal: {other:?}"),
3699            }
3700        }
3701        // The same wind, finite, lands both bodies.
3702        let result = fly(5.0).unwrap();
3703        assert_eq!(result.termination, Termination::Separated);
3704        assert!(result.bodies_landed(), "{:?}", result.bodies.len());
3705    }
3706
3707    /// Sea-level air, with field `field` (density, pressure, temperature, speed of sound,
3708    /// viscosity, in that order) set to `value` below `below_msl_m` above sea level.
3709    #[derive(Debug)]
3710    struct AirBelow {
3711        below_msl_m: f64,
3712        field: usize,
3713        value: f64,
3714    }
3715
3716    impl hpr_atmos::Atmosphere for AirBelow {
3717        fn air(&self, height_msl_m: f64) -> Result<hpr_atmos::AirSample, hpr_atmos::AtmosError> {
3718            let mut air = UniformAir::sea_level().0;
3719            if height_msl_m < self.below_msl_m {
3720                *[
3721                    &mut air.density_kg_m3,
3722                    &mut air.pressure_pa,
3723                    &mut air.temperature_k,
3724                    &mut air.speed_of_sound_m_s,
3725                    &mut air.dynamic_viscosity_pa_s,
3726                ]
3727                .into_iter()
3728                .nth(self.field)
3729                .unwrap() = self.value;
3730            }
3731            Ok(hpr_atmos::AirSample {
3732                air,
3733                extrapolated: None,
3734            })
3735        }
3736    }
3737
3738    #[test]
3739    fn a_separated_body_refuses_air_it_cannot_use() {
3740        // Issue #301: the bodies flown apart after a separation read the air through their own
3741        // point-mass rates, not the rigid-body step. Before, a density that was NaN, negative or
3742        // minus infinity turned their drag off, and both landed at about 140 m/s with a
3743        // successful result. Air the flight can't use below 1,000 m over the site (2,400 m above
3744        // sea level) is now refused there, naming the field, with the height, as the bodies come
3745        // down through it under their devices. A zero density or pressure, a vacuum's, is taken.
3746        use crate::environment::{
3747            AIR_DENSITY_REFUSED, AIR_PRESSURE_REFUSED, AIR_SPEED_OF_SOUND_REFUSED,
3748            AIR_TEMPERATURE_REFUSED, AIR_VISCOSITY_REFUSED,
3749        };
3750        let assembly = two_stage().assemble("j760-i175").unwrap();
3751        let tumble = DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap();
3752        let devices = || {
3753            vec![
3754                Device::new(
3755                    "sustainer main",
3756                    DeviceDrag::canopy(CanopyType::FlatCircular, 1.8),
3757                    Trigger::Altitude {
3758                        height_above_ground_m: 1_500.0,
3759                    },
3760                ),
3761                Device::new(
3762                    "booster tumble",
3763                    tumble,
3764                    Trigger::Altitude {
3765                        height_above_ground_m: 1_500.0,
3766                    },
3767                )
3768                .on_body(1),
3769            ]
3770        };
3771        let below_msl_m = crate::testing::site().height_m + 1_000.0;
3772        let fly = |field: usize, value: f64| {
3773            let mut environment = analytic_environment(UniformAir::sea_level(), G);
3774            environment.atmosphere = std::sync::Arc::new(AirBelow {
3775                below_msl_m,
3776                field,
3777                value,
3778            });
3779            let sim = staged_flight(environment, devices(), Separation::new(Trigger::Apogee, 0));
3780            let start = dropped(&sim, 2_000.0, DVec3::new(0.0, 0.0, -0.5));
3781            sim.run_free(START_S, start, &mut ())
3782        };
3783        // Each field, its refusal, and whether a zero is taken.
3784        let fields = [
3785            (AIR_DENSITY_REFUSED, true),
3786            (AIR_PRESSURE_REFUSED, true),
3787            (AIR_TEMPERATURE_REFUSED, false),
3788            (AIR_SPEED_OF_SOUND_REFUSED, false),
3789            (AIR_VISCOSITY_REFUSED, false),
3790        ];
3791        for (field, (refusal, takes_zero)) in fields.into_iter().enumerate() {
3792            let zero = if takes_zero { vec![] } else { vec![0.0] };
3793            for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0]
3794                .into_iter()
3795                .chain(zero)
3796            {
3797                match fly(field, bad) {
3798                    Err(SimError::Domain { what, value }) => {
3799                        assert_eq!(what, refusal, "{field} {bad}");
3800                        // Within a step below the air's edge.
3801                        assert!(
3802                            value < below_msl_m && value > below_msl_m - 50.0,
3803                            "{field} {bad}: {value}"
3804                        );
3805                    }
3806                    other => panic!("{field} {bad}: not the air's refusal: {other:?}"),
3807                }
3808            }
3809            if takes_zero {
3810                let result = fly(field, 0.0).unwrap();
3811                assert_eq!(result.termination, Termination::Separated, "{field}");
3812                assert!(result.bodies_landed(), "{field}");
3813            }
3814        }
3815        // Good air lands both bodies.
3816        let result = fly(0, UniformAir::sea_level().0.density_kg_m3).unwrap();
3817        assert_eq!(result.termination, Termination::Separated);
3818        assert!(result.bodies_landed(), "{:?}", result.bodies.len());
3819    }
3820
3821    #[test]
3822    fn a_separation_conserves_momentum_and_gives_each_body_its_own_start() {
3823        // An ideal separation adds no impulse: each body leaves with the velocity its own center
3824        // of mass already had, so the bodies' momenta add to the stack's.
3825        let air = UniformAir::sea_level();
3826        let assembly = two_stage().assemble("j760-i175").unwrap();
3827        let devices = vec![
3828            Device::new(
3829                "sustainer",
3830                DeviceDrag::canopy(CanopyType::FlatCircular, 1.5),
3831                Trigger::Altitude {
3832                    height_above_ground_m: 1_500.0,
3833                },
3834            ),
3835            Device::new(
3836                "booster",
3837                DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap(),
3838                Trigger::Altitude {
3839                    height_above_ground_m: 1_500.0,
3840                },
3841            )
3842            .on_body(1),
3843        ];
3844        let sim = staged_flight(
3845            analytic_wind_environment(air, G, ConstantWind::new(5.0, 0.9).unwrap()),
3846            devices,
3847            Separation::new(Trigger::Apogee, 0),
3848        );
3849        // Separating with a body rate, so that `ω × r` is part of each body's start.
3850        let mut start = dropped(&sim, 2_000.0, DVec3::new(3.0, 0.0, -2.0));
3851        start.body_rate_rad_s = DVec3::new(0.0, 0.6, 0.0);
3852        let result = sim.run_free(START_S, start, &mut ()).unwrap();
3853        let separation = result.event(EventKind::Separation).unwrap().sample;
3854        let whole_kg = sim.assembly().mass_properties(separation.time_s).mass_kg;
3855        let momentum: DVec3 = result
3856            .bodies
3857            .iter()
3858            .map(|body| body.start_sample.cg_velocity_enu_m_s * body.mass_kg)
3859            .sum();
3860        let expected = separation.cg_velocity_enu_m_s * whole_kg;
3861        assert!(
3862            (momentum - expected).length() < 1e-9 * expected.length(),
3863            "{momentum} vs {expected}"
3864        );
3865        // And each body starts where **its own** center of mass was, not the stack's: the two
3866        // are 0.817 m apart on this design.
3867        let state = result.final_sample.state;
3868        for body in &result.bodies {
3869            let lit = vec![Some(0.0); sim.assembly().motors.len()];
3870            let cg_m =
3871                body_mass_properties(sim.assembly(), body.stages, separation.time_s, &lit).cg_m;
3872            let expected = state.point_enu_m(cg_m);
3873            assert!(
3874                (body.start_sample.cg_enu_m - expected).length() < 1e-12,
3875                "body {}: {} vs {expected}",
3876                body.body,
3877                body.start_sample.cg_enu_m
3878            );
3879        }
3880        let gap = (result.bodies[0].start_sample.cg_enu_m - result.bodies[1].start_sample.cg_enu_m)
3881            .length();
3882        assert!((gap - 0.817).abs() < 0.01, "{gap}");
3883    }
3884
3885    #[test]
3886    fn a_body_separated_while_climbing_finds_its_own_apogee() {
3887        // Found in review: a body's system watched only the ground and its own deployment
3888        // heights, so a body that separated while still climbing never saw a descending moment,
3889        // its apogee charge never fired, and it hit the ground in a vacuum at 165 m/s reported as
3890        // an ordinary landing. Both bodies now find their own apogee.
3891        let air = UniformAir::sea_level();
3892        let assembly = two_stage().assemble("j760-i175").unwrap();
3893        let devices = vec![
3894            Device::new(
3895                "sustainer",
3896                DeviceDrag::canopy(CanopyType::FlatCircular, 1.5),
3897                Trigger::Apogee,
3898            ),
3899            Device::new(
3900                "booster",
3901                DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap(),
3902                Trigger::Apogee,
3903            )
3904            .on_body(1),
3905        ];
3906        let burnout_s = assembly
3907            .motors
3908            .iter()
3909            .map(|motor| motor.mounted.motor.burnout_time_s())
3910            .fold(0.0, f64::max);
3911        let sim = staged_flight(
3912            analytic_environment(air, G),
3913            devices,
3914            Separation::new(
3915                Trigger::Time {
3916                    time_s: burnout_s + 1.0,
3917                },
3918                0,
3919            ),
3920        );
3921        // Still climbing hard when the stack comes apart.
3922        let start = dropped(&sim, 1_000.0, DVec3::new(0.0, 0.0, 100.0));
3923        let result = sim.run_free(burnout_s + 1.0, start, &mut ()).unwrap();
3924        assert_eq!(result.termination, Termination::Separated);
3925        assert!(result.bodies_landed(), "{:?}", result.bodies.len());
3926        let rho = air.0.density_kg_m3;
3927        for body in &result.bodies {
3928            assert!(
3929                body.start_sample.vertical_speed_m_s > 50.0,
3930                "body {} should still be climbing: {:?}",
3931                body.body,
3932                body.start_sample.vertical_speed_m_s
3933            );
3934            // Each body records its own apogee, then fires its charge and opens.
3935            let apogee = body
3936                .event(EventKind::Apogee)
3937                .unwrap_or_else(|| panic!("body {} found no apogee", body.body))
3938                .sample;
3939            assert!(apogee.vertical_speed_m_s.abs() < 1e-6, "{apogee:?}");
3940            assert!(
3941                apogee.height_above_ground_m > 1_400.0,
3942                "body {}: {:?}",
3943                body.body,
3944                apogee.height_above_ground_m
3945            );
3946            assert!(body.event(EventKind::Deployment(body.body)).is_some());
3947            // And it lands at its own terminal speed, not ballistically.
3948            let drag_area_m2 = sim.recovery()[body.body].drag.drag_area_m2();
3949            let terminal_m_s = terminal_speed_m_s(body.mass_kg, drag_area_m2, rho, G);
3950            let landing = body.event(EventKind::GroundHit).unwrap().sample;
3951            assert!(
3952                (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 0.01,
3953                "body {}: {} vs {terminal_m_s}",
3954                body.body,
3955                landing.vertical_speed_m_s
3956            );
3957        }
3958    }
3959
3960    #[test]
3961    fn a_timed_separation_fires_at_its_own_time() {
3962        // Found in review: the separation's trigger was neither a stop time nor a located event,
3963        // so a timed or height separation fired at whatever boundary happened to come next,
3964        // measured hundreds of seconds late, or never. Its time is a stop time now, and its
3965        // height is an event.
3966        let air = UniformAir::sea_level();
3967        let assembly = two_stage().assemble("j760-i175").unwrap();
3968        let devices = || {
3969            vec![
3970                Device::new(
3971                    "sustainer",
3972                    DeviceDrag::canopy(CanopyType::FlatCircular, 1.5),
3973                    Trigger::Altitude {
3974                        height_above_ground_m: 300.0,
3975                    },
3976                ),
3977                Device::new(
3978                    "booster",
3979                    DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap(),
3980                    Trigger::Apogee,
3981                )
3982                .on_body(1),
3983            ]
3984        };
3985        let burnout_s = assembly
3986            .motors
3987            .iter()
3988            .map(|motor| motor.mounted.motor.burnout_time_s())
3989            .fold(0.0, f64::max);
3990        // A time, well after the burnout and nowhere near a thrust knot or a device's trigger.
3991        let at_s = burnout_s + 7.5;
3992        let sim = staged_flight(
3993            analytic_environment(air, G),
3994            devices(),
3995            Separation::new(Trigger::Time { time_s: at_s }, 0),
3996        );
3997        let result = sim
3998            .run_free(
3999                burnout_s + 0.5,
4000                dropped(&sim, 2_000.0, DVec3::new(0.0, 0.0, -1.0)),
4001                &mut (),
4002            )
4003            .unwrap();
4004        let separation = result.event(EventKind::Separation).unwrap().sample;
4005        assert!(
4006            (separation.time_s - at_s).abs() < 1e-9,
4007            "{} vs {at_s}",
4008            separation.time_s
4009        );
4010
4011        // And a height separation fires at its height, located rather than polled.
4012        let sim = staged_flight(
4013            analytic_environment(air, G),
4014            devices(),
4015            Separation::new(
4016                Trigger::Altitude {
4017                    height_above_ground_m: 1_000.0,
4018                },
4019                0,
4020            ),
4021        );
4022        let result = sim
4023            .run_free(
4024                burnout_s + 0.5,
4025                dropped(&sim, 2_000.0, DVec3::new(0.0, 0.0, -1.0)),
4026                &mut (),
4027            )
4028            .unwrap();
4029        let separation = result.event(EventKind::Separation).unwrap().sample;
4030        assert!(
4031            (separation.height_above_ground_m - 1_000.0).abs() < 1e-6,
4032            "{:?}",
4033            separation.height_above_ground_m
4034        );
4035        assert!(result.bodies_landed(), "{:?}", result.bodies.len());
4036    }
4037
4038    #[test]
4039    fn a_body_separated_while_climbing_with_its_device_lagging_is_refused() {
4040        // Found in review of M4.6a: a part parted on the way up, its device fired by the split
4041        // but waiting out its lag, climbed through the lag with no drag at all (a Monte Carlo
4042        // run's drawn lag did it). It is refused by name; the same flight whose device opened
4043        // before the split flies.
4044        let air = UniformAir::sea_level();
4045        let assembly = two_stage().assemble("j760-i175").unwrap();
4046        let burnout_s = assembly
4047            .motors
4048            .iter()
4049            .map(|motor| motor.mounted.motor.burnout_time_s())
4050            .fold(0.0, f64::max);
4051        let fly = |lag_s: f64| {
4052            let sim = staged_flight(
4053                analytic_environment(air, G),
4054                vec![
4055                    Device::new(
4056                        "sustainer",
4057                        DeviceDrag::canopy(CanopyType::FlatCircular, 1.5),
4058                        Trigger::Time {
4059                            time_s: burnout_s + 1.2,
4060                        },
4061                    )
4062                    .with_lag_s(lag_s),
4063                    Device::new(
4064                        "booster",
4065                        DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap(),
4066                        Trigger::Apogee,
4067                    )
4068                    .on_body(1),
4069                ],
4070                Separation::new(
4071                    Trigger::Time {
4072                        time_s: burnout_s + 1.5,
4073                    },
4074                    0,
4075                ),
4076            );
4077            let start = dropped(&sim, 1_000.0, DVec3::new(0.0, 0.0, 100.0));
4078            sim.run_free(burnout_s + 1.0, start, &mut ())
4079        };
4080        // Fired at 1.2 s after the burnout, open at 1.3 s: open at the split, so it flies.
4081        let result = fly(0.1).unwrap();
4082        assert_eq!(result.termination, Termination::Separated);
4083        assert!(result.bodies_landed(), "{:?}", result.bodies.len());
4084        // Open at 2.2 s, 0.7 s after the split: refused, at the split's time.
4085        let error = fly(1.0).expect_err("a part climbing with nothing open");
4086        assert!(
4087            matches!(
4088                error,
4089                SimError::Domain { what, value }
4090                    if what.starts_with("time of a split with nothing left to burn, before \
4091                                         apogee")
4092                        && (value - (burnout_s + 1.5)).abs() < 1e-9
4093            ),
4094            "{error:?}"
4095        );
4096    }
4097
4098    #[test]
4099    fn a_separated_part_warns_of_179_and_of_354_while_it_has_nothing_open() {
4100        // M10.1d5 (ADR-200): the stack parts on the way down. The booster tumbles from the split,
4101        // so the flight meets #179 whatever the sustainer does; the sustainer's canopy fired
4102        // 0.3 s before the split meets #354 only when its lag leaves it closed at the split.
4103        use crate::issues::{KnownIssue, separated_part_issue_warnings};
4104        use crate::metrics::Peak;
4105        let air = UniformAir::sea_level();
4106        let assembly = two_stage().assemble("j760-i175").unwrap();
4107        let burnout_s = assembly
4108            .motors
4109            .iter()
4110            .map(|motor| motor.mounted.motor.burnout_time_s())
4111            .fold(0.0, f64::max);
4112        let split_s = burnout_s + 1.5;
4113        let fly = |lag_s: f64| {
4114            let sim = staged_flight(
4115                analytic_environment(air, G),
4116                vec![
4117                    Device::new(
4118                        "sustainer",
4119                        DeviceDrag::canopy(CanopyType::FlatCircular, 1.5),
4120                        Trigger::Time {
4121                            time_s: burnout_s + 1.2,
4122                        },
4123                    )
4124                    .with_lag_s(lag_s),
4125                    // Fired at the split itself, with no lag: open from the booster's start.
4126                    Device::new(
4127                        "booster",
4128                        DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap(),
4129                        Trigger::Time { time_s: split_s },
4130                    )
4131                    .on_body(1),
4132                ],
4133                Separation::new(Trigger::Time { time_s: split_s }, 0),
4134            );
4135            let start = dropped(&sim, 1_000.0, DVec3::new(0.0, 0.0, -5.0));
4136            sim.run_free(burnout_s + 1.0, start, &mut ()).unwrap()
4137        };
4138        let peak = Peak {
4139            value: 0.2,
4140            time_s: 0.0,
4141            height_above_ground_m: 0.0,
4142        };
4143        let warned = |result: &FlightResult| -> Vec<KnownIssue> {
4144            separated_part_issue_warnings(result, Some(peak))
4145                .into_iter()
4146                .map(|warning| warning.issue)
4147                .collect()
4148        };
4149        // Open 0.1 s before the split: every body starts with drag, so only the booster's #179.
4150        let open = fly(0.2);
4151        assert_eq!(open.termination, Termination::Separated);
4152        assert!(open.bodies_landed(), "{:?}", open.bodies.len());
4153        let [sustainer, booster] = &open.bodies[..] else {
4154            panic!("{} bodies", open.bodies.len())
4155        };
4156        assert_eq!((sustainer.stages, booster.stages), ((0, 0), (1, 1)));
4157        assert!(sustainer.start_sample.recovery_drag_area_m2 > 0.0);
4158        // The booster's start is the instant before its tumble opens, at that same instant.
4159        assert_eq!(booster.start_sample.recovery_drag_area_m2, 0.0);
4160        let tumbled = booster.event(EventKind::Deployment(1)).unwrap().sample;
4161        assert_eq!(tumbled.time_s, booster.start_sample.time_s);
4162        assert!(tumbled.recovery_drag_area_m2 > 0.0);
4163        assert_eq!(warned(&open), [KnownIssue::BoosterAirframeDrag]);
4164        // Open 0.7 s after it: the sustainer falls with nothing open until then, so #354 too.
4165        let lagging = fly(1.0);
4166        assert!(lagging.bodies_landed(), "{:?}", lagging.bodies.len());
4167        let sustainer = &lagging.bodies[0];
4168        assert_eq!(sustainer.start_sample.recovery_drag_area_m2, 0.0);
4169        let opened_s = sustainer
4170            .event(EventKind::Deployment(0))
4171            .unwrap()
4172            .sample
4173            .time_s;
4174        assert!((opened_s - (split_s + 0.7)).abs() < 1e-9, "{opened_s}");
4175        assert_eq!(
4176            warned(&lagging),
4177            [
4178                KnownIssue::BoosterAirframeDrag,
4179                KnownIssue::DragFreeSeparatedPart
4180            ]
4181        );
4182        // A device that opens just after the start counts as closed at the start; a NaN drag area
4183        // counts as none, so a broken one can't hide #354; a flight with no top Mach number warns
4184        // of nothing; a flight that never parts meets neither.
4185        let mut late = open.clone();
4186        let deployment = &mut late.bodies[1].events[1];
4187        assert_eq!(deployment.kind, EventKind::Deployment(1));
4188        deployment.sample.time_s = deployment.sample.time_s.next_up();
4189        assert_eq!(warned(&late).len(), 2);
4190        let mut broken = open.clone();
4191        broken.bodies[0].start_sample.recovery_drag_area_m2 = f64::NAN;
4192        assert_eq!(warned(&broken).len(), 2);
4193        let mut timeless = open.clone();
4194        timeless.bodies[1].events[1].sample.time_s = f64::NAN;
4195        assert_eq!(warned(&timeless).len(), 2);
4196        // Both are drag issues, by these names in a record.
4197        for (issue, name) in [
4198            (KnownIssue::BoosterAirframeDrag, "\"booster_airframe_drag\""),
4199            (
4200                KnownIssue::DragFreeSeparatedPart,
4201                "\"drag_free_separated_part\"",
4202            ),
4203        ] {
4204            assert!(!issue.is_stability() && !issue.has_mach_condition());
4205            assert_eq!(serde_json::to_string(&issue).unwrap(), name);
4206        }
4207        assert!(separated_part_issue_warnings(&lagging, None).is_empty());
4208        // Bodies all in the nose's stage, as an ejection's are, meet no #179.
4209        let mut forward = open.clone();
4210        forward.bodies.truncate(1);
4211        assert!(warned(&forward).is_empty());
4212        let mut whole = open;
4213        whole.bodies.clear();
4214        assert!(warned(&whole).is_empty());
4215    }
4216
4217    #[test]
4218    fn a_body_whose_device_never_opens_is_refused() {
4219        // Found in review: a device that is attached but never fires (an altimeter set above the
4220        // body's own apogee) dropped the body with no drag at all, at 170 m/s, reported as an
4221        // ordinary landing. A body that reaches the ground with nothing open is an error.
4222        let air = UniformAir::sea_level();
4223        let assembly = two_stage().assemble("j760-i175").unwrap();
4224        let sim = staged_flight(
4225            analytic_environment(air, G),
4226            vec![
4227                Device::new(
4228                    "sustainer",
4229                    DeviceDrag::canopy(CanopyType::FlatCircular, 1.5),
4230                    Trigger::Apogee,
4231                ),
4232                Device::new(
4233                    "booster",
4234                    DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap(),
4235                    // Long after the body lands, so it never fires. (A height above the body
4236                    // fires at once on a body already descending below it, which is the
4237                    // altimeter's rule; this test once used one and passed only because the
4238                    // sustainer's canopy, opened on the stack at the separation, was not
4239                    // counted as open. See ADR-074.)
4240                    Trigger::Time { time_s: 100_000.0 },
4241                )
4242                .on_body(1),
4243            ],
4244            Separation::new(Trigger::Apogee, 0),
4245        );
4246        let error = sim
4247            .run_free(
4248                10.0,
4249                dropped(&sim, 1_000.0, DVec3::new(0.0, 0.0, -1.0)),
4250                &mut (),
4251            )
4252            .expect_err("a body with nothing open");
4253        // The booster, body 1, is the one refused.
4254        assert!(
4255            matches!(error, SimError::Domain { value, .. } if value == 1.0),
4256            "{error:?}"
4257        );
4258    }
4259
4260    #[test]
4261    fn a_body_that_runs_out_of_time_says_so() {
4262        // A flight that separates says only that the stack came apart: each body's own
4263        // termination says whether it reached the ground.
4264        let air = UniformAir::sea_level();
4265        let assembly = two_stage().assemble("j760-i175").unwrap();
4266        let sim = Simulation::new(
4267            &two_stage(),
4268            "j760-i175",
4269            analytic_environment(air, G),
4270            Rail::vertical(6.0),
4271            FlightSettings {
4272                max_time_s: 60.0,
4273                ..FlightSettings::default()
4274            },
4275        )
4276        .unwrap()
4277        .with_recovery(vec![
4278            Device::new(
4279                "sustainer",
4280                DeviceDrag::canopy(CanopyType::FlatCircular, 1.8),
4281                Trigger::Apogee,
4282            ),
4283            Device::new(
4284                "booster",
4285                DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap(),
4286                Trigger::Apogee,
4287            )
4288            .on_body(1),
4289        ])
4290        .unwrap()
4291        .with_separation(Separation::new(Trigger::Apogee, 0))
4292        .unwrap();
4293        let result = sim
4294            .run_free(10.0, dropped(&sim, 2_000.0, DVec3::ZERO), &mut ())
4295            .unwrap();
4296        assert_eq!(result.termination, Termination::Separated);
4297        assert!(!result.bodies_landed(), "{:?}", result.bodies.len());
4298        assert!(
4299            result
4300                .bodies
4301                .iter()
4302                .any(|body| body.termination == Termination::TimeCap),
4303            "{:?}",
4304            result
4305                .bodies
4306                .iter()
4307                .map(|b| b.termination)
4308                .collect::<Vec<_>>()
4309        );
4310        assert!(result.landings().len() < result.bodies.len());
4311    }
4312
4313    #[test]
4314    fn separations_outside_their_domain_are_refused() {
4315        let environment = || analytic_environment(UniformAir::sea_level(), G);
4316        let canopy = DeviceDrag::canopy(CanopyType::FlatCircular, 1.5);
4317        // The devices are checked when they are given, the separation when it is; a caller sees
4318        // whichever refuses first.
4319        let build = |devices: Vec<Device>, separation: Separation| {
4320            Simulation::new(
4321                &two_stage(),
4322                "j760-i175",
4323                environment(),
4324                Rail::vertical(6.0),
4325                FlightSettings::default(),
4326            )
4327            .unwrap()
4328            .with_recovery(devices)
4329            .and_then(|sim| sim.with_separation(separation))
4330            .map(|_| ())
4331        };
4332        let both = || {
4333            vec![
4334                Device::new("sustainer", canopy, Trigger::Apogee),
4335                Device::new("booster", canopy, Trigger::Apogee).on_body(1),
4336            ]
4337        };
4338        // No stage aft of the split.
4339        let error = build(both(), Separation::new(Trigger::Apogee, 1)).expect_err("no aft stage");
4340        assert!(matches!(error, SimError::Domain { .. }), "{error:?}");
4341        // A body with no device would fall in a vacuum.
4342        let error = build(
4343            vec![Device::new("sustainer", canopy, Trigger::Apogee)],
4344            Separation::new(Trigger::Apogee, 0),
4345        )
4346        .expect_err("the booster has nothing");
4347        assert!(matches!(error, SimError::Domain { .. }), "{error:?}");
4348        // A device on a body the separation doesn't make. An ejection given afterwards could
4349        // make it, so it is refused when the flight starts rather than by the builders.
4350        let error = Simulation::new(
4351            &two_stage(),
4352            "j760-i175",
4353            environment(),
4354            Rail::vertical(6.0),
4355            FlightSettings::default(),
4356        )
4357        .unwrap()
4358        .with_recovery(vec![
4359            Device::new("sustainer", canopy, Trigger::Apogee),
4360            Device::new("booster", canopy, Trigger::Apogee).on_body(1),
4361            Device::new("ghost", canopy, Trigger::Apogee).on_body(2),
4362        ])
4363        .and_then(|sim| sim.with_separation(Separation::new(Trigger::Apogee, 0)))
4364        .and_then(|sim| sim.run(&mut ()))
4365        .expect_err("a third body");
4366        assert!(
4367            matches!(error, SimError::Domain { what, value }
4368                if what == "body a device is attached to (the separation and ejections don't \
4369                            make it)" && value == 2.0),
4370            "{error:?}"
4371        );
4372        // A release across the separation has no line to act through.
4373        let error = build(
4374            vec![
4375                Device::new("sustainer", canopy, Trigger::Apogee).with_release_by(1),
4376                Device::new("booster", canopy, Trigger::Apogee).on_body(1),
4377            ],
4378            Separation::new(Trigger::Apogee, 0),
4379        )
4380        .expect_err("a release across bodies");
4381        assert!(matches!(error, SimError::Domain { .. }), "{error:?}");
4382        // And the ordinary case is accepted.
4383        assert!(build(both(), Separation::new(Trigger::Apogee, 0)).is_ok());
4384    }
4385
4386    #[test]
4387    fn a_separation_before_burnout_is_refused() {
4388        // A body's mass is held constant through its descent, so a separation under thrust would
4389        // fly the wrong mass. A time that is known to precede the burnout is refused when the
4390        // separation is given; an apogee or height trigger can only be checked in flight, and is.
4391        let air = UniformAir::sea_level();
4392        let assembly = two_stage().assemble("j760-i175").unwrap();
4393        let canopy = DeviceDrag::canopy(CanopyType::FlatCircular, 1.5);
4394        let devices = || {
4395            vec![
4396                Device::new("sustainer", canopy, Trigger::Apogee),
4397                Device::new(
4398                    "booster",
4399                    DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap(),
4400                    Trigger::Apogee,
4401                )
4402                .on_body(1),
4403            ]
4404        };
4405        let burnout_s = assembly
4406            .motors
4407            .iter()
4408            .map(|motor| motor.mounted.motor.burnout_time_s())
4409            .fold(0.0, f64::max);
4410        assert!(burnout_s > 1.0, "{burnout_s}");
4411        let build = |separation| {
4412            Simulation::new(
4413                &two_stage(),
4414                "j760-i175",
4415                analytic_environment(air, G),
4416                Rail::vertical(6.0),
4417                FlightSettings::default(),
4418            )
4419            .unwrap()
4420            .with_recovery(devices())
4421            .unwrap()
4422            .with_separation(separation)
4423        };
4424        // Known in advance: refused at once, even though a flight might start after it.
4425        let error = build(Separation::new(
4426            Trigger::Time {
4427                time_s: 0.5 * burnout_s,
4428            },
4429            0,
4430        ))
4431        .expect_err("a timed separation under thrust");
4432        assert!(matches!(error, SimError::Domain { .. }), "{error:?}");
4433
4434        // Only knowable in flight: a height a climbing rocket passes before its burnout.
4435        let sim = build(Separation::new(
4436            Trigger::Altitude {
4437                height_above_ground_m: 500.0,
4438            },
4439            0,
4440        ))
4441        .unwrap();
4442        let error = sim
4443            .run_free(
4444                0.5 * burnout_s,
4445                dropped(&sim, 400.0, DVec3::new(0.0, 0.0, -1.0)),
4446                &mut (),
4447            )
4448            .expect_err("a height separation under thrust");
4449        assert!(matches!(error, SimError::Domain { .. }), "{error:?}");
4450    }
4451
4452    #[test]
4453    fn devices_outside_their_domain_are_refused() {
4454        let environment = || analytic_environment(UniformAir::sea_level(), G);
4455        let rocket = design("rocketpy-valetudo");
4456        let build = |devices: Vec<Device>| {
4457            Simulation::new(
4458                &rocket,
4459                "example",
4460                environment(),
4461                Rail::vertical(3.0),
4462                FlightSettings::default(),
4463            )
4464            .unwrap()
4465            .with_recovery(devices)
4466            .map(|_| ())
4467        };
4468        let canopy = DeviceDrag::canopy(CanopyType::FlatCircular, 1.0);
4469        let apogee = Trigger::Apogee;
4470        for devices in [
4471            vec![Device::new(
4472                "zero",
4473                DeviceDrag::DragArea { cd_s_m2: 0.0 },
4474                apogee,
4475            )],
4476            vec![Device::new(
4477                "nan",
4478                DeviceDrag::DragArea { cd_s_m2: f64::NAN },
4479                apogee,
4480            )],
4481            vec![Device::new("negative lag", canopy, apogee).with_lag_s(-1.0)],
4482            vec![Device::new(
4483                "ground",
4484                canopy,
4485                Trigger::Altitude {
4486                    height_above_ground_m: 0.0,
4487                },
4488            )],
4489            vec![Device::new(
4490                "before ignition",
4491                canopy,
4492                Trigger::Time { time_s: -1.0 },
4493            )],
4494            vec![Device::new(
4495                "no such motor",
4496                canopy,
4497                Trigger::MotorDelay { motor: 7 },
4498            )],
4499            vec![Device::new("itself", canopy, apogee).with_release_by(0)],
4500            vec![Device::new("no such device", canopy, apogee).with_release_by(3)],
4501            vec![
4502                Device::new("no diameter", DeviceDrag::DragArea { cd_s_m2: 1.0 }, apogee)
4503                    .with_inflation(Inflation::FillConstant {
4504                        constant: 8.0,
4505                        exponent: 2.0,
4506                    }),
4507            ],
4508            vec![Device::new("bad exponent", canopy, apogee).with_inflation(
4509                Inflation::FillingTime {
4510                    time_s: 1.0,
4511                    exponent: 0.0,
4512                },
4513            )],
4514        ] {
4515            let name = devices[0].name.clone();
4516            let error = build(devices).expect_err(&name);
4517            assert!(
4518                matches!(error, SimError::Domain { .. }),
4519                "{name}: {error:?}"
4520            );
4521        }
4522        // A cycle of releases is refused: it could leave nothing open.
4523        let cycle = build(vec![
4524            Device::new("a", canopy, apogee).with_release_by(1),
4525            Device::new("b", canopy, apogee).with_release_by(0),
4526        ])
4527        .expect_err("a release cycle");
4528        assert!(matches!(cycle, SimError::Domain { .. }), "{cycle:?}");
4529        // A chain that ends is fine.
4530        assert!(
4531            build(vec![
4532                Device::new("a", canopy, apogee).with_release_by(1),
4533                Device::new("b", canopy, apogee).with_release_by(2),
4534                Device::new("c", canopy, apogee),
4535            ])
4536            .is_ok()
4537        );
4538        // Valetudo's motor has no ejection charge in its example, so it can't fire a device.
4539        let error = build(vec![Device::new(
4540            "no charge",
4541            canopy,
4542            Trigger::MotorDelay { motor: 0 },
4543        )])
4544        .expect_err("a motor with no delay");
4545        assert!(matches!(error, SimError::Domain { .. }), "{error:?}");
4546        // With a delay set, it can.
4547        assert!(
4548            Simulation::new(
4549                &with_delay(rocket.clone(), 3.0),
4550                "example",
4551                environment(),
4552                Rail::vertical(3.0),
4553                FlightSettings::default(),
4554            )
4555            .unwrap()
4556            .with_recovery(vec![Device::new(
4557                "charge",
4558                canopy,
4559                Trigger::MotorDelay { motor: 0 }
4560            )])
4561            .is_ok()
4562        );
4563        // And a device that is fine passes.
4564        assert!(build(vec![Device::new("fine", canopy, apogee).with_lag_s(1.5)]).is_ok());
4565    }
4566
4567    #[test]
4568    fn oversized_canopy_and_ten_km_descent_land_without_step_collapse() {
4569        // Loft lesson L28. Loft's explicit RK4 needed a 2e-4 s step floor under a stiff canopy,
4570        // and its 1200 s cap left slow descents from height unlanded: a 10 km descent at 3 m/s
4571        // takes an hour. Here an oversized canopy opens at 100 m/s at 10 km, and the adaptive
4572        // integrator flies the whole descent in a few hundred steps.
4573        let air = UniformAir::sea_level();
4574        let device = open_at_start(DeviceDrag::canopy(CanopyType::FlatCircular, 5.0));
4575        let drag_area_m2 = device.drag.drag_area_m2();
4576        let sim = flight(analytic_environment(air, G), vec![device], 3600.0);
4577        let mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
4578        let terminal_m_s = terminal_speed_m_s(mass_kg, drag_area_m2, air.0.density_kg_m3, G);
4579        // Measured: 2.95 m/s under a 5 m canopy, so the descent takes about 3,400 s.
4580        assert!(terminal_m_s < 3.5, "{terminal_m_s}");
4581        let result = sim
4582            .run_free(
4583                START_S,
4584                dropped(&sim, 10_000.0, DVec3::new(0.0, 0.0, -100.0)),
4585                &mut (),
4586            )
4587            .unwrap();
4588        assert_eq!(result.termination, Termination::GroundHit);
4589        let landing = result.event(EventKind::GroundHit).unwrap().sample;
4590        assert!(
4591            (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 1e-6,
4592            "{}",
4593            landing.vertical_speed_m_s
4594        );
4595        // The descent takes about 10 km / v_t, and the flight has to reach the ground inside the
4596        // default one-hour cap.
4597        let flown_s = landing.time_s - START_S;
4598        assert!(
4599            (flown_s - 10_000.0 / terminal_m_s).abs() < 60.0,
4600            "{flown_s} s"
4601        );
4602        assert!(flown_s < 3_600.0, "{flown_s} s");
4603        // No step collapse: the mean step stays near half a second, where Loft's explicit RK4
4604        // needed a 2e-4 s floor. Measured: 6,914 accepted steps and 2 rejected over 3,392 s, a
4605        // mean step of 0.49 s, against the 1.7e7 steps Loft's floor would have taken. The step is
4606        // limited by the tolerances (unit weights on a 10 km height), not by stiffness.
4607        let mean_step_s = flown_s / result.stats.accepted_steps as f64;
4608        assert!(
4609            mean_step_s > 0.1,
4610            "{mean_step_s} s mean step: {:?}",
4611            result.stats
4612        );
4613        assert!(result.stats.accepted_steps < 20_000, "{:?}", result.stats);
4614        assert!(result.stats.rejected_steps < 100, "{:?}", result.stats);
4615    }
4616
4617    #[test]
4618    fn several_separations_number_their_bodies_from_the_tail() {
4619        // Three stages, the booster (2) dropped first, then the middle (1): body 1 is the
4620        // booster, body 2 the middle, body 0 the nose's.
4621        let at = |after_stage| Separation::new(Trigger::Apogee, after_stage);
4622        let two = [at(1), at(0)];
4623        assert_eq!(
4624            (0..3)
4625                .map(|stage| Separation::body_of(&two, stage))
4626                .collect::<Vec<_>>(),
4627            [0, 2, 1]
4628        );
4629        assert_eq!(Separation::stages_of_body(&two, 0, 3), Some((0, 0)));
4630        assert_eq!(Separation::stages_of_body(&two, 1, 3), Some((2, 2)));
4631        assert_eq!(Separation::stages_of_body(&two, 2, 3), Some((1, 1)));
4632        assert_eq!(Separation::stages_of_body(&two, 3, 3), None);
4633        // One separation agrees with `stages_of`, and none leaves every stage on body 0.
4634        for (after_stage, stages) in [(0, 3), (1, 3), (0, 2)] {
4635            for body in 0..3 {
4636                assert_eq!(
4637                    Separation::stages_of_body(&[at(after_stage)], body, stages),
4638                    at(after_stage).stages_of(body, stages)
4639                );
4640            }
4641        }
4642        assert_eq!(Separation::stages_of_body(&[], 0, 3), Some((0, 2)));
4643        assert_eq!(Separation::body_of(&[], 2), 0);
4644        // Boundaries that don't move forward, or one at the last stage, make no bodies.
4645        assert_eq!(Separation::stages_of_body(&[at(0), at(1)], 0, 3), None);
4646        assert_eq!(Separation::stages_of_body(&[at(2)], 1, 3), None);
4647        // Five stages dropped two at a time and then one.
4648        let uneven = [at(2), at(0)];
4649        assert_eq!(Separation::stages_of_body(&uneven, 1, 5), Some((3, 4)));
4650        assert_eq!(Separation::stages_of_body(&uneven, 2, 5), Some((1, 2)));
4651        assert_eq!(
4652            (0..5)
4653                .map(|stage| Separation::body_of(&uneven, stage))
4654                .collect::<Vec<_>>(),
4655            [0, 2, 2, 1, 1]
4656        );
4657    }
4658}