Skip to main content

hpr_sim/
releases.rs

1//! Mass released in flight: ballast dropped or a payload let go on a trigger, with the rest of the
2//! rocket flying on in six degrees of freedom and the part falling to the ground on its own.
3//!
4//! A [`MassRelease`] lets one part carried inside the airframe, with everything inside it, leave
5//! at an instant. At that instant the rocket's mass properties step to the rest's
6//! ([`hpr_design::MassProperties::without_part`]), and its state carries straight across: the
7//! state is the nose tip's, which the rest keeps, as a sustainer keeps it at a powered separation.
8//! The part leaves at the velocity its own center of mass had as part of the airframe,
9//! `v_O + ω × c`, so the mass and the momentum of the two together are those of the rocket just
10//! before. The part then falls as a point mass under its own drag area to the ground (the decision
11//! record on released mass, [ADR-088][adr-088]).
12//!
13//! Method: the documentation site's [Released mass][page] page.
14//!
15//! [page]: https://github.com/nrdptel/hpr-sim/blob/main/docs/physics/released-mass.md
16//! [adr-088]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-088-mass-released-in-flight-2026-09-26
17
18use hpr_core::DVec3;
19use hpr_design::{Assembly, MassProperties, Rocket};
20use serde::{Deserialize, Serialize};
21
22use crate::error::SimError;
23use crate::events::Direction;
24use crate::flight::{EventKind, Simulation, Termination};
25use crate::integrator::{Advance, IntegrationError, Integrator, OdeSystem, Stats};
26use crate::recovery::{BodyEvent, BodySample, Trigger};
27use crate::shifts::{NotAPart, NotCarried, check_carried, inside, locate_part};
28
29/// A part carried inside the airframe that leaves it on `trigger`, and then falls to the ground
30/// on its own under `drag_area_m2`.
31///
32/// The part is an internal component named by its id, and it leaves with everything inside it.
33/// It can't be a body component or an external one, one copy of a cluster's, or hold a motor.
34/// Its mass can't be under an override: not its stage's, and not one on a component around it
35/// that covers what that component holds, since the override doesn't say how much of the mass is
36/// the part's. It must have mass, and leave the airframe some, motors aside. A part can be released
37/// once, and not from inside another part that is released.
38///
39/// The drag area `C_D S` is the part's own once it is out, m²: a tumbling weight's, or its
40/// parachute's taken as open at once. It must be positive: a part with none would fall as if in a
41/// vacuum, which is a wrong number rather than a model.
42///
43/// A release can't come before the rocket leaves the rail: the part has nowhere to go on the pad.
44///
45/// ```
46/// use hpr_sim::{MassRelease, Trigger};
47///
48/// // The part with id "payload" leaves at apogee and falls under 0.3 m² of parachute.
49/// let release = MassRelease::new(Trigger::Apogee, "payload", 0.3);
50/// assert_eq!(release.drag_area_m2, 0.3);
51/// ```
52///
53/// Give it to a flight with [`crate::Simulation::with_releases`].
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55#[serde(deny_unknown_fields)]
56#[non_exhaustive]
57pub struct MassRelease {
58    /// When the part leaves: the same triggers as a recovery device's.
59    pub trigger: Trigger,
60    /// The id of the internal component that leaves.
61    pub component: String,
62    /// The part's own drag area `C_D S` once it is out, m²: positive.
63    pub drag_area_m2: f64,
64}
65
66impl MassRelease {
67    /// Internal component `component` leaving on `trigger`, then falling under `drag_area_m2`.
68    #[must_use]
69    pub fn new(trigger: Trigger, component: impl Into<String>, drag_area_m2: f64) -> Self {
70        Self {
71            trigger,
72            component: component.into(),
73            drag_area_m2,
74        }
75    }
76}
77
78/// A released part's own flight, from the instant it left the rocket to its landing.
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80#[non_exhaustive]
81pub struct ReleasedFlight {
82    /// Which release, by its index in the flight's releases ([`crate::Simulation::with_releases`]).
83    pub release: usize,
84    /// The part as it left: its center of mass where it was in the airframe, with the velocity
85    /// that point had, `v_O + ω × c`.
86    pub start_sample: BodySample,
87    /// Why its flight ended.
88    pub termination: Termination,
89    /// Its events, in order: its own [`EventKind::Apogee`] if it left climbing, and its
90    /// [`EventKind::GroundHit`].
91    pub events: Vec<BodyEvent>,
92    /// Where it ended.
93    pub final_sample: BodySample,
94    /// The integrator's work on it.
95    pub stats: Stats,
96}
97
98impl ReleasedFlight {
99    /// Its first event of `kind`.
100    #[must_use]
101    pub fn event(&self, kind: EventKind) -> Option<&BodyEvent> {
102        self.events.iter().find(|event| event.kind == kind)
103    }
104}
105
106/// A flight's releases: each part that leaves, as the design places it.
107#[derive(Debug, Clone, Default)]
108pub(crate) struct Releases {
109    /// Each release's part with everything inside it, where the design puts it.
110    parts: Vec<MassProperties>,
111}
112
113impl Releases {
114    /// The releases of `assembly` (of `rocket`).
115    ///
116    /// # Errors
117    ///
118    /// [`SimError::MassRelease`] for a part that can't be released ([`MassRelease`] says which) or
119    /// that has no mass, or releases that would leave the airframe none, its motors aside, and
120    /// [`SimError::Domain`] for a drag area that is not positive and finite.
121    pub(crate) fn new(
122        rocket: &Rocket,
123        assembly: &Assembly,
124        releases: &[MassRelease],
125    ) -> Result<Self, SimError> {
126        let components = &assembly.layout.components;
127        let refuse = |what: &'static str, component: &str| SimError::MassRelease {
128            what,
129            component: component.to_owned(),
130        };
131        let mut indices: Vec<usize> = Vec::with_capacity(releases.len());
132        for release in releases {
133            let id = release.component.as_str();
134            if !(release.drag_area_m2.is_finite() && release.drag_area_m2 > 0.0) {
135                return Err(SimError::Domain {
136                    what: "drag area of a released part, m² (positive)",
137                    value: release.drag_area_m2,
138                });
139            }
140            let index = locate_part(assembly, id).map_err(|refusal| {
141                refuse(
142                    match refusal {
143                        NotAPart::Missing => {
144                            "a mass release names a component the design doesn't have"
145                        }
146                        NotAPart::BodyComponent => {
147                            "a mass release lets go of a part carried inside the airframe, and \
148                             this is a body component"
149                        }
150                        NotAPart::Outside => {
151                            "a mass release lets go of a part carried inside the airframe, and \
152                             this part is outside it"
153                        }
154                        NotAPart::NotOnePart => {
155                            "a mass release of a part that isn't exactly one part (one of several \
156                             copies in a cluster of tubes, or none)"
157                        }
158                    },
159                    id,
160                )
161            })?;
162            if indices.contains(&index) {
163                return Err(refuse("a mass release of a part released already", id));
164            }
165            indices.push(index);
166        }
167        for &index in &indices {
168            let id = components[index].id.as_str();
169            if indices
170                .iter()
171                .any(|&other| other != index && inside(components, index, other))
172            {
173                return Err(refuse(
174                    "a mass release of a part inside another that is released",
175                    id,
176                ));
177            }
178            check_carried(rocket, assembly, index).map_err(|refusal| {
179                refuse(
180                    match refusal {
181                        NotCarried::HoldsMotor => {
182                            "a mass release of a part that holds a motor (the rocket's motors \
183                             stay with it)"
184                        }
185                        NotCarried::StageOverride => {
186                            "a mass release in a stage whose mass is overridden (the override \
187                             doesn't say how much of it is the part's)"
188                        }
189                        NotCarried::CoveredOverride => {
190                            "a mass release inside a component whose overridden mass covers what \
191                             it holds (the override doesn't say how much of it is the part's)"
192                        }
193                    },
194                    id,
195                )
196            })?;
197        }
198        let parts: Vec<MassProperties> = indices
199            .iter()
200            .map(|&index| components[index].with_children)
201            .collect();
202        let mut rest_kg = assembly.layout.structure.mass_kg;
203        for (part, release) in parts.iter().zip(releases) {
204            // A part with no mass would fall with no weight to carry it through the air.
205            if !(part.mass_kg.is_finite() && part.mass_kg > 0.0) {
206                return Err(refuse(
207                    "a mass release of a part with no mass",
208                    &release.component,
209                ));
210            }
211            rest_kg -= part.mass_kg;
212            if rest_kg <= 0.0 {
213                return Err(refuse(
214                    "a mass release that leaves the airframe, its motors aside, with no mass",
215                    &release.component,
216                ));
217            }
218        }
219        Ok(Self { parts })
220    }
221
222    /// Release `index`'s part with everything inside it, where the design puts it, in body axes.
223    pub(crate) fn part(&self, index: usize) -> &MassProperties {
224        &self.parts[index]
225    }
226}
227
228/// A released part as the integrator sees it: a point mass under its own drag area, with its
229/// center of mass and that point's velocity as the state.
230///
231/// The equations are a separated body's (`docs/physics/recovery.md`) with a fixed drag area:
232/// `m a = −½ ρ (C_D S) |v − w| (v − w) + m (g + a_Coriolis)`.
233struct PartSystem<'a> {
234    simulation: &'a Simulation,
235    mass_kg: f64,
236    drag_area_m2: f64,
237    /// Whether it is watching for its own apogee, which is event 1 when it is.
238    apogee: bool,
239    failure: Option<SimError>,
240}
241
242impl OdeSystem<6> for PartSystem<'_> {
243    type Error = SimError;
244
245    fn derivative(&mut self, _t_s: f64, y: &[f64; 6]) -> Result<[f64; 6], SimError> {
246        self.simulation
247            .point_mass_derivative(y, self.mass_kg, self.drag_area_m2)
248    }
249
250    fn event_count(&self) -> usize {
251        1 + usize::from(self.apogee)
252    }
253
254    fn event_direction(&self, _index: usize) -> Direction {
255        Direction::Falling
256    }
257
258    fn event_value(&mut self, index: usize, t_s: f64, y: &[f64; 6]) -> f64 {
259        match self
260            .simulation
261            .point_sample(t_s, y, self.mass_kg, self.drag_area_m2)
262        {
263            // The ground, then its apogee if it is looking for one.
264            Ok(sample) if index == 0 => sample.height_above_ground_m,
265            Ok(sample) => sample.vertical_speed_m_s,
266            Err(error) => {
267                self.failure.get_or_insert(error);
268                f64::NAN
269            }
270        }
271    }
272}
273
274impl Simulation {
275    /// Flies release `release`'s part, of `mass_kg`, from `t0` with its center of mass at
276    /// `cg_enu_m` moving at `velocity_enu_m_s`, to the ground or the time cap.
277    pub(crate) fn fly_released(
278        &self,
279        release: usize,
280        t0: f64,
281        (cg_enu_m, velocity_enu_m_s): (DVec3, DVec3),
282        mass_kg: f64,
283    ) -> Result<ReleasedFlight, SimError> {
284        let settings = self.settings();
285        let drag_area_m2 = self.releases()[release].drag_area_m2;
286        let start = [
287            cg_enu_m.x,
288            cg_enu_m.y,
289            cg_enu_m.z,
290            velocity_enu_m_s.x,
291            velocity_enu_m_s.y,
292            velocity_enu_m_s.z,
293        ];
294        let start_sample = self.point_sample(t0, &start, mass_kg, drag_area_m2)?;
295        if start_sample.height_above_ground_m <= 0.0 {
296            // Let go as the rocket reaches the ground, with its center at or below the site: it
297            // has landed. The ground event is a falling crossing, so it would otherwise integrate
298            // underground to the time cap.
299            return Ok(ReleasedFlight {
300                release,
301                start_sample,
302                termination: Termination::GroundHit,
303                events: vec![BodyEvent {
304                    kind: EventKind::GroundHit,
305                    sample: start_sample,
306                    after: None,
307                }],
308                final_sample: start_sample,
309                stats: Stats::default(),
310            });
311        }
312        let mut integrator =
313            Integrator::new(settings.method, t0, start)?.with_step_limit(settings.step_limit);
314        let cap = settings.max_time_s;
315        let mut events: Vec<BodyEvent> = Vec::new();
316        let termination = loop {
317            if integrator.time_s() >= cap {
318                break Termination::TimeCap;
319            }
320            let apogee = events.is_empty() && start_sample.vertical_speed_m_s > 0.0;
321            let mut system = PartSystem {
322                simulation: self,
323                mass_kg,
324                drag_area_m2,
325                apogee,
326                failure: None,
327            };
328            let outcome = integrator.advance(&mut system, cap);
329            if let Some(error) = system.failure.take() {
330                return Err(error);
331            }
332            let outcome = match outcome {
333                Ok(outcome) => outcome,
334                Err(IntegrationError::StepLimit { .. }) => break Termination::StepLimit,
335                Err(IntegrationError::Derivative { source, .. }) => return Err(source),
336                Err(error) => return Err(SimError::Integration(Box::new(error))),
337            };
338            if let Advance::Events = outcome {
339                let fired = integrator.fired_events().to_vec();
340                let (t, y) = (integrator.time_s(), *integrator.state());
341                let sample = self.point_sample(t, &y, mass_kg, drag_area_m2)?;
342                if apogee && fired.contains(&1) {
343                    events.push(BodyEvent {
344                        kind: EventKind::Apogee,
345                        sample,
346                        after: None,
347                    });
348                }
349                if fired.contains(&0) {
350                    events.push(BodyEvent {
351                        kind: EventKind::GroundHit,
352                        sample,
353                        after: None,
354                    });
355                    break Termination::GroundHit;
356                }
357            }
358        };
359        let final_sample = self.point_sample(
360            integrator.time_s(),
361            integrator.state(),
362            mass_kg,
363            drag_area_m2,
364        )?;
365        Ok(ReleasedFlight {
366            release,
367            start_sample,
368            termination,
369            events,
370            final_sample,
371            stats: integrator.stats(),
372        })
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use hpr_core::DMat3;
379    use hpr_design::Part;
380
381    use super::*;
382    use crate::MassShift;
383    use crate::flight::{FlightSettings, Simulation};
384    use crate::integrator::{Adaptive, Method};
385    use crate::pieces::Ejection;
386    use crate::rail::Rail;
387    use crate::recorder::Sample;
388    use crate::recovery::{Device, DeviceDrag, Separation, terminal_speed_m_s};
389    use crate::state::State;
390    use crate::testing::{
391        BALLAST_KG, Ends, UniformAir, analytic_environment, design, with_ballast, with_sleeve,
392    };
393
394    const G: f64 = 9.806_65;
395    /// The release: 5 s after launch, well after the I175's burnout at 2.5 s.
396    const RELEASE_S: f64 = 5.0;
397    /// The ballast's own drag area once it is out, m².
398    const PART_DRAG_AREA_M2: f64 = 0.01;
399
400    fn simulation(
401        rocket: &Rocket,
402        air: UniformAir,
403        g: f64,
404        settings: FlightSettings,
405    ) -> Simulation {
406        Simulation::new(
407            rocket,
408            "i175",
409            analytic_environment(air, g),
410            Rail::vertical(3.0),
411            settings,
412        )
413        .unwrap()
414    }
415
416    fn release(trigger: Trigger) -> MassRelease {
417        MassRelease::new(trigger, "ballast", PART_DRAG_AREA_M2)
418    }
419
420    fn tight() -> Method {
421        Method::DormandPrince54(Adaptive {
422            relative_tolerance: 1e-12,
423            absolute_tolerance: 1e-12,
424            ..Adaptive::default()
425        })
426    }
427
428    /// The ballast, with what it holds, as the design places it.
429    fn ballast(sim: &Simulation) -> MassProperties {
430        sim.assembly()
431            .layout
432            .find("ballast")
433            .unwrap()
434            .1
435            .with_children
436    }
437
438    fn close(a: f64, b: f64, tolerance: f64, what: &str) {
439        assert!(
440            (a - b).abs() <= tolerance,
441            "{what}: {a} vs {b} ({:e})",
442            a - b
443        );
444    }
445
446    fn close_vec(a: DVec3, b: DVec3, tolerance: f64, what: &str) {
447        assert!(
448            (a - b).length() <= tolerance,
449            "{what}: {a:?} vs {b:?} ({:e})",
450            (a - b).length()
451        );
452    }
453
454    #[test]
455    fn mass_properties_after_a_release_match_the_hand_calculation() {
456        let sim = simulation(
457            &with_ballast(0.01),
458            UniformAir::sea_level(),
459            G,
460            FlightSettings::default(),
461        )
462        .with_releases(vec![release(Trigger::Time { time_s: RELEASE_S })])
463        .unwrap();
464        let mut ends = Ends::default();
465        let result = sim.run(&mut ends).unwrap();
466        let event = result.event(EventKind::MassRelease(0)).unwrap().sample;
467        assert_eq!(event.time_s, RELEASE_S);
468
469        // By hand, as two bodies: the part (m, center c, own inertia I_p) and the rest (M − m).
470        // The rest's center is (M cg − m c)/(M − m). About the whole's center the inertia is the
471        // two bodies' own plus μ(|L|² E − L Lᵀ), with μ = m(M − m)/M the reduced mass and L the
472        // vector from the rest's center to the part's; so the rest's own is what is left.
473        let part = ballast(&sim);
474        let whole = sim.assembly().mass_properties(RELEASE_S);
475        let (m, big_m) = (part.mass_kg, whole.mass_kg);
476        assert_eq!(m, BALLAST_KG);
477        let rest_kg = big_m - m;
478        let rest_cg = (whole.cg_m * big_m - part.cg_m * m) / rest_kg;
479        let l = part.cg_m - rest_cg;
480        let mu = m * rest_kg / big_m;
481        let pair = DMat3::from_diagonal(DVec3::splat(l.length_squared()))
482            - DMat3::from_cols(l * l.x, l * l.y, l * l.z);
483        let rest_inertia = whole.inertia_kg_m2 - part.inertia_kg_m2 - pair * mu;
484
485        // Before, the design's; at the release and after, the rest's.
486        assert_eq!(
487            sim.mass_properties(&result, RELEASE_S - 1e-3),
488            sim.assembly().mass_properties(RELEASE_S - 1e-3)
489        );
490        for t_s in [RELEASE_S, RELEASE_S + 1.0] {
491            let got = sim.mass_properties(&result, t_s);
492            let what = format!("at {t_s} s");
493            close(got.mass_kg, rest_kg, 1e-15, &what);
494            close_vec(got.cg_m, rest_cg, 1e-15, &what);
495            let diff = (got.inertia_kg_m2 - rest_inertia)
496                .to_cols_array()
497                .iter()
498                .fold(0.0_f64, |most, v| most.max(v.abs()));
499            assert!(diff <= 1e-15, "{what}: inertia off by {diff:e}");
500        }
501
502        // And the design built without the ballast, which shares no arithmetic with either.
503        let mut without = with_ballast(0.01);
504        without.stages[0].components[1]
505            .children
506            .retain(|child| child.id != "ballast");
507        let built = lenient(&without)
508            .assembly()
509            .mass_properties(RELEASE_S + 1.0);
510        let got = sim.mass_properties(&result, RELEASE_S + 1.0);
511        close(got.mass_kg, built.mass_kg, 1e-15, "built without it");
512        close_vec(got.cg_m, built.cg_m, 1e-15, "built without it");
513        let diff = (got.inertia_kg_m2 - built.inertia_kg_m2)
514            .to_cols_array()
515            .iter()
516            .fold(0.0_f64, |most, v| most.max(v.abs()));
517        assert!(diff <= 1e-15, "built without it: inertia off by {diff:e}");
518
519        // The flight flew them: each step's mass, and its center where the rest's is.
520        for sample in &ends.0 {
521            let expected = if sample.time_s <= RELEASE_S {
522                sim.assembly().mass_properties(sample.time_s).mass_kg
523            } else {
524                rest_kg
525            };
526            close(sample.mass_kg, expected, 1e-15, "a step's mass");
527            if sample.time_s > RELEASE_S {
528                close_vec(
529                    sample.cg_enu_m,
530                    sample.state.point_enu_m(rest_cg),
531                    1e-12,
532                    "a step's center",
533                );
534            }
535        }
536
537        // The part leaves from where it was, with the velocity that point had, and lands.
538        assert_eq!(result.released.len(), 1);
539        let flown = &result.released[0];
540        assert_eq!(flown.release, 0);
541        let start = flown.start_sample;
542        assert_eq!(start.time_s, RELEASE_S);
543        assert_eq!(start.mass_kg, m);
544        assert_eq!(start.recovery_drag_area_m2, PART_DRAG_AREA_M2);
545        let state = event.state;
546        close_vec(
547            start.cg_enu_m,
548            state.point_enu_m(part.cg_m),
549            1e-12,
550            "the part's start",
551        );
552        close_vec(
553            start.cg_velocity_enu_m_s,
554            state.velocity_enu_m_s
555                + state
556                    .unit_attitude()
557                    .mul_vec3(state.body_rate_rad_s.cross(part.cg_m)),
558            1e-12,
559            "the part's velocity",
560        );
561        assert_eq!(flown.termination, Termination::GroundHit);
562        assert!(flown.final_sample.height_above_ground_m.abs() < 1e-6);
563        assert_eq!(result.termination, Termination::GroundHit);
564    }
565
566    #[test]
567    fn a_release_conserves_mass_and_momentum_in_free_space() {
568        // No air and no gravity, the motor spent, the rocket turning about all three axes: nothing
569        // acts on the rocket or on the part once it is out, so the two together keep the momentum
570        // and the angular momentum the rocket had. The ballast is 1 cm off the axis, so it leaves
571        // with a velocity across the rocket's and carries angular momentum away.
572        let t0 = 10.0;
573        let release_s = t0 + 0.5;
574        let sim = simulation(
575            &with_ballast(0.01),
576            UniformAir::vacuum(),
577            0.0,
578            FlightSettings {
579                method: tight(),
580                max_time_s: t0 + 2.0,
581                ..FlightSettings::default()
582            },
583        )
584        .with_releases(vec![release(Trigger::Time { time_s: release_s })])
585        .unwrap();
586        let attitude = Rail::vertical(3.0).attitude();
587        let cg_m = sim.assembly().mass_properties(t0).cg_m;
588        let state = State {
589            position_enu_m: DVec3::new(0.0, 0.0, 1000.0) - attitude.mul_vec3(cg_m),
590            velocity_enu_m_s: DVec3::new(3.0, -2.0, 10.0),
591            attitude,
592            body_rate_rad_s: DVec3::new(0.5, 0.2, 3.0),
593        };
594        let mut ends = Ends::default();
595        let result = sim.run_free(t0, state, &mut ends).unwrap();
596        assert_eq!(result.termination, Termination::TimeCap);
597        let before = result.event(EventKind::MassRelease(0)).unwrap().sample;
598        assert_eq!(before.time_s, release_s);
599        let flown = &result.released[0];
600        assert_eq!(flown.termination, Termination::TimeCap);
601        let part = ballast(&sim);
602        let whole = sim.assembly().mass_properties(t0);
603        let rest = sim.mass_properties(&result, release_s);
604
605        // Mass: the rest and the part make the rocket.
606        close(
607            rest.mass_kg + flown.start_sample.mass_kg,
608            whole.mass_kg,
609            1e-15,
610            "mass",
611        );
612        // About the center of mass of the rocket, then of the rest and the part together, which
613        // moves at the rocket's velocity `V` from where the rocket's center was at the release:
614        // `Σ m (x − X) × (v − V)` plus each body's spin `R I ω`. At the release it is the rocket's
615        // spin alone. The part flies as a point mass, so its own spin, `R I_p ω` at the release,
616        // is carried as it was.
617        let spin = |inertia: DMat3, sample: &Sample| {
618            sample
619                .state
620                .unit_attitude()
621                .mul_vec3(inertia * sample.state.body_rate_rad_s)
622        };
623        let (origin, center_velocity) = (before.cg_enu_m, before.cg_velocity_enu_m_s);
624        let momentum0 = center_velocity * whole.mass_kg;
625        let angular0 = spin(whole.inertia_kg_m2, &before);
626        let part_spin = spin(part.inertia_kg_m2, &before);
627        let v_part = flown.start_sample.cg_velocity_enu_m_s;
628        let (mut linear_error, mut angular_error, mut after) = (0.0_f64, 0.0_f64, 0);
629        for sample in ends.0.iter().filter(|sample| sample.time_s > release_s) {
630            after += 1;
631            let x_part = flown.start_sample.cg_enu_m + v_part * (sample.time_s - release_s);
632            let momentum = sample.cg_velocity_enu_m_s * rest.mass_kg + v_part * part.mass_kg;
633            let center = origin + center_velocity * (sample.time_s - release_s);
634            let angular = (sample.cg_enu_m - center)
635                .cross((sample.cg_velocity_enu_m_s - center_velocity) * rest.mass_kg)
636                + spin(rest.inertia_kg_m2, sample)
637                + (x_part - center).cross((v_part - center_velocity) * part.mass_kg)
638                + part_spin;
639            linear_error = linear_error.max((momentum - momentum0).length() / momentum0.length());
640            angular_error = angular_error.max((angular - angular0).length() / angular0.length());
641        }
642        assert!(after > 10, "{after} steps after the release");
643        // Measured over 213 steps: 1.5e-13 of the momentum and 7.3e-12 of the angular momentum.
644        assert!(linear_error < 1e-12, "momentum: {linear_error:e}");
645        assert!(angular_error < 1e-10, "angular momentum: {angular_error:e}");
646        // The part keeps its velocity with nothing acting on it.
647        close_vec(
648            flown.final_sample.cg_velocity_enu_m_s,
649            v_part,
650            1e-12,
651            "the part's final velocity",
652        );
653        // `ω × c` matters here: the part leaves 0.146 m/s from the rocket center's velocity, so
654        // leaving at the nose tip's velocity would put the momentum off by 5.0e-3 of itself, and
655        // at the center's by 3.4e-3.
656        let off = |v: DVec3| (v - v_part).length() * part.mass_kg / momentum0.length();
657        let (nose, center) = (
658            off(before.state.velocity_enu_m_s),
659            off(before.cg_velocity_enu_m_s),
660        );
661        assert!(nose > 1e-3 && center > 1e-3, "{nose:e}, {center:e}");
662        // The part's own spin, which a point mass doesn't carry on, against the rocket's:
663        // measured 1.5e-3.
664        assert!(part_spin.length() / angular0.length() < 3e-3);
665
666        // A flight that starts after the release starts without the part, and records nothing.
667        let later = sim.run_free(t0 + 1.0, state, &mut ()).unwrap();
668        assert!(later.event(EventKind::MassRelease(0)).is_none());
669        assert!(later.released.is_empty());
670        close(later.final_sample.mass_kg, rest.mass_kg, 1e-15, "later");
671    }
672
673    #[test]
674    fn a_payload_let_go_under_the_drogue_lands_slower_and_falls_at_its_own_speed() {
675        // In uniform sea-level air, a 0.3 m² drogue opens at apogee and the ballast leaves at
676        // 150 m on the way down, under 0.05 m² of its own. Each settles at its own terminal speed
677        // √(2 m g / (ρ C_D S)): the rest under the drogue, lighter than the rocket was, and the
678        // ballast on its own. A speed nears its terminal one at the rate 2g/v_t, so the 20 s of
679        // the fall bring the 8 m/s ballast to it to far below the tolerance.
680        let drogue = Device::new(
681            "drogue",
682            DeviceDrag::DragArea { cd_s_m2: 0.3 },
683            Trigger::Apogee,
684        );
685        let sim = simulation(
686            &with_ballast(0.0),
687            UniformAir::sea_level(),
688            G,
689            FlightSettings {
690                method: tight(),
691                ..FlightSettings::default()
692            },
693        )
694        .with_recovery(vec![drogue])
695        .unwrap()
696        .with_releases(vec![MassRelease::new(
697            Trigger::Altitude {
698                height_above_ground_m: 150.0,
699            },
700            "ballast",
701            0.05,
702        )])
703        .unwrap();
704        let result = sim.run(&mut ()).unwrap();
705        let rho = UniformAir::sea_level().0.density_kg_m3;
706        let event = result.event(EventKind::MassRelease(0)).unwrap().sample;
707        close(event.height_above_ground_m, 150.0, 1e-6, "release height");
708        let whole = sim.assembly().mass_properties(event.time_s);
709        let rest = sim.mass_properties(&result, event.time_s);
710        // Before: the whole rocket's terminal speed under the drogue.
711        close(
712            event.cg_velocity_enu_m_s.length(),
713            terminal_speed_m_s(whole.mass_kg, 0.3, rho, G),
714            1e-6,
715            "before",
716        );
717        assert_eq!(result.termination, Termination::GroundHit);
718        close(
719            result.final_sample.cg_velocity_enu_m_s.length(),
720            terminal_speed_m_s(rest.mass_kg, 0.3, rho, G),
721            1e-6,
722            "the rest",
723        );
724        let flown = &result.released[0];
725        assert_eq!(flown.termination, Termination::GroundHit);
726        // It left falling, so it has no apogee of its own.
727        assert_eq!(flown.events.len(), 1);
728        assert_eq!(flown.events[0].kind, EventKind::GroundHit);
729        close(
730            flown.final_sample.cg_velocity_enu_m_s.length(),
731            terminal_speed_m_s(BALLAST_KG, 0.05, rho, G),
732            1e-6,
733            "the part",
734        );
735    }
736
737    #[test]
738    fn a_release_comes_at_apogee_and_a_part_let_go_climbing_has_its_own() {
739        let sim = |trigger: Trigger| {
740            simulation(
741                &with_ballast(0.0),
742                UniformAir::sea_level(),
743                G,
744                FlightSettings::default(),
745            )
746            .with_releases(vec![release(trigger)])
747            .unwrap()
748        };
749        let result = sim(Trigger::Apogee).run(&mut ()).unwrap();
750        let apogee = result.event(EventKind::Apogee).unwrap().sample.time_s;
751        let released = result.event(EventKind::MassRelease(0)).unwrap().sample;
752        assert_eq!(released.time_s, apogee);
753        // Let go climbing, 1 s after burnout, the part rises to an apogee of its own.
754        let result = sim(Trigger::Burnout {
755            motor: 0,
756            delay_s: 1.0,
757        })
758        .run(&mut ())
759        .unwrap();
760        let flown = &result.released[0];
761        assert!(flown.start_sample.vertical_speed_m_s > 0.0);
762        let kinds: Vec<EventKind> = flown.events.iter().map(|event| event.kind).collect();
763        assert_eq!(kinds, [EventKind::Apogee, EventKind::GroundHit]);
764        assert!(flown.events[0].sample.vertical_speed_m_s.abs() < 1e-6);
765    }
766
767    /// The `what` and the component of a refused release, or the error when it is another kind.
768    fn release_refusal(sim: Simulation, releases: Vec<MassRelease>) -> (&'static str, String) {
769        match sim.with_releases(releases) {
770            Err(SimError::MassRelease { what, component }) => (what, component),
771            other => panic!("{other:?}"),
772        }
773    }
774
775    /// Flies `rocket` with its design checks' errors accepted: these tests are of the releases.
776    fn lenient(rocket: &Rocket) -> Simulation {
777        simulation(
778            rocket,
779            UniformAir::sea_level(),
780            G,
781            FlightSettings {
782                accept_design_errors: true,
783                ..FlightSettings::default()
784            },
785        )
786    }
787
788    #[test]
789    fn releases_that_cannot_be_made_are_refused() {
790        let time = Trigger::Time { time_s: RELEASE_S };
791        let of = |id: &str| MassRelease::new(time, id, PART_DRAG_AREA_M2);
792        let refused = |releases: Vec<MassRelease>, rocket: &Rocket, starts: &str, id: &str| {
793            let (what, component) = release_refusal(lenient(rocket), releases);
794            assert!(what.starts_with(starts), "{id}: {what}");
795            assert_eq!(component, id);
796        };
797        let ballasted = with_ballast(0.0);
798        refused(
799            vec![of("no-such-part")],
800            &ballasted,
801            "a mass release names a component",
802            "no-such-part",
803        );
804        refused(
805            vec![of("sustainer-airframe")],
806            &ballasted,
807            "a mass release lets go of a part carried inside the airframe, and this is a body",
808            "sustainer-airframe",
809        );
810        refused(
811            vec![of("sustainer-rail-buttons")],
812            &ballasted,
813            "a mass release lets go of a part carried inside the airframe, and this part is \
814             outside",
815            "sustainer-rail-buttons",
816        );
817        refused(
818            vec![of("sustainer-motor-mount")],
819            &ballasted,
820            "a mass release of a part that holds a motor",
821            "sustainer-motor-mount",
822        );
823        refused(
824            vec![of("ballast"), of("ballast")],
825            &ballasted,
826            "a mass release of a part released already",
827            "ballast",
828        );
829        // A part inside another that is released; the sleeve could leave on its own.
830        lenient(&with_sleeve())
831            .with_releases(vec![of("sleeve")])
832            .unwrap();
833        refused(
834            vec![of("sleeve"), of("held")],
835            &with_sleeve(),
836            "a mass release of a part inside another that is released",
837            "held",
838        );
839        // One of a cluster's copies.
840        let mut clustered = with_sleeve();
841        let sleeve = clustered.stages[0].components[1]
842            .children
843            .iter_mut()
844            .find(|child| child.id == "sleeve")
845            .unwrap();
846        let Part::InnerTube(tube) = &mut sleeve.part else {
847            panic!("the motor mount is an inner tube");
848        };
849        tube.cluster_m = vec![[0.004, 0.0], [-0.004, 0.0]];
850        refused(
851            vec![of("held")],
852            &clustered,
853            "a mass release of a part that isn't exactly one",
854            "held",
855        );
856        // A stage whose mass is overridden.
857        let mut overridden = with_ballast(0.0);
858        overridden.stages[0].overrides.mass_kg = Some(1.0);
859        refused(
860            vec![of("ballast")],
861            &overridden,
862            "a mass release in a stage whose mass is overridden",
863            "ballast",
864        );
865        // A holder whose overridden mass covers what it holds; one covering itself alone is fine.
866        for covers_children in [false, true] {
867            let mut overridden = with_ballast(0.0);
868            let airframe = &mut overridden.stages[0].components[1];
869            airframe.overrides.mass_kg = Some(0.5);
870            airframe.overrides_include_children = covers_children;
871            let result = lenient(&overridden).with_releases(vec![of("ballast")]);
872            if covers_children {
873                assert!(
874                    matches!(&result, Err(SimError::MassRelease { what, component })
875                        if what.starts_with("a mass release inside a component whose overridden")
876                            && component == "ballast"),
877                    "{result:?}"
878                );
879            } else {
880                result.unwrap();
881            }
882        }
883        // A drag area that is not positive and finite.
884        for area in [0.0, -0.1, f64::NAN, f64::INFINITY] {
885            let result =
886                lenient(&ballasted).with_releases(vec![MassRelease::new(time, "ballast", area)]);
887            assert!(
888                matches!(&result, Err(SimError::Domain { what, .. })
889                    if what.starts_with("drag area of a released part")),
890                "{area}: {result:?}"
891            );
892        }
893    }
894
895    #[test]
896    fn triggers_a_release_cannot_have_are_refused_in_its_own_words() {
897        let domain = |result: Result<Simulation, SimError>| match result {
898            Err(SimError::Domain { what, .. }) => what,
899            other => panic!("{other:?}"),
900        };
901        let sim = || lenient(&with_ballast(0.0));
902        for (trigger, starts) in [
903            (
904                Trigger::Altitude {
905                    height_above_ground_m: -1.0,
906                },
907                "height above the launch site at which a part is released",
908            ),
909            (
910                Trigger::Time { time_s: -1.0 },
911                "time of a mass release after launch",
912            ),
913            (
914                Trigger::MotorDelay { motor: 3 },
915                "index of the motor whose delay releases a part",
916            ),
917        ] {
918            let what = domain(sim().with_releases(vec![release(trigger)]));
919            assert!(what.starts_with(starts), "{what}");
920        }
921        let mut plugged = with_ballast(0.0);
922        plugged.configurations[0].motors[0].delay = None;
923        let what = domain(
924            lenient(&plugged).with_releases(vec![release(Trigger::MotorDelay { motor: 0 })]),
925        );
926        assert!(
927            what.starts_with("the motor whose delay releases a part has no ejection"),
928            "{what}"
929        );
930        let mut failed = with_ballast(0.0);
931        failed.configurations[0].motors[0].failed_tubes = vec![0];
932        let what = domain(
933            lenient(&failed).with_releases(vec![release(Trigger::Burnout {
934                motor: 0,
935                delay_s: 1.0,
936            })]),
937        );
938        assert!(
939            what.starts_with("index of the motor a mass release is timed from"),
940            "{what}"
941        );
942        // A release that would come on the pad or the rail is refused when it comes.
943        for time_s in [0.0, 0.1] {
944            let error = sim()
945                .with_releases(vec![release(Trigger::Time { time_s })])
946                .unwrap()
947                .run(&mut ())
948                .unwrap_err();
949            assert!(
950                matches!(&error, SimError::Domain { what, value }
951                    if what.starts_with("time of a mass release, s (it must come once")
952                        && *value == time_s),
953                "{error:?}"
954            );
955        }
956    }
957
958    #[test]
959    fn a_release_is_refused_with_partings_or_shifts_in_either_order() {
960        let time = Trigger::Time { time_s: RELEASE_S };
961        let unsupported = |result: Result<Simulation, SimError>| {
962            assert!(
963                matches!(&result, Err(SimError::Unsupported { what })
964                    if what.starts_with("a mass release in a flight with")),
965                "{result:?}"
966            );
967        };
968        let sim = || lenient(&with_ballast(0.0));
969        let shift = MassShift::new(time, "ballast", 0.1, 1.0);
970        let ejection = Ejection::aft_of(Trigger::Apogee, "nose");
971        unsupported(
972            sim()
973                .with_releases(vec![release(time)])
974                .unwrap()
975                .with_shifts(vec![shift.clone()]),
976        );
977        unsupported(
978            sim()
979                .with_shifts(vec![shift])
980                .unwrap()
981                .with_releases(vec![release(time)]),
982        );
983        unsupported(
984            sim()
985                .with_releases(vec![release(time)])
986                .unwrap()
987                .with_ejections(vec![ejection.clone()]),
988        );
989        unsupported(
990            sim()
991                .with_ejections(vec![ejection])
992                .unwrap()
993                .with_releases(vec![release(time)]),
994        );
995        unsupported(
996            sim()
997                .with_releases(vec![release(time)])
998                .unwrap()
999                .with_separation(Separation::new(Trigger::Apogee, 0)),
1000        );
1001        let two_stage = Simulation::new(
1002            &design("synthetic-two-stage-75mm-54mm"),
1003            "j760-i175",
1004            analytic_environment(UniformAir::sea_level(), G),
1005            Rail::vertical(3.0),
1006            FlightSettings::default(),
1007        )
1008        .unwrap()
1009        .with_separation(Separation::new(Trigger::Apogee, 0))
1010        .unwrap();
1011        unsupported(two_stage.with_releases(vec![release(time)]));
1012    }
1013
1014    /// The flight's apogee events.
1015    fn apogees(result: &crate::FlightResult) -> usize {
1016        result
1017            .events
1018            .iter()
1019            .filter(|event| event.kind == EventKind::Apogee)
1020            .count()
1021    }
1022
1023    #[test]
1024    fn a_release_at_apogee_on_a_tilted_rail_leaves_one_apogee() {
1025        // Off a rail 5° from vertical, in a 5 m/s wind, the rocket turns at its apogee. The rest's
1026        // center sits aft of the rocket's, so it rises at ω × Δ for a moment after the part
1027        // leaves; the flight still has one apogee, the rocket's.
1028        let rail = Rail {
1029            elevation_rad: 85f64.to_radians(),
1030            ..Rail::vertical(3.0)
1031        };
1032        for drogue in [false, true] {
1033            let mut sim = Simulation::new(
1034                &with_ballast(0.0),
1035                "i175",
1036                crate::testing::analytic_wind_environment(
1037                    UniformAir::sea_level(),
1038                    G,
1039                    hpr_atmos::ConstantWind::new(5.0, 1.5 * std::f64::consts::PI).unwrap(),
1040                ),
1041                rail,
1042                FlightSettings::default(),
1043            )
1044            .unwrap();
1045            if drogue {
1046                sim = sim
1047                    .with_recovery(vec![Device::new(
1048                        "drogue",
1049                        DeviceDrag::DragArea { cd_s_m2: 0.3 },
1050                        Trigger::Apogee,
1051                    )])
1052                    .unwrap();
1053            }
1054            let result = sim
1055                .with_releases(vec![release(Trigger::Apogee)])
1056                .unwrap()
1057                .run(&mut ())
1058                .unwrap();
1059            assert_eq!(apogees(&result), 1, "drogue {drogue}: {:?}", result.events);
1060            let apogee = result.event(EventKind::Apogee).unwrap().sample.time_s;
1061            let left = result
1062                .event(EventKind::MassRelease(0))
1063                .unwrap()
1064                .sample
1065                .time_s;
1066            assert_eq!(left, apogee);
1067            assert_eq!(result.termination, Termination::GroundHit);
1068        }
1069    }
1070
1071    #[test]
1072    fn a_part_let_go_just_before_apogee_can_make_the_apogee_there() {
1073        // In a vacuum, the rocket pitched 45° and turning about a transverse axis, its center
1074        // rising at half the speed the release adds to the rest's center downward (ω × Δ, Δ the
1075        // center's step aft). Before the ballast leaves the rocket is still rising; after, the
1076        // rest is already falling, so the apogee, and the drogue it fires, are at the release.
1077        // With a second part waiting for the apogee, listed either side of the first, it leaves
1078        // there too, after the apogee is recorded on the rest without the first.
1079        let t0 = 10.0;
1080        let tilted = Rail {
1081            elevation_rad: 45f64.to_radians(),
1082            ..Rail::vertical(3.0)
1083        };
1084        let timed = release(Trigger::Time { time_s: t0 });
1085        let waiting = MassRelease::new(Trigger::Apogee, "sleeve", PART_DRAG_AREA_M2);
1086        // Each case: the design, its releases, the timed one's index, and a bound on the setup's
1087        // downward jump of the rest's center, m/s, which the rocket's rise is half of.
1088        for (rocket, releases, timed_index, bound) in [
1089            (with_ballast(0.0), vec![timed.clone()], 0, -0.05),
1090            (
1091                with_sleeve(),
1092                vec![waiting.clone(), timed.clone()],
1093                1,
1094                -0.04,
1095            ),
1096            (with_sleeve(), vec![timed, waiting], 0, -0.04),
1097        ] {
1098            let drogue = Device::new(
1099                "drogue",
1100                DeviceDrag::DragArea { cd_s_m2: 0.3 },
1101                Trigger::Apogee,
1102            );
1103            let count = releases.len();
1104            let sim = simulation(
1105                &rocket,
1106                UniformAir::vacuum(),
1107                G,
1108                FlightSettings {
1109                    max_time_s: t0 + 2.0,
1110                    accept_design_errors: true,
1111                    ..FlightSettings::default()
1112                },
1113            )
1114            .with_recovery(vec![drogue])
1115            .unwrap()
1116            .with_releases(releases)
1117            .unwrap();
1118            let attitude = tilted.attitude();
1119            let whole = sim.assembly().mass_properties(t0);
1120            let rest = whole.without_part(&ballast(&sim));
1121            let step = rest.cg_m - whole.cg_m;
1122            let up = |omega: DVec3| attitude.mul_vec3(omega.cross(step)).z;
1123            let omega = if up(DVec3::X) < 0.0 {
1124                DVec3::X
1125            } else {
1126                -DVec3::X
1127            };
1128            let jump = up(omega);
1129            assert!(jump < bound, "{jump}");
1130            let cg_velocity = DVec3::new(2.0, 0.0, -0.5 * jump);
1131            let state = State {
1132                position_enu_m: DVec3::new(0.0, 0.0, 1000.0) - attitude.mul_vec3(whole.cg_m),
1133                velocity_enu_m_s: cg_velocity - attitude.mul_vec3(omega.cross(whole.cg_m)),
1134                attitude,
1135                body_rate_rad_s: omega,
1136            };
1137            let result = sim.run_free(t0, state, &mut ()).unwrap();
1138            let left = result
1139                .event(EventKind::MassRelease(timed_index))
1140                .unwrap()
1141                .sample;
1142            assert!(left.vertical_speed_m_s > 0.0, "{left:?}");
1143            assert_eq!(apogees(&result), 1, "{:?}", result.events);
1144            let apogee = result.event(EventKind::Apogee).unwrap().sample;
1145            assert_eq!(apogee.time_s, t0);
1146            assert!(apogee.vertical_speed_m_s < 0.0, "{apogee:?}");
1147            close(
1148                apogee.mass_kg,
1149                rest.mass_kg,
1150                1e-15,
1151                "the rest's mass at the apogee",
1152            );
1153            let fired = result.event(EventKind::Trigger(0)).unwrap().sample.time_s;
1154            assert_eq!(fired, t0);
1155            // Every part left at the apogee.
1156            assert_eq!(result.released.len(), count);
1157            for flown in &result.released {
1158                assert_eq!(flown.start_sample.time_s, t0);
1159            }
1160        }
1161    }
1162
1163    #[test]
1164    fn parts_waiting_for_the_apogee_all_leave_at_it() {
1165        // Off a rail 5° from vertical, in wind, two parts both let go at apogee, listed either
1166        // way round. The first leaving sets the rest's center rising for a moment; the second
1167        // still leaves at the flight's one apogee.
1168        let rail = Rail {
1169            elevation_rad: 85f64.to_radians(),
1170            ..Rail::vertical(3.0)
1171        };
1172        let at_apogee = |id: &str| MassRelease::new(Trigger::Apogee, id, PART_DRAG_AREA_M2);
1173        for ids in [["ballast", "sleeve"], ["sleeve", "ballast"]] {
1174            let result = Simulation::new(
1175                &with_sleeve(),
1176                "i175",
1177                crate::testing::analytic_wind_environment(
1178                    UniformAir::sea_level(),
1179                    G,
1180                    hpr_atmos::ConstantWind::new(5.0, 1.5 * std::f64::consts::PI).unwrap(),
1181                ),
1182                rail,
1183                FlightSettings {
1184                    accept_design_errors: true,
1185                    ..FlightSettings::default()
1186                },
1187            )
1188            .unwrap()
1189            .with_releases(ids.iter().map(|id| at_apogee(id)).collect())
1190            .unwrap()
1191            .run(&mut ())
1192            .unwrap();
1193            assert_eq!(apogees(&result), 1, "{ids:?}");
1194            let apogee = result.event(EventKind::Apogee).unwrap().sample.time_s;
1195            assert_eq!(result.released.len(), 2, "{ids:?}");
1196            for flown in &result.released {
1197                assert_eq!(flown.start_sample.time_s, apogee, "{ids:?}");
1198            }
1199        }
1200    }
1201
1202    #[test]
1203    fn a_main_set_above_the_apogee_opens_there_whatever_leaves() {
1204        // Off a rail 5° from vertical, in wind, the rocket peaks near 1533 m, below its main's
1205        // 1600 m setting, so the main opens at apogee. A part let go there can set the rest's
1206        // center rising for a moment; the main still opens, and a part set to leave below
1207        // 1600 m leaves there too, whichever part is listed first.
1208        let rail = Rail {
1209            elevation_rad: 85f64.to_radians(),
1210            ..Rail::vertical(3.0)
1211        };
1212        let high = Trigger::Altitude {
1213            height_above_ground_m: 1600.0,
1214        };
1215        let part = |trigger: Trigger, id: &str| MassRelease::new(trigger, id, PART_DRAG_AREA_M2);
1216        let cases = [
1217            vec![],
1218            vec![part(Trigger::Apogee, "ballast")],
1219            vec![part(Trigger::Apogee, "sleeve")],
1220            vec![part(high, "sleeve")],
1221            vec![part(Trigger::Apogee, "ballast"), part(high, "sleeve")],
1222            vec![part(high, "sleeve"), part(Trigger::Apogee, "ballast")],
1223        ];
1224        for releases in cases {
1225            let count = releases.len();
1226            let result = Simulation::new(
1227                &with_sleeve(),
1228                "i175",
1229                crate::testing::analytic_wind_environment(
1230                    UniformAir::sea_level(),
1231                    G,
1232                    hpr_atmos::ConstantWind::new(5.0, 1.5 * std::f64::consts::PI).unwrap(),
1233                ),
1234                rail,
1235                FlightSettings {
1236                    accept_design_errors: true,
1237                    ..FlightSettings::default()
1238                },
1239            )
1240            .unwrap()
1241            .with_recovery(vec![Device::new(
1242                "main",
1243                DeviceDrag::DragArea { cd_s_m2: 0.5 },
1244                high,
1245            )])
1246            .unwrap()
1247            .with_releases(releases)
1248            .unwrap()
1249            .run(&mut ())
1250            .unwrap();
1251            let apogee = result.event(EventKind::Apogee).unwrap().sample;
1252            assert!(apogee.height_above_ground_m < 1600.0, "{apogee:?}");
1253            let opened = result.event(EventKind::Trigger(0)).map(|e| e.sample.time_s);
1254            assert_eq!(opened, Some(apogee.time_s), "{count} {:?}", result.events);
1255            assert_eq!(result.released.len(), count, "{:?}", result.events);
1256            for flown in &result.released {
1257                assert_eq!(flown.start_sample.time_s, apogee.time_s);
1258            }
1259            // Under the main, not ballistic.
1260            assert!(
1261                result.final_sample.cg_velocity_enu_m_s.length() < 10.0,
1262                "{:?}",
1263                result.final_sample
1264            );
1265        }
1266    }
1267
1268    #[test]
1269    fn a_release_that_puts_the_rest_on_the_ground_lands_it() {
1270        // Straight down under a drogue with its attitude frozen nose up, the rocket lets its
1271        // ballast go 5 cm above the ground. The ballast sat forward of the center, so the rest's
1272        // center steps 9.5 cm down, below the ground: the rocket has landed there. Climbing
1273        // instead, it has not, and the flight is refused.
1274        let sim = simulation(
1275            &with_ballast(0.0),
1276            UniformAir::sea_level(),
1277            G,
1278            FlightSettings::default(),
1279        )
1280        .with_recovery(vec![Device::new(
1281            "drogue",
1282            DeviceDrag::DragArea { cd_s_m2: 0.3 },
1283            Trigger::Apogee,
1284        )])
1285        .unwrap()
1286        .with_releases(vec![release(Trigger::Altitude {
1287            height_above_ground_m: 0.05,
1288        })])
1289        .unwrap();
1290        let result = sim.run(&mut ()).unwrap();
1291        assert_eq!(result.termination, Termination::GroundHit);
1292        let left = result.event(EventKind::MassRelease(0)).unwrap().sample;
1293        close(left.height_above_ground_m, 0.05, 1e-6, "the release height");
1294        let landed = result.event(EventKind::GroundHit).unwrap().sample;
1295        assert_eq!(landed.time_s, left.time_s);
1296        // The rest's center is lower by the step of its station, turned by the attitude.
1297        let whole = sim.assembly().mass_properties(left.time_s);
1298        let rest = whole.without_part(&ballast(&sim));
1299        let step_m = left
1300            .state
1301            .unit_attitude()
1302            .mul_vec3(rest.cg_m - whole.cg_m)
1303            .z;
1304        assert!(step_m < -0.09, "{step_m}");
1305        // Heights come through geodetic coordinates some 6.4e6 m from the Earth's center, where
1306        // an f64 resolves about 1e-9 m.
1307        close(
1308            landed.height_above_ground_m,
1309            left.height_above_ground_m + step_m,
1310            1e-8,
1311            "the rest's height",
1312        );
1313        close(landed.mass_kg, rest.mass_kg, 1e-15, "the rest's mass");
1314        assert_eq!(result.final_sample, landed);
1315        assert_eq!(result.released[0].termination, Termination::GroundHit);
1316
1317        // Climbing 5 cm up, in a vacuum, the same release is refused.
1318        let t0 = 10.0;
1319        let sim = simulation(
1320            &with_ballast(0.0),
1321            UniformAir::vacuum(),
1322            G,
1323            FlightSettings::default(),
1324        )
1325        .with_releases(vec![release(Trigger::Time { time_s: t0 })])
1326        .unwrap();
1327        let attitude = Rail::vertical(3.0).attitude();
1328        let state = State {
1329            position_enu_m: DVec3::new(0.0, 0.0, 0.05) - attitude.mul_vec3(whole.cg_m),
1330            velocity_enu_m_s: DVec3::new(0.0, 0.0, 1.0),
1331            attitude,
1332            body_rate_rad_s: DVec3::ZERO,
1333        };
1334        let refused = sim.run_free(t0, state, &mut ());
1335        assert!(
1336            matches!(&refused, Err(SimError::Domain { what, value })
1337                if what.contains("while climbing") && *value < 0.0),
1338            "{refused:?}"
1339        );
1340    }
1341
1342    #[test]
1343    fn the_optimum_delay_holds_a_release_on_the_motor_s_charge() {
1344        // A release fired by the motor's own ejection charge is held with the charge, so the
1345        // optimum delay is the rocket's, whatever delay is flown.
1346        let delays = |delay: Option<f64>| {
1347            let mut rocket = with_ballast(0.0);
1348            let mut releases = Vec::new();
1349            if let Some(delay_s) = delay {
1350                rocket.configurations[0].motors[0].delay = Some(hpr_motor::Delay::Seconds(delay_s));
1351                releases.push(release(Trigger::MotorDelay { motor: 0 }));
1352            }
1353            let sim = lenient(&rocket).with_releases(releases).unwrap();
1354            crate::metrics::optimum_delays(&sim).unwrap().unwrap()[0].delay_s
1355        };
1356        let alone = delays(None);
1357        assert_eq!(delays(Some(2.0)), alone);
1358        assert_eq!(delays(Some(6.0)), alone);
1359        // And a shift fired so.
1360        let mut rocket = with_ballast(0.0);
1361        rocket.configurations[0].motors[0].delay = Some(hpr_motor::Delay::Seconds(2.0));
1362        let sim = lenient(&rocket)
1363            .with_shifts(vec![MassShift::new(
1364                Trigger::MotorDelay { motor: 0 },
1365                "ballast",
1366                0.3,
1367                1.0,
1368            )])
1369            .unwrap();
1370        assert_eq!(
1371            crate::metrics::optimum_delays(&sim).unwrap().unwrap()[0].delay_s,
1372            alone
1373        );
1374    }
1375
1376    #[test]
1377    fn two_releases_leave_the_design_without_both_parts() {
1378        // The sleeve with the weight it holds leaves at 4 s, the ballast at 6 s, given in the
1379        // other order. After both the rocket is the design with neither.
1380        let rocket = with_sleeve();
1381        let sim = lenient(&rocket)
1382            .with_releases(vec![
1383                MassRelease::new(Trigger::Time { time_s: 6.0 }, "ballast", 0.01),
1384                MassRelease::new(Trigger::Time { time_s: 4.0 }, "sleeve", 0.01),
1385            ])
1386            .unwrap();
1387        let result = sim.run(&mut ()).unwrap();
1388        let order: Vec<usize> = result.released.iter().map(|flown| flown.release).collect();
1389        assert_eq!(order, [1, 0]);
1390        let mut without = rocket.clone();
1391        without.stages[0].components[1]
1392            .children
1393            .retain(|child| child.id != "ballast" && child.id != "sleeve");
1394        let expected = lenient(&without).assembly().mass_properties(7.0);
1395        let got = sim.mass_properties(&result, 7.0);
1396        close(got.mass_kg, expected.mass_kg, 1e-15, "mass");
1397        close_vec(got.cg_m, expected.cg_m, 1e-15, "center");
1398        let diff = (got.inertia_kg_m2 - expected.inertia_kg_m2)
1399            .to_cols_array()
1400            .iter()
1401            .fold(0.0_f64, |most, v| most.max(v.abs()));
1402        assert!(diff <= 1e-15, "inertia off by {diff:e}");
1403        // Between the two, only the sleeve is gone.
1404        let between = sim.mass_properties(&result, 5.0).mass_kg;
1405        close(
1406            between,
1407            sim.assembly().mass_properties(5.0).mass_kg
1408                - sim
1409                    .assembly()
1410                    .layout
1411                    .find("sleeve")
1412                    .unwrap()
1413                    .1
1414                    .with_children
1415                    .mass_kg,
1416            1e-15,
1417            "between",
1418        );
1419    }
1420
1421    #[test]
1422    fn a_part_let_go_at_the_ground_has_landed() {
1423        // The rocket flies ballistic, nose first into the ground; 2 ms before its center lands,
1424        // the ballast, forward of it, is already at or below the site.
1425        let ballistic = simulation(
1426            &with_ballast(0.0),
1427            UniformAir::sea_level(),
1428            G,
1429            FlightSettings::default(),
1430        );
1431        let landed_s = ballistic.run(&mut ()).unwrap().final_sample.time_s;
1432        let result = simulation(
1433            &with_ballast(0.0),
1434            UniformAir::sea_level(),
1435            G,
1436            FlightSettings::default(),
1437        )
1438        .with_releases(vec![release(Trigger::Time {
1439            time_s: landed_s - 0.002,
1440        })])
1441        .unwrap()
1442        .run(&mut ())
1443        .unwrap();
1444        let flown = &result.released[0];
1445        assert!(flown.start_sample.height_above_ground_m <= 0.0, "{flown:?}");
1446        assert_eq!(flown.termination, Termination::GroundHit);
1447        assert_eq!(flown.final_sample, flown.start_sample);
1448        assert!(flown.event(EventKind::GroundHit).is_some());
1449        assert_eq!(result.termination, Termination::GroundHit);
1450    }
1451
1452    #[test]
1453    fn a_release_and_its_flight_read_back_as_written() {
1454        let release = MassRelease::new(Trigger::Apogee, "ballast", 0.25);
1455        let text = serde_json::to_string(&release).unwrap();
1456        assert_eq!(serde_json::from_str::<MassRelease>(&text).unwrap(), release);
1457        let sim = simulation(
1458            &with_ballast(0.0),
1459            UniformAir::sea_level(),
1460            G,
1461            FlightSettings::default(),
1462        )
1463        .with_releases(vec![release])
1464        .unwrap();
1465        let result = sim.run(&mut ()).unwrap();
1466        let text = serde_json::to_string(&result).unwrap();
1467        assert!(text.contains("\"released\""));
1468        let back: crate::FlightResult = serde_json::from_str(&text).unwrap();
1469        assert_eq!(back, result);
1470    }
1471
1472    #[test]
1473    fn parts_with_no_mass_are_refused() {
1474        let mut empty = with_ballast(0.0);
1475        let airframe = &mut empty.stages[0].components[1];
1476        let ballast = airframe
1477            .children
1478            .iter_mut()
1479            .find(|child| child.id == "ballast")
1480            .unwrap();
1481        let Part::MassComponent(mass) = &mut ballast.part else {
1482            panic!("the ballast is a mass component");
1483        };
1484        mass.mass_kg = 0.0;
1485        let (what, id) = release_refusal(lenient(&empty), vec![release(Trigger::Apogee)]);
1486        assert!(
1487            what.starts_with("a mass release of a part with no mass"),
1488            "{what}"
1489        );
1490        assert_eq!(id, "ballast");
1491
1492        // Every other component's own mass overridden to nothing: letting the ballast go would
1493        // leave nothing behind.
1494        fn weightless(component: &mut hpr_design::Component) {
1495            if component.id != "ballast" {
1496                component.overrides.mass_kg = Some(0.0);
1497            }
1498            component.children.iter_mut().for_each(weightless);
1499        }
1500        let mut hollow = with_ballast(0.0);
1501        hollow.stages[0].components.iter_mut().for_each(weightless);
1502        let (what, id) = release_refusal(lenient(&hollow), vec![release(Trigger::Apogee)]);
1503        assert!(
1504            what.starts_with(
1505                "a mass release that leaves the airframe, its motors aside, with no mass"
1506            ),
1507            "{what}"
1508        );
1509        assert_eq!(id, "ballast");
1510    }
1511}