Skip to main content

hpr_sim/
shifts.rs

1//! Mass that moves along the airframe in flight: a ballast weight or a payload slid fore or aft
2//! on a trigger, with the rocket's mass properties and its equations of motion following it.
3//!
4//! A [`MassShift`] moves one part carried inside the airframe, with everything inside it, by a
5//! set distance along the axis over a set time, starting on a trigger as a recovery device does.
6//! It follows a cycloid (the cam designer's "cycloidal motion"): its speed and acceleration are
7//! zero at both ends, so the center of mass moves smoothly. The rocket's mass is unchanged; its
8//! center of mass and inertia follow the part ([`hpr_design::MassProperties::with_part_moved`]).
9//! The equations of motion take the moving center of mass's terms as they take a burning motor's,
10//! and add the part's angular momentum relative to the airframe, which is not zero when it moves
11//! off the axis (the decision record on moving mass, [ADR-087][adr-087]).
12//!
13//! Method: the documentation site's [Moving mass][page] page.
14//!
15//! [page]: https://github.com/nrdptel/hpr-sim/blob/main/docs/physics/moving-mass.md
16//! [adr-087]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-087-mass-that-moves-along-the-airframe-2026-09-26
17
18use std::f64::consts::TAU;
19
20use hpr_core::{DMat3, DVec3};
21use hpr_design::{Assembly, MassProperties, PlacedComponent, Rocket};
22use serde::{Deserialize, Serialize};
23
24use crate::dynamics::MassState;
25use crate::error::SimError;
26use crate::pieces::node;
27use crate::recovery::Trigger;
28
29/// The shortest time a [`MassShift`] may take, s.
30///
31/// A shorter move is nearer an impact than a motion: the cycloid's peak acceleration is
32/// `2π travel/T²`, 63 km/s² for each meter of travel at this bound, and the part's stop at the end
33/// would be a shock the rigid airframe here doesn't have. The equations take the shift's rates in
34/// closed form, so they hold at any duration, and [`SHIFT_STOPS`] stop times across each move
35/// make even a fixed-step integrator follow it.
36pub const MIN_SHIFT_DURATION_S: f64 = 0.01;
37
38/// How many equal intervals a shift's time is cut into by stop times, so that an integrator takes
39/// at least this many steps across it: a fixed step as long as the move would take it in one, and
40/// the part's Coriolis-like term would be weighed at a single midpoint. A numerical detail, which
41/// may change.
42pub const SHIFT_STOPS: usize = 16;
43
44/// A part carried inside the airframe moving along its axis: `travel_m` aft (forward when
45/// negative) over `duration_s`, starting on `trigger`.
46///
47/// The part is an internal component named by its id, and it moves with everything inside it. It
48/// can't be a body component or an external one, one copy of a cluster's, or hold a motor (the
49/// motor would stay where it is). Its mass can't be under an override: not its stage's, and not
50/// one on a component around it that covers what that component holds, since the override doesn't
51/// say how much of the mass is the part's. Shifts of one part add; a part can't move inside
52/// another part that moves. Every shift of a part, forward ones together and aft ones together,
53/// must keep it inside the component that holds it, or no further out than the design already
54/// puts it.
55///
56/// The position along the travel is the cycloid `s(τ) = τ − sin(2πτ)/2π` of the fraction of the
57/// time gone, `τ = (t − t₀)/T`: at rest at both ends, fastest at the middle at `2 travel/T`.
58///
59/// A shift can't start before the rocket leaves the rail: the rail has no stop at its foot, so a
60/// part thrown aft on the pad could push the rocket up the rail and leave it there.
61///
62/// ```
63/// use hpr_sim::{MassShift, Trigger};
64///
65/// // The part with id "ballast" slides 0.3 m toward the tail over 1 s, starting 5 s after launch.
66/// let shift = MassShift::new(Trigger::Time { time_s: 5.0 }, "ballast", 0.3, 1.0);
67/// assert_eq!(shift.travel_m, 0.3);
68/// ```
69///
70/// Give it to a flight with [`crate::Simulation::with_shifts`].
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73#[non_exhaustive]
74pub struct MassShift {
75    /// When the part starts to move: the same triggers as a recovery device's.
76    pub trigger: Trigger,
77    /// The id of the internal component that moves.
78    pub component: String,
79    /// How far it moves along the axis, m: positive aft, toward the tail.
80    pub travel_m: f64,
81    /// How long it takes, s: at least [`MIN_SHIFT_DURATION_S`].
82    pub duration_s: f64,
83}
84
85impl MassShift {
86    /// Internal component `component` moving `travel_m` aft (forward when negative) over
87    /// `duration_s`, starting on `trigger`.
88    #[must_use]
89    pub fn new(
90        trigger: Trigger,
91        component: impl Into<String>,
92        travel_m: f64,
93        duration_s: f64,
94    ) -> Self {
95        Self {
96            trigger,
97            component: component.into(),
98            travel_m,
99            duration_s,
100        }
101    }
102}
103
104/// Why an id doesn't name one part carried inside the airframe: a check a [`MassShift`] and a
105/// [`crate::MassRelease`] share, each put in its own words.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub(crate) enum NotAPart {
108    /// The design has no component with the id.
109    Missing,
110    /// It is a body component, not a part carried inside one.
111    BodyComponent,
112    /// It is outside the airframe.
113    Outside,
114    /// It isn't exactly one part: one of several copies in a cluster of tubes, or none.
115    NotOnePart,
116}
117
118/// Why a part carried inside the airframe can't be moved or released: a check a [`MassShift`] and
119/// a [`crate::MassRelease`] share, each put in its own words.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub(crate) enum NotCarried {
122    /// It holds a motor, which would stay behind.
123    HoldsMotor,
124    /// Its stage's mass is overridden, and the override doesn't say how much of it is the part's.
125    StageOverride,
126    /// A component around it has an overridden mass that covers what it holds.
127    CoveredOverride,
128}
129
130/// The index in `assembly`'s components of the internal part with id `id`, one part carried inside
131/// the airframe.
132pub(crate) fn locate_part(assembly: &Assembly, id: &str) -> Result<usize, NotAPart> {
133    let (index, placed) = assembly.layout.find(id).ok_or(NotAPart::Missing)?;
134    // A pod's body components hang from its pod set but are still body components.
135    if placed.parent.is_none() || placed.part.is_body() {
136        Err(NotAPart::BodyComponent)
137    } else if placed.body_radius_m.is_some() {
138        Err(NotAPart::Outside)
139    } else if placed.copies.len() != 1 {
140        Err(NotAPart::NotOnePart)
141    } else {
142        Ok(index)
143    }
144}
145
146/// Whether component `index` is inside component `ancestor`, at any depth.
147pub(crate) fn inside(components: &[PlacedComponent], index: usize, ancestor: usize) -> bool {
148    let mut at = components[index].parent;
149    while let Some(parent) = at {
150        if parent == ancestor {
151            return true;
152        }
153        at = components[parent].parent;
154    }
155    false
156}
157
158/// Refuses part `index` of `assembly` (of `rocket`) if it holds a motor or if an override covers
159/// its mass: its stage's, or one on a component around it that covers what that component holds.
160pub(crate) fn check_carried(
161    rocket: &Rocket,
162    assembly: &Assembly,
163    index: usize,
164) -> Result<(), NotCarried> {
165    let components = &assembly.layout.components;
166    let holds_motor = assembly.motors.iter().any(|motor| {
167        components
168            .iter()
169            .position(|component| component.id == motor.mount)
170            .is_some_and(|mount| mount == index || inside(components, mount, index))
171    });
172    if holds_motor {
173        return Err(NotCarried::HoldsMotor);
174    }
175    let stage = &assembly.layout.stages[components[index].stage];
176    if rocket
177        .stages
178        .iter()
179        .find(|written| written.id == stage.id)
180        .is_some_and(|written| !written.overrides.is_empty())
181    {
182        return Err(NotCarried::StageOverride);
183    }
184    let mut at = components[index].parent;
185    while let Some(parent) = at {
186        if node(rocket, &components[parent].id)
187            .is_some_and(|holder| holder.overrides_include_children && !holder.overrides.is_empty())
188        {
189            return Err(NotCarried::CoveredOverride);
190        }
191        at = components[parent].parent;
192    }
193    Ok(())
194}
195
196/// The cycloid `s(τ) = τ − sin(2πτ)/2π` and its first two derivatives in `τ`, held at its ends
197/// outside `[0, 1]`.
198fn cycloid(tau: f64) -> (f64, f64, f64) {
199    if tau <= 0.0 {
200        (0.0, 0.0, 0.0)
201    } else if tau >= 1.0 {
202        (1.0, 0.0, 0.0)
203    } else {
204        let (sin, cos) = (TAU * tau).sin_cos();
205        (tau - sin / TAU, 1.0 - cos, TAU * sin)
206    }
207}
208
209/// One shift as the equations fly it.
210#[derive(Debug, Clone)]
211struct ShiftTerms {
212    /// Which of [`Shifts::parts`] moves.
213    part: usize,
214    travel_m: f64,
215    duration_s: f64,
216    /// When it starts on the flight's clock, s: infinite while not known.
217    start_s: f64,
218}
219
220impl ShiftTerms {
221    /// The part's offset along body `z` from this shift at `t`, and its first two time
222    /// derivatives, m, m/s, m/s². Aft is `−z`.
223    fn offset(&self, t: f64) -> (f64, f64, f64) {
224        if !self.start_s.is_finite() {
225            return (0.0, 0.0, 0.0);
226        }
227        let (s, ds, dds) = cycloid((t - self.start_s) / self.duration_s);
228        let along = -self.travel_m;
229        (
230            along * s,
231            along * ds / self.duration_s,
232            along * dds / (self.duration_s * self.duration_s),
233        )
234    }
235}
236
237/// A flight's mass shifts: the parts that move, as the design places them, and each shift.
238#[derive(Debug, Clone, Default)]
239pub(crate) struct Shifts {
240    /// Each moving part with everything inside it, where the design puts it.
241    parts: Vec<MassProperties>,
242    terms: Vec<ShiftTerms>,
243}
244
245impl Shifts {
246    /// The shifts of `assembly` (of `rocket`), each starting at `start_s` (one per shift, `None`
247    /// while not known).
248    ///
249    /// # Errors
250    ///
251    /// [`SimError::Shift`] for a part that can't move ([`MassShift`] says which), and
252    /// [`SimError::Domain`] for a travel that is not finite or a duration shorter than
253    /// [`MIN_SHIFT_DURATION_S`] or not finite.
254    pub(crate) fn new(
255        rocket: &Rocket,
256        assembly: &Assembly,
257        shifts: &[MassShift],
258        start_s: &[Option<f64>],
259    ) -> Result<Self, SimError> {
260        let components = &assembly.layout.components;
261        let refuse = |what: &'static str, component: &str| SimError::Shift {
262            what,
263            component: component.to_owned(),
264        };
265        let mut moved: Vec<usize> = Vec::new();
266        let mut terms = Vec::with_capacity(shifts.len());
267        for (shift, start_s) in shifts.iter().zip(start_s) {
268            let id = shift.component.as_str();
269            if !shift.travel_m.is_finite() {
270                return Err(SimError::Domain {
271                    what: "travel of a mass shift, m",
272                    value: shift.travel_m,
273                });
274            }
275            if !(shift.duration_s.is_finite() && shift.duration_s >= MIN_SHIFT_DURATION_S) {
276                return Err(SimError::Domain {
277                    what: "duration of a mass shift, s (0.01 s or more)",
278                    value: shift.duration_s,
279                });
280            }
281            let index = locate_part(assembly, id).map_err(|refusal| {
282                refuse(
283                    match refusal {
284                        NotAPart::Missing => {
285                            "a mass shift names a component the design doesn't have"
286                        }
287                        NotAPart::BodyComponent => {
288                            "a mass shift moves a part carried inside the airframe, and this is a \
289                             body component"
290                        }
291                        NotAPart::Outside => {
292                            "a mass shift moves a part carried inside the airframe, and this part \
293                             is outside it"
294                        }
295                        NotAPart::NotOnePart => {
296                            "a mass shift of a part that isn't exactly one part (one of several \
297                             copies in a cluster of tubes, or none)"
298                        }
299                    },
300                    id,
301                )
302            })?;
303            if !moved.contains(&index) {
304                moved.push(index);
305            }
306            terms.push(ShiftTerms {
307                part: moved.iter().position(|&part| part == index).unwrap_or(0),
308                travel_m: shift.travel_m,
309                duration_s: shift.duration_s,
310                start_s: start_s.unwrap_or(f64::INFINITY),
311            });
312        }
313
314        for &index in &moved {
315            let id = components[index].id.as_str();
316            if moved
317                .iter()
318                .any(|&other| other != index && inside(components, index, other))
319            {
320                return Err(refuse(
321                    "a mass shift of a part inside another that moves",
322                    id,
323                ));
324            }
325            check_carried(rocket, assembly, index).map_err(|refusal| {
326                refuse(
327                    match refusal {
328                        NotCarried::HoldsMotor => {
329                            "a mass shift of a part that holds a motor (the motor would stay where \
330                             it is)"
331                        }
332                        NotCarried::StageOverride => {
333                            "a mass shift in a stage whose mass is overridden (the override \
334                             doesn't say how much of it is the part's)"
335                        }
336                        NotCarried::CoveredOverride => {
337                            "a mass shift inside a component whose overridden mass covers what it \
338                             holds (the override doesn't say how much of it is the part's)"
339                        }
340                    },
341                    id,
342                )
343            })?;
344            // Every shift forward together, and every one aft, keep it inside its holder.
345            let (mut forward_m, mut aft_m) = (0.0, 0.0);
346            for (shift, term) in shifts.iter().zip(&terms) {
347                if moved[term.part] == index {
348                    if shift.travel_m < 0.0 {
349                        forward_m -= shift.travel_m;
350                    } else {
351                        aft_m += shift.travel_m;
352                    }
353                }
354            }
355            // The holder's extent, or the part's where the design already puts it past the
356            // holder's (a weight in a nose cone's shoulder, say).
357            let part = &components[index];
358            if let Some(holder) = part.parent.map(|parent| &components[parent])
359                && (part.fore_station_m - forward_m
360                    < holder.fore_station_m.min(part.fore_station_m)
361                    || part.fore_station_m + part.length_m + aft_m
362                        > (holder.fore_station_m + holder.length_m)
363                            .max(part.fore_station_m + part.length_m))
364            {
365                return Err(refuse(
366                    "a mass shift that can take the part out of the component that holds it",
367                    id,
368                ));
369            }
370        }
371
372        Ok(Self {
373            parts: moved
374                .iter()
375                .map(|&index| components[index].with_children)
376                .collect(),
377            terms,
378        })
379    }
380
381    /// Whether there are none.
382    pub(crate) fn is_empty(&self) -> bool {
383        self.terms.is_empty()
384    }
385
386    /// Starts shift `index` at `t_s`.
387    pub(crate) fn start(&mut self, index: usize, t_s: f64) {
388        if let Some(term) = self.terms.get_mut(index) {
389            term.start_s = t_s;
390        }
391    }
392
393    /// Holds shift `index`: it doesn't start unless [`Self::start`] starts it.
394    pub(crate) fn hold(&mut self, index: usize) {
395        self.start(index, f64::INFINITY);
396    }
397
398    /// When shift `index` starts, s: `None` while not known.
399    pub(crate) fn start_s(&self, index: usize) -> Option<f64> {
400        self.terms
401            .get(index)
402            .map(|term| term.start_s)
403            .filter(|t| t.is_finite())
404    }
405
406    /// The stop times of every shift whose start is known: its start, its end, and
407    /// [`SHIFT_STOPS`] equal intervals between, s.
408    pub(crate) fn knots_s(&self) -> Vec<f64> {
409        (0..self.terms.len())
410            .flat_map(|index| self.stops_s(index))
411            .collect()
412    }
413
414    /// Shift `index`'s stop times, once its start is known: its start, its end, and
415    /// [`SHIFT_STOPS`] equal intervals between, s.
416    pub(crate) fn stops_s(&self, index: usize) -> Vec<f64> {
417        match (self.start_s(index), self.terms.get(index)) {
418            (Some(start_s), Some(term)) => (0..=SHIFT_STOPS)
419                .map(|k| start_s + term.duration_s * (k as f64 / SHIFT_STOPS as f64))
420                .collect(),
421            _ => Vec::new(),
422        }
423    }
424
425    /// Each moving part's offset along body `z` at `t`, and its first two time derivatives.
426    fn offsets(&self, t: f64) -> Vec<(f64, f64, f64)> {
427        let mut offsets = vec![(0.0, 0.0, 0.0); self.parts.len()];
428        for term in &self.terms {
429            let (s, v, a) = term.offset(t);
430            let sum = &mut offsets[term.part];
431            *sum = (sum.0 + s, sum.1 + v, sum.2 + a);
432        }
433        offsets
434    }
435
436    /// `whole` (the design's mass properties at `t`, every part where the design puts it) with
437    /// each moving part where it is at `t`.
438    pub(crate) fn apply(&self, whole: MassProperties, t: f64) -> MassProperties {
439        self.moved(whole, &self.offsets(t))
440    }
441
442    /// `whole` with each moving part moved by its offset in `offsets`.
443    fn moved(&self, whole: MassProperties, offsets: &[(f64, f64, f64)]) -> MassProperties {
444        self.parts
445            .iter()
446            .zip(offsets)
447            .filter(|(_, (offset, _, _))| *offset != 0.0)
448            .fold(whole, |whole, (part, (offset, _, _))| {
449                whole.with_part_moved(part, DVec3::new(0.0, 0.0, *offset))
450            })
451    }
452
453    /// Moves `state`, the design's mass state at `t` with every part where the design puts it
454    /// (and `mass_second_kg_s2` its mass's second derivative, `M″`), to the moving parts' places,
455    /// adding their rates in closed form. With `M` the whole's mass and, for each part, `m` its
456    /// mass, `δ` its offset and `c = ρ₀ + δ` its center,
457    ///
458    /// ```text
459    /// r   = r_a + Σ m δ/M
460    /// r′  = r_a′ + Σ m (δ′/M − δ M′/M²)
461    /// r″  = r_a″ + Σ m (δ″/M − 2 δ′ M′/M² + δ (2M′²/M³ − M″/M²))
462    /// I_O = I_Oa + Σ m (J(c) − J(ρ₀)),   J(v) = |v|² E − v vᵀ
463    /// I_O′ = I_Oa′ + Σ m (2 (c·δ′) E − δ′ cᵀ − c δ′ᵀ)
464    /// h   = Σ m c × δ′,   h′ = Σ m c × δ″   (δ′ × δ′ = 0)
465    /// ```
466    ///
467    /// where `r_a` and `I_Oa` are the design's, and `h` is the parts' angular momentum about the
468    /// nose tip relative to the airframe: a part only translates, so every point of it moves at
469    /// `δ′`.
470    pub(crate) fn shift_state(&self, state: &mut MassState, mass_second_kg_s2: f64, t: f64) {
471        let offsets = self.offsets(t);
472        let whole = MassProperties {
473            mass_kg: state.mass_kg,
474            cg_m: state.cg_m,
475            inertia_kg_m2: state.inertia_cg,
476        };
477        let moved = self.moved(whole, &offsets);
478        state.cg_m = moved.cg_m;
479        state.inertia_cg = moved.inertia_kg_m2;
480        state.inertia_o = moved.inertia_about(DVec3::ZERO);
481        let big_m = state.mass_kg;
482        let (dm, ddm) = (state.mass_rate_kg_s, mass_second_kg_s2);
483        for (part, (offset, speed, acceleration)) in self.parts.iter().zip(offsets) {
484            if speed == 0.0 && acceleration == 0.0 && (offset == 0.0 || dm == 0.0) {
485                continue;
486            }
487            let m = part.mass_kg;
488            let (d, v, a) = (DVec3::Z * offset, DVec3::Z * speed, DVec3::Z * acceleration);
489            let m2 = big_m * big_m;
490            state.cg_rate_m_s += v * (m / big_m) - d * (m * dm / m2);
491            state.cg_accel_m_s2 += a * (m / big_m) - v * (2.0 * m * dm / m2)
492                + d * (m * (2.0 * dm * dm / (m2 * big_m) - ddm / m2));
493            let c = part.cg_m + d;
494            state.inertia_o_rate +=
495                (DMat3::from_diagonal(DVec3::splat(2.0 * c.dot(v))) - outer(v, c) - outer(c, v))
496                    * m;
497            state.relative_momentum += c.cross(v) * m;
498            state.relative_momentum_rate += c.cross(a) * m;
499        }
500    }
501}
502
503/// The outer product `u vᵀ`.
504fn outer(u: DVec3, v: DVec3) -> DMat3 {
505    DMat3::from_cols(u * v.x, u * v.y, u * v.z)
506}
507
508#[cfg(test)]
509mod tests {
510    use hpr_design::{Part, Position};
511
512    use super::*;
513    use crate::flight::{EventKind, FlightResult, FlightSettings, Simulation, Termination};
514    use crate::integrator::{Adaptive, Method};
515    use crate::metrics::FlightMetrics;
516    use crate::pieces::Ejection;
517    use crate::rail::Rail;
518    use crate::recorder::Sample;
519    use crate::state::State;
520    use crate::testing::{
521        Ends, UniformAir, analytic_environment, design, with_ballast, with_sleeve,
522    };
523
524    const G: f64 = 9.806_65;
525    /// The shift: 0.3 m aft over 1 s from 5 s, well after the I175's burnout at 2.5 s.
526    const TRAVEL_M: f64 = 0.3;
527    const START_S: f64 = 5.0;
528    const DURATION_S: f64 = 1.0;
529
530    fn simulation(rocket: &Rocket, settings: FlightSettings) -> Simulation {
531        Simulation::new(
532            rocket,
533            "i175",
534            analytic_environment(UniformAir::sea_level(), G),
535            Rail::vertical(3.0),
536            settings,
537        )
538        .unwrap()
539    }
540
541    fn shift(trigger: Trigger) -> MassShift {
542        MassShift::new(trigger, "ballast", TRAVEL_M, DURATION_S)
543    }
544
545    /// The ballast's center, and it with what it holds, as the design places it.
546    fn ballast(sim: &Simulation) -> MassProperties {
547        sim.assembly()
548            .layout
549            .components
550            .iter()
551            .find(|component| component.id == "ballast")
552            .unwrap()
553            .with_children
554    }
555
556    fn close(a: f64, b: f64, tolerance: f64, what: &str) {
557        assert!(
558            (a - b).abs() <= tolerance,
559            "{what}: {a} vs {b} ({:e})",
560            a - b
561        );
562    }
563
564    #[test]
565    fn mass_properties_before_during_and_after_a_shift_match_the_hand_calculation() {
566        let sim = simulation(&with_ballast(0.0), FlightSettings::default())
567            .with_shifts(vec![shift(Trigger::Time { time_s: START_S })])
568            .unwrap();
569        let result = sim.run(&mut ()).unwrap();
570        assert_eq!(result.termination, Termination::GroundHit);
571        let started = result.event(EventKind::Shift(0)).unwrap().sample;
572        assert_eq!(started.time_s, START_S);
573
574        // By hand, for a part of mass m moved aft by Δ in a rocket of mass M, as two bodies: the
575        // part and the rest. The center moves aft by mΔ/M. About it the inertia is the two
576        // bodies' own plus μ(|L|² E − L Lᵀ), with μ = m(M − m)/M the reduced mass and L the
577        // vector from the rest's center to the part's, `(ρ − cg) M/(M − m)`, whose `z` falls by
578        // Δ. The rail buttons put the rocket's center 30 µm off the axis, so `L` has an `x`.
579        let part = ballast(&sim);
580        let still = sim.assembly().mass_properties(START_S);
581        let (m, big_m) = (part.mass_kg, still.mass_kg);
582        let mu = m * (big_m - m) / big_m;
583        let l = (part.cg_m - still.cg_m) * (big_m / (big_m - m));
584        // Before; halfway, where the cycloid is at half the travel exactly; and after.
585        for (t_s, fraction) in [(4.9, 0.0), (5.5, 0.5), (6.5, 1.0)] {
586            let delta = TRAVEL_M * fraction;
587            let got = sim.mass_properties(&result, t_s);
588            let what = format!("at {t_s} s");
589            assert_eq!(got.mass_kg, big_m, "{what}");
590            close(got.cg_m.x, still.cg_m.x, 1e-18, &what);
591            close(got.cg_m.y, still.cg_m.y, 1e-18, &what);
592            close(got.cg_m.z, still.cg_m.z - m * delta / big_m, 1e-15, &what);
593            let (i, i0) = (got.inertia_kg_m2, still.inertia_kg_m2);
594            let (x, y, z0, z1) = (l.x, l.y, l.z, l.z - delta);
595            // I_xx = μ(y² + z²), I_yy = μ(x² + z²), I_zz = μ(x² + y²), I_xz = −μ x z, I_yz = −μ y z.
596            let change = |i: f64, i0: f64, before: f64, after: f64, which: &str| {
597                close(
598                    i - i0,
599                    mu * (after - before),
600                    1e-15,
601                    &format!("{what}, {which}"),
602                );
603            };
604            change(
605                i.x_axis.x,
606                i0.x_axis.x,
607                y * y + z0 * z0,
608                y * y + z1 * z1,
609                "I_xx",
610            );
611            change(
612                i.y_axis.y,
613                i0.y_axis.y,
614                x * x + z0 * z0,
615                x * x + z1 * z1,
616                "I_yy",
617            );
618            change(i.z_axis.z, i0.z_axis.z, 0.0, 0.0, "I_zz");
619            change(i.x_axis.y, i0.x_axis.y, 0.0, 0.0, "I_xy");
620            change(i.x_axis.z, i0.x_axis.z, -x * z0, -x * z1, "I_xz");
621            change(i.y_axis.z, i0.y_axis.z, -y * z0, -y * z1, "I_yz");
622        }
623        // Measured: M = 0.8188 kg after the burn, so the center moves 7.328 cm aft.
624        close(big_m, 0.818_80, 1e-5, "mass");
625        close(m * TRAVEL_M / big_m, 0.073_28, 1e-5, "center's travel");
626    }
627
628    #[test]
629    fn a_moving_mass_shifts_the_static_margin_by_the_hand_calculation() {
630        // The static margin is the center of pressure at Mach 0 against the center of mass of the
631        // instant, so on the coast, with the mass fixed, the hand calculation says it moves by
632        // −m Δ s(τ)/(M d) as the ballast travels Δ s(τ) aft.
633        let sim = simulation(&with_ballast(0.0), FlightSettings::default())
634            .with_shifts(vec![shift(Trigger::Time { time_s: START_S })])
635            .unwrap();
636        let mut metrics = FlightMetrics::new();
637        let result = sim.run(&mut metrics).unwrap();
638        let apogee_s = result.event(EventKind::Apogee).unwrap().sample.time_s;
639        assert!(apogee_s > START_S + DURATION_S + 1.0, "{apogee_s}");
640        let part = ballast(&sim);
641        let still = sim.assembly().mass_properties(START_S);
642        let coast: Vec<_> = metrics
643            .stability()
644            .iter()
645            .filter(|sample| sample.time_s > 3.0)
646            .collect();
647        let before = coast.first().unwrap();
648        let d = before.reference_diameter_m;
649        let margin_before = before.static_margin.margin_cal.unwrap();
650        let (mut during, mut after) = (0, 0);
651        for sample in &coast {
652            let (s, _, _) = cycloid((sample.time_s - START_S) / DURATION_S);
653            let expected = margin_before - part.mass_kg * TRAVEL_M * s / (still.mass_kg * d);
654            let got = sample.static_margin.margin_cal.unwrap();
655            close(
656                got,
657                expected,
658                1e-12,
659                &format!("margin at {} s", sample.time_s),
660            );
661            close(
662                sample.cg_station_m,
663                -still.cg_m.z + part.mass_kg * TRAVEL_M * s / still.mass_kg,
664                1e-15,
665                "center of mass",
666            );
667            if s > 0.0 && s < 1.0 {
668                during += 1;
669            } else if s == 1.0 {
670                after += 1;
671            }
672        }
673        assert!(during >= 3 && after >= 3, "{during} during, {after} after");
674        // Measured: 4.30 calibres before, 3.00 after the ballast's 0.3 m (1.30 calibres less).
675        let margin_after = coast.last().unwrap().static_margin.margin_cal.unwrap();
676        close(margin_before, 4.2973, 1e-4, "before");
677        close(margin_before - margin_after, 1.3016, 1e-4, "change");
678    }
679
680    #[test]
681    fn a_shift_s_rates_are_the_derivatives_of_its_mass_properties() {
682        // Through the burn, where the mass falls, and on the coast: the closed forms against
683        // central differences of the mass properties the shift gives, 1 µs apart for the rates
684        // and 0.1 ms for the acceleration (at 1 µs its rounding is 2e-4 m/s²).
685        for (start_s, at_s) in [(0.5, 1.2), (4.0, 4.3)] {
686            let sim = simulation(&with_ballast(0.01), FlightSettings::default());
687            let mut vehicle = crate::dynamics::Vehicle::lit(
688                sim.assembly().clone(),
689                sim.aero().clone(),
690                sim.assembly().ignition_times_s(|_| None),
691            )
692            .unwrap();
693            vehicle.shifts = Shifts::new(
694                &with_ballast(0.01),
695                sim.assembly(),
696                &[shift(Trigger::Time { time_s: start_s })],
697                &[Some(start_s)],
698            )
699            .unwrap();
700            let window = (start_s, start_s + DURATION_S);
701            let state = vehicle.mass_state(at_s, window);
702            let h = 1e-6;
703            let at = |t: f64| {
704                let whole = vehicle
705                    .assembly
706                    .mass_properties_lit(t, vehicle.ignition_s());
707                vehicle.shifts.apply(whole, t)
708            };
709            let (minus, mid, plus) = (at(at_s - h), at(at_s), at(at_s + h));
710            let what = format!("at {at_s} s");
711            assert_eq!(state.cg_m, mid.cg_m, "{what}");
712            let rate = (plus.cg_m - minus.cg_m) / (2.0 * h);
713            let wide = 1e-4;
714            let accel =
715                (at(at_s + wide).cg_m - 2.0 * mid.cg_m + at(at_s - wide).cg_m) / (wide * wide);
716            let inertia_rate =
717                (plus.inertia_about(DVec3::ZERO) - minus.inertia_about(DVec3::ZERO)) * (0.5 / h);
718            assert!((state.cg_rate_m_s - rate).length() < 1e-9, "{what}: r′");
719            // Measured: 6e-12 and 2e-11 m/s for r′; 2.3e-8 and 2.9e-8 of r″, the 0.1 ms
720            // difference's own truncation, `(2π h/T)²/12 = 3.3e-8`.
721            assert!(
722                (state.cg_accel_m_s2 - accel).length() < 1e-7 * accel.length(),
723                "{what}: r″ {:?} vs {accel:?}",
724                state.cg_accel_m_s2
725            );
726            let error = (state.inertia_o_rate - inertia_rate)
727                .to_cols_array()
728                .iter()
729                .fold(0.0_f64, |m, v| m.max(v.abs()));
730            assert!(error < 1e-9, "{what}: I′ {error:e}");
731            // The part's own relative angular momentum, off the axis by 1 cm in x: `m c × δ′`.
732            let (_, ds, _) = cycloid((at_s - start_s) / DURATION_S);
733            let speed = -TRAVEL_M * ds / DURATION_S;
734            let part = ballast(&sim);
735            let expected =
736                DVec3::new(part.cg_m.y * speed, -part.cg_m.x * speed, 0.0) * part.mass_kg;
737            assert!(
738                (state.relative_momentum - expected).length() < 1e-18,
739                "{what}: h"
740            );
741            assert!(expected.length() > 1e-4, "{what}: h {expected:?}");
742        }
743    }
744
745    #[test]
746    fn a_part_moving_off_the_axis_keeps_both_momenta_in_free_space() {
747        // No air and no gravity, the motor spent, the rocket turning about all three axes: nothing
748        // acts on it, so its center of mass keeps its velocity and its angular momentum about that
749        // center is constant in the launch frame. The ballast, 1 cm off the axis, slides 0.3 m
750        // aft over 1 s; its angular momentum relative to the airframe is what `ω × h + h′` carries.
751        let t0 = 10.0;
752        let settings = FlightSettings {
753            method: Method::DormandPrince54(Adaptive {
754                relative_tolerance: 1e-12,
755                absolute_tolerance: 1e-12,
756                ..Adaptive::default()
757            }),
758            max_time_s: t0 + 2.0,
759            ..FlightSettings::default()
760        };
761        let sim = Simulation::new(
762            &with_ballast(0.01),
763            "i175",
764            analytic_environment(UniformAir::vacuum(), 0.0),
765            Rail::vertical(3.0),
766            settings,
767        )
768        .unwrap()
769        .with_shifts(vec![MassShift::new(
770            Trigger::Time { time_s: t0 + 0.5 },
771            "ballast",
772            TRAVEL_M,
773            DURATION_S,
774        )])
775        .unwrap();
776        let attitude = Rail::vertical(3.0).attitude();
777        let cg_m = sim.assembly().mass_properties(t0).cg_m;
778        let state = State {
779            position_enu_m: DVec3::new(0.0, 0.0, 1000.0) - attitude.mul_vec3(cg_m),
780            velocity_enu_m_s: DVec3::new(3.0, -2.0, 10.0),
781            attitude,
782            body_rate_rad_s: DVec3::new(0.5, 0.2, 3.0),
783        };
784        let mut ends = Ends::default();
785        let result: FlightResult = sim.run_free(t0, state, &mut ends).unwrap();
786        assert_eq!(result.termination, Termination::TimeCap);
787        assert!(result.event(EventKind::Shift(0)).is_some());
788
789        let part = ballast(&sim);
790        let momentum = |sample: &Sample| {
791            let t = sample.time_s;
792            let (s, ds, _) = cycloid((t - t0 - 0.5) / DURATION_S);
793            let rho = part.cg_m - DVec3::Z * (TRAVEL_M * s);
794            let rho_rate = -DVec3::Z * (TRAVEL_M * ds / DURATION_S);
795            let whole = sim.mass_properties(&result, t);
796            let relative = (rho - whole.cg_m).cross(rho_rate) * part.mass_kg;
797            let body = whole.inertia_kg_m2 * sample.state.body_rate_rad_s + relative;
798            (sample.state.unit_attitude().mul_vec3(body), relative)
799        };
800        let (h0, _) = momentum(&ends.0[0]);
801        let v0 = ends.0[0].cg_velocity_enu_m_s;
802        let (mut angular_error, mut linear_error, mut relative_peak) = (0.0_f64, 0.0_f64, 0.0_f64);
803        for sample in &ends.0 {
804            let (h, relative) = momentum(sample);
805            angular_error = angular_error.max((h - h0).length() / h0.length());
806            linear_error = linear_error.max((sample.cg_velocity_enu_m_s - v0).length());
807            relative_peak = relative_peak.max(relative.length() / h0.length());
808        }
809        assert!(ends.0.len() > 20, "{} steps", ends.0.len());
810        // Measured: 6.9e-12 of the angular momentum and 2.6e-12 m/s, against a relative angular
811        // momentum that peaks at 1.8% of the whole: without its terms the error would be of that
812        // order.
813        assert!(angular_error < 1e-10, "angular momentum: {angular_error:e}");
814        assert!(
815            linear_error < 1e-10,
816            "center's velocity: {linear_error:e} m/s"
817        );
818        assert!(
819            relative_peak > 1e-2,
820            "relative angular momentum: {relative_peak:e}"
821        );
822    }
823
824    #[test]
825    fn a_shift_starts_at_apogee_or_at_its_height_on_the_way_down() {
826        for (trigger, what) in [
827            (Trigger::Apogee, "apogee"),
828            (
829                Trigger::Altitude {
830                    height_above_ground_m: 200.0,
831                },
832                "height",
833            ),
834        ] {
835            let sim = simulation(&with_ballast(0.0), FlightSettings::default())
836                .with_shifts(vec![shift(trigger)])
837                .unwrap();
838            let result = sim.run(&mut ()).unwrap();
839            let started = result.event(EventKind::Shift(0)).unwrap().sample;
840            let apogee = result.event(EventKind::Apogee).unwrap().sample;
841            match trigger {
842                Trigger::Apogee => close(started.time_s, apogee.time_s, 0.0, what),
843                _ => {
844                    assert!(started.vertical_speed_m_s < 0.0, "{started:?}");
845                    close(started.height_above_ground_m, 200.0, 1e-6, what);
846                }
847            }
848            // It is where the design put it until then, and the travel on from its end.
849            let part = ballast(&sim);
850            let still = sim.assembly().mass_properties(started.time_s);
851            let before = sim.mass_properties(&result, started.time_s - 1e-3);
852            let after = sim.mass_properties(&result, started.time_s + DURATION_S);
853            assert_eq!(
854                before,
855                sim.assembly().mass_properties(started.time_s - 1e-3)
856            );
857            close(
858                after.cg_m.z,
859                still.cg_m.z - part.mass_kg * TRAVEL_M / still.mass_kg,
860                1e-15,
861                what,
862            );
863        }
864    }
865
866    /// The `what` of a refused shift.
867    fn refusal(shifts: Vec<MassShift>) -> SimError {
868        simulation(&with_ballast(0.0), FlightSettings::default())
869            .with_shifts(shifts)
870            .unwrap_err()
871    }
872
873    #[test]
874    fn shifts_that_cannot_be_made_are_refused() {
875        let time = Trigger::Time { time_s: START_S };
876        let refused = |component: &str, travel_m: f64, starts: &str| {
877            let error = refusal(vec![MassShift::new(time, component, travel_m, 1.0)]);
878            let SimError::Shift {
879                what,
880                component: id,
881            } = &error
882            else {
883                panic!("{error:?}");
884            };
885            assert!(what.starts_with(starts), "{component}: {what}");
886            assert_eq!(id, component);
887        };
888        refused("no-such-part", 0.1, "a mass shift names a component");
889        refused(
890            "sustainer-airframe",
891            0.1,
892            "a mass shift moves a part carried inside the airframe, and this is a body",
893        );
894        refused(
895            "sustainer-rail-buttons",
896            0.1,
897            "a mass shift moves a part carried inside the airframe, and this part is outside",
898        );
899        refused(
900            "sustainer-motor-mount",
901            0.1,
902            "a mass shift of a part that holds a motor",
903        );
904        // 0.1 m from the airframe's forward end: 0.15 m forward leaves it.
905        refused("ballast", -0.15, "a mass shift that can take the part out");
906        refused("ballast", 0.8, "a mass shift that can take the part out");
907        // Forward and aft each add up: two of 0.04 m forward stay inside, three don't.
908        let forward = MassShift::new(time, "ballast", -0.04, 1.0);
909        simulation(&with_ballast(0.0), FlightSettings::default())
910            .with_shifts(vec![forward.clone(), forward.clone()])
911            .unwrap();
912        assert!(matches!(
913            refusal(vec![forward.clone(), forward.clone(), forward]),
914            SimError::Shift { what, .. } if what.starts_with("a mass shift that can take")
915        ));
916
917        for (travel_m, duration_s, starts) in [
918            (f64::NAN, 1.0, "travel of a mass shift"),
919            (0.1, 0.009, "duration of a mass shift"),
920            (0.1, f64::INFINITY, "duration of a mass shift"),
921        ] {
922            let error = refusal(vec![MassShift::new(time, "ballast", travel_m, duration_s)]);
923            assert!(
924                matches!(&error, SimError::Domain { what, .. } if what.starts_with(starts)),
925                "{error:?}"
926            );
927        }
928        let error = refusal(vec![shift(Trigger::Altitude {
929            height_above_ground_m: -1.0,
930        })]);
931        assert!(
932            matches!(&error, SimError::Domain { what, .. } if what.starts_with("height above the launch site at which a part")),
933            "{error:?}"
934        );
935
936        // With ejections, in either order.
937        let ejection = Ejection::aft_of(Trigger::Apogee, "nose");
938        let error = simulation(&with_ballast(0.0), FlightSettings::default())
939            .with_shifts(vec![shift(time)])
940            .unwrap()
941            .with_ejections(vec![ejection.clone()])
942            .unwrap_err();
943        assert!(
944            matches!(error, SimError::Unsupported { what } if what.starts_with("a mass shift in a flight with"))
945        );
946        let error = simulation(&with_ballast(0.0), FlightSettings::default())
947            .with_ejections(vec![ejection])
948            .unwrap()
949            .with_shifts(vec![shift(time)])
950            .unwrap_err();
951        assert!(
952            matches!(error, SimError::Unsupported { what } if what.starts_with("a mass shift in a flight with"))
953        );
954    }
955
956    /// Flies `rocket` with its design checks' errors accepted: these tests are of the shifts.
957    fn lenient(rocket: &Rocket) -> Simulation {
958        simulation(
959            rocket,
960            FlightSettings {
961                accept_design_errors: true,
962                ..FlightSettings::default()
963            },
964        )
965    }
966
967    /// The `what` and the component of a refused shift, or the error when it is another kind.
968    fn shift_refusal(sim: Simulation, shifts: Vec<MassShift>) -> (&'static str, String) {
969        match sim.with_shifts(shifts) {
970            Err(SimError::Shift { what, component }) => (what, component),
971            other => panic!("{other:?}"),
972        }
973    }
974
975    /// A pod's parts, as a shift or a release looks them up: its body components are body
976    /// components though they hang from the pod set, the pod set is outside the airframe, and a
977    /// part inside a pod is one part only when there is one pod.
978    #[test]
979    fn a_pod_s_parts_are_located_as_the_airframe_s_are() {
980        let podded = |count: u32| {
981            let mut rocket = with_sleeve();
982            let airframe = &mut rocket.stages[0].components[1];
983            let mut held = airframe
984                .children
985                .iter()
986                .find(|child| child.id == "ballast")
987                .unwrap()
988                .clone();
989            held.id = "pod-ballast".to_owned();
990            held.position = Some(Position::Top { aft_offset_m: 0.0 });
991            let mut pod_tube = airframe.clone();
992            pod_tube.id = "pod-tube".to_owned();
993            pod_tube.auto.clear();
994            pod_tube.motor_mount = None;
995            pod_tube.children = vec![held];
996            let mut pods = pod_tube.clone();
997            pods.id = "pods".to_owned();
998            pods.part = Part::PodSet(hpr_design::PodSet {
999                count,
1000                radial_offset_m: 0.2,
1001                angle_rad: 0.0,
1002            });
1003            pods.position = Some(Position::Top { aft_offset_m: 0.0 });
1004            pods.children = vec![pod_tube];
1005            airframe.children.push(pods);
1006            let id = rocket.configurations[0].id.clone();
1007            rocket.assemble(&id).unwrap()
1008        };
1009        let one = podded(1);
1010        assert_eq!(locate_part(&one, "pod-tube"), Err(NotAPart::BodyComponent));
1011        assert_eq!(locate_part(&one, "pods"), Err(NotAPart::Outside));
1012        assert!(locate_part(&one, "pod-ballast").is_ok());
1013        assert_eq!(
1014            locate_part(&podded(2), "pod-ballast"),
1015            Err(NotAPart::NotOnePart)
1016        );
1017    }
1018
1019    #[test]
1020    fn refusals_that_need_a_design_of_their_own() {
1021        let time = Trigger::Time { time_s: START_S };
1022        let move_by = |id: &str, travel_m: f64| MassShift::new(time, id, travel_m, 1.0);
1023
1024        // A part inside another that moves. The sleeve holds no motor and could move on its own.
1025        lenient(&with_sleeve())
1026            .with_shifts(vec![move_by("sleeve", 0.01)])
1027            .unwrap();
1028        let (what, id) = shift_refusal(
1029            lenient(&with_sleeve()),
1030            vec![move_by("sleeve", 0.01), move_by("held", 0.01)],
1031        );
1032        assert!(
1033            what.starts_with("a mass shift of a part inside another"),
1034            "{what}"
1035        );
1036        assert_eq!(id, "held");
1037
1038        // One of a cluster's copies.
1039        let mut clustered = with_sleeve();
1040        let sleeve = clustered.stages[0].components[1]
1041            .children
1042            .iter_mut()
1043            .find(|child| child.id == "sleeve")
1044            .unwrap();
1045        let Part::InnerTube(tube) = &mut sleeve.part else {
1046            panic!("the motor mount is an inner tube");
1047        };
1048        tube.cluster_m = vec![[0.004, 0.0], [-0.004, 0.0]];
1049        let (what, id) = shift_refusal(lenient(&clustered), vec![move_by("held", 0.01)]);
1050        assert!(
1051            what.starts_with("a mass shift of a part that isn't exactly one"),
1052            "{what}"
1053        );
1054        assert_eq!(id, "held");
1055
1056        // A stage whose mass is overridden.
1057        let mut overridden = with_ballast(0.0);
1058        overridden.stages[0].overrides.mass_kg = Some(1.0);
1059        let (what, id) = shift_refusal(lenient(&overridden), vec![move_by("ballast", 0.1)]);
1060        assert!(
1061            what.starts_with("a mass shift in a stage whose mass is overridden"),
1062            "{what}"
1063        );
1064        assert_eq!(id, "ballast");
1065
1066        // A holder whose overridden mass covers what it holds; one covering itself alone is fine.
1067        for covers_children in [false, true] {
1068            let mut overridden = with_ballast(0.0);
1069            let airframe = &mut overridden.stages[0].components[1];
1070            airframe.overrides.mass_kg = Some(0.5);
1071            airframe.overrides_include_children = covers_children;
1072            let result = lenient(&overridden).with_shifts(vec![move_by("ballast", 0.1)]);
1073            if covers_children {
1074                assert!(
1075                    matches!(&result, Err(SimError::Shift { what, component })
1076                        if what.starts_with("a mass shift inside a component whose overridden")
1077                            && component == "ballast"),
1078                    "{result:?}"
1079                );
1080            } else {
1081                result.unwrap();
1082            }
1083        }
1084
1085        // A part the design already puts past its holder's aft end: it may move back in, not out.
1086        let mut past = with_ballast(0.0);
1087        let ballast = past.stages[0].components[1]
1088            .children
1089            .iter_mut()
1090            .find(|child| child.id == "ballast")
1091            .unwrap();
1092        ballast.position = Some(Position::Top { aft_offset_m: 0.87 });
1093        lenient(&past)
1094            .with_shifts(vec![move_by("ballast", -0.1)])
1095            .unwrap();
1096        let (what, _) = shift_refusal(lenient(&past), vec![move_by("ballast", 0.01)]);
1097        assert!(
1098            what.starts_with("a mass shift that can take the part out"),
1099            "{what}"
1100        );
1101        // And one that reaches 2 cm forward of its holder's forward end: aft, not forward.
1102        let ballast = past.stages[0].components[1]
1103            .children
1104            .iter_mut()
1105            .find(|child| child.id == "ballast")
1106            .unwrap();
1107        ballast.position = Some(Position::Top {
1108            aft_offset_m: -0.02,
1109        });
1110        lenient(&past)
1111            .with_shifts(vec![move_by("ballast", 0.1)])
1112            .unwrap();
1113        let (what, _) = shift_refusal(lenient(&past), vec![move_by("ballast", -0.01)]);
1114        assert!(
1115            what.starts_with("a mass shift that can take the part out"),
1116            "{what}"
1117        );
1118
1119        // A stage override of the center of mass alone is an override too.
1120        let mut overridden = with_ballast(0.0);
1121        overridden.stages[0].overrides.cg_aft_m = Some(0.5);
1122        let (what, _) = shift_refusal(lenient(&overridden), vec![move_by("ballast", 0.1)]);
1123        assert!(
1124            what.starts_with("a mass shift in a stage whose mass is overridden"),
1125            "{what}"
1126        );
1127    }
1128
1129    #[test]
1130    fn triggers_a_shift_cannot_have_are_refused_in_its_own_words() {
1131        let domain = |result: Result<Simulation, SimError>| match result {
1132            Err(SimError::Domain { what, .. }) => what,
1133            other => panic!("{other:?}"),
1134        };
1135        let sim = || lenient(&with_ballast(0.0));
1136        let what = domain(sim().with_shifts(vec![shift(Trigger::Time { time_s: -1.0 })]));
1137        assert!(
1138            what.starts_with("start time of a mass shift after launch"),
1139            "{what}"
1140        );
1141        let what = domain(sim().with_shifts(vec![shift(Trigger::Burnout {
1142            motor: 0,
1143            delay_s: -1.0,
1144        })]));
1145        assert!(
1146            what.starts_with("delay after a motor's burnout, s"),
1147            "{what}"
1148        );
1149        let what = domain(sim().with_shifts(vec![shift(Trigger::MotorDelay { motor: 3 })]));
1150        assert!(
1151            what.starts_with("index of the motor whose delay starts a mass shift"),
1152            "{what}"
1153        );
1154        let mut plugged = with_ballast(0.0);
1155        plugged.configurations[0].motors[0].delay = None;
1156        let what =
1157            domain(lenient(&plugged).with_shifts(vec![shift(Trigger::MotorDelay { motor: 0 })]));
1158        assert!(
1159            what.starts_with("the motor whose delay starts a mass shift has no ejection"),
1160            "{what}"
1161        );
1162        // A motor that never lights has no burnout to count from.
1163        let mut failed = with_ballast(0.0);
1164        failed.configurations[0].motors[0].failed_tubes = vec![0];
1165        let what = domain(lenient(&failed).with_shifts(vec![shift(Trigger::Burnout {
1166            motor: 0,
1167            delay_s: 1.0,
1168        })]));
1169        assert!(
1170            what.starts_with("index of the motor a mass shift is timed from"),
1171            "{what}"
1172        );
1173        // A separation after the shifts, as after ejections.
1174        let result = sim()
1175            .with_shifts(vec![shift(Trigger::Time { time_s: START_S })])
1176            .unwrap()
1177            .with_separation(crate::recovery::Separation::new(Trigger::Apogee, 0));
1178        assert!(
1179            matches!(&result, Err(SimError::Unsupported { what }) if what.starts_with("a mass shift in a flight with")),
1180            "{result:?}"
1181        );
1182        // Shifts given after a separation, on the two-stage test design.
1183        let two_stage = Simulation::new(
1184            &design("synthetic-two-stage-75mm-54mm"),
1185            "j760-i175",
1186            analytic_environment(UniformAir::sea_level(), G),
1187            Rail::vertical(3.0),
1188            FlightSettings::default(),
1189        )
1190        .unwrap()
1191        .with_separation(crate::recovery::Separation::new(Trigger::Apogee, 0))
1192        .unwrap()
1193        .with_shifts(vec![shift(Trigger::Time { time_s: START_S })]);
1194        assert!(
1195            matches!(&two_stage, Err(SimError::Unsupported { what }) if what.starts_with("a mass shift in a flight with")),
1196            "{two_stage:?}"
1197        );
1198        // A shift that would start on the rail is refused when it comes.
1199        for time_s in [0.0, 0.1] {
1200            let error = sim()
1201                .with_shifts(vec![shift(Trigger::Time { time_s })])
1202                .unwrap()
1203                .run(&mut ())
1204                .unwrap_err();
1205            assert!(
1206                matches!(&error, SimError::Domain { what, value }
1207                    if what.starts_with("start time of a mass shift, s (it must start once")
1208                        && *value == time_s),
1209                "{error:?}"
1210            );
1211        }
1212    }
1213
1214    #[test]
1215    fn a_canopy_that_opens_while_the_ballast_moves_keeps_the_center_s_velocity() {
1216        // In a vacuum, so the canopy has no air to drag on: the ballast starts to move at apogee
1217        // and a drogue opens 0.5 s into the move. The descent takes the center's velocity as it
1218        // was, and from then on only gravity changes it, while the ballast finishes its move and
1219        // the nose tip reacts.
1220        let drogue = crate::recovery::Device::new(
1221            "drogue",
1222            crate::recovery::DeviceDrag::canopy(crate::recovery::CanopyType::FlatCircular, 0.6),
1223            Trigger::Apogee,
1224        )
1225        .with_lag_s(0.5);
1226        let sim = Simulation::new(
1227            &with_ballast(0.0),
1228            "i175",
1229            analytic_environment(UniformAir::vacuum(), G),
1230            Rail::vertical(3.0),
1231            FlightSettings {
1232                method: Method::DormandPrince54(Adaptive {
1233                    relative_tolerance: 1e-12,
1234                    absolute_tolerance: 1e-12,
1235                    ..Adaptive::default()
1236                }),
1237                ..FlightSettings::default()
1238            },
1239        )
1240        .unwrap()
1241        .with_recovery(vec![drogue])
1242        .unwrap()
1243        .with_shifts(vec![shift(Trigger::Apogee)])
1244        .unwrap();
1245        let mut ends = Ends::default();
1246        let result = sim.run(&mut ends).unwrap();
1247        let started = result.event(EventKind::Shift(0)).unwrap().sample.time_s;
1248        let opened = result.event(EventKind::Deployment(0)).unwrap().sample;
1249        close(
1250            opened.time_s - started,
1251            0.5,
1252            1e-9,
1253            "the drogue opens halfway",
1254        );
1255        // The step that ends at the deployment, in free flight, and the descent's first sample.
1256        let before = ends
1257            .0
1258            .iter()
1259            .rfind(|sample| sample.time_s == opened.time_s && sample.phase == crate::Phase::Free)
1260            .unwrap();
1261        let jump = (opened.cg_velocity_enu_m_s - before.cg_velocity_enu_m_s).length();
1262        assert!(jump < 1e-12, "the center's velocity jumps by {jump:e} m/s");
1263        // The ballast is moving then, so the nose tip's velocity differs from the center's.
1264        let relative = (before.state.velocity_enu_m_s - before.cg_velocity_enu_m_s).length();
1265        assert!(relative > 0.01, "{relative} m/s");
1266        // Through the rest of the move and after it, the center falls freely.
1267        let mut checked = 0;
1268        for sample in ends.0.iter().filter(|sample| {
1269            sample.phase == crate::Phase::Descent && sample.time_s <= started + 2.0 * DURATION_S
1270        }) {
1271            let fallen =
1272                opened.cg_velocity_enu_m_s - DVec3::Z * (G * (sample.time_s - opened.time_s));
1273            let error = (sample.cg_velocity_enu_m_s - fallen).length();
1274            assert!(error < 1e-9, "at {} s: {error:e} m/s", sample.time_s);
1275            checked += 1;
1276        }
1277        assert!(checked >= 5, "{checked} samples");
1278    }
1279
1280    #[test]
1281    fn a_shift_the_flight_starts_gets_its_stops_too() {
1282        // RK4 at 50 ms steps and a 10 ms move from apogee: the stops are inserted when it starts.
1283        let sim = simulation(
1284            &with_ballast(0.0),
1285            FlightSettings {
1286                method: Method::Rk4 { step_s: 0.05 },
1287                ..FlightSettings::default()
1288            },
1289        )
1290        .with_shifts(vec![MassShift::new(
1291            Trigger::Apogee,
1292            "ballast",
1293            TRAVEL_M,
1294            MIN_SHIFT_DURATION_S,
1295        )])
1296        .unwrap();
1297        let mut ends = Ends::default();
1298        let result = sim.run(&mut ends).unwrap();
1299        let started = result.event(EventKind::Shift(0)).unwrap().sample.time_s;
1300        let during = ends
1301            .0
1302            .iter()
1303            .filter(|sample| {
1304                sample.time_s > started && sample.time_s <= started + MIN_SHIFT_DURATION_S
1305            })
1306            .count();
1307        assert_eq!(during, SHIFT_STOPS);
1308    }
1309
1310    #[test]
1311    fn a_fixed_step_follows_a_short_shift_in_its_stops() {
1312        // The free-space case with RK4 at 10 ms steps and a 10 ms move: without the stops across
1313        // it the step would take the whole move at once.
1314        let t0 = 10.0;
1315        let settings = FlightSettings {
1316            method: Method::Rk4 { step_s: 0.01 },
1317            max_time_s: t0 + 1.0,
1318            ..FlightSettings::default()
1319        };
1320        let sim = Simulation::new(
1321            &with_ballast(0.01),
1322            "i175",
1323            analytic_environment(UniformAir::vacuum(), 0.0),
1324            Rail::vertical(3.0),
1325            settings,
1326        )
1327        .unwrap()
1328        .with_shifts(vec![MassShift::new(
1329            Trigger::Time { time_s: t0 + 0.5 },
1330            "ballast",
1331            TRAVEL_M,
1332            MIN_SHIFT_DURATION_S,
1333        )])
1334        .unwrap();
1335        let attitude = Rail::vertical(3.0).attitude();
1336        let cg_m = sim.assembly().mass_properties(t0).cg_m;
1337        let state = State {
1338            position_enu_m: DVec3::new(0.0, 0.0, 1000.0) - attitude.mul_vec3(cg_m),
1339            velocity_enu_m_s: DVec3::new(3.0, -2.0, 10.0),
1340            attitude,
1341            body_rate_rad_s: DVec3::new(0.5, 0.2, 3.0),
1342        };
1343        let mut ends = Ends::default();
1344        sim.run_free(t0, state, &mut ends).unwrap();
1345        let v0 = ends.0[0].cg_velocity_enu_m_s;
1346        let error = ends
1347            .0
1348            .iter()
1349            .map(|sample| (sample.cg_velocity_enu_m_s - v0).length())
1350            .fold(0.0_f64, f64::max);
1351        let during = ends
1352            .0
1353            .iter()
1354            .filter(|sample| sample.time_s > t0 + 0.5 && sample.time_s <= t0 + 0.51)
1355            .count();
1356        // Measured: 1.2e-4 m/s over the flight, in 16 steps across the move. Taking the move in
1357        // one 10 ms step, review measured 0.071 m/s.
1358        assert_eq!(during, SHIFT_STOPS);
1359        assert!(error < 3e-4, "center's velocity: {error:e} m/s");
1360    }
1361
1362    #[test]
1363    fn the_cycloid_rests_at_both_ends_and_is_fastest_halfway() {
1364        assert_eq!(cycloid(-0.5), (0.0, 0.0, 0.0));
1365        assert_eq!(cycloid(1.5), (1.0, 0.0, 0.0));
1366        let (s, ds, dds) = cycloid(0.5);
1367        assert!((s - 0.5).abs() < 1e-16);
1368        assert!((ds - 2.0).abs() < 1e-15);
1369        assert!(dds.abs() < 1e-14);
1370        // A quarter of the way: s = ¼ − 1/2π, s′ = 1, s″ = 2π.
1371        let (s, ds, dds) = cycloid(0.25);
1372        assert!((s - (0.25 - 1.0 / TAU)).abs() < 1e-16);
1373        assert!((ds - 1.0).abs() < 1e-15);
1374        assert!((dds - TAU).abs() < 1e-14);
1375        // Its derivatives are the derivatives: central differences agree.
1376        let h = 1e-6;
1377        for tau in [0.1, 0.3, 0.7, 0.9] {
1378            let (s_minus, ds_minus, _) = cycloid(tau - h);
1379            let (s_plus, ds_plus, _) = cycloid(tau + h);
1380            let (_, ds, dds) = cycloid(tau);
1381            assert!(((s_plus - s_minus) / (2.0 * h) - ds).abs() < 1e-8, "{tau}");
1382            assert!(
1383                ((ds_plus - ds_minus) / (2.0 * h) - dds).abs() < 1e-7,
1384                "{tau}"
1385            );
1386        }
1387    }
1388}