Skip to main content

hpr_sim/
pieces.rs

1//! Ejected pieces: an airframe that comes apart at any joint, or lets a payload out, and the pieces
2//! that fly on to their own landings.
3//!
4//! A [`Separation`] parts the stack at a stage boundary. An [`Ejection`] parts it anywhere else:
5//! at the joint aft of any body component (a nose cone pushed off its airframe, say), or around a
6//! payload carried inside (an internal component and everything inside it). Each has a trigger, as
7//! a recovery device does. The pieces are fixed before the flight by where the airframe can part.
8//! At any moment a body is the pieces still joined, and its mass is the sum of theirs (the
9//! decision record on ejected pieces, [ADR-085][adr-085], which extends the one on separation,
10//! [ADR-014][adr-014]).
11//!
12//! Method: `docs/physics/recovery.md`, *Ejected pieces*.
13//!
14//! [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
15//! [adr-085]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-085-ejected-pieces-an-airframe-that-parts-at-any-joint-2026-09-26
16
17use hpr_design::{Assembly, Component, MassProperties, Rocket};
18use serde::{Deserialize, Serialize};
19
20use crate::error::SimError;
21use crate::recovery::{DeviceDrag, Separation, Trigger};
22
23/// Where an airframe parts at an [`Ejection`].
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case", deny_unknown_fields)]
26#[non_exhaustive]
27pub enum Parting {
28    /// At the joint just aft of the body component with this id. Everything aft of the joint, up
29    /// to the next joint that can part, is a new piece. The part forward of it keeps its number,
30    /// so an ejected nose cone is the forward side of the joint after the nose.
31    AftOf {
32        /// The id of the body component forward of the joint.
33        component: String,
34    },
35    /// Around the internal component with this id: it and everything inside it leave the piece
36    /// that carries it, as a payload pushed out of a body tube does. It must be inside the
37    /// airframe, one part (not one copy of a cluster's), and not inside another payload.
38    Payload {
39        /// The id of the internal component that leaves.
40        component: String,
41    },
42}
43
44/// A piece of the airframe leaving the rest on a trigger: the airframe parts at a joint, or lets a
45/// payload out.
46///
47/// The piece it makes flies on as a body of its own, numbered after the separation's aft body:
48/// with a [`Separation`] the first ejection makes body 2, without one body 1, and each ejection
49/// after it the next number, in the order given. A body is the pieces still joined, and it is
50/// numbered by the piece nearest the nose among them, so the body that keeps the nose is always
51/// body 0. Pieces joined by a shock cord fly as one, so a joint whose pieces stay tied together
52/// is not an ejection at all.
53///
54/// When the airframe first comes apart each body starts at its own center of mass with the
55/// velocity that point had. A body already flying as a point mass has no attitude to place its
56/// pieces by, so they start where it was, at its velocity. Every motor must have burned out by the
57/// time an ejection fires. The decision record on ejected pieces, [ADR-085][adr-085], has the
58/// reasoning.
59///
60/// An ejection can push its two sides apart with an impulse `J` ([`Self::with_impulse`]), the
61/// charge's or spring's: the side forward of the joint, or a payload, which leaves forward, takes
62/// `+J` along the airframe's axis toward the nose, and the other side `−J`, so each changes
63/// velocity by `J/m` for its own mass `m` and the momentum is unchanged. The axis is the
64/// airframe's while it flies with nothing open. A body with no attitude of its own (a point mass,
65/// or a stack whose attitude froze when a device opened earlier) is taken to point its forward end
66/// against its velocity through the air if it hangs from a device, any but a tumble, open just
67/// before that instant, and along it, as a stable airframe does, if nothing is open or it only
68/// tumbles; below 1 mm/s through the air, up ([ADR-086][adr-086]). Partings at one instant part a
69/// body together. A separation adds no impulse, and a pushed payload in the nose's piece is
70/// refused.
71///
72/// [adr-085]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-085-ejected-pieces-an-airframe-that-parts-at-any-joint-2026-09-26
73/// [adr-086]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-086-ejection-impulse-and-tumbling-pieces-2026-09-26
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76#[non_exhaustive]
77pub struct Ejection {
78    /// When the piece leaves: the same triggers as a recovery device's.
79    pub trigger: Trigger,
80    /// Where the airframe parts.
81    pub parting: Parting,
82    /// The impulse `J` that pushes the two sides apart, N·s: zero (the default) for none.
83    #[serde(default, skip_serializing_if = "is_zero")]
84    pub impulse_n_s: f64,
85}
86
87/// Whether an impulse is zero, so a file without one writes none.
88fn is_zero(impulse_n_s: &f64) -> bool {
89    *impulse_n_s == 0.0
90}
91
92impl Ejection {
93    /// The airframe parting at the joint aft of body component `component`, on `trigger`.
94    #[must_use]
95    pub fn aft_of(trigger: Trigger, component: impl Into<String>) -> Self {
96        Self {
97            trigger,
98            parting: Parting::AftOf {
99                component: component.into(),
100            },
101            impulse_n_s: 0.0,
102        }
103    }
104
105    /// Internal component `component` and what it holds leaving the airframe, on `trigger`.
106    #[must_use]
107    pub fn payload(trigger: Trigger, component: impl Into<String>) -> Self {
108        Self {
109            trigger,
110            parting: Parting::Payload {
111                component: component.into(),
112            },
113            impulse_n_s: 0.0,
114        }
115    }
116
117    /// The same ejection, pushing its two sides apart with the impulse `impulse_n_s`, N·s: the
118    /// side forward of the joint, or the payload, gets it toward the nose, and the other side its
119    /// opposite. [`crate::Simulation::with_ejections`] refuses one that is negative or not
120    /// finite. A pushed payload needs a way out forward: one in the nose's piece is refused when
121    /// the flight starts, and one whose section's forward joint hasn't parted when it leaves is
122    /// an error in flight.
123    #[must_use]
124    pub fn with_impulse(mut self, impulse_n_s: f64) -> Self {
125        self.impulse_n_s = impulse_n_s;
126        self
127    }
128}
129
130/// The pieces a design can come apart into, fixed before the flight by its separations and its
131/// ejections.
132///
133/// Piece 0 is the one with the nose. Separation `k` makes piece `k + 1`, and ejection `k` makes
134/// the piece after the separations'. A split's index in that order is its piece's minus one.
135#[derive(Debug, Clone)]
136pub(crate) struct Pieces {
137    /// The piece that carries each piece, when it is a payload or a parallel stage; `None` for a
138    /// section.
139    host: Vec<Option<usize>>,
140    /// Whether each piece is a parallel stage: hung beside the piece that carries it, with tubes
141    /// and fins of its own, it leaves at a separation as a payload leaves its host.
142    beside: Vec<bool>,
143    /// The section pieces, nose to tail.
144    sections: Vec<usize>,
145    /// The structure of each piece: mass properties that add up to it, motors not included.
146    structure: Vec<Vec<MassProperties>>,
147    /// The piece of each placed motor.
148    motor_piece: Vec<usize>,
149    /// The first and last stage each piece has a component in.
150    stages: Vec<(usize, usize)>,
151    /// The piece of each placed component.
152    piece_of: Vec<usize>,
153}
154
155impl Pieces {
156    /// The pieces of `assembly` (assembled from `rocket`) under `separations` and `ejections`.
157    ///
158    /// # Errors
159    ///
160    /// [`SimError::Parting`] for an ejection that names a component the design doesn't have, a
161    /// joint aft of an internal component or of the last body component, a pod's body component as
162    /// a joint or a payload, a payload that is a body component, an external part, one of several
163    /// copies in a cluster or inside another payload, two partings at one joint or of one payload,
164    /// or a split through a stage or component whose overridden mass doesn't say how it divides;
165    /// [`SimError::Domain`] for a separation with no stage aft of it.
166    pub(crate) fn new(
167        rocket: &Rocket,
168        assembly: &Assembly,
169        separations: &[Separation],
170        ejections: &[Ejection],
171    ) -> Result<Self, SimError> {
172        let components = &assembly.layout.components;
173        let refuse = |what: &'static str, component: &str| SimError::Parting {
174            what,
175            component: component.to_owned(),
176        };
177        let find = |id: &str| {
178            components
179                .iter()
180                .position(|component| component.id == id)
181                .ok_or_else(|| refuse("an ejection names a component the design doesn't have", id))
182        };
183        let top: Vec<usize> = (0..components.len())
184            .filter(|&index| components[index].parent.is_none())
185            .collect();
186        let count = 1 + separations.len() + ejections.len();
187
188        // Where each section starts, as a position in `top`, and each payload.
189        let mut starts: Vec<(usize, usize)> = Vec::new();
190        let mut payloads: Vec<(usize, usize)> = Vec::new();
191        let mut beside = vec![false; count];
192        let mut piece = 1;
193        for separation in separations {
194            let first = separation.after_stage + 1;
195            if assembly
196                .layout
197                .stages
198                .get(first)
199                .is_some_and(|stage| stage.hung_on.is_some())
200            {
201                // A parallel stage drops alone (`Simulation::with_separations` checks that what a
202                // separation drops hangs together): the pod set it is laid out as, and everything
203                // in it, leaves the piece it hangs on (ADR-171).
204                let pods = (0..components.len()).find(|&index| {
205                    components[index].stage == first
206                        && components[index]
207                            .parent
208                            .is_some_and(|parent| components[parent].stage != first)
209                });
210                let Some(pods) = pods else {
211                    return Err(SimError::Domain {
212                        what: "stage boundary of a separation (the parallel stage aft of it has no \
213                               parts)",
214                        value: separation.after_stage as f64,
215                    });
216                };
217                payloads.push((pods, piece));
218                beside[piece] = true;
219                piece += 1;
220                continue;
221            }
222            let Some(at) = top
223                .iter()
224                .position(|&index| components[index].stage > separation.after_stage)
225            else {
226                return Err(SimError::Domain {
227                    what: "stage boundary of a separation (there is no stage aft of it)",
228                    value: separation.after_stage as f64,
229                });
230            };
231            starts.push((at, piece));
232            piece += 1;
233        }
234        for ejection in ejections {
235            match &ejection.parting {
236                Parting::AftOf { component } => {
237                    let index = find(component)?;
238                    if components[index].parent.is_some() && components[index].part.is_body() {
239                        return Err(refuse(
240                            "a pod's body component leaves with the tube its pod set hangs \
241                             from, at no joint of its own and not as a payload",
242                            component,
243                        ));
244                    }
245                    if components[index].parent.is_some() {
246                        return Err(refuse(
247                            "a joint is aft of a body component, and this one is inside another \
248                             (a part carried inside is a `Parting::Payload`)",
249                            component,
250                        ));
251                    }
252                    // `index` is a body component, so it is in `top`.
253                    let Some(at) = top.iter().position(|&body| body == index).map(|at| at + 1)
254                    else {
255                        return Err(refuse(
256                            "a body component missing from the layout",
257                            component,
258                        ));
259                    };
260                    if at >= top.len() {
261                        return Err(refuse(
262                            "a joint aft of the last body component (nothing is aft of it)",
263                            component,
264                        ));
265                    }
266                    if starts.iter().any(|(start, _)| *start == at) {
267                        return Err(refuse(
268                            "two partings at the joint aft of this component (a separation's \
269                             stage boundary is a joint too)",
270                            component,
271                        ));
272                    }
273                    starts.push((at, piece));
274                }
275                Parting::Payload { component } => {
276                    let index = find(component)?;
277                    if components[index].part.is_body() && components[index].parent.is_some() {
278                        return Err(refuse(
279                            "a pod's body component leaves with the tube its pod set hangs \
280                             from, at no joint of its own and not as a payload",
281                            component,
282                        ));
283                    }
284                    if components[index].parent.is_none() {
285                        return Err(refuse(
286                            "a payload is carried inside the airframe, and this is a body \
287                             component (a body component leaves at a joint, `Parting::AftOf`)",
288                            component,
289                        ));
290                    }
291                    if components[index].body_radius_m.is_some() {
292                        return Err(refuse(
293                            "a payload is carried inside the airframe, and this part is outside it",
294                            component,
295                        ));
296                    }
297                    if components[index].copies.len() != 1 {
298                        return Err(refuse(
299                            "a payload that isn't exactly one part (one of several copies in a \
300                             cluster of tubes, or none)",
301                            component,
302                        ));
303                    }
304                    if payloads.iter().any(|(payload, _)| *payload == index) {
305                        return Err(refuse("two ejections of one payload", component));
306                    }
307                    payloads.push((index, piece));
308                }
309            }
310            piece += 1;
311        }
312
313        // A payload inside another would need a piece carried by a piece; not flown yet.
314        for &(payload, _) in &payloads {
315            let mut ancestor = components[payload].parent;
316            while let Some(index) = ancestor {
317                if payloads.iter().any(|(other, _)| *other == index) {
318                    return Err(refuse(
319                        "a payload inside another payload",
320                        &components[payload].id,
321                    ));
322                }
323                ancestor = components[index].parent;
324            }
325        }
326
327        // Every component's piece. A parent is placed before its children, so its piece is known.
328        let mut piece_of = vec![0; components.len()];
329        let mut host = vec![None; count];
330        let mut sections = vec![0];
331        let mut section = 0;
332        let mut position = 0;
333        for index in 0..components.len() {
334            let carried = match components[index].parent {
335                None => {
336                    if let Some(&(_, start)) = starts.iter().find(|(at, _)| *at == position) {
337                        section = start;
338                        sections.push(start);
339                    }
340                    position += 1;
341                    section
342                }
343                Some(parent) => piece_of[parent],
344            };
345            piece_of[index] = match payloads.iter().find(|(payload, _)| *payload == index) {
346                Some(&(_, payload)) => {
347                    host[payload] = Some(carried);
348                    payload
349                }
350                None => carried,
351            };
352        }
353
354        // Whether a component's subtree in its own stage spans more than one piece. A parallel
355        // stage hung on a component is no part of its mass (`with_children`), so it doesn't count.
356        let same_stage =
357            |index: usize, parent: usize| components[index].stage == components[parent].stage;
358        let mut mixed = vec![false; components.len()];
359        for index in (0..components.len()).rev() {
360            if let Some(parent) = components[index].parent
361                && same_stage(index, parent)
362                && (mixed[index] || piece_of[index] != piece_of[parent])
363            {
364                mixed[parent] = true;
365            }
366        }
367        let mut children: Vec<Vec<usize>> = vec![Vec::new(); components.len()];
368        for (index, component) in components.iter().enumerate() {
369            if let Some(parent) = component.parent
370                && same_stage(index, parent)
371            {
372                children[parent].push(index);
373            }
374        }
375
376        // The structure: a stage wholly in one piece by its own mass, which carries the stage's
377        // overrides; otherwise its components, each by its subtree's mass where that subtree is in
378        // one piece and by its own mass plus its children's where it isn't.
379        let mut structure: Vec<Vec<MassProperties>> = vec![Vec::new(); count];
380        for (stage, placed) in assembly.layout.stages.iter().enumerate() {
381            let pieces: Vec<usize> = (0..components.len())
382                .filter(|&index| components[index].stage == stage)
383                .map(|index| piece_of[index])
384                .collect();
385            if let Some(&first) = pieces.first()
386                && pieces.iter().all(|&piece| piece == first)
387            {
388                structure[first].push(placed.mass);
389                continue;
390            }
391            if rocket
392                .stages
393                .iter()
394                .find(|stage| stage.id == placed.id)
395                .is_some_and(|stage| !stage.overrides.is_empty())
396            {
397                return Err(refuse(
398                    "a parting through a stage whose mass is overridden (the override doesn't \
399                     say which piece its mass is in)",
400                    &placed.id,
401                ));
402            }
403            // The stage's roots: its body components, or a parallel stage's pod set.
404            let mut pending: Vec<usize> = (0..components.len())
405                .filter(|&index| {
406                    components[index].stage == stage
407                        && components[index]
408                            .parent
409                            .is_none_or(|parent| !same_stage(index, parent))
410                })
411                .rev()
412                .collect();
413            while let Some(index) = pending.pop() {
414                let component = &components[index];
415                if !mixed[index] {
416                    structure[piece_of[index]].push(component.with_children);
417                    continue;
418                }
419                if node(rocket, &component.id).is_some_and(|node| {
420                    node.overrides_include_children && !node.overrides.is_empty()
421                }) {
422                    return Err(refuse(
423                        "a parting inside a component whose overridden mass covers what it holds \
424                         (the override doesn't say which piece that mass is in)",
425                        &component.id,
426                    ));
427                }
428                structure[piece_of[index]].push(component.own);
429                pending.extend(children[index].iter().rev());
430            }
431        }
432
433        let motor_piece = assembly
434            .motors
435            .iter()
436            .map(|motor| {
437                components
438                    .iter()
439                    .position(|component| component.id == motor.mount)
440                    .map(|index| piece_of[index])
441                    .ok_or_else(|| refuse("a motor's mount is not in the design", &motor.mount))
442            })
443            .collect::<Result<Vec<_>, _>>()?;
444
445        let mut stages = vec![(usize::MAX, 0); count];
446        for (index, component) in components.iter().enumerate() {
447            let (first, last) = &mut stages[piece_of[index]];
448            *first = (*first).min(component.stage);
449            *last = (*last).max(component.stage);
450        }
451        for (first, last) in &mut stages {
452            if *first == usize::MAX {
453                // A piece with no component can't happen: every parting leaves one on each side.
454                (*first, *last) = (0, 0);
455            }
456        }
457
458        Ok(Self {
459            host,
460            beside,
461            sections,
462            structure,
463            motor_piece,
464            stages,
465            piece_of,
466        })
467    }
468
469    /// How many pieces there are.
470    pub(crate) fn count(&self) -> usize {
471        self.host.len()
472    }
473
474    /// The body each piece is in when the splits with `open[split]` have happened: the body's
475    /// number is its lead piece's, the section nearest the nose among its pieces, or the payload
476    /// itself for a payload flying alone.
477    pub(crate) fn leaders(&self, open: &[bool]) -> Vec<usize> {
478        let mut leader: Vec<usize> = (0..self.count()).collect();
479        let mut current = 0;
480        for &section in &self.sections {
481            if section == 0 || open[section - 1] {
482                current = section;
483            }
484            leader[section] = current;
485        }
486        for piece in 0..self.count() {
487            leader[piece] = self.lead(piece, open, &leader);
488        }
489        leader
490    }
491
492    /// The lead piece of `piece`'s body, with the sections' in `sections_led`.
493    fn lead(&self, piece: usize, open: &[bool], sections_led: &[usize]) -> usize {
494        match self.host[piece] {
495            None => sections_led[piece],
496            Some(_) if open[piece - 1] => piece,
497            Some(host) => self.lead(host, open, sections_led),
498        }
499    }
500
501    /// The mass properties of the pieces for which `member` is true, with their motors at flight
502    /// time `t_s`, each lit at its `ignition_s`.
503    pub(crate) fn mass_properties(
504        &self,
505        member: impl Fn(usize) -> bool,
506        assembly: &Assembly,
507        t_s: f64,
508        ignition_s: &[Option<f64>],
509    ) -> MassProperties {
510        let mut parts: Vec<MassProperties> = Vec::new();
511        for (piece, structure) in self.structure.iter().enumerate() {
512            if member(piece) {
513                parts.extend(structure);
514            }
515        }
516        for ((motor, ignition), piece) in assembly
517            .motors
518            .iter()
519            .zip(ignition_s)
520            .zip(&self.motor_piece)
521        {
522            if member(*piece) {
523                parts.push(motor.mass_properties_lit(t_s, *ignition));
524            }
525        }
526        MassProperties::combine(parts.iter())
527    }
528
529    /// The impulse the motors of the pieces for which `member` is true still have to give at
530    /// flight time `t_s`, each lit at its `ignition_s`, N·s: each lit motor's curve total less
531    /// what it gave by then (ADR-172). A body's descent is a point mass with no thrust, so a body
532    /// dropped with a motor still burning leaves this out. A motor not yet lit counts nothing:
533    /// the flight refuses to drop one.
534    pub(crate) fn impulse_left_n_s(
535        &self,
536        member: impl Fn(usize) -> bool,
537        assembly: &Assembly,
538        t_s: f64,
539        ignition_s: &[Option<f64>],
540    ) -> f64 {
541        let mut left_n_s = 0.0;
542        for ((motor, ignition), piece) in assembly
543            .motors
544            .iter()
545            .zip(ignition_s)
546            .zip(&self.motor_piece)
547        {
548            if let (true, Some(ignition_s)) = (member(*piece), ignition)
549                && *ignition_s <= t_s
550            {
551                let curve = motor.mounted.motor.curve();
552                left_n_s += curve.total_impulse_ns() - curve.impulse_ns(t_s - ignition_s);
553            }
554        }
555        left_n_s
556    }
557
558    /// Refuses a pushed payload in the nose's piece: it leaves forward, and that piece is closed at
559    /// the nose. It needs the flight's separation as well as its `ejections`, the ones these
560    /// pieces were made with, so it runs when the flight starts, whatever order the builders came
561    /// in.
562    ///
563    /// # Errors
564    ///
565    /// [`SimError::Parting`], naming the payload.
566    pub(crate) fn check_pushed_payloads(&self, ejections: &[Ejection]) -> Result<(), SimError> {
567        // The ejections make the last pieces, in order.
568        let first_ejection = self.count() - ejections.len();
569        for (index, ejection) in ejections.iter().enumerate() {
570            if let Parting::Payload { component } = &ejection.parting
571                && ejection.impulse_n_s > 0.0
572                && self.host[first_ejection + index] == Some(0)
573            {
574                return Err(SimError::Parting {
575                    what: "a pushed payload leaves forward, and this one is in the nose's piece, \
576                           which is closed at the nose (part a joint forward of it, or give it no \
577                           impulse)",
578                    component: component.clone(),
579                });
580            }
581        }
582        Ok(())
583    }
584
585    /// The piece that carries `piece`, when it is a payload.
586    pub(crate) fn host(&self, piece: usize) -> Option<usize> {
587        self.host[piece]
588    }
589
590    /// The piece on the other side of the split that makes `piece` (not piece 0), and whether
591    /// `piece` is forward of that split: a section is aft of its joint, with the section forward
592    /// of the joint on the other side, and a payload leaves its host forward.
593    pub(crate) fn across(&self, piece: usize) -> (usize, bool) {
594        match self.host[piece] {
595            Some(host) => (host, true),
596            None => {
597                let at = self.sections.iter().position(|&section| section == piece);
598                // Every section is in the list, piece 0 first; piece 0 makes no split, so the
599                // section a split makes has one ahead of it.
600                debug_assert!(
601                    at.is_some_and(|at| at > 0),
602                    "piece {piece} is no split's section"
603                );
604                (self.sections[at.unwrap_or(0).saturating_sub(1)], false)
605            }
606        }
607    }
608
609    /// The drag area of piece `piece` tumbling on its own: its own components' body profile and
610    /// fins ([`DeviceDrag::tumbling_where`]).
611    ///
612    /// # Errors
613    ///
614    /// [`SimError::Domain`] for a piece that isn't one, or a payload (it is inside the airframe,
615    /// with no body tube or fin of its own for the model to take), and as
616    /// [`DeviceDrag::tumbling`].
617    pub(crate) fn tumbling(
618        &self,
619        piece: usize,
620        assembly: &Assembly,
621    ) -> Result<DeviceDrag, SimError> {
622        if piece >= self.count() {
623            return Err(SimError::Domain {
624                what: "piece to tumble (there is one per split, and the nose's)",
625                value: piece as f64,
626            });
627        }
628        if self.host[piece].is_some() && !self.beside[piece] {
629            return Err(SimError::Domain {
630                what: "piece to tumble (a payload has no body tube or fin of its own, which the \
631                       tumble model covers)",
632                value: piece as f64,
633            });
634        }
635        DeviceDrag::tumbling_where(assembly, |index, _| self.piece_of[index] == piece)
636    }
637
638    /// The first and last stage the pieces for which `member` is true have a component in.
639    pub(crate) fn stages(&self, member: impl Fn(usize) -> bool) -> (usize, usize) {
640        let mut span: Option<(usize, usize)> = None;
641        for (piece, &(first, last)) in self.stages.iter().enumerate() {
642            if member(piece) {
643                span = Some(span.map_or((first, last), |(a, b)| (a.min(first), b.max(last))));
644            }
645        }
646        span.unwrap_or((0, 0))
647    }
648}
649
650/// The design's component with id `id`, searched through every stage.
651pub(crate) fn node<'a>(rocket: &'a Rocket, id: &str) -> Option<&'a Component> {
652    fn within<'a>(components: &'a [Component], id: &str) -> Option<&'a Component> {
653        components.iter().find_map(|component| {
654            if component.id == id {
655                Some(component)
656            } else {
657                within(&component.children, id)
658            }
659        })
660    }
661    rocket
662        .stages
663        .iter()
664        .find_map(|stage| within(&stage.components, id))
665}
666
667#[cfg(test)]
668mod tests {
669    use hpr_atmos::{ConstantWind, Wind};
670    use hpr_core::DVec3;
671    use hpr_design::{Part, Position};
672
673    use super::*;
674    use crate::flight::{EventKind, FlightResult, FlightSettings, Simulation, Termination};
675    use crate::rail::Rail;
676    use crate::recovery::{
677        BodyEvent, CanopyType, Device, DeviceDrag, Inflation, terminal_speed_m_s,
678    };
679    use crate::state::State;
680    use crate::testing::{UniformAir, analytic_environment, analytic_wind_environment, design};
681
682    const G: f64 = 9.806_65;
683    /// The I175 burns out at 2.5 s; a drop starts well after that.
684    const START_S: f64 = 10.0;
685    const PAYLOAD_KG: f64 = 0.25;
686
687    /// The 54 mm single-stage test design with a 0.25 kg payload carried in its airframe, 0.15 m
688    /// aft of the airframe's forward end.
689    fn with_payload() -> Rocket {
690        let mut rocket = design("synthetic-54mm-three-fin");
691        let airframe = &mut rocket.stages[0].components[1];
692        assert_eq!(airframe.id, "sustainer-airframe");
693        let mut payload = airframe
694            .children
695            .iter()
696            .find(|child| child.id == "altimeter")
697            .cloned()
698            .unwrap();
699        payload.id = "payload".to_owned();
700        let Part::MassComponent(mass) = &mut payload.part else {
701            panic!("the altimeter is a mass component");
702        };
703        mass.mass_kg = PAYLOAD_KG;
704        payload.position = Some(Position::Top { aft_offset_m: 0.15 });
705        airframe.children.push(payload);
706        rocket
707    }
708
709    /// The two-stage test design with its booster strapped beside the sustainer's airframe as a
710    /// parallel stage of `count` pods.
711    fn strapped(count: u32) -> Rocket {
712        let mut rocket = design("synthetic-two-stage-75mm-54mm");
713        let booster = &mut rocket.stages[1];
714        booster.components.retain(|c| c.id != "interstage");
715        booster.parallel = Some(hpr_design::ParallelStage {
716            on: "sustainer-airframe".to_owned(),
717            position: Position::Bottom { aft_offset_m: 0.0 },
718            pods: hpr_design::PodSet {
719                count,
720                radial_offset_m: 0.06815,
721                angle_rad: 0.0,
722            },
723        });
724        rocket
725    }
726
727    /// However the partings cut a rocket with a parallel stage, its pieces' structures add up to
728    /// the rocket's: the parallel stage is weighed once, in the piece it is in, whether the stage
729    /// it hangs on is cut (a payload out of the sustainer) or it is (a payload out of a pod).
730    #[test]
731    fn the_pieces_of_a_parallel_stage_weigh_the_rocket() {
732        let apogee = Trigger::Apogee;
733        let drop = Separation::new(apogee, 0);
734        for (count, separations, ejections) in [
735            (
736                2,
737                vec![],
738                vec![Ejection::payload(apogee, "sustainer-parachute")],
739            ),
740            (
741                2,
742                vec![drop],
743                vec![Ejection::payload(apogee, "sustainer-parachute")],
744            ),
745            (2, vec![drop], vec![Ejection::aft_of(apogee, "nose")]),
746            (
747                1,
748                vec![],
749                vec![Ejection::payload(apogee, "booster-electronics")],
750            ),
751            (1, vec![drop], vec![]),
752        ] {
753            let rocket = strapped(count);
754            let assembly = rocket.assemble("j760-i175").unwrap();
755            let pieces = Pieces::new(&rocket, &assembly, &separations, &ejections).unwrap();
756            let total = MassProperties::combine(pieces.structure.iter().flatten());
757            let want = assembly.layout.structure;
758            let label = format!(
759                "{count} pods, {} separations, {ejections:?}",
760                separations.len()
761            );
762            assert!(
763                (total.mass_kg - want.mass_kg).abs() < 1e-12 * want.mass_kg,
764                "{label}: {} against {}",
765                total.mass_kg,
766                want.mass_kg
767            );
768            assert!((total.cg_m - want.cg_m).length() < 1e-12, "{label}");
769            let inertia = total.inertia_kg_m2 - want.inertia_kg_m2;
770            for column in 0..3 {
771                assert!(inertia.col(column).length() < 1e-12, "{label}");
772            }
773        }
774    }
775
776    /// A canopy of `diameter_m` on `body`, fired by `trigger`.
777    fn canopy(body: usize, diameter_m: f64, trigger: Trigger) -> Device {
778        Device::new(
779            "canopy",
780            DeviceDrag::canopy(CanopyType::FlatCircular, diameter_m),
781            trigger,
782        )
783        .on_body(body)
784    }
785
786    /// The nose cone under its own 0.45 m canopy from apogee, the airframe under a 0.9 m one from
787    /// apogee, and the payload under a 0.6 m one as it leaves at 300 m.
788    fn devices() -> Vec<Device> {
789        let payload_height = Trigger::Altitude {
790            height_above_ground_m: 300.0,
791        };
792        vec![
793            canopy(0, 0.45, Trigger::Apogee),
794            canopy(1, 0.9, Trigger::Apogee),
795            canopy(2, 0.6, payload_height),
796        ]
797    }
798
799    /// The nose cone pushed off at apogee, and the payload out of the airframe at 300 m.
800    fn ejections() -> Vec<Ejection> {
801        vec![
802            Ejection::aft_of(Trigger::Apogee, "nose"),
803            Ejection::payload(
804                Trigger::Altitude {
805                    height_above_ground_m: 300.0,
806                },
807                "payload",
808            ),
809        ]
810    }
811
812    fn simulation(environment: crate::Environment) -> Simulation {
813        Simulation::new(
814            &with_payload(),
815            "i175",
816            environment,
817            Rail::vertical(3.0),
818            FlightSettings {
819                max_time_s: 3600.0,
820                ..FlightSettings::default()
821            },
822        )
823        .unwrap()
824        .with_recovery(devices())
825        .unwrap()
826        .with_ejections(ejections())
827        .unwrap()
828    }
829
830    /// The state with the center of mass `height_m` above the site, nose up, moving at
831    /// `velocity_enu_m_s` and turning at `body_rate_rad_s`.
832    fn dropped(
833        sim: &Simulation,
834        height_m: f64,
835        velocity_enu_m_s: DVec3,
836        body_rate_rad_s: DVec3,
837    ) -> State {
838        let attitude = Rail::vertical(3.0).attitude();
839        let cg_m = sim.assembly().mass_properties(START_S).cg_m;
840        State {
841            position_enu_m: DVec3::new(0.0, 0.0, height_m) - attitude.mul_vec3(cg_m),
842            velocity_enu_m_s,
843            attitude,
844            body_rate_rad_s,
845        }
846    }
847
848    /// The mass of the layout's component `id` with everything inside it, kg.
849    fn component_kg(sim: &Simulation, id: &str) -> f64 {
850        sim.assembly()
851            .layout
852            .components
853            .iter()
854            .find(|component| component.id == id)
855            .unwrap()
856            .with_children
857            .mass_kg
858    }
859
860    fn close(a: f64, b: f64, relative: f64, what: &str) {
861        assert!((a - b).abs() <= relative * b.abs(), "{what}: {a} vs {b}");
862    }
863
864    #[test]
865    fn an_ejected_nose_cone_and_payload_each_land_under_their_own_canopy() {
866        // The milestone's design, flown from the pad in a 4 m/s wind from the west: the nose cone
867        // leaves at apogee, the payload at 300 m, and each piece reaches the ground on its own,
868        // somewhere of its own.
869        let wind = ConstantWind::new(4.0, 1.5 * std::f64::consts::PI).unwrap();
870        let sim = simulation(analytic_wind_environment(UniformAir::sea_level(), G, wind));
871        let result = sim.run(&mut ()).unwrap();
872        assert_eq!(result.termination, Termination::Separated);
873        let apogee = result.event(EventKind::Apogee).unwrap().sample;
874        let ejected = result.event(EventKind::Ejection(0)).unwrap().sample;
875        assert!((ejected.time_s - apogee.time_s).abs() < 1e-9, "{ejected:?}");
876        assert!(result.event(EventKind::Ejection(1)).is_none());
877        assert_eq!(result.bodies.len(), 3);
878        for (index, body) in result.bodies.iter().enumerate() {
879            assert_eq!(body.body, index);
880            assert_eq!(body.termination, Termination::GroundHit, "body {index}");
881            assert_eq!(body.pieces, vec![index]);
882            // The nose cone's canopy opens on the stack in the pass that parts it, so its
883            // deployment is the flight's; the others open on their own bodies.
884            let deployment = EventKind::Deployment(index);
885            assert!(
886                body.event(deployment).is_some() || result.event(deployment).is_some(),
887                "body {index}"
888            );
889        }
890        // The payload leaves the airframe's body at its height, and starts its own flight there.
891        let airframe = &result.bodies[1];
892        let payload = &result.bodies[2];
893        let parting = airframe.event(EventKind::Ejection(1)).unwrap().sample;
894        assert!(
895            (parting.height_above_ground_m - 300.0).abs() < 1e-6,
896            "{parting:?}"
897        );
898        assert_eq!(payload.start_sample.time_s, parting.time_s);
899        assert!(result.bodies_landed());
900        // Every piece is in the nose's stage and opens its canopy as it parts: no separated
901        // part's warning (M10.1d5, ADR-200), neither #179 nor #354.
902        assert!(result.bodies.iter().all(|body| body.stages == (0, 0)));
903        let peak = crate::metrics::Peak {
904            value: 0.5,
905            time_s: 0.0,
906            height_above_ground_m: 0.0,
907        };
908        assert_eq!(
909            crate::issues::separated_part_issue_warnings(&result, Some(peak)),
910            []
911        );
912        let landings = result.landings();
913        assert_eq!(landings.len(), 3);
914        for landing in &landings {
915            assert!(landing.height_above_ground_m.abs() < 1e-6, "{landing:?}");
916        }
917        // Measured: apogee at 14.985 s; the nose cone (0.063 kg) lands at 559.21 s, 2 048 m
918        // downwind, the airframe (0.556 kg) at 331.76 s and the payload (0.250 kg), let out at
919        // 261.17 s, at 331.33 s, both near 1 135 m.
920        let pinned = [
921            (559.212, 2_047.87),
922            (331.760, 1_136.24),
923            (331.332, 1_134.53),
924        ];
925        for (landing, (time_s, east_m)) in landings.iter().zip(pinned) {
926            assert!((landing.time_s - time_s).abs() < 0.05, "{landing:?}");
927            assert!((landing.cg_enu_m.x - east_m).abs() < 0.5, "{landing:?}");
928        }
929        // Under a canopy a piece drifts with the wind, so the nose cone's extra time aloft puts it
930        // that many seconds of wind further on: 911.6 m against 4 m/s × 227.5 s = 909.8 m.
931        let apart_m = landings[0].cg_enu_m.x - landings[1].cg_enu_m.x;
932        let drift_m = 4.0 * (landings[0].time_s - landings[1].time_s);
933        close(apart_m, drift_m, 0.01, "the nose cone's extra drift");
934    }
935
936    /// #231's other half: an ejection known to come while the rocket is still on the rail is
937    /// refused when it comes, not fired late at the rail exit. A rail long enough to hold the
938    /// rocket past its burnout makes one.
939    #[test]
940    fn an_ejection_on_the_rail_is_refused() {
941        let environment =
942            || analytic_wind_environment(UniformAir::sea_level(), G, ConstantWind::calm());
943        let burnout_s = simulation(environment())
944            .run(&mut ())
945            .unwrap()
946            .event(EventKind::Burnout)
947            .unwrap()
948            .sample
949            .time_s;
950        let charge = Trigger::Time {
951            time_s: burnout_s + 0.2,
952        };
953        let error = Simulation::new(
954            &with_payload(),
955            "i175",
956            environment(),
957            Rail::vertical(2000.0),
958            FlightSettings::default(),
959        )
960        .unwrap()
961        .with_recovery(vec![
962            canopy(0, 0.45, charge),
963            canopy(1, 0.9, Trigger::Apogee),
964        ])
965        .unwrap()
966        .with_ejections(vec![Ejection::aft_of(charge, "nose")])
967        .unwrap()
968        .run(&mut ())
969        .expect_err("an ejection on the rail");
970        assert!(
971            matches!(error, SimError::Domain { what, value }
972                if what == "time of an ejection, s (it must come once the rocket has left the rail)"
973                    && value == burnout_s + 0.2),
974            "{error:?}"
975        );
976    }
977
978    #[test]
979    fn a_nose_cone_ejected_before_apogee_leaves_the_summary_with_no_apogee() {
980        // Body 0 of an ejection is its lead piece, here the nose cone alone: its apogee under its
981        // own canopy is not the rocket's, so the summary has none, as it had before body 0's
982        // apogee stood in for a separation's (ADR-165).
983        let environment =
984            || analytic_wind_environment(UniformAir::sea_level(), G, ConstantWind::calm());
985        let apogee_s = simulation(environment())
986            .run(&mut ())
987            .unwrap()
988            .event(EventKind::Apogee)
989            .unwrap()
990            .sample
991            .time_s;
992        let early = Trigger::Time {
993            time_s: apogee_s - 3.0,
994        };
995        let sim = Simulation::new(
996            &with_payload(),
997            "i175",
998            environment(),
999            Rail::vertical(3.0),
1000            FlightSettings {
1001                max_time_s: 3600.0,
1002                ..FlightSettings::default()
1003            },
1004        )
1005        .unwrap()
1006        .with_recovery(vec![
1007            canopy(0, 0.45, early),
1008            canopy(1, 0.9, Trigger::Apogee),
1009        ])
1010        .unwrap()
1011        .with_ejections(vec![Ejection::aft_of(early, "nose")])
1012        .unwrap();
1013        let mut metrics = crate::metrics::FlightMetrics::new();
1014        let result = sim.run(&mut metrics).unwrap();
1015        assert_eq!(result.termination, Termination::Separated);
1016        assert!(result.event(EventKind::Ejection(0)).is_some());
1017        assert!(result.event(EventKind::Apogee).is_none());
1018        let nose = result.bodies.iter().find(|body| body.body == 0).unwrap();
1019        assert!(nose.event(EventKind::Apogee).is_some());
1020        let summary = metrics.summary(&result, sim.environment()).unwrap();
1021        assert_eq!(summary.apogee, None);
1022
1023        // The nose's canopy, fired by the charge but opening 0.5 s after it, would leave the
1024        // nose climbing through the lag with no drag: refused at the charge (M4.6a's review).
1025        let error = Simulation::new(
1026            &with_payload(),
1027            "i175",
1028            environment(),
1029            Rail::vertical(3.0),
1030            FlightSettings {
1031                max_time_s: 3600.0,
1032                ..FlightSettings::default()
1033            },
1034        )
1035        .unwrap()
1036        .with_recovery(vec![
1037            canopy(0, 0.45, early).with_lag_s(0.5),
1038            canopy(1, 0.9, Trigger::Apogee),
1039        ])
1040        .unwrap()
1041        .with_ejections(vec![Ejection::aft_of(early, "nose")])
1042        .unwrap()
1043        .run(&mut ())
1044        .expect_err("a nose climbing with nothing open");
1045        assert!(
1046            matches!(
1047                error,
1048                SimError::Domain { what, value }
1049                    if what.starts_with("time of a split with nothing left to burn, before \
1050                                         apogee")
1051                        && (value - (apogee_s - 3.0)).abs() < 1e-9
1052            ),
1053            "{error:?}"
1054        );
1055    }
1056
1057    #[test]
1058    fn the_pieces_masses_add_up_and_each_split_conserves_momentum() {
1059        // Split with wind, a sideways velocity and a body rate, so that `ω × r` is in each
1060        // body's start and the check is not the trivial one.
1061        let air = UniformAir::sea_level();
1062        let sim = simulation(analytic_wind_environment(
1063            air,
1064            G,
1065            ConstantWind::new(5.0, 0.9).unwrap(),
1066        ));
1067        let start = dropped(
1068            &sim,
1069            1_500.0,
1070            DVec3::new(3.0, 0.0, -2.0),
1071            DVec3::new(0.0, 0.6, 0.0),
1072        );
1073        let result = sim.run_free(START_S, start, &mut ()).unwrap();
1074        let [nose, airframe, payload] = &result.bodies[..] else {
1075            panic!("{} bodies", result.bodies.len());
1076        };
1077
1078        // At the first split the two bodies are the whole rocket, and the nose cone and the
1079        // payload are their own components' masses.
1080        let first = result.event(EventKind::Ejection(0)).unwrap().sample;
1081        let whole = sim.assembly().mass_properties(first.time_s);
1082        close(
1083            nose.start_sample.mass_kg + airframe.start_sample.mass_kg,
1084            whole.mass_kg,
1085            1e-12,
1086            "the two bodies' mass",
1087        );
1088        close(
1089            nose.mass_kg,
1090            component_kg(&sim, "nose"),
1091            1e-12,
1092            "the nose cone",
1093        );
1094        close(payload.mass_kg, PAYLOAD_KG, 1e-12, "the payload");
1095        close(
1096            nose.mass_kg + airframe.mass_kg + payload.mass_kg,
1097            whole.mass_kg,
1098            1e-12,
1099            "the three pieces' mass",
1100        );
1101
1102        // Momentum at the first split: each body leaves with its own center of mass's velocity,
1103        // and together they carry the stack's.
1104        let momentum = nose.start_sample.cg_velocity_enu_m_s * nose.start_sample.mass_kg
1105            + airframe.start_sample.cg_velocity_enu_m_s * airframe.start_sample.mass_kg;
1106        let expected = first.cg_velocity_enu_m_s * whole.mass_kg;
1107        assert!(
1108            (momentum - expected).length() < 1e-9 * expected.length(),
1109            "{momentum} vs {expected}"
1110        );
1111        // Each starts where its own center of mass was: the nose cone's is its component's.
1112        let state = result.final_sample.state;
1113        let nose_cg_m = sim
1114            .assembly()
1115            .layout
1116            .components
1117            .iter()
1118            .find(|component| component.id == "nose")
1119            .unwrap()
1120            .with_children
1121            .cg_m;
1122        let expected = state.point_enu_m(nose_cg_m);
1123        assert!(
1124            (nose.start_sample.cg_enu_m - expected).length() < 1e-12,
1125            "{} vs {expected}",
1126            nose.start_sample.cg_enu_m
1127        );
1128        let gap = (nose.start_sample.cg_enu_m - airframe.start_sample.cg_enu_m).length();
1129        assert!(gap > 0.3, "{gap}");
1130
1131        // Momentum at the second, on the way down: the payload leaves the airframe's body at
1132        // its point and velocity, and the masses before and after agree.
1133        let parting = airframe.event(EventKind::Ejection(1)).unwrap().sample;
1134        close(
1135            parting.mass_kg,
1136            airframe.mass_kg + payload.mass_kg,
1137            1e-12,
1138            "the airframe's body before the payload left",
1139        );
1140        assert_eq!(payload.start_sample.cg_enu_m, parting.cg_enu_m);
1141        let before = parting.cg_velocity_enu_m_s * parting.mass_kg;
1142        let after = parting.cg_velocity_enu_m_s * airframe.mass_kg
1143            + payload.start_sample.cg_velocity_enu_m_s * payload.mass_kg;
1144        assert!(
1145            (after - before).length() < 1e-9 * before.length(),
1146            "{after} vs {before}"
1147        );
1148    }
1149
1150    #[test]
1151    fn each_piece_comes_down_at_its_own_terminal_speed() {
1152        // In uniform air each body ends its descent at `√(2 m g/(ρ C_D S))` for its own mass and
1153        // canopy: the airframe at its mass after the payload left.
1154        let air = UniformAir::sea_level();
1155        let sim = simulation(analytic_environment(air, G));
1156        let start = dropped(&sim, 1_500.0, DVec3::new(0.0, 0.0, -0.5), DVec3::ZERO);
1157        let result = sim.run_free(START_S, start, &mut ()).unwrap();
1158        assert!(result.bodies_landed());
1159        let rho = air.0.density_kg_m3;
1160        for body in &result.bodies {
1161            let landing = body.event(EventKind::GroundHit).unwrap().sample;
1162            let drag_area_m2 = sim.recovery()[body.body].drag.drag_area_m2();
1163            let terminal_m_s = terminal_speed_m_s(body.mass_kg, drag_area_m2, rho, G);
1164            close(
1165                -landing.vertical_speed_m_s,
1166                terminal_m_s,
1167                1e-3,
1168                &format!("body {}", body.body),
1169            );
1170            assert_eq!(landing.mass_kg, body.mass_kg);
1171            assert!((landing.recovery_drag_area_m2 - drag_area_m2).abs() < 1e-12);
1172        }
1173    }
1174
1175    /// The milestone's design and devices, with `ejections` in place of its own.
1176    fn simulation_with(environment: crate::Environment, ejections: Vec<Ejection>) -> Simulation {
1177        Simulation::new(
1178            &with_payload(),
1179            "i175",
1180            environment,
1181            Rail::vertical(3.0),
1182            FlightSettings {
1183                max_time_s: 3600.0,
1184                ..FlightSettings::default()
1185            },
1186        )
1187        .unwrap()
1188        .with_recovery(devices())
1189        .unwrap()
1190        .with_ejections(ejections)
1191        .unwrap()
1192    }
1193
1194    /// The same ejections as [`ejections`], each pushing with `impulse_n_s`.
1195    fn pushed(impulse_n_s: f64) -> Vec<Ejection> {
1196        ejections()
1197            .into_iter()
1198            .map(|ejection| ejection.with_impulse(impulse_n_s))
1199            .collect()
1200    }
1201
1202    fn close_vec(a: DVec3, b: DVec3, relative: f64, what: &str) {
1203        assert!(
1204            (a - b).length() <= relative * b.length(),
1205            "{what}: {a} vs {b}"
1206        );
1207    }
1208
1209    /// As [`dropped`], from the attitude of a rail at `elevation_deg` and `azimuth_deg`.
1210    fn dropped_at(
1211        sim: &Simulation,
1212        (elevation_deg, azimuth_deg): (f64, f64),
1213        height_m: f64,
1214        velocity_enu_m_s: DVec3,
1215        body_rate_rad_s: DVec3,
1216    ) -> State {
1217        let attitude = Rail {
1218            elevation_rad: elevation_deg.to_radians(),
1219            azimuth_rad: azimuth_deg.to_radians(),
1220            ..Rail::vertical(3.0)
1221        }
1222        .attitude();
1223        let cg_m = sim.assembly().mass_properties(START_S).cg_m;
1224        State {
1225            position_enu_m: DVec3::new(0.0, 0.0, height_m) - attitude.mul_vec3(cg_m),
1226            velocity_enu_m_s,
1227            attitude,
1228            body_rate_rad_s,
1229        }
1230    }
1231
1232    /// The change in the velocity each body starts with, between two flights that are the same
1233    /// until they part.
1234    fn start_change(pushed: &FlightResult, unpushed: &FlightResult, body: usize) -> DVec3 {
1235        pushed.bodies[body].start_sample.cg_velocity_enu_m_s
1236            - unpushed.bodies[body].start_sample.cg_velocity_enu_m_s
1237    }
1238
1239    #[test]
1240    fn an_impulse_changes_each_pieces_velocity_by_j_over_m_and_conserves_momentum() {
1241        // 1 N·s on each ejection, in wind, from a stack tilted 60° up toward 30° east of north,
1242        // moving sideways and turning at 0.6 rad/s. The first parting, from the stack in free
1243        // flight, pushes along its axis. At the second the airframe hangs under its canopy, so
1244        // the payload leaves against its velocity through the air, toward the canopy.
1245        const J: f64 = 1.0;
1246        let wind = ConstantWind::new(5.0, 0.9).unwrap();
1247        let wind_enu = wind.wind(0.0).unwrap().velocity_enu_m_s;
1248        let fly = |impulse_n_s: f64| {
1249            let environment = analytic_wind_environment(UniformAir::sea_level(), G, wind);
1250            let sim = simulation_with(environment, pushed(impulse_n_s));
1251            let start = dropped_at(
1252                &sim,
1253                (60.0, 30.0),
1254                1_500.0,
1255                DVec3::new(3.0, 0.0, -2.0),
1256                DVec3::new(0.0, 0.6, 0.0),
1257            );
1258            let result = sim.run_free(START_S, start, &mut ()).unwrap();
1259            (sim, result)
1260        };
1261        let (sim, pushed) = fly(J);
1262        let (_, unpushed) = fly(0.0);
1263        let [nose, airframe, payload] = &pushed.bodies[..] else {
1264            panic!("{} bodies", pushed.bodies.len());
1265        };
1266
1267        // The first parting: by hand, the axis of a rail 60° up toward 30° east of north is
1268        // (cos 60° sin 30°, cos 60° cos 30°, sin 60°) in east, north, up. The nose cone, forward
1269        // of the joint, gains `J/m` along it, and the airframe with the payload inside loses
1270        // `J/m` for its own mass.
1271        let first = pushed.event(EventKind::Ejection(0)).unwrap().sample;
1272        assert_eq!(
1273            first,
1274            unpushed.event(EventKind::Ejection(0)).unwrap().sample
1275        );
1276        let (elevation, azimuth) = (60_f64.to_radians(), 30_f64.to_radians());
1277        let axis = DVec3::new(
1278            elevation.cos() * azimuth.sin(),
1279            elevation.cos() * azimuth.cos(),
1280            elevation.sin(),
1281        );
1282        let whole = sim.assembly().mass_properties(first.time_s);
1283        let nose_kg = component_kg(&sim, "nose");
1284        let rest_kg = whole.mass_kg - nose_kg;
1285        close(nose.start_sample.mass_kg, nose_kg, 1e-12, "the nose cone");
1286        close(airframe.start_sample.mass_kg, rest_kg, 1e-12, "the rest");
1287        close_vec(
1288            start_change(&pushed, &unpushed, 0),
1289            axis * (J / nose_kg),
1290            1e-9,
1291            "the nose cone's push",
1292        );
1293        close_vec(
1294            start_change(&pushed, &unpushed, 1),
1295            -axis * (J / rest_kg),
1296            1e-9,
1297            "the airframe's push",
1298        );
1299        // 1 N·s on the 0.063 kg nose cone is 15.9 m/s.
1300        close(J / nose_kg, 15.85, 1e-3, "the nose cone's change of speed");
1301        let momentum = nose.start_sample.cg_velocity_enu_m_s * nose.start_sample.mass_kg
1302            + airframe.start_sample.cg_velocity_enu_m_s * airframe.start_sample.mass_kg;
1303        close_vec(
1304            momentum,
1305            first.cg_velocity_enu_m_s * whole.mass_kg,
1306            1e-9,
1307            "the momentum at the first parting",
1308        );
1309
1310        // The second, on the way down: by hand, 1 N·s on the 0.25 kg payload is 4 m/s, against
1311        // the airframe's velocity through the air.
1312        let parting = airframe.event(EventKind::Ejection(1)).unwrap();
1313        let (before, after) = (parting.sample, parting.after.unwrap());
1314        assert!(before.recovery_drag_area_m2 > 0.0, "{before:?}");
1315        let through_air = (before.cg_velocity_enu_m_s - wind_enu).normalize();
1316        close(
1317            payload.start_sample.mass_kg,
1318            PAYLOAD_KG,
1319            1e-12,
1320            "the payload",
1321        );
1322        close(
1323            after.mass_kg,
1324            before.mass_kg - PAYLOAD_KG,
1325            1e-12,
1326            "the airframe after",
1327        );
1328        close_vec(
1329            payload.start_sample.cg_velocity_enu_m_s - before.cg_velocity_enu_m_s,
1330            -through_air * 4.0,
1331            1e-9,
1332            "the payload's push",
1333        );
1334        close_vec(
1335            after.cg_velocity_enu_m_s - before.cg_velocity_enu_m_s,
1336            through_air * (J / after.mass_kg),
1337            1e-9,
1338            "the airframe's push",
1339        );
1340        assert_eq!(after.cg_enu_m, before.cg_enu_m);
1341        assert_eq!(payload.start_sample.cg_enu_m, before.cg_enu_m);
1342        close_vec(
1343            after.cg_velocity_enu_m_s * after.mass_kg
1344                + payload.start_sample.cg_velocity_enu_m_s * payload.start_sample.mass_kg,
1345            before.cg_velocity_enu_m_s * before.mass_kg,
1346            1e-9,
1347            "the momentum at the second parting",
1348        );
1349
1350        // Every piece still lands, each at its own terminal speed once the push has died away.
1351        assert!(pushed.bodies_landed());
1352        let rho = UniformAir::sea_level().0.density_kg_m3;
1353        for body in &pushed.bodies {
1354            let landing = body.event(EventKind::GroundHit).unwrap().sample;
1355            let drag_area_m2 = sim.recovery()[body.body].drag.drag_area_m2();
1356            let terminal_m_s = terminal_speed_m_s(body.mass_kg, drag_area_m2, rho, G);
1357            close(
1358                landing.airspeed_m_s,
1359                terminal_m_s,
1360                1e-3,
1361                &format!("body {}", body.body),
1362            );
1363        }
1364    }
1365
1366    #[test]
1367    fn a_body_with_nothing_open_is_pushed_along_its_flight() {
1368        // The airframe's canopy waits for 200 m, so at 300 m it falls with nothing open, nose
1369        // first as a stable airframe does, and the payload leaves down its velocity through the
1370        // air.
1371        let at_200_m = Trigger::Altitude {
1372            height_above_ground_m: 200.0,
1373        };
1374        let sim = Simulation::new(
1375            &with_payload(),
1376            "i175",
1377            analytic_environment(UniformAir::sea_level(), G),
1378            Rail::vertical(3.0),
1379            FlightSettings {
1380                max_time_s: 3600.0,
1381                ..FlightSettings::default()
1382            },
1383        )
1384        .unwrap()
1385        .with_recovery(vec![
1386            canopy(0, 0.45, Trigger::Apogee),
1387            canopy(1, 0.9, at_200_m),
1388            canopy(2, 0.6, at_200_m),
1389        ])
1390        .unwrap()
1391        .with_ejections(pushed(1.0))
1392        .unwrap();
1393        let start = dropped(&sim, 1_500.0, DVec3::new(2.0, 1.0, -0.5), DVec3::ZERO);
1394        let result = sim.run_free(START_S, start, &mut ()).unwrap();
1395        let airframe = &result.bodies[1];
1396        let payload = &result.bodies[2];
1397        let parting = airframe.event(EventKind::Ejection(1)).unwrap();
1398        let (before, after) = (parting.sample, parting.after.unwrap());
1399        assert_eq!(before.recovery_drag_area_m2, 0.0);
1400        let through_air = before.cg_velocity_enu_m_s.normalize();
1401        close_vec(
1402            payload.start_sample.cg_velocity_enu_m_s - before.cg_velocity_enu_m_s,
1403            through_air * 4.0,
1404            1e-9,
1405            "the payload's push",
1406        );
1407        close_vec(
1408            after.cg_velocity_enu_m_s - before.cg_velocity_enu_m_s,
1409            -through_air * (1.0 / after.mass_kg),
1410            1e-9,
1411            "the airframe's push",
1412        );
1413        assert!(result.bodies_landed());
1414    }
1415
1416    #[test]
1417    fn a_stack_already_hanging_from_a_device_is_pushed_up_its_flight() {
1418        // A drogue on the stack at apogee, from a stack tilted 60°, and the nose cone off at
1419        // 300 m. By then the stack has hung from the drogue for minutes: its attitude is the one
1420        // frozen at apogee, which says nothing of how it hangs, so the push goes up its velocity
1421        // through the air, as on a body under a canopy (found in review: it went along the
1422        // frozen axis, nearly sideways).
1423        let at_300_m = Trigger::Altitude {
1424            height_above_ground_m: 300.0,
1425        };
1426        let wind = ConstantWind::new(4.0, 2.0).unwrap();
1427        let wind_enu = wind.wind(0.0).unwrap().velocity_enu_m_s;
1428        let fly = |impulse_n_s: f64| {
1429            let sim = Simulation::new(
1430                &with_payload(),
1431                "i175",
1432                analytic_wind_environment(UniformAir::sea_level(), G, wind),
1433                Rail::vertical(3.0),
1434                FlightSettings {
1435                    max_time_s: 3600.0,
1436                    ..FlightSettings::default()
1437                },
1438            )
1439            .unwrap()
1440            .with_recovery(vec![
1441                canopy(0, 0.3, Trigger::Apogee),
1442                canopy(1, 0.9, at_300_m),
1443            ])
1444            .unwrap()
1445            .with_ejections(vec![
1446                Ejection::aft_of(at_300_m, "nose").with_impulse(impulse_n_s),
1447            ])
1448            .unwrap();
1449            let start = dropped_at(
1450                &sim,
1451                (60.0, 30.0),
1452                1_500.0,
1453                DVec3::new(3.0, 0.0, -2.0),
1454                DVec3::ZERO,
1455            );
1456            let result = sim.run_free(START_S, start, &mut ()).unwrap();
1457            (sim, result)
1458        };
1459        let (sim, pushed) = fly(1.0);
1460        let (_, unpushed) = fly(0.0);
1461        let first = pushed.event(EventKind::Ejection(0)).unwrap().sample;
1462        assert!(
1463            (first.height_above_ground_m - 300.0).abs() < 1e-6,
1464            "{first:?}"
1465        );
1466        let through_air = (first.cg_velocity_enu_m_s - wind_enu).normalize();
1467        let nose_kg = component_kg(&sim, "nose");
1468        let rest_kg = sim.assembly().mass_properties(first.time_s).mass_kg - nose_kg;
1469        let nose_push = start_change(&pushed, &unpushed, 0);
1470        close_vec(
1471            nose_push,
1472            -through_air * (1.0 / nose_kg),
1473            1e-9,
1474            "the nose cone's push",
1475        );
1476        close_vec(
1477            start_change(&pushed, &unpushed, 1),
1478            through_air * (1.0 / rest_kg),
1479            1e-9,
1480            "the airframe's push",
1481        );
1482        // Not the frozen axis: that points 60° up, the push nearly straight up.
1483        let frozen_axis = pushed.final_sample.state.attitude.mul_vec3(DVec3::Z);
1484        assert!(nose_push.normalize().dot(frozen_axis) < 0.95, "{nose_push}");
1485        assert!(pushed.bodies_landed());
1486    }
1487
1488    #[test]
1489    fn a_push_in_still_air_is_taken_as_up() {
1490        // Climbing straight up in calm air, the nose cone leaves on a timer; the payload leaves
1491        // the airframe's body at its own apogee, where it doesn't move through the air, so the
1492        // push is up: 4 m/s on the payload. The airframe's canopy waits for 200 m, so nothing is
1493        // open, and along its velocity (a hair downward, past the apogee) would point down.
1494        let sim = Simulation::new(
1495            &with_payload(),
1496            "i175",
1497            analytic_environment(UniformAir::sea_level(), G),
1498            Rail::vertical(3.0),
1499            FlightSettings {
1500                max_time_s: 3600.0,
1501                ..FlightSettings::default()
1502            },
1503        )
1504        .unwrap()
1505        .with_recovery(vec![
1506            canopy(0, 0.45, Trigger::Apogee),
1507            canopy(
1508                1,
1509                0.9,
1510                Trigger::Altitude {
1511                    height_above_ground_m: 200.0,
1512                },
1513            ),
1514            canopy(2, 0.6, Trigger::Apogee),
1515        ])
1516        .unwrap()
1517        .with_ejections(vec![
1518            Ejection::aft_of(Trigger::Time { time_s: START_S }, "nose"),
1519            Ejection::payload(Trigger::Apogee, "payload").with_impulse(1.0),
1520        ])
1521        .unwrap();
1522        let start = dropped(&sim, 1_000.0, DVec3::new(0.0, 0.0, 20.0), DVec3::ZERO);
1523        let result = sim.run_free(START_S, start, &mut ()).unwrap();
1524        let airframe = &result.bodies[1];
1525        let payload = &result.bodies[2];
1526        let parting = airframe.event(EventKind::Ejection(1)).unwrap().sample;
1527        assert!(parting.airspeed_m_s < 1e-3, "{parting:?}");
1528        assert_eq!(parting.recovery_drag_area_m2, 0.0);
1529        close_vec(
1530            payload.start_sample.cg_velocity_enu_m_s - parting.cg_velocity_enu_m_s,
1531            DVec3::new(0.0, 0.0, 4.0),
1532            1e-9,
1533            "the payload's push",
1534        );
1535        assert!(result.bodies_landed());
1536    }
1537
1538    #[test]
1539    fn a_separation_with_pushed_ejections_pushes_only_the_ejections_sides() {
1540        // The two-stage design parts at its stage boundary and pushes its nose cone off with
1541        // 1 N·s, both at apogee from the stack in free flight: the booster (body 1, the
1542        // separation's) gets no push, and the nose cone and the sustainer's airframe (body 2)
1543        // share the one push along the axis.
1544        let fly = |impulse_n_s: f64| {
1545            let rocket = design("synthetic-two-stage-75mm-54mm");
1546            let assembly = rocket.assemble("j760-i175").unwrap();
1547            let tumble = DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap();
1548            let sim = Simulation::new(
1549                &rocket,
1550                "j760-i175",
1551                analytic_environment(UniformAir::sea_level(), G),
1552                Rail::vertical(6.0),
1553                FlightSettings {
1554                    max_time_s: 3600.0,
1555                    ..FlightSettings::default()
1556                },
1557            )
1558            .unwrap()
1559            .with_separation(Separation::new(Trigger::Apogee, 0))
1560            .unwrap()
1561            .with_ejections(vec![
1562                Ejection::aft_of(Trigger::Apogee, "nose").with_impulse(impulse_n_s),
1563            ])
1564            .unwrap()
1565            .with_recovery(vec![
1566                canopy(0, 0.6, Trigger::Apogee),
1567                Device::new("booster", tumble, Trigger::Apogee).on_body(1),
1568                canopy(2, 1.2, Trigger::Apogee),
1569            ])
1570            .unwrap();
1571            let start = dropped_at(
1572                &sim,
1573                (70.0, 200.0),
1574                1_500.0,
1575                DVec3::new(0.0, 0.0, -0.5),
1576                DVec3::ZERO,
1577            );
1578            let result = sim.run_free(START_S, start, &mut ()).unwrap();
1579            (sim, result)
1580        };
1581        let (sim, pushed) = fly(1.0);
1582        let (_, unpushed) = fly(0.0);
1583        let axis = pushed.final_sample.state.attitude.mul_vec3(DVec3::Z);
1584        assert_eq!(start_change(&pushed, &unpushed, 1), DVec3::ZERO);
1585        let nose_kg = pushed.bodies[0].start_sample.mass_kg;
1586        let airframe_kg = pushed.bodies[2].start_sample.mass_kg;
1587        close(nose_kg, component_kg(&sim, "nose"), 1e-12, "the nose cone");
1588        close_vec(
1589            start_change(&pushed, &unpushed, 0),
1590            axis * (1.0 / nose_kg),
1591            1e-9,
1592            "the nose cone's push",
1593        );
1594        close_vec(
1595            start_change(&pushed, &unpushed, 2),
1596            -axis * (1.0 / airframe_kg),
1597            1e-9,
1598            "the airframe's push",
1599        );
1600        let first = pushed.event(EventKind::Separation).unwrap().sample;
1601        let whole_kg = sim.assembly().mass_properties(first.time_s).mass_kg;
1602        let momentum: DVec3 = pushed
1603            .bodies
1604            .iter()
1605            .map(|body| body.start_sample.cg_velocity_enu_m_s * body.start_sample.mass_kg)
1606            .sum();
1607        close_vec(
1608            momentum,
1609            first.cg_velocity_enu_m_s * whole_kg,
1610            1e-9,
1611            "the momentum",
1612        );
1613    }
1614
1615    #[test]
1616    fn pushed_sections_parting_together_on_the_way_down_push_as_the_stack_does() {
1617        // The booster section leaves at apogee; at 300 m the nose and the interstage both leave
1618        // the body left, each with 1 N·s, in one pass, as the stack's partings do at the first:
1619        // each final body takes the pushes of the joints on its sides. By hand, on the way the
1620        // nose points (against the velocity through the air, under the canopy it hangs from since
1621        // apogee): the nose cone `+J/m`, the sustainer's airframe between the two joints `−J/m +
1622        // J/m = 0`, and the interstage `−J/m`. Given in either order, the same (found in review:
1623        // taken one at a time, the order moved the nose cone's push from 15.85 to 17.66 m/s).
1624        let at_300_m = Trigger::Altitude {
1625            height_above_ground_m: 300.0,
1626        };
1627        let orders = [
1628            vec![
1629                Ejection::aft_of(Trigger::Apogee, "interstage"),
1630                Ejection::aft_of(at_300_m, "nose").with_impulse(1.0),
1631                Ejection::aft_of(at_300_m, "sustainer-airframe").with_impulse(1.0),
1632            ],
1633            vec![
1634                Ejection::aft_of(Trigger::Apogee, "interstage"),
1635                Ejection::aft_of(at_300_m, "sustainer-airframe").with_impulse(1.0),
1636                Ejection::aft_of(at_300_m, "nose").with_impulse(1.0),
1637            ],
1638        ];
1639        let mut landings = Vec::new();
1640        for (order, ejections) in orders.into_iter().enumerate() {
1641            let (sim, result) = two_stage_pieces(ejections, 4);
1642            assert!(result.bodies_landed(), "order {order}");
1643            // Both partings are the nose's body's, at the one instant.
1644            let nose = &result.bodies[0];
1645            let partings: Vec<&BodyEvent> = nose
1646                .events
1647                .iter()
1648                .filter(|event| matches!(event.kind, EventKind::Ejection(_)))
1649                .collect();
1650            assert_eq!(partings.len(), 2, "order {order}");
1651            let (before, after) = (partings[0].sample, partings[0].after.unwrap());
1652            assert_eq!(partings[1].sample, before);
1653            let nose_ward = -before.cg_velocity_enu_m_s.normalize();
1654            let (airframe, interstage) = if order == 0 {
1655                (&result.bodies[2], &result.bodies[3])
1656            } else {
1657                (&result.bodies[3], &result.bodies[2])
1658            };
1659            close(
1660                after.mass_kg,
1661                component_kg(&sim, "nose"),
1662                1e-12,
1663                "the nose cone",
1664            );
1665            let change = |velocity: DVec3| velocity - before.cg_velocity_enu_m_s;
1666            close_vec(
1667                change(after.cg_velocity_enu_m_s),
1668                nose_ward * (1.0 / after.mass_kg),
1669                1e-9,
1670                "the nose cone's push",
1671            );
1672            assert!(
1673                change(airframe.start_sample.cg_velocity_enu_m_s).length() < 1e-12,
1674                "order {order}: {}",
1675                change(airframe.start_sample.cg_velocity_enu_m_s)
1676            );
1677            close_vec(
1678                change(interstage.start_sample.cg_velocity_enu_m_s),
1679                -nose_ward * (1.0 / interstage.start_sample.mass_kg),
1680                1e-9,
1681                "the interstage's push",
1682            );
1683            let momentum = after.cg_velocity_enu_m_s * after.mass_kg
1684                + airframe.start_sample.cg_velocity_enu_m_s * airframe.start_sample.mass_kg
1685                + interstage.start_sample.cg_velocity_enu_m_s * interstage.start_sample.mass_kg;
1686            close_vec(
1687                momentum,
1688                before.cg_velocity_enu_m_s * before.mass_kg,
1689                1e-9,
1690                "the momentum",
1691            );
1692            landings.push([
1693                nose.final_sample.cg_enu_m,
1694                airframe.final_sample.cg_enu_m,
1695                interstage.final_sample.cg_enu_m,
1696            ]);
1697        }
1698        for (first, second) in landings[0].iter().zip(&landings[1]) {
1699            assert!((*first - *second).length() < 1e-6, "{first} vs {second}");
1700        }
1701    }
1702
1703    #[test]
1704    fn a_device_opening_as_its_body_parts_is_not_yet_hung_from() {
1705        // The airframe falls with nothing open, and its canopy and the payload's parting both
1706        // come at 300 m. At that instant it doesn't yet hang from the canopy, so the payload
1707        // leaves down its flight, whether the canopy opens at once or fills over a second (found
1708        // in review: the push flipped with the inflation law).
1709        let at_300_m = Trigger::Altitude {
1710            height_above_ground_m: 300.0,
1711        };
1712        let changes: Vec<DVec3> = [
1713            Inflation::Instant,
1714            Inflation::FillingTime {
1715                time_s: 1.0,
1716                exponent: 2.0,
1717            },
1718        ]
1719        .into_iter()
1720        .map(|inflation| {
1721            let sim = Simulation::new(
1722                &with_payload(),
1723                "i175",
1724                analytic_environment(UniformAir::sea_level(), G),
1725                Rail::vertical(3.0),
1726                FlightSettings {
1727                    max_time_s: 3600.0,
1728                    ..FlightSettings::default()
1729                },
1730            )
1731            .unwrap()
1732            .with_recovery(vec![
1733                canopy(0, 0.45, Trigger::Apogee),
1734                canopy(1, 0.9, at_300_m).with_inflation(inflation),
1735                canopy(2, 0.6, at_300_m),
1736            ])
1737            .unwrap()
1738            .with_ejections(pushed(1.0))
1739            .unwrap();
1740            let start = dropped(&sim, 1_500.0, DVec3::new(2.0, 1.0, -0.5), DVec3::ZERO);
1741            let result = sim.run_free(START_S, start, &mut ()).unwrap();
1742            let parting = result.bodies[1]
1743                .event(EventKind::Ejection(1))
1744                .unwrap()
1745                .sample;
1746            let change =
1747                result.bodies[2].start_sample.cg_velocity_enu_m_s - parting.cg_velocity_enu_m_s;
1748            close_vec(
1749                change,
1750                parting.cg_velocity_enu_m_s.normalize() * 4.0,
1751                1e-9,
1752                "the payload's push",
1753            );
1754            change
1755        })
1756        .collect();
1757        assert!(changes[0].z < -3.9, "{}", changes[0]);
1758    }
1759
1760    #[test]
1761    fn a_pushed_payload_whose_way_forward_is_still_closed_is_refused_in_flight() {
1762        // The payload is pushed out at apogee, but the joint forward of its airframe only parts
1763        // at 300 m: it has no way out forward then.
1764        let sim = simulation_with(
1765            analytic_environment(UniformAir::sea_level(), G),
1766            vec![
1767                Ejection::aft_of(
1768                    Trigger::Altitude {
1769                        height_above_ground_m: 300.0,
1770                    },
1771                    "nose",
1772                ),
1773                Ejection::payload(Trigger::Apogee, "payload").with_impulse(1.0),
1774            ],
1775        );
1776        let start = dropped(&sim, 1_500.0, DVec3::new(0.0, 0.0, -0.5), DVec3::ZERO);
1777        let error = sim.run_free(START_S, start, &mut ()).unwrap_err();
1778        assert!(
1779            matches!(error, SimError::Domain { what, value }
1780                if what.starts_with("time of a pushed payload's ejection") && value == START_S),
1781            "{error:?}"
1782        );
1783    }
1784
1785    /// The milestone's design with `devices` and the nose cone pushed off at 300 m with
1786    /// `impulse_n_s`, dropped from a stack tilted 60° in a 4 m/s wind.
1787    fn nose_off_at_300_m(devices: Vec<Device>, impulse_n_s: f64) -> (Simulation, FlightResult) {
1788        let at_300_m = Trigger::Altitude {
1789            height_above_ground_m: 300.0,
1790        };
1791        let sim = Simulation::new(
1792            &with_payload(),
1793            "i175",
1794            analytic_wind_environment(
1795                UniformAir::sea_level(),
1796                G,
1797                ConstantWind::new(4.0, 2.0).unwrap(),
1798            ),
1799            Rail::vertical(3.0),
1800            FlightSettings {
1801                max_time_s: 3600.0,
1802                ..FlightSettings::default()
1803            },
1804        )
1805        .unwrap()
1806        .with_recovery(devices)
1807        .unwrap()
1808        .with_ejections(vec![
1809            Ejection::aft_of(at_300_m, "nose").with_impulse(impulse_n_s),
1810        ])
1811        .unwrap();
1812        let start = dropped_at(
1813            &sim,
1814            (60.0, 30.0),
1815            1_500.0,
1816            DVec3::new(3.0, 0.0, -2.0),
1817            DVec3::ZERO,
1818        );
1819        let result = sim.run_free(START_S, start, &mut ()).unwrap();
1820        (sim, result)
1821    }
1822
1823    /// The nose cone's change of velocity at its parting from [`nose_off_at_300_m`] with 1 N·s,
1824    /// against the same flight unpushed, over the unit velocity through the air there and the
1825    /// push's size `J/m`.
1826    fn nose_push_through_air(devices: impl Fn() -> Vec<Device>) -> f64 {
1827        let (sim, pushed) = nose_off_at_300_m(devices(), 1.0);
1828        let (_, unpushed) = nose_off_at_300_m(devices(), 0.0);
1829        let first = pushed.event(EventKind::Ejection(0)).unwrap().sample;
1830        let wind_enu = ConstantWind::new(4.0, 2.0)
1831            .unwrap()
1832            .wind(0.0)
1833            .unwrap()
1834            .velocity_enu_m_s;
1835        let through_air = (first.cg_velocity_enu_m_s - wind_enu).normalize();
1836        let push = start_change(&pushed, &unpushed, 0);
1837        let size_m_s = 1.0 / component_kg(&sim, "nose");
1838        // Along the velocity through the air, one way or the other, and nowhere else.
1839        let along = push.dot(through_air) / size_m_s;
1840        close_vec(
1841            push,
1842            through_air * along * size_m_s,
1843            1e-9,
1844            "the push's line",
1845        );
1846        along
1847    }
1848
1849    #[test]
1850    fn a_stack_tumbling_since_apogee_is_pushed_along_its_flight() {
1851        // Only a tumble on the stack since apogee: its attitude froze then, so the push goes by
1852        // its velocity through the air, but it hangs from nothing, so the nose cone is pushed
1853        // along it, as a stable airframe's nose would point.
1854        let along = nose_push_through_air(|| {
1855            let tumble = DeviceDrag::tumbling(&with_payload().assemble("i175").unwrap()).unwrap();
1856            vec![
1857                Device::new("tumble", tumble, Trigger::Apogee),
1858                canopy(
1859                    1,
1860                    0.9,
1861                    Trigger::Altitude {
1862                        height_above_ground_m: 300.0,
1863                    },
1864                ),
1865            ]
1866        });
1867        close(along, 1.0, 1e-9, "along the flight");
1868    }
1869
1870    #[test]
1871    fn a_drogue_released_as_the_body_parts_is_still_hung_from() {
1872        // A drogue on the stack since apogee, cut away by a main that opens at 300 m, as the nose
1873        // cone leaves. Up to that instant the stack hung from the drogue, so the nose cone goes
1874        // against its velocity through the air whether the main opens at once, releasing the drogue at
1875        // that instant, or fills over a second (found in review: the push flipped with the law).
1876        for inflation in [
1877            Inflation::Instant,
1878            Inflation::FillingTime {
1879                time_s: 1.0,
1880                exponent: 2.0,
1881            },
1882        ] {
1883            let along = nose_push_through_air(|| {
1884                vec![
1885                    canopy(0, 0.3, Trigger::Apogee).with_release_by(1),
1886                    canopy(
1887                        0,
1888                        0.9,
1889                        Trigger::Altitude {
1890                            height_above_ground_m: 300.0,
1891                        },
1892                    )
1893                    .with_inflation(inflation),
1894                    canopy(
1895                        1,
1896                        0.9,
1897                        Trigger::Altitude {
1898                            height_above_ground_m: 300.0,
1899                        },
1900                    ),
1901                ]
1902            });
1903            close(along, -1.0, 1e-9, &format!("{inflation:?}"));
1904        }
1905    }
1906
1907    #[test]
1908    fn one_charge_can_push_off_the_nose_cone_and_let_the_payload_out() {
1909        // Both at apogee from the stack in free flight, 1 N·s each: by hand, along the axis, the
1910        // nose cone `+1 N·s`, the payload, leaving forward through the joint that opens with it,
1911        // `+1 N·s`, and the airframe between them `−2 N·s`, each over its own mass.
1912        let fly = |impulse_n_s: f64| {
1913            let sim = Simulation::new(
1914                &with_payload(),
1915                "i175",
1916                analytic_environment(UniformAir::sea_level(), G),
1917                Rail::vertical(3.0),
1918                FlightSettings {
1919                    max_time_s: 3600.0,
1920                    ..FlightSettings::default()
1921                },
1922            )
1923            .unwrap()
1924            .with_recovery(vec![
1925                canopy(0, 0.45, Trigger::Apogee),
1926                canopy(1, 0.9, Trigger::Apogee),
1927                canopy(2, 0.6, Trigger::Apogee),
1928            ])
1929            .unwrap()
1930            .with_ejections(vec![
1931                Ejection::aft_of(Trigger::Apogee, "nose").with_impulse(impulse_n_s),
1932                Ejection::payload(Trigger::Apogee, "payload").with_impulse(impulse_n_s),
1933            ])
1934            .unwrap();
1935            let start = dropped_at(
1936                &sim,
1937                (70.0, 120.0),
1938                1_500.0,
1939                DVec3::new(0.0, 0.0, -0.5),
1940                DVec3::ZERO,
1941            );
1942            sim.run_free(START_S, start, &mut ()).unwrap()
1943        };
1944        let (pushed, unpushed) = (fly(1.0), fly(0.0));
1945        assert!(pushed.bodies_landed());
1946        let axis = pushed.final_sample.state.attitude.mul_vec3(DVec3::Z);
1947        for (body, impulse_n_s) in [(0, 1.0), (1, -2.0), (2, 1.0)] {
1948            let kg = pushed.bodies[body].start_sample.mass_kg;
1949            close_vec(
1950                start_change(&pushed, &unpushed, body),
1951                axis * (impulse_n_s / kg),
1952                1e-9,
1953                &format!("body {body}"),
1954            );
1955        }
1956        close(
1957            pushed.bodies[2].start_sample.mass_kg,
1958            PAYLOAD_KG,
1959            1e-12,
1960            "the payload",
1961        );
1962    }
1963
1964    #[test]
1965    fn a_pushed_payload_behind_a_separation_is_accepted_in_either_builder_order() {
1966        // The two-stage design's booster electronics, pushed out at apogee, are in the booster's
1967        // piece, forward of which the separation parts: a way out, whichever builder comes first.
1968        // Without the separation they would be in the nose's piece, and refused at the start.
1969        let rocket = design("synthetic-two-stage-75mm-54mm");
1970        let tumble =
1971            DeviceDrag::tumbling_stages(&rocket.assemble("j760-i175").unwrap(), (1, 1)).unwrap();
1972        let devices = || {
1973            vec![
1974                canopy(0, 1.2, Trigger::Apogee),
1975                Device::new("booster", tumble, Trigger::Apogee).on_body(1),
1976                canopy(2, 0.3, Trigger::Apogee),
1977            ]
1978        };
1979        let ejections =
1980            || vec![Ejection::payload(Trigger::Apogee, "booster-electronics").with_impulse(1.0)];
1981        let base = || {
1982            Simulation::new(
1983                &rocket,
1984                "j760-i175",
1985                analytic_environment(UniformAir::sea_level(), G),
1986                Rail::vertical(6.0),
1987                FlightSettings {
1988                    max_time_s: 3600.0,
1989                    ..FlightSettings::default()
1990                },
1991            )
1992            .unwrap()
1993        };
1994        let separation = Separation::new(Trigger::Apogee, 0);
1995        let orders = [
1996            base()
1997                .with_separation(separation)
1998                .unwrap()
1999                .with_ejections(ejections())
2000                .unwrap()
2001                .with_recovery(devices())
2002                .unwrap(),
2003            base()
2004                .with_ejections(ejections())
2005                .unwrap()
2006                .with_separation(separation)
2007                .unwrap()
2008                .with_recovery(devices())
2009                .unwrap(),
2010        ];
2011        for sim in orders {
2012            let start = dropped(&sim, 1_500.0, DVec3::new(0.0, 0.0, -0.5), DVec3::ZERO);
2013            let result = sim.run_free(START_S, start, &mut ()).unwrap();
2014            assert!(result.bodies_landed());
2015        }
2016        let error = base()
2017            .with_ejections(ejections())
2018            .unwrap()
2019            .with_recovery(vec![
2020                canopy(0, 1.2, Trigger::Apogee),
2021                canopy(1, 0.3, Trigger::Apogee),
2022            ])
2023            .unwrap()
2024            .run(&mut ())
2025            .unwrap_err();
2026        assert!(
2027            matches!(&error, SimError::Parting { what, component }
2028                if what.starts_with("a pushed payload leaves forward")
2029                    && component == "booster-electronics"),
2030            "{error:?}"
2031        );
2032        // With the separation, one in the sustainer's airframe is still in the nose's piece.
2033        let error = base()
2034            .with_separation(separation)
2035            .unwrap()
2036            .with_ejections(vec![
2037                Ejection::payload(Trigger::Apogee, "sustainer-parachute").with_impulse(1.0),
2038            ])
2039            .unwrap()
2040            .with_recovery(devices())
2041            .unwrap()
2042            .run(&mut ())
2043            .unwrap_err();
2044        assert!(
2045            matches!(&error, SimError::Parting { what, component }
2046                if what.starts_with("a pushed payload leaves forward")
2047                    && component == "sustainer-parachute"),
2048            "{error:?}"
2049        );
2050    }
2051
2052    #[test]
2053    fn a_drogue_cut_away_before_the_parting_is_not_hung_from() {
2054        // A drogue on the stack since apogee, released at 600 m by a tumble: by 300 m, where the
2055        // nose cone is pushed off, the stack hangs from nothing, so the push goes along its
2056        // velocity through the air.
2057        let along = nose_push_through_air(|| {
2058            let tumble = DeviceDrag::tumbling(&with_payload().assemble("i175").unwrap()).unwrap();
2059            vec![
2060                canopy(0, 0.3, Trigger::Apogee).with_release_by(1),
2061                Device::new(
2062                    "tumble",
2063                    tumble,
2064                    Trigger::Altitude {
2065                        height_above_ground_m: 600.0,
2066                    },
2067                ),
2068                canopy(
2069                    1,
2070                    0.9,
2071                    Trigger::Altitude {
2072                        height_above_ground_m: 300.0,
2073                    },
2074                ),
2075            ]
2076        });
2077        close(along, 1.0, 1e-9, "along the flight");
2078    }
2079
2080    #[test]
2081    fn a_parting_without_an_impulse_records_the_body_after_it_unpushed() {
2082        // With no impulse the body after a parting on the way down has the same point and
2083        // velocity, and only its mass steps.
2084        let sim = simulation(analytic_environment(UniformAir::sea_level(), G));
2085        let start = dropped(&sim, 1_500.0, DVec3::new(0.0, 0.0, -0.5), DVec3::ZERO);
2086        let result = sim.run_free(START_S, start, &mut ()).unwrap();
2087        let airframe = &result.bodies[1];
2088        let parting = airframe.event(EventKind::Ejection(1)).unwrap();
2089        let after = parting.after.unwrap();
2090        assert_eq!(
2091            after.cg_velocity_enu_m_s,
2092            parting.sample.cg_velocity_enu_m_s
2093        );
2094        assert_eq!(after.cg_enu_m, parting.sample.cg_enu_m);
2095        close(after.mass_kg, airframe.mass_kg, 1e-12, "the airframe after");
2096        // Only partings carry one.
2097        for body in &result.bodies {
2098            for event in &body.events {
2099                let parts = matches!(event.kind, EventKind::Ejection(_) | EventKind::Separation);
2100                assert_eq!(event.after.is_some(), parts, "{event:?}");
2101            }
2102        }
2103    }
2104
2105    #[test]
2106    fn a_tumbling_nose_cone_lands_at_its_tumble_models_terminal_speed() {
2107        // The nose cone pushed off at apogee with nothing but its own tumble; the airframe, with
2108        // the payload still inside, under its canopy.
2109        let air = UniformAir::sea_level();
2110        let base = Simulation::new(
2111            &with_payload(),
2112            "i175",
2113            analytic_environment(air, G),
2114            Rail::vertical(3.0),
2115            FlightSettings {
2116                max_time_s: 3600.0,
2117                ..FlightSettings::default()
2118            },
2119        )
2120        .unwrap()
2121        .with_ejections(vec![
2122            Ejection::aft_of(Trigger::Apogee, "nose").with_impulse(0.5),
2123        ])
2124        .unwrap();
2125        let tumble = base.tumbling_piece(0).unwrap();
2126        let DeviceDrag::Tumble {
2127            drag_area_m2,
2128            body_profile_m2,
2129            fin_area_m2,
2130        } = tumble
2131        else {
2132            panic!("{tumble:?}");
2133        };
2134        // By hand: the nose is a 0.25 m tangent ogive on the 54 mm airframe, whose arc has radius
2135        // `ρ = (R² + L²)/(2R)`, and whose side area is `L√(ρ² − L²) + ρ² asin(L/ρ) + 2(R − ρ)L`.
2136        // It has no fins, so its drag area is 0.56 times that.
2137        let nose = sim_component(&base, "nose");
2138        let (length_m, radius_m) = (0.25_f64, nose.part.aft_radius_m().unwrap());
2139        let arc_m = (radius_m * radius_m + length_m * length_m) / (2.0 * radius_m);
2140        let side_m2 = length_m * (arc_m * arc_m - length_m * length_m).sqrt()
2141            + arc_m * arc_m * (length_m / arc_m).asin()
2142            + 2.0 * (radius_m - arc_m) * length_m;
2143        close(body_profile_m2, side_m2, 1e-12, "the nose cone's side area");
2144        assert_eq!(fin_area_m2, 0.0);
2145        close(
2146            drag_area_m2,
2147            0.56 * side_m2,
2148            1e-12,
2149            "the nose cone's drag area",
2150        );
2151
2152        let sim = base
2153            .with_recovery(vec![
2154                Device::new("nose tumble", tumble, Trigger::Apogee).on_body(0),
2155                canopy(1, 0.9, Trigger::Apogee),
2156            ])
2157            .unwrap();
2158        let start = dropped(&sim, 1_500.0, DVec3::new(0.0, 0.0, -0.5), DVec3::ZERO);
2159        let result = sim.run_free(START_S, start, &mut ()).unwrap();
2160        assert!(result.bodies_landed());
2161        let nose_body = &result.bodies[0];
2162        close(
2163            nose_body.mass_kg,
2164            component_kg(&sim, "nose"),
2165            1e-12,
2166            "the nose cone",
2167        );
2168        let landing = nose_body.event(EventKind::GroundHit).unwrap().sample;
2169        let terminal_m_s =
2170            terminal_speed_m_s(nose_body.mass_kg, drag_area_m2, air.0.density_kg_m3, G);
2171        close(
2172            -landing.vertical_speed_m_s,
2173            terminal_m_s,
2174            1e-6,
2175            "the nose cone's landing",
2176        );
2177        // Measured: 13.849 m/s for the 0.0631 kg nose cone on its 5.27e-3 m² drag area.
2178        assert!((terminal_m_s - 13.849).abs() < 1e-3, "{terminal_m_s}");
2179    }
2180
2181    /// The layout's component `id`.
2182    fn sim_component<'a>(sim: &'a Simulation, id: &str) -> &'a hpr_design::PlacedComponent {
2183        sim.assembly()
2184            .layout
2185            .components
2186            .iter()
2187            .find(|component| component.id == id)
2188            .unwrap()
2189    }
2190
2191    #[test]
2192    fn the_pieces_tumbling_areas_add_up_to_the_whole_airframes() {
2193        // Cut into sections, the airframe's tumble is theirs summed: here the nose cone, and the
2194        // airframe with its fins.
2195        let sim = Simulation::new(
2196            &with_payload(),
2197            "i175",
2198            analytic_environment(UniformAir::sea_level(), G),
2199            Rail::vertical(3.0),
2200            FlightSettings::default(),
2201        )
2202        .unwrap()
2203        .with_ejections(vec![
2204            Ejection::aft_of(Trigger::Apogee, "nose"),
2205            Ejection::payload(Trigger::Apogee, "payload"),
2206        ])
2207        .unwrap();
2208        let area = |drag: DeviceDrag| match drag {
2209            DeviceDrag::Tumble {
2210                drag_area_m2,
2211                body_profile_m2,
2212                fin_area_m2,
2213            } => [drag_area_m2, body_profile_m2, fin_area_m2],
2214            other => panic!("{other:?}"),
2215        };
2216        let whole = area(DeviceDrag::tumbling(sim.assembly()).unwrap());
2217        let nose = area(sim.tumbling_piece(0).unwrap());
2218        let rest = area(sim.tumbling_piece(1).unwrap());
2219        for k in 0..3 {
2220            close(nose[k] + rest[k], whole[k], 1e-12, "the sum");
2221        }
2222        assert!(nose[2] == 0.0 && rest[2] > 0.0, "{nose:?} {rest:?}");
2223
2224        // A payload has no tube or fin of its own, and piece 3 is not made.
2225        for (piece, starts) in [
2226            (2, "piece to tumble (a payload has no body tube or fin"),
2227            (3, "piece to tumble (there is one per split"),
2228        ] {
2229            let error = sim.tumbling_piece(piece).unwrap_err();
2230            assert!(
2231                matches!(error, SimError::Domain { what, value }
2232                    if what.starts_with(starts) && value == piece as f64),
2233                "{error:?}"
2234            );
2235        }
2236    }
2237
2238    #[test]
2239    fn an_ejection_with_a_separation_numbers_the_bodies_after_it() {
2240        // The two-stage design parts at its stage boundary and pushes its nose cone off, both at
2241        // apogee: the booster is body 1, the nose cone keeps body 0, and the sustainer's airframe
2242        // is the ejection's body 2.
2243        let air = UniformAir::sea_level();
2244        let rocket = design("synthetic-two-stage-75mm-54mm");
2245        let assembly = rocket.assemble("j760-i175").unwrap();
2246        let tumble = DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap();
2247        let sim = Simulation::new(
2248            &rocket,
2249            "j760-i175",
2250            analytic_environment(air, G),
2251            Rail::vertical(6.0),
2252            FlightSettings {
2253                max_time_s: 3600.0,
2254                ..FlightSettings::default()
2255            },
2256        )
2257        .unwrap()
2258        .with_ejections(vec![Ejection::aft_of(Trigger::Apogee, "nose")])
2259        .unwrap()
2260        .with_recovery(vec![
2261            canopy(0, 0.6, Trigger::Apogee),
2262            Device::new("booster", tumble, Trigger::Apogee).on_body(1),
2263            canopy(2, 1.2, Trigger::Apogee),
2264        ])
2265        .unwrap()
2266        .with_separation(Separation::new(Trigger::Apogee, 0))
2267        .unwrap();
2268        let start = dropped(&sim, 1_500.0, DVec3::new(0.0, 0.0, -0.5), DVec3::ZERO);
2269        let result = sim.run_free(START_S, start, &mut ()).unwrap();
2270        assert!(result.event(EventKind::Separation).is_some());
2271        assert!(result.event(EventKind::Ejection(0)).is_some());
2272        assert!(result.bodies_landed());
2273        let stages: Vec<(usize, usize)> = result.bodies.iter().map(|body| body.stages).collect();
2274        assert_eq!(stages, vec![(0, 0), (1, 1), (0, 0)]);
2275        let whole_kg = sim.assembly().mass_properties(START_S).mass_kg;
2276        let sum_kg: f64 = result.bodies.iter().map(|body| body.mass_kg).sum();
2277        close(sum_kg, whole_kg, 1e-12, "the bodies' mass");
2278        close(
2279            result.bodies[0].mass_kg,
2280            component_kg(&sim, "nose"),
2281            1e-12,
2282            "the nose cone",
2283        );
2284        // The booster is its stage and motor, as a separation alone makes it.
2285        let lit = vec![Some(0.0); sim.assembly().motors.len()];
2286        let booster = crate::recovery::body_mass_properties(sim.assembly(), (1, 1), START_S, &lit);
2287        close(
2288            result.bodies[1].mass_kg,
2289            booster.mass_kg,
2290            1e-12,
2291            "the booster",
2292        );
2293    }
2294
2295    #[test]
2296    fn a_split_on_the_way_down_waits_for_its_own_trigger() {
2297        // The payload's height is below where the airframe's body starts, so it leaves only on
2298        // the way down; a payload whose height is never reached lands inside its airframe.
2299        let sim = simulation(analytic_environment(UniformAir::sea_level(), G));
2300        let start = dropped(&sim, 1_500.0, DVec3::new(0.0, 0.0, -0.5), DVec3::ZERO);
2301        let result = sim.run_free(START_S, start, &mut ()).unwrap();
2302        let parting = result.bodies[1]
2303            .event(EventKind::Ejection(1))
2304            .unwrap()
2305            .sample;
2306        assert!(parting.time_s > START_S + 10.0, "{parting:?}");
2307        assert!(parting.vertical_speed_m_s < 0.0);
2308
2309        // With the payload let out on a timer after the landing, it never leaves.
2310        let late = Simulation::new(
2311            &with_payload(),
2312            "i175",
2313            analytic_environment(UniformAir::sea_level(), G),
2314            Rail::vertical(3.0),
2315            FlightSettings {
2316                max_time_s: 3600.0,
2317                ..FlightSettings::default()
2318            },
2319        )
2320        .unwrap()
2321        .with_recovery(devices())
2322        .unwrap()
2323        .with_ejections(vec![
2324            Ejection::aft_of(Trigger::Apogee, "nose"),
2325            Ejection::payload(Trigger::Time { time_s: 3_000.0 }, "payload"),
2326        ])
2327        .unwrap();
2328        let result = late.run_free(START_S, start, &mut ()).unwrap();
2329        assert_eq!(result.bodies.len(), 2);
2330        assert_eq!(result.bodies[1].pieces, vec![1, 2]);
2331        assert!(result.bodies[1].event(EventKind::Ejection(1)).is_none());
2332        let whole_kg = late.assembly().mass_properties(START_S).mass_kg;
2333        close(
2334            result.bodies[0].mass_kg + result.bodies[1].mass_kg,
2335            whole_kg,
2336            1e-12,
2337            "the two bodies",
2338        );
2339    }
2340
2341    /// The two-stage test design with no separation, flown from a drop at 1,500 m with a canopy
2342    /// on each of its `bodies` and `ejections`.
2343    fn two_stage_pieces(ejections: Vec<Ejection>, bodies: usize) -> (Simulation, FlightResult) {
2344        let air = UniformAir::sea_level();
2345        let sim = Simulation::new(
2346            &design("synthetic-two-stage-75mm-54mm"),
2347            "j760-i175",
2348            analytic_environment(air, G),
2349            Rail::vertical(6.0),
2350            FlightSettings {
2351                max_time_s: 3600.0,
2352                ..FlightSettings::default()
2353            },
2354        )
2355        .unwrap()
2356        .with_recovery(
2357            (0..bodies)
2358                .map(|body| canopy(body, 0.8, Trigger::Apogee))
2359                .collect(),
2360        )
2361        .unwrap()
2362        .with_ejections(ejections)
2363        .unwrap();
2364        let start = dropped(&sim, 1_500.0, DVec3::new(0.0, 0.0, -0.5), DVec3::ZERO);
2365        let result = sim.run_free(START_S, start, &mut ()).unwrap();
2366        (sim, result)
2367    }
2368
2369    #[test]
2370    fn partings_that_fire_together_on_the_way_down_count_each_piece_once() {
2371        // The booster section leaves at apogee. At 300 m two more partings fire in the same pass
2372        // on the body left: aft of the nose and aft of the sustainer's airframe. They part it
2373        // together, whichever is listed first, and each piece is counted once (found in review:
2374        // taken one at a time, the interstage was counted twice, 1.7651 kg landed from a
2375        // 1.6752 kg rocket). Both are the parting body's events.
2376        let at_300_m = Trigger::Altitude {
2377            height_above_ground_m: 300.0,
2378        };
2379        let orders = [
2380            vec![
2381                Ejection::aft_of(Trigger::Apogee, "interstage"),
2382                Ejection::aft_of(at_300_m, "nose"),
2383                Ejection::aft_of(at_300_m, "sustainer-airframe"),
2384            ],
2385            vec![
2386                Ejection::aft_of(Trigger::Apogee, "interstage"),
2387                Ejection::aft_of(at_300_m, "sustainer-airframe"),
2388                Ejection::aft_of(at_300_m, "nose"),
2389            ],
2390        ];
2391        for (order, ejections) in orders.into_iter().enumerate() {
2392            let (sim, result) = two_stage_pieces(ejections, 4);
2393            assert!(result.bodies_landed(), "order {order}");
2394            let pieces: Vec<Vec<usize>> = result
2395                .bodies
2396                .iter()
2397                .map(|body| body.pieces.clone())
2398                .collect();
2399            assert_eq!(
2400                pieces,
2401                vec![vec![0], vec![1], vec![2], vec![3]],
2402                "order {order}"
2403            );
2404            let whole_kg = sim.assembly().mass_properties(START_S).mass_kg;
2405            let sum_kg: f64 = result.bodies.iter().map(|body| body.mass_kg).sum();
2406            close(
2407                sum_kg,
2408                whole_kg,
2409                1e-12,
2410                &format!("order {order}: the bodies' mass"),
2411            );
2412            // Each body is its own components: the nose, and the interstage alone.
2413            close(
2414                result.bodies[0].mass_kg,
2415                component_kg(&sim, "nose"),
2416                1e-12,
2417                "the nose cone",
2418            );
2419            let interstage = if order == 0 { 3 } else { 2 };
2420            close(
2421                result.bodies[interstage].mass_kg,
2422                component_kg(&sim, "interstage"),
2423                1e-12,
2424                &format!("order {order}: the interstage"),
2425            );
2426            for kind in [EventKind::Ejection(1), EventKind::Ejection(2)] {
2427                assert!(
2428                    result.bodies[0].event(kind).is_some(),
2429                    "order {order}: {kind:?}"
2430                );
2431                assert!(
2432                    result.bodies[2].event(kind).is_none(),
2433                    "order {order}: {kind:?}"
2434                );
2435            }
2436        }
2437    }
2438
2439    /// The parting error of `ejections` on the payload design, with a device on every body.
2440    fn refused(ejections: Vec<Ejection>) -> SimError {
2441        let devices = (0..=ejections.len())
2442            .map(|body| canopy(body, 0.5, Trigger::Apogee))
2443            .collect();
2444        Simulation::new(
2445            &with_payload(),
2446            "i175",
2447            analytic_environment(UniformAir::sea_level(), G),
2448            Rail::vertical(3.0),
2449            FlightSettings::default(),
2450        )
2451        .unwrap()
2452        .with_recovery(devices)
2453        .unwrap()
2454        .with_ejections(ejections)
2455        .unwrap_err()
2456    }
2457
2458    fn what(error: SimError) -> (&'static str, String) {
2459        match error {
2460            SimError::Parting { what, component } => (what, component),
2461            SimError::Domain { what, .. } => (what, String::new()),
2462            other => panic!("{other:?}"),
2463        }
2464    }
2465
2466    #[test]
2467    fn partings_the_design_cant_make_are_refused() {
2468        let apogee = Trigger::Apogee;
2469        let cases: Vec<(Vec<Ejection>, &str, &str)> = vec![
2470            (
2471                vec![Ejection::aft_of(apogee, "fairing")],
2472                "an ejection names a component the design doesn't have",
2473                "fairing",
2474            ),
2475            (
2476                vec![Ejection::aft_of(apogee, "payload")],
2477                "a joint is aft of a body component, and this one is inside another (a part \
2478                 carried inside is a `Parting::Payload`)",
2479                "payload",
2480            ),
2481            (
2482                vec![Ejection::aft_of(apogee, "sustainer-airframe")],
2483                "a joint aft of the last body component (nothing is aft of it)",
2484                "sustainer-airframe",
2485            ),
2486            (
2487                vec![Ejection::payload(apogee, "sustainer-fins")],
2488                "a payload is carried inside the airframe, and this part is outside it",
2489                "sustainer-fins",
2490            ),
2491            (
2492                vec![Ejection::payload(apogee, "nose")],
2493                "a payload is carried inside the airframe, and this is a body component (a body \
2494                 component leaves at a joint, `Parting::AftOf`)",
2495                "nose",
2496            ),
2497            (
2498                vec![
2499                    Ejection::aft_of(apogee, "nose"),
2500                    Ejection::aft_of(Trigger::Time { time_s: 20.0 }, "nose"),
2501                ],
2502                "two partings at the joint aft of this component (a separation's stage boundary \
2503                 is a joint too)",
2504                "nose",
2505            ),
2506            (
2507                vec![
2508                    Ejection::payload(apogee, "payload"),
2509                    Ejection::payload(apogee, "payload"),
2510                ],
2511                "two ejections of one payload",
2512                "payload",
2513            ),
2514        ];
2515        for (ejections, expected, component) in cases {
2516            assert_eq!(
2517                what(refused(ejections)),
2518                (expected, component.to_owned()),
2519                "{expected}"
2520            );
2521        }
2522
2523        // A payload inside another payload.
2524        let mut rocket = with_payload();
2525        let airframe = &mut rocket.stages[0].components[1];
2526        let mut inner = airframe
2527            .children
2528            .iter()
2529            .find(|child| child.id == "altimeter")
2530            .cloned()
2531            .unwrap();
2532        inner.id = "inner".to_owned();
2533        inner.position = Some(Position::Top { aft_offset_m: 0.0 });
2534        let outer = airframe
2535            .children
2536            .iter_mut()
2537            .find(|child| child.id == "sustainer-motor-mount")
2538            .unwrap();
2539        outer.children.push(inner);
2540        let error = Simulation::new(
2541            &rocket,
2542            "i175",
2543            analytic_environment(UniformAir::sea_level(), G),
2544            Rail::vertical(3.0),
2545            FlightSettings::default(),
2546        )
2547        .unwrap()
2548        .with_ejections(vec![
2549            Ejection::payload(apogee, "sustainer-motor-mount"),
2550            Ejection::payload(apogee, "inner"),
2551        ])
2552        .unwrap_err();
2553        assert_eq!(
2554            what(error),
2555            ("a payload inside another payload", "inner".to_owned())
2556        );
2557
2558        // A pod's body tube is neither a joint nor a payload, though one pod's is a single copy.
2559        let mut rocket = with_payload();
2560        let airframe = &mut rocket.stages[0].components[1];
2561        let mut pod_tube = airframe.clone();
2562        pod_tube.id = "pod-tube".to_owned();
2563        pod_tube.auto.clear();
2564        pod_tube.motor_mount = None;
2565        pod_tube.children.clear();
2566        let mut pods = pod_tube.clone();
2567        pods.id = "pods".to_owned();
2568        pods.part = hpr_design::Part::PodSet(hpr_design::PodSet {
2569            count: 1,
2570            radial_offset_m: 0.2,
2571            angle_rad: 0.0,
2572        });
2573        pods.position = Some(Position::Top { aft_offset_m: 0.0 });
2574        pods.children = vec![pod_tube];
2575        airframe.children.push(pods);
2576        let assembly = rocket.assemble("i175").unwrap();
2577        for ejection in [
2578            Ejection::payload(apogee, "pod-tube"),
2579            Ejection::aft_of(apogee, "pod-tube"),
2580        ] {
2581            let error = Pieces::new(&rocket, &assembly, &[], &[ejection])
2582                .err()
2583                .unwrap();
2584            assert_eq!(
2585                what(error),
2586                (
2587                    "a pod's body component leaves with the tube its pod set hangs from, at no \
2588                     joint of its own and not as a payload",
2589                    "pod-tube".to_owned()
2590                )
2591            );
2592        }
2593
2594        // A stage whose mass is overridden can't be divided between pieces.
2595        let mut rocket = with_payload();
2596        rocket.stages[0].overrides.mass_kg = Some(1.0);
2597        let error = Simulation::new(
2598            &rocket,
2599            "i175",
2600            analytic_environment(UniformAir::sea_level(), G),
2601            Rail::vertical(3.0),
2602            FlightSettings::default(),
2603        )
2604        .unwrap()
2605        .with_ejections(vec![Ejection::payload(apogee, "payload")])
2606        .unwrap_err();
2607        assert_eq!(
2608            what(error),
2609            (
2610                "a parting through a stage whose mass is overridden (the override doesn't say \
2611                 which piece its mass is in)",
2612                "sustainer".to_owned()
2613            )
2614        );
2615
2616        // Nor can an airframe whose override covers what it holds lose a payload from inside.
2617        let mut rocket = with_payload();
2618        rocket.stages[0].components[1].overrides.mass_kg = Some(1.0);
2619        rocket.stages[0].components[1].overrides_include_children = true;
2620        let error = Simulation::new(
2621            &rocket,
2622            "i175",
2623            analytic_environment(UniformAir::sea_level(), G),
2624            Rail::vertical(3.0),
2625            FlightSettings::default(),
2626        )
2627        .unwrap()
2628        .with_ejections(vec![Ejection::payload(apogee, "payload")])
2629        .unwrap_err();
2630        assert_eq!(
2631            what(error),
2632            (
2633                "a parting inside a component whose overridden mass covers what it holds (the \
2634                 override doesn't say which piece that mass is in)",
2635                "sustainer-airframe".to_owned()
2636            )
2637        );
2638    }
2639
2640    #[test]
2641    fn ejections_outside_their_domain_are_refused() {
2642        // During the burn: every body is a point mass of constant mass.
2643        let error = refused(vec![Ejection::aft_of(
2644            Trigger::Time { time_s: 0.5 },
2645            "nose",
2646        )]);
2647        assert_eq!(
2648            what(error).0,
2649            "time of an ejection (every motor must have burned out by then; this is when one of \
2650             them does)"
2651        );
2652        let error = refused(vec![Ejection::aft_of(
2653            Trigger::Altitude {
2654                height_above_ground_m: -1.0,
2655            },
2656            "nose",
2657        )]);
2658        assert_eq!(
2659            what(error).0,
2660            "height above the launch site at which a piece is ejected, m"
2661        );
2662        // A pushed payload leaves forward, and with no joint forward of it that is the nose tip:
2663        // refused when the flight starts, since a separation given later could open one. Without
2664        // a push it may still leave.
2665        let payload_only = |impulse_n_s: f64| {
2666            Simulation::new(
2667                &with_payload(),
2668                "i175",
2669                analytic_environment(UniformAir::sea_level(), G),
2670                Rail::vertical(3.0),
2671                FlightSettings::default(),
2672            )
2673            .unwrap()
2674            .with_recovery(vec![
2675                canopy(0, 0.5, Trigger::Apogee),
2676                canopy(1, 0.5, Trigger::Apogee),
2677            ])
2678            .unwrap()
2679            .with_ejections(vec![
2680                Ejection::payload(Trigger::Apogee, "payload").with_impulse(impulse_n_s),
2681            ])
2682            .unwrap()
2683            .run(&mut ())
2684        };
2685        let (what_, component) = what(payload_only(1.0).unwrap_err());
2686        assert!(
2687            what_.starts_with("a pushed payload leaves forward, and this one is in the nose's")
2688                && component == "payload",
2689            "{what_}"
2690        );
2691        assert!(payload_only(0.0).is_ok());
2692        for impulse_n_s in [-1.0, f64::NAN, f64::INFINITY] {
2693            let error = refused(vec![
2694                Ejection::aft_of(Trigger::Apogee, "nose").with_impulse(impulse_n_s),
2695            ]);
2696            assert!(
2697                matches!(error, SimError::Domain { what, value }
2698                    if what == "impulse of an ejection, N·s (zero or more: it pushes the two \
2699                                sides apart)"
2700                        && (value == impulse_n_s || value.is_nan() && impulse_n_s.is_nan())),
2701                "{error:?}"
2702            );
2703        }
2704
2705        // Every body needs a device, and no device may name a body that isn't made.
2706        let base = || {
2707            Simulation::new(
2708                &with_payload(),
2709                "i175",
2710                analytic_environment(UniformAir::sea_level(), G),
2711                Rail::vertical(3.0),
2712                FlightSettings::default(),
2713            )
2714            .unwrap()
2715        };
2716        let error = base()
2717            .with_recovery(vec![canopy(0, 0.5, Trigger::Apogee)])
2718            .unwrap()
2719            .with_ejections(vec![Ejection::aft_of(Trigger::Apogee, "nose")])
2720            .unwrap_err();
2721        assert!(
2722            matches!(error, SimError::Domain { what, value }
2723                if what.starts_with("recovery devices on a separated body") && value == 1.0),
2724            "{error:?}"
2725        );
2726        // That one only when the flight starts: a separation given later would make body 2.
2727        let error = base()
2728            .with_ejections(vec![Ejection::aft_of(Trigger::Apogee, "nose")])
2729            .unwrap()
2730            .with_recovery(vec![
2731                canopy(0, 0.5, Trigger::Apogee),
2732                canopy(1, 0.5, Trigger::Apogee),
2733                canopy(2, 0.5, Trigger::Apogee),
2734            ])
2735            .unwrap()
2736            .run(&mut ())
2737            .unwrap_err();
2738        assert!(
2739            matches!(error, SimError::Domain { what, value }
2740                if what.starts_with("body a device is attached to") && value == 2.0),
2741            "{error:?}"
2742        );
2743    }
2744
2745    #[test]
2746    fn a_flight_without_partings_is_unchanged() {
2747        // No ejection, no separation: one piece, no bodies, and the same flight to the bit as one
2748        // never given the empty list.
2749        let sim = Simulation::new(
2750            &with_payload(),
2751            "i175",
2752            analytic_environment(UniformAir::sea_level(), G),
2753            Rail::vertical(3.0),
2754            FlightSettings {
2755                max_time_s: 3600.0,
2756                ..FlightSettings::default()
2757            },
2758        )
2759        .unwrap()
2760        .with_recovery(vec![canopy(0, 0.9, Trigger::Apogee)])
2761        .unwrap();
2762        let plain: FlightResult = sim.run(&mut ()).unwrap();
2763        let result = sim
2764            .with_ejections(Vec::new())
2765            .unwrap()
2766            .run(&mut ())
2767            .unwrap();
2768        assert_eq!(result.termination, Termination::GroundHit);
2769        assert!(result.bodies.is_empty());
2770        assert_eq!(result, plain);
2771    }
2772
2773    #[test]
2774    fn an_override_that_covers_only_its_own_part_still_divides() {
2775        // The airframe's own mass overridden, not what it holds: the payload's mass is still its
2776        // own, and the pieces still add up to the rocket.
2777        let mut rocket = with_payload();
2778        rocket.stages[0].components[1].overrides.mass_kg = Some(0.5);
2779        let sim = Simulation::new(
2780            &rocket,
2781            "i175",
2782            analytic_environment(UniformAir::sea_level(), G),
2783            Rail::vertical(3.0),
2784            FlightSettings {
2785                max_time_s: 3600.0,
2786                ..FlightSettings::default()
2787            },
2788        )
2789        .unwrap()
2790        .with_recovery(devices())
2791        .unwrap()
2792        .with_ejections(ejections())
2793        .unwrap();
2794        let start = dropped(&sim, 1_500.0, DVec3::new(0.0, 0.0, -0.5), DVec3::ZERO);
2795        let result = sim.run_free(START_S, start, &mut ()).unwrap();
2796        assert!(result.bodies_landed());
2797        let whole_kg = sim.assembly().mass_properties(START_S).mass_kg;
2798        let sum_kg: f64 = result.bodies.iter().map(|body| body.mass_kg).sum();
2799        close(sum_kg, whole_kg, 1e-12, "the pieces' mass");
2800        close(result.bodies[2].mass_kg, PAYLOAD_KG, 1e-12, "the payload");
2801    }
2802
2803    #[test]
2804    fn refusals_in_flight_name_what_fired() {
2805        // The two-stage design with its sustainer lit by the separation half a second after the
2806        // booster burns out.
2807        let mut rocket = design("synthetic-two-stage-75mm-54mm");
2808        rocket.configurations[0]
2809            .motors
2810            .iter_mut()
2811            .find(|motor| motor.mount == "sustainer-motor-mount")
2812            .unwrap()
2813            .ignition = hpr_design::Ignition::Separation { delay_s: 0.0 };
2814        let build = || {
2815            Simulation::new(
2816                &rocket,
2817                "j760-i175",
2818                analytic_environment(UniformAir::sea_level(), G),
2819                Rail::vertical(6.0),
2820                FlightSettings {
2821                    max_time_s: 3600.0,
2822                    ..FlightSettings::default()
2823                },
2824            )
2825            .unwrap()
2826        };
2827        let booster = build()
2828            .assembly()
2829            .motors
2830            .iter()
2831            .position(|motor| motor.mount == "booster-motor-mount")
2832            .unwrap();
2833        let sustainer = 1 - booster;
2834        let devices = (0..3)
2835            .map(|body| canopy(body, 0.8, Trigger::Time { time_s: 0.0 }))
2836            .collect::<Vec<_>>();
2837        let domain = |error: SimError| match error {
2838            SimError::Domain { what, .. } => what,
2839            other => panic!("{other:?}"),
2840        };
2841
2842        // A powered separation in a flight with ejections: the sustainer's pieces aren't tracked.
2843        let error = build()
2844            .with_recovery(devices.clone())
2845            .unwrap()
2846            .with_separation(Separation::new(
2847                Trigger::Burnout {
2848                    motor: booster,
2849                    delay_s: 0.5,
2850                },
2851                0,
2852            ))
2853            .unwrap()
2854            .with_ejections(vec![Ejection::aft_of(Trigger::Apogee, "nose")])
2855            .unwrap()
2856            .run(&mut ())
2857            .unwrap_err();
2858        assert_eq!(
2859            domain(error),
2860            "time of a powered separation in a flight with ejections (the pieces of a sustainer \
2861             aren't tracked)"
2862        );
2863
2864        // An ejection ahead of a separation that would light the sustainer.
2865        let error = build()
2866            .with_recovery(devices.clone())
2867            .unwrap()
2868            .with_separation(Separation::new(Trigger::Time { time_s: 3_000.0 }, 0))
2869            .unwrap()
2870            .with_ejections(vec![Ejection::aft_of(Trigger::Apogee, "nose")])
2871            .unwrap()
2872            .run(&mut ())
2873            .unwrap_err();
2874        assert_eq!(
2875            domain(error),
2876            "time of an ejection before the separation that lights a motor (the pieces would \
2877             never light it)"
2878        );
2879
2880        // An ejection timed from the sustainer, which has no ignition before the flight.
2881        let error = build()
2882            .with_recovery(devices)
2883            .unwrap()
2884            .with_separation(Separation::new(Trigger::Time { time_s: 3_000.0 }, 0))
2885            .unwrap()
2886            .with_ejections(vec![Ejection::aft_of(
2887                Trigger::MotorDelay { motor: sustainer },
2888                "nose",
2889            )])
2890            .unwrap_err();
2891        assert_eq!(
2892            domain(error),
2893            "index of the motor an ejection is timed from (it has no ignition time before the \
2894             flight, so the ejection could never fire)"
2895        );
2896    }
2897
2898    #[test]
2899    fn an_ejection_reads_and_writes_as_json() {
2900        let ejection = Ejection::payload(
2901            Trigger::Altitude {
2902                height_above_ground_m: 300.0,
2903            },
2904            "payload",
2905        );
2906        let text = serde_json::to_string(&ejection).unwrap();
2907        assert_eq!(
2908            text,
2909            r#"{"trigger":{"altitude":{"height_above_ground_m":300.0}},"parting":{"payload":{"component":"payload"}}}"#
2910        );
2911        assert_eq!(serde_json::from_str::<Ejection>(&text).unwrap(), ejection);
2912        // An impulse is written when there is one, and read back.
2913        let pushed = ejection.clone().with_impulse(0.75);
2914        let text = serde_json::to_string(&pushed).unwrap();
2915        assert!(text.ends_with(r#""impulse_n_s":0.75}"#), "{text}");
2916        assert_eq!(serde_json::from_str::<Ejection>(&text).unwrap(), pushed);
2917        // A misspelt field is refused, inside the parting as well as outside it.
2918        for text in [
2919            r#"{"trigger":"apogee","parting":{"aft_of":{"component":"nose","x":1}}}"#,
2920            r#"{"trigger":"apogee","parting":{"aft_of":{"component":"nose"}},"x":1}"#,
2921        ] {
2922            assert!(serde_json::from_str::<Ejection>(text).is_err(), "{text}");
2923        }
2924        let read: Ejection = serde_json::from_str(
2925            r#"{"trigger":"apogee","parting":{"aft_of":{"component":"nose"}}}"#,
2926        )
2927        .unwrap();
2928        assert_eq!(read, Ejection::aft_of(Trigger::Apogee, "nose"));
2929    }
2930}