Skip to main content

hpr_motor/
motor.rs

1//! The solid motor model: thrust, propellant consumption, and the motor's mass, center of mass and
2//! inertia at any time in the burn.
3//!
4//! **Consumption by impulse fraction.** With a constant effective exhaust velocity `c = F/ṁ`
5//! (NASA SP-8039, glossary p. 95), the propellant burned by time `t` is in proportion to the
6//! impulse delivered:
7//!
8//! ```text
9//! c = I_total / m_p0,   ṁ(t) = F(t) / c,   m_p(t) = m_p0 (1 − I(t)/I_total)
10//! ```
11//!
12//! This is RocketPy's `SolidMotor` consumption (`rocketpy/motors/solid_motor.py:401-418`,
13//! `motor.py:483-524`), and the mass column of ThrustCurve.org's `.rse` files follows it
14//! (`docs/format/rse.md`). It is an approximation: the real `c` drifts with chamber pressure and
15//! nozzle erosion (SP-8039 p. 14). All propellant is gone when the thrust curve ends.
16//!
17//! **Where the propellant is.** A [`Propellant::Column`] keeps its shape and loses density as it
18//! burns, so its center stays put and its inertia scales with its mass (the model of RocketPy's
19//! `GenericMotor`). [`Propellant::Grains`] regress as BATES grains ([`crate::grains`]).
20//!
21//! **The whole motor** combines the propellant with the dry mass (case, closures, nozzle, liner)
22//! about the instantaneous center of mass ([`MassElement::combine`]).
23//!
24//! **Ambient pressure.** A curve measured at reference pressure `p_ref` gives, at ambient `p_a`,
25//! `F = F_ref + (p_ref − p_a) A_e` (from SP-8039 eq. 2, `F = ṁ u_e + (p_e − p_a) A_e`, with the
26//! flow unchanged; RocketPy's `Motor.pressure_thrust`, `motor.py:1173-1191`). It holds while the
27//! nozzle flows full. See `docs/physics/motor.md`.
28
29use std::f64::consts::PI;
30
31use serde::{Deserialize, Serialize};
32
33use crate::curve::ThrustCurve;
34use crate::error::MotorError;
35use crate::grains::BatesGrains;
36use crate::mass::MassElement;
37
38/// What a motor whose figures imply an impossible exhaust velocity should check: the hint that
39/// ends that refusal's message, so a program can show it apart from the fact (`hpr` writes it as
40/// a `help:` line).
41pub const UNITS_HINT: &str = "check the units of the masses and the curve";
42
43/// Standard sea-level pressure (US Standard Atmosphere 1976), Pa.
44///
45/// A thrust curve's reference pressure is the ambient pressure where the motor was static-tested,
46/// which motor files and catalogs don't record; this value is only a stand-in when the test site's
47/// pressure is unknown.
48pub const STANDARD_SEA_LEVEL_PRESSURE_PA: f64 = 101_325.0;
49
50/// A propellant charge of fixed shape whose density falls as it burns.
51#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
52#[serde(deny_unknown_fields)]
53pub struct PropellantColumn {
54    /// Initial propellant mass, kg.
55    pub mass_kg: f64,
56    /// Center of the column along the motor axis, m from the nozzle exit.
57    pub center_m: f64,
58    /// Outer radius, m.
59    pub outer_radius_m: f64,
60    /// Inner (bore) radius, m; zero for a solid column.
61    pub inner_radius_m: f64,
62    /// Length, m.
63    pub length_m: f64,
64}
65
66/// How the propellant is laid out and how its shape evolves. Serialized with a `model` tag
67/// (`"column"`, `"grains"`).
68///
69/// Not `Copy`, so that a later model can hold tabulated data.
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
71#[serde(tag = "model", rename_all = "snake_case")]
72#[non_exhaustive]
73pub enum Propellant {
74    /// A fixed-shape column: the default when only a motor's envelope is known.
75    Column(PropellantColumn),
76    /// BATES grains that regress on their bores and ends.
77    Grains(BatesGrains),
78}
79
80/// A nozzle, for the ambient-pressure correction of thrust.
81#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
82#[serde(deny_unknown_fields)]
83pub struct Nozzle {
84    /// Exit radius, m.
85    pub exit_radius_m: f64,
86    /// Throat radius, m, when known (informational: the thrust curve already carries its effect).
87    pub throat_radius_m: Option<f64>,
88    /// The ambient pressure the thrust curve was measured at, Pa: the static test site's. The
89    /// thrust is then corrected to the ambient pressure in flight.
90    ///
91    /// `None` flies the curve as it is at every ambient pressure, with no correction. That is
92    /// RocketPy's default (`Motor(reference_pressure=None)`, whose `pressure_thrust` is then zero,
93    /// `motor.py:1188-1189`), so a RocketPy input transcribed into hpr says `None`.
94    ///
95    /// Which to give: motor files don't record where the curve was measured. For a motor tested
96    /// near sea level, [`STANDARD_SEA_LEVEL_PRESSURE_PA`] adds the thrust a higher site gains
97    /// (16 kPa × `A_e` at 1,400 m); `None` leaves it out. A design must say which: the field is
98    /// required, as `null` for `None`, so leaving it out is an error rather than a silent choice.
99    /// Motors read from `.eng` or `.rse` files, or from the catalog, carry no nozzle and so no
100    /// correction.
101    #[serde(deserialize_with = "Option::deserialize")]
102    // Required, and `null` allowed: `required` alone would drop the `null`.
103    #[schemars(required, extend("type" = ["number", "null"]))]
104    pub reference_pressure_pa: Option<f64>,
105}
106
107impl Nozzle {
108    /// The exit area `A_e = π r_e²`, m².
109    pub fn exit_area_m2(&self) -> f64 {
110        PI * self.exit_radius_m * self.exit_radius_m
111    }
112}
113
114/// A solid rocket motor.
115///
116/// Nothing here can tell a hybrid's thrust curve from a solid's, so the checks are at the edges:
117/// [`crate::catalog::CatalogMotor::motor`] refuses hybrids and the `.rse` reader warns about them
118/// (`.eng` files don't say).
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
120#[serde(try_from = "MotorData", into = "MotorData")]
121pub struct SolidMotor {
122    curve: ThrustCurve,
123    propellant: Propellant,
124    dry: MassElement,
125    nozzle: Option<Nozzle>,
126    /// `m_p0`, kg.
127    propellant_mass_kg: f64,
128}
129
130/// The serialized form of a [`SolidMotor`].
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
132#[serde(deny_unknown_fields)]
133struct MotorData {
134    curve: ThrustCurve,
135    propellant: Propellant,
136    dry: MassElement,
137    nozzle: Option<Nozzle>,
138}
139
140impl TryFrom<MotorData> for SolidMotor {
141    type Error = MotorError;
142
143    fn try_from(data: MotorData) -> Result<Self, Self::Error> {
144        Self::new(data.curve, data.propellant, data.dry, data.nozzle)
145    }
146}
147
148impl From<SolidMotor> for MotorData {
149    fn from(motor: SolidMotor) -> Self {
150        Self {
151            curve: motor.curve,
152            propellant: motor.propellant,
153            dry: motor.dry,
154            nozzle: motor.nozzle,
155        }
156    }
157}
158
159/// A motor's thrust and mass properties at one time.
160#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
161pub struct MotorState {
162    /// Time since ignition, s.
163    pub time_s: f64,
164    /// Thrust from the curve (at its reference pressure), N.
165    pub thrust_n: f64,
166    /// Propellant consumption rate `ṁ = F/c`, kg/s (non-negative; the motor's mass falls at this
167    /// rate).
168    pub mass_flow_kg_s: f64,
169    /// The propellant left, about its own center of mass.
170    pub propellant: MassElement,
171    /// The whole motor (dry mass plus propellant), about the motor's center of mass.
172    pub total: MassElement,
173}
174
175/// The effective exhaust velocity `c = I/m_p` a solid motor's curve and propellant mass have to
176/// imply, m/s.
177///
178/// Measured over the 1,708 ThrustCurve.org simulator files with both a parsed impulse and a
179/// catalog propellant mass (the mass [`crate::CatalogMotor::motor`] uses, which prefers the
180/// metadata over the curve file's header), `c` runs 236 to 3,031 m/s, with a median of 1,867 and
181/// 90% of them between 928 and 2,210 (the full table is in
182/// `docs/research/exhaust-velocity-guard.md`). The bulk is APCP; the tail below
183/// about 900 m/s is black powder, read low because Estes and Quest count the delay grain and the
184/// ejection charge as propellant.
185///
186/// **The bound rejects none of those 1,708.** It is not a filter on propellant: it is there to
187/// catch a mass-unit slip, grams given as kilograms, which moves `c` by a factor of 1,000: the
188/// worked example in [`SolidMotor::from_envelope`]'s test lands at 1.8 m/s. `c` doesn't depend on
189/// the motor's size, so sizes given in millimeters pass it. The headroom is real but not enormous
190/// at the low end (the lowest catalog entry is 1.2x above the floor, the highest 1.65x below the
191/// ceiling), and a 1/8A whose recorded propellant mass is mostly delay grain could fall through
192/// the floor; issue #11 records the fallback, which is to apply the bound only above a couple of
193/// grams.
194pub const EXHAUST_VELOCITY_RANGE_M_S: std::ops::RangeInclusive<f64> = 200.0..=5000.0;
195
196impl SolidMotor {
197    /// Builds a motor from its thrust curve, propellant, dry mass (about its own center) and
198    /// optional nozzle.
199    ///
200    /// # Errors
201    ///
202    /// - [`MotorError::Domain`] for a non-positive or non-finite dry mass (the motor at burnout,
203    ///   whose center of mass needs some mass), a negative or non-finite dry inertia, a non-finite
204    ///   position, a column with non-positive mass, radius or length, a nozzle with a non-positive
205    ///   exit radius, a throat radius outside `(0, exit radius]`, or a negative or non-finite
206    ///   reference pressure.
207    /// - [`MotorError::Inconsistent`] for a column bore at least as wide as the column, bad grain
208    ///   geometry ([`BatesGrains::validate`]), or a curve and propellant mass whose effective
209    ///   exhaust velocity `I/m_p` is outside [`EXHAUST_VELOCITY_RANGE_M_S`], which is what a
210    ///   propellant mass in the wrong unit looks like. Sizes can't be checked this way.
211    pub fn new(
212        curve: ThrustCurve,
213        propellant: Propellant,
214        dry: MassElement,
215        nozzle: Option<Nozzle>,
216    ) -> Result<Self, MotorError> {
217        dry.validate([
218            "dry mass (kg)",
219            "dry center of mass (m)",
220            "dry axial inertia (kg·m²)",
221            "dry transverse inertia (kg·m²)",
222        ])?;
223        if dry.mass_kg <= 0.0 {
224            return Err(MotorError::Domain {
225                what: "dry mass (kg), which must be positive",
226                value: dry.mass_kg,
227            });
228        }
229        let propellant_mass_kg = match &propellant {
230            Propellant::Column(column) => {
231                for (value, what) in [
232                    (column.mass_kg, "propellant mass (kg)"),
233                    (column.outer_radius_m, "propellant outer radius (m)"),
234                    (column.length_m, "propellant length (m)"),
235                ] {
236                    if !(value.is_finite() && value > 0.0) {
237                        return Err(MotorError::Domain { what, value });
238                    }
239                }
240                if !column.center_m.is_finite() {
241                    return Err(MotorError::Domain {
242                        what: "propellant center (m)",
243                        value: column.center_m,
244                    });
245                }
246                if !(column.inner_radius_m.is_finite() && column.inner_radius_m >= 0.0) {
247                    return Err(MotorError::Domain {
248                        what: "propellant bore radius (m)",
249                        value: column.inner_radius_m,
250                    });
251                }
252                if column.inner_radius_m >= column.outer_radius_m {
253                    return Err(MotorError::Inconsistent(format!(
254                        "propellant bore radius {} m is not inside the outer radius {} m",
255                        column.inner_radius_m, column.outer_radius_m
256                    )));
257                }
258                column.mass_kg
259            }
260            Propellant::Grains(grains) => {
261                grains.validate()?;
262                grains.initial_mass_kg()
263            }
264        };
265        if let Some(nozzle) = &nozzle {
266            if !(nozzle.exit_radius_m.is_finite() && nozzle.exit_radius_m > 0.0) {
267                return Err(MotorError::Domain {
268                    what: "nozzle exit radius (m)",
269                    value: nozzle.exit_radius_m,
270                });
271            }
272            if let Some(throat) = nozzle.throat_radius_m
273                && !(throat.is_finite() && throat > 0.0 && throat <= nozzle.exit_radius_m)
274            {
275                return Err(MotorError::Domain {
276                    what: "nozzle throat radius (m), which must be in (0, exit radius]",
277                    value: throat,
278                });
279            }
280            if let Some(reference) = nozzle.reference_pressure_pa
281                && !(reference.is_finite() && reference >= 0.0)
282            {
283                return Err(MotorError::Domain {
284                    what: "thrust-curve reference pressure (Pa)",
285                    value: reference,
286                });
287            }
288        }
289        // A mass-unit slip is the failure this catches: the 411I175 built from grams read as
290        // kilograms is accepted by every check above, and flies with an effective exhaust velocity
291        // of 1.8 m/s. Its sizes in millimeters don't change `c`, so nothing here can catch them.
292        let exhaust_velocity_m_s = curve.total_impulse_ns() / propellant_mass_kg;
293        if !EXHAUST_VELOCITY_RANGE_M_S.contains(&exhaust_velocity_m_s) {
294            return Err(MotorError::Inconsistent(format!(
295                "a total impulse of {} N·s from {propellant_mass_kg} kg of propellant is an \
296                 effective exhaust velocity of {exhaust_velocity_m_s} m/s, outside the {} to {} \
297                 m/s a solid motor can have; {UNITS_HINT}",
298                curve.total_impulse_ns(),
299                EXHAUST_VELOCITY_RANGE_M_S.start(),
300                EXHAUST_VELOCITY_RANGE_M_S.end()
301            )));
302        }
303        Ok(Self {
304            curve,
305            propellant,
306            dry,
307            nozzle,
308            propellant_mass_kg,
309        })
310    }
311
312    /// A motor known only by its envelope, as in a motor file or catalog: diameter `D`, length `L`,
313    /// propellant mass and loaded mass.
314    ///
315    /// The assumptions are crude, and a motor with measured data should be built with
316    /// [`SolidMotor::new`] instead:
317    ///
318    /// - the propellant is a solid column of radius `D/2` filling the length, centered at `L/2`;
319    /// - the dry mass (loaded minus propellant) is a thin tube of radius `D/2` and length `L`,
320    ///   centered at `L/2`;
321    /// - no nozzle is known, so thrust gets no ambient-pressure correction.
322    ///
323    /// # Errors
324    ///
325    /// [`MotorError::Domain`] for a non-positive or non-finite dimension or propellant mass, and
326    /// [`MotorError::Inconsistent`] when the propellant mass is not below the loaded mass (the
327    /// motor would weigh nothing at burnout), or when the curve and the propellant mass imply an
328    /// effective exhaust velocity outside [`EXHAUST_VELOCITY_RANGE_M_S`], which is what masses
329    /// given in grams look like. Sizes given in millimeters don't change it, so this check can't
330    /// catch them.
331    pub fn from_envelope(
332        curve: ThrustCurve,
333        diameter_m: f64,
334        length_m: f64,
335        propellant_mass_kg: f64,
336        loaded_mass_kg: f64,
337    ) -> Result<Self, MotorError> {
338        for (value, what) in [
339            (propellant_mass_kg, "propellant mass (kg)"),
340            (loaded_mass_kg, "loaded motor mass (kg)"),
341            (diameter_m, "motor diameter (m)"),
342            (length_m, "motor length (m)"),
343        ] {
344            if !(value.is_finite() && value > 0.0) {
345                return Err(MotorError::Domain { what, value });
346            }
347        }
348        if propellant_mass_kg >= loaded_mass_kg {
349            return Err(MotorError::Inconsistent(format!(
350                "propellant mass {propellant_mass_kg} kg is not below the loaded mass \
351                 {loaded_mass_kg} kg"
352            )));
353        }
354        let radius = 0.5 * diameter_m;
355        let center = 0.5 * length_m;
356        let column = PropellantColumn {
357            mass_kg: propellant_mass_kg,
358            center_m: center,
359            outer_radius_m: radius,
360            inner_radius_m: 0.0,
361            length_m,
362        };
363        let dry = MassElement::thin_tube(
364            loaded_mass_kg - propellant_mass_kg,
365            center,
366            radius,
367            length_m,
368        );
369        Self::new(curve, Propellant::Column(column), dry, None)
370    }
371
372    /// The same motor with more dry mass, such as a reload's case and closures (when the loaded
373    /// mass excludes them) or a motor retainer, combined about the new dry center of mass.
374    ///
375    /// # Errors
376    ///
377    /// [`MotorError::Domain`] for a negative or non-finite mass or inertia, or a non-finite
378    /// position.
379    pub fn with_added_dry_mass(mut self, hardware: MassElement) -> Result<Self, MotorError> {
380        hardware.validate([
381            "added dry mass (kg)",
382            "added dry mass center (m)",
383            "added dry axial inertia (kg·m²)",
384            "added dry transverse inertia (kg·m²)",
385        ])?;
386        self.dry = MassElement::combine([&self.dry, &hardware]);
387        Ok(self)
388    }
389
390    /// The thrust curve.
391    pub fn curve(&self) -> &ThrustCurve {
392        &self.curve
393    }
394
395    /// The propellant model.
396    pub fn propellant(&self) -> &Propellant {
397        &self.propellant
398    }
399
400    /// The dry mass, about its own center of mass.
401    pub fn dry(&self) -> MassElement {
402        self.dry
403    }
404
405    /// The nozzle, when known.
406    pub fn nozzle(&self) -> Option<Nozzle> {
407        self.nozzle
408    }
409
410    /// The propellant mass at ignition `m_p0`, kg.
411    pub fn propellant_initial_mass_kg(&self) -> f64 {
412        self.propellant_mass_kg
413    }
414
415    /// The effective exhaust velocity `c = I_total / m_p0`, m/s.
416    pub fn exhaust_velocity_m_s(&self) -> f64 {
417        self.curve.total_impulse_ns() / self.propellant_mass_kg
418    }
419
420    /// The time the thrust curve ends and the propellant is gone, s.
421    pub fn burnout_time_s(&self) -> f64 {
422        self.curve.end_time_s()
423    }
424
425    /// The propellant left at time `t`, `m_p0 (1 − I(t)/I_total)`, kg. A NaN time gives NaN.
426    pub fn propellant_mass_kg(&self, t: f64) -> f64 {
427        if t.is_nan() {
428            return f64::NAN;
429        }
430        let fraction = self.curve.impulse_ns(t) / self.curve.total_impulse_ns();
431        (self.propellant_mass_kg * (1.0 - fraction)).max(0.0)
432    }
433
434    /// The thrust and mass properties at `t` seconds after ignition. Before ignition the motor is
435    /// loaded and after burnout it is empty.
436    pub fn state(&self, t: f64) -> MotorState {
437        let thrust_n = self.curve.thrust_n(t);
438        let mass = self.propellant_mass_kg(t);
439        let propellant = match &self.propellant {
440            Propellant::Column(column) => MassElement::hollow_cylinder(
441                mass,
442                column.center_m,
443                column.outer_radius_m,
444                column.inner_radius_m,
445                column.length_m,
446            ),
447            Propellant::Grains(grains) => grains.mass_element(mass),
448        };
449        MotorState {
450            time_s: t,
451            thrust_n,
452            mass_flow_kg_s: thrust_n / self.exhaust_velocity_m_s(),
453            propellant,
454            total: MassElement::combine([&self.dry, &propellant]),
455        }
456    }
457
458    /// The thrust at `t` with ambient pressure `ambient_pa`, N: `F + (p_ref − p_a) A_e`, with the
459    /// nozzle's [`Nozzle::reference_pressure_pa`], strictly inside the burn (`0 < t < t_end`, as
460    /// RocketPy's flight applies it) where the curve's thrust is positive, never below zero; the
461    /// curve's thrust elsewhere, without a nozzle, and with a nozzle that gives no reference
462    /// pressure. A NaN time gives NaN, and so does a NaN ambient pressure where the term applies.
463    ///
464    /// The term is the full-flow value throughout, so the thrust steps by it just after ignition
465    /// and again at `t_end` (both events for an integrator), and in the ignition transient and the
466    /// tail-off, where a real nozzle's exit pressure is far from its full-flow value, it misstates
467    /// the thrust (`docs/physics/motor.md`).
468    pub fn thrust_at_pressure_n(&self, t: f64, ambient_pa: f64) -> f64 {
469        let thrust = self.curve.thrust_n(t);
470        match self.nozzle {
471            Some(
472                nozzle @ Nozzle {
473                    reference_pressure_pa: Some(reference_pa),
474                    ..
475                },
476            ) if t > 0.0 && t < self.curve.end_time_s() && (thrust > 0.0 || thrust.is_nan()) => {
477                let corrected = thrust + (reference_pa - ambient_pa) * nozzle.exit_area_m2();
478                if corrected.is_nan() {
479                    corrected
480                } else {
481                    corrected.max(0.0)
482                }
483            }
484            _ => thrust,
485        }
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    fn curve() -> ThrustCurve {
494        ThrustCurve::new(vec![0.0, 0.1, 1.0, 1.2], vec![0.0, 500.0, 400.0, 0.0]).unwrap()
495    }
496
497    #[test]
498    fn column_mass_follows_the_impulse_fraction() {
499        let motor = SolidMotor::from_envelope(curve(), 0.038, 0.25, 0.3, 0.6).unwrap();
500        let total = motor.curve().total_impulse_ns();
501        assert!((total - (25.0 + 405.0 + 40.0)).abs() < 1e-12);
502        assert!((motor.exhaust_velocity_m_s() - total / 0.3).abs() < 1e-9);
503        for t in [-1.0, 0.0, 0.05, 0.5, 1.1, 1.2, 5.0] {
504            let state = motor.state(t);
505            let expected = 0.3 * (1.0 - motor.curve().impulse_ns(t) / total);
506            assert!((state.propellant.mass_kg - expected).abs() < 1e-15, "{t}");
507            assert!((state.total.mass_kg - (0.3 + expected)).abs() < 1e-15);
508            assert!(
509                (state.mass_flow_kg_s * motor.exhaust_velocity_m_s() - state.thrust_n).abs() < 1e-9
510            );
511        }
512        assert_eq!(motor.state(0.0).propellant.mass_kg, 0.3);
513        assert_eq!(motor.state(1.2).propellant.mass_kg, 0.0);
514        // The ṁ integral is the propellant burned: trapezoids on a fine grid.
515        let n = 12_000;
516        let dt = 1.2 / f64::from(n);
517        let burned: f64 = (0..n)
518            .map(|i| {
519                let (a, b) = (f64::from(i) * dt, f64::from(i + 1) * dt);
520                0.5 * (motor.state(a).mass_flow_kg_s + motor.state(b).mass_flow_kg_s) * dt
521            })
522            .sum();
523        assert!((burned - 0.3).abs() < 1e-6, "{burned}");
524    }
525
526    #[test]
527    fn a_units_slip_is_refused_by_its_exhaust_velocity() {
528        // The worked example from the issue: the 411I175's envelope in millimeters and grams,
529        // read as meters and kilograms. Every dimension is positive and finite, the propellant is
530        // below the loaded mass, and the motor is nonsense: 437.5 kg of motor, and 411 N·s from
531        // 228.9 kg of propellant is an exhaust velocity of 1.8 m/s.
532        let i175 = ThrustCurve::new(vec![0.0, 0.1, 2.3, 2.4], vec![0.0, 220.0, 150.0, 0.0])
533            .expect("a plausible I-class curve");
534        // 425.5 N·s: an I by the NFPA classes, which is what the designation says.
535        let impulse = i175.total_impulse_ns();
536        assert!((impulse - 425.5).abs() < 0.1, "{impulse}");
537        let slipped = SolidMotor::from_envelope(i175.clone(), 38.0, 245.0, 228.9, 437.5)
538            .expect_err("millimeters and grams read as meters and kilograms");
539        assert!(
540            matches!(&slipped, MotorError::Inconsistent(message)
541                if message.contains("exhaust velocity") && message.contains("check the units")),
542            "{slipped}"
543        );
544        // The same motor in SI is accepted: 38 mm by 245 mm, 228.9 g of propellant, 437.5 g
545        // loaded, c = 1,859 m/s, which is within 0.5% of the median of the 1,710 surveyed files.
546        let motor = SolidMotor::from_envelope(i175.clone(), 0.038, 0.245, 0.2289, 0.4375)
547            .expect("the same motor in meters and kilograms");
548        let c = motor.curve().total_impulse_ns() / motor.propellant_mass_kg(0.0);
549        assert!((1700.0..1900.0).contains(&c), "{c}");
550
551        // The bound is on the physics, not on which constructor was used.
552        let dry = MassElement::thin_tube(0.2086, 0.1225, 0.019, 0.245);
553        let column = PropellantColumn {
554            mass_kg: 2.289,
555            center_m: 0.1225,
556            outer_radius_m: 0.017,
557            inner_radius_m: 0.005,
558            length_m: 0.2,
559        };
560        let ten_times = SolidMotor::new(i175, Propellant::Column(column), dry, None)
561            .expect_err("ten times the propellant for the same impulse");
562        assert!(
563            // 425.5 N·s over 2.289 kg is 185.9 m/s, just under the floor.
564            matches!(&ten_times, MotorError::Inconsistent(message) if message.contains("185.8")),
565            "{ten_times}"
566        );
567
568        assert!(!EXHAUST_VELOCITY_RANGE_M_S.contains(&1.8));
569    }
570
571    #[test]
572    fn every_bundled_motor_is_inside_the_range() {
573        // The check's other half: a bound real motors fall outside is a bug, not a check. This
574        // builds all 32 rather than asserting a remembered pair of numbers: the doc's figures
575        // came out of the catalog's stored impulse once, which is not what the check divides.
576        let catalog = crate::Catalog::bundled().expect("the bundled catalog parses");
577        let mut lowest = f64::INFINITY;
578        let mut highest: f64 = 0.0;
579        let (mut low_name, mut high_name) = (String::new(), String::new());
580        for entry in &catalog.motors {
581            let motor = entry
582                .bundled_motor()
583                .unwrap_or_else(|error| panic!("{}: {error}", entry.designation));
584            let c = motor.curve().total_impulse_ns() / motor.propellant_mass_kg(0.0);
585            assert!(
586                EXHAUST_VELOCITY_RANGE_M_S.contains(&c),
587                "{}: c = {c} m/s",
588                entry.designation
589            );
590            if c < lowest {
591                lowest = c;
592                low_name = entry.common_name.clone();
593            }
594            if c > highest {
595                highest = c;
596                high_name = entry.common_name.clone();
597            }
598        }
599        // `docs/physics/motor.md` quotes these; they are measured here so the doc cannot drift.
600        assert!((lowest - 708.59).abs() < 0.01, "{low_name} at {lowest}");
601        assert!((highest - 2651.64).abs() < 0.01, "{high_name} at {highest}");
602    }
603
604    #[test]
605    fn a_curve_ending_above_zero_is_empty_and_silent_at_its_end() {
606        let cut = ThrustCurve::new(vec![0.0, 1.0], vec![20.0, 20.0]).unwrap();
607        // 20 N·s from 10 g is c = 2,000 m/s, which is what APCP does; the 200 g this used to burn
608        // was 100 m/s, and `EXHAUST_VELOCITY_RANGE_M_S` now refuses it.
609        let motor = SolidMotor::from_envelope(cut, 0.038, 0.25, 0.01, 0.5).unwrap();
610        let before = motor.state(1.0 - 1e-9);
611        assert_eq!(before.thrust_n, 20.0);
612        assert!(before.mass_flow_kg_s > 0.0 && before.propellant.mass_kg > 0.0);
613        let end = motor.state(motor.burnout_time_s());
614        assert_eq!(
615            (end.thrust_n, end.mass_flow_kg_s, end.propellant.mass_kg),
616            (0.0, 0.0, 0.0)
617        );
618        assert_eq!(end.total.mass_kg, 0.49);
619    }
620
621    #[test]
622    fn envelope_defaults_are_centered_tubes_and_columns() {
623        let motor = SolidMotor::from_envelope(curve(), 0.038, 0.25, 0.3, 0.6).unwrap();
624        let loaded = motor.state(0.0);
625        assert_eq!(loaded.total.cg_m, 0.125);
626        let r2 = 0.019f64 * 0.019;
627        let dry_axial = 0.3 * r2;
628        let prop_axial = 0.5 * 0.3 * r2;
629        assert!((loaded.total.axial_inertia_kg_m2 - (dry_axial + prop_axial)).abs() < 1e-15);
630        let dry_t = 0.3 * (r2 / 2.0 + 0.25f64.powi(2) / 12.0);
631        let prop_t = 0.3 * (r2 / 4.0 + 0.25f64.powi(2) / 12.0);
632        assert!((loaded.total.transverse_inertia_kg_m2 - (dry_t + prop_t)).abs() < 1e-15);
633        assert!(motor.nozzle().is_none());
634        assert_eq!(
635            motor.thrust_at_pressure_n(0.5, 0.0),
636            motor.curve().thrust_n(0.5)
637        );
638    }
639
640    #[test]
641    fn pressure_correction_uses_the_exit_area() {
642        let dry = MassElement::thin_tube(0.3, 0.125, 0.019, 0.25);
643        let column = PropellantColumn {
644            mass_kg: 0.3,
645            center_m: 0.15,
646            outer_radius_m: 0.017,
647            inner_radius_m: 0.005,
648            length_m: 0.2,
649        };
650        let nozzle = Nozzle {
651            exit_radius_m: 0.01,
652            throat_radius_m: Some(0.004),
653            reference_pressure_pa: Some(STANDARD_SEA_LEVEL_PRESSURE_PA),
654        };
655        let motor =
656            SolidMotor::new(curve(), Propellant::Column(column), dry, Some(nozzle)).unwrap();
657        let area = PI * 1e-4;
658        let f = motor.curve().thrust_n(0.5);
659        let vacuum = motor.thrust_at_pressure_n(0.5, 0.0);
660        assert!((vacuum - (f + 101_325.0 * area)).abs() < 1e-9);
661        assert_eq!(motor.thrust_at_pressure_n(0.5, 101_325.0), f);
662        assert_eq!(motor.thrust_at_pressure_n(2.0, 0.0), 0.0);
663        assert_eq!(motor.thrust_at_pressure_n(0.001, 1e9), 0.0);
664        // The curve's own reference pressure is used: tested at altitude, it gains less in vacuum.
665        let high = SolidMotor::new(
666            curve(),
667            Propellant::Column(column),
668            dry,
669            Some(Nozzle {
670                reference_pressure_pa: Some(80_000.0),
671                ..nozzle
672            }),
673        )
674        .unwrap();
675        assert!((high.thrust_at_pressure_n(0.5, 0.0) - (f + 80_000.0 * area)).abs() < 1e-9);
676        assert!((high.thrust_at_pressure_n(0.5, 101_325.0) - (f - 21_325.0 * area)).abs() < 1e-9);
677        // With no reference pressure, as RocketPy's `Motor(reference_pressure=None)`, the curve is
678        // flown as it is at every pressure: its `pressure_thrust` is zero (`motor.py:1188-1189`).
679        let uncorrected = SolidMotor::new(
680            curve(),
681            Propellant::Column(column),
682            dry,
683            Some(Nozzle {
684                reference_pressure_pa: None,
685                ..nozzle
686            }),
687        )
688        .unwrap();
689        for pressure in [0.0, 50_000.0, 101_325.0, 1e9] {
690            assert_eq!(uncorrected.thrust_at_pressure_n(0.5, pressure), f);
691        }
692        // A design says which it means: `null` is no correction, and leaving the key out is an
693        // error, not a quiet `None`.
694        let read = |text: &str| serde_json::from_str::<Nozzle>(text);
695        assert_eq!(
696            read(
697                r#"{"exit_radius_m": 0.01, "throat_radius_m": null, "reference_pressure_pa": null}"#
698            )
699            .unwrap()
700            .reference_pressure_pa,
701            None
702        );
703        assert!(read(r#"{"exit_radius_m": 0.01, "throat_radius_m": null}"#).is_err());
704        // Only strictly inside the burn: nothing is added at ignition or at the last sample.
705        assert_eq!(motor.thrust_at_pressure_n(0.0, 0.0), 0.0);
706        assert_eq!(motor.thrust_at_pressure_n(1.2, 0.0), 0.0);
707        assert!(motor.thrust_at_pressure_n(0.5, f64::NAN).is_nan());
708        assert!(motor.thrust_at_pressure_n(f64::NAN, 0.0).is_nan());
709        // A NaN time gives NaN mass properties, for columns and grains alike.
710        let state = motor.state(f64::NAN);
711        assert!(state.total.mass_kg.is_nan() && state.propellant.mass_kg.is_nan());
712        assert!(state.total.cg_m.is_nan());
713        // No flow, no pressure term: a zero-thrust gap inside the burn gets none. The thrusts are
714        // 400 N rather than the 10 N this used to use, so that 0.3 kg of propellant implies
715        // c = 1,990 m/s instead of 50 m/s, which `EXHAUST_VELOCITY_RANGE_M_S` refuses.
716        let gap = ThrustCurve::new(
717            vec![0.0, 1.0, 1.0, 2.0, 2.0, 3.0],
718            vec![400.0, 400.0, 0.0, 0.0, 400.0, 0.0],
719        )
720        .unwrap();
721        let gapped = SolidMotor::new(gap, Propellant::Column(column), dry, Some(nozzle)).unwrap();
722        assert_eq!(gapped.thrust_at_pressure_n(1.5, 0.0), 0.0);
723        assert!(gapped.thrust_at_pressure_n(2.5, 0.0) > gapped.curve().thrust_n(2.5));
724    }
725
726    #[test]
727    fn rejects_bad_inputs() {
728        let dry = MassElement::thin_tube(0.3, 0.125, 0.019, 0.25);
729        let column = PropellantColumn {
730            mass_kg: 0.3,
731            center_m: 0.15,
732            outer_radius_m: 0.017,
733            inner_radius_m: 0.005,
734            length_m: 0.2,
735        };
736        let build = |column: PropellantColumn, dry: MassElement, nozzle: Option<Nozzle>| {
737            SolidMotor::new(curve(), Propellant::Column(column), dry, nozzle)
738        };
739        assert!(build(column, dry, None).is_ok());
740        assert!(
741            build(
742                PropellantColumn {
743                    mass_kg: 0.0,
744                    ..column
745                },
746                dry,
747                None
748            )
749            .is_err()
750        );
751        assert!(
752            build(
753                PropellantColumn {
754                    inner_radius_m: 0.017,
755                    ..column
756                },
757                dry,
758                None
759            )
760            .is_err()
761        );
762        assert!(
763            build(
764                PropellantColumn {
765                    center_m: f64::NAN,
766                    ..column
767                },
768                dry,
769                None
770            )
771            .is_err()
772        );
773        assert!(
774            build(
775                column,
776                MassElement {
777                    mass_kg: -0.1,
778                    ..dry
779                },
780                None
781            )
782            .is_err()
783        );
784        // No dry mass: nothing would be left at burnout to have a center of mass.
785        assert!(
786            build(
787                column,
788                MassElement {
789                    mass_kg: 0.0,
790                    ..dry
791                },
792                None
793            )
794            .is_err()
795        );
796        let nozzle = |exit, throat, reference| {
797            Some(Nozzle {
798                exit_radius_m: exit,
799                throat_radius_m: throat,
800                reference_pressure_pa: Some(reference),
801            })
802        };
803        assert!(build(column, dry, nozzle(0.01, Some(0.004), 101_325.0)).is_ok());
804        assert!(build(column, dry, nozzle(0.0, None, 101_325.0)).is_err());
805        assert!(build(column, dry, nozzle(0.01, Some(0.02), 101_325.0)).is_err());
806        assert!(build(column, dry, nozzle(0.01, None, -1.0)).is_err());
807        assert!(build(column, dry, nozzle(0.01, None, f64::NAN)).is_err());
808        assert!(SolidMotor::from_envelope(curve(), 0.038, 0.25, 0.7, 0.6).is_err());
809        assert!(SolidMotor::from_envelope(curve(), 0.038, 0.25, 0.6, 0.6).is_err());
810        assert!(matches!(
811            SolidMotor::from_envelope(curve(), 0.038, 0.25, f64::NAN, 0.6),
812            Err(MotorError::Domain {
813                what: "propellant mass (kg)",
814                ..
815            })
816        ));
817        assert!(SolidMotor::from_envelope(curve(), 0.0, 0.25, 0.3, 0.6).is_err());
818    }
819
820    #[test]
821    fn added_hardware_joins_the_dry_mass() {
822        let motor = SolidMotor::from_envelope(curve(), 0.038, 0.25, 0.3, 0.6).unwrap();
823        let retainer = MassElement::thin_tube(0.05, -0.01, 0.022, 0.02);
824        let with = motor.clone().with_added_dry_mass(retainer).unwrap();
825        let burnout = with.state(with.burnout_time_s()).total;
826        let expected = MassElement::combine([&motor.dry(), &retainer]);
827        assert_eq!(
828            burnout,
829            MassElement::combine([
830                &expected,
831                &MassElement {
832                    cg_m: 0.125,
833                    ..MassElement::ZERO
834                }
835            ])
836        );
837        assert!((burnout.mass_kg - 0.35).abs() < 1e-15);
838        assert!(burnout.cg_m < 0.125);
839        assert!(
840            motor
841                .with_added_dry_mass(MassElement {
842                    mass_kg: f64::NAN,
843                    ..retainer
844                })
845                .is_err()
846        );
847    }
848
849    #[derive(Debug, serde::Deserialize)]
850    struct Oracle {
851        oracle: String,
852        cases: Vec<OracleCase>,
853    }
854
855    #[derive(Debug, serde::Deserialize)]
856    struct OracleCase {
857        name: String,
858        inputs: OracleInputs,
859        scalars: OracleScalars,
860        series: OracleSeries,
861    }
862
863    #[derive(Debug, serde::Deserialize)]
864    struct OracleInputs {
865        thrust_file: String,
866        thrust_file_sha256: String,
867        dry_mass: f64,
868        dry_inertia: [f64; 3],
869        center_of_dry_mass_position: f64,
870        nozzle_position: f64,
871        nozzle_radius: f64,
872        throat_radius: f64,
873        grain_number: u32,
874        grain_density: f64,
875        grain_outer_radius: f64,
876        grain_initial_inner_radius: f64,
877        grain_initial_height: f64,
878        grain_separation: f64,
879        grains_center_of_mass_position: f64,
880        coordinate_system_orientation: String,
881        only_radial_burn: bool,
882        interpolation_method: String,
883        burn_time: Option<f64>,
884    }
885
886    #[derive(Debug, serde::Deserialize)]
887    struct OracleScalars {
888        total_impulse_ns: f64,
889        burn_out_time_s: f64,
890        exhaust_velocity_mps: f64,
891        propellant_initial_mass_kg: f64,
892    }
893
894    #[derive(Debug, serde::Deserialize)]
895    struct OracleSeries {
896        time_s: Vec<f64>,
897        thrust: Vec<f64>,
898        mass_flow_rate: Vec<f64>,
899        propellant_mass: Vec<f64>,
900        total_mass: Vec<f64>,
901        center_of_propellant_mass: Vec<f64>,
902        center_of_mass: Vec<f64>,
903        grain_inner_radius: Vec<f64>,
904        grain_height: Vec<f64>,
905        #[serde(rename = "propellant_I_11")]
906        propellant_i_11: Vec<f64>,
907        #[serde(rename = "propellant_I_33")]
908        propellant_i_33: Vec<f64>,
909        #[serde(rename = "I_11")]
910        i_11: Vec<f64>,
911        #[serde(rename = "I_33")]
912        i_33: Vec<f64>,
913    }
914
915    /// M1.3's done-when: mass and inertia evolution matches RocketPy's `SolidMotor` for three
916    /// motors within 1%. The fixture comes from `validation/oracles/rocketpy/solid_motor.py`,
917    /// which runs RocketPy 1.13.0 on three bundled curves with BATES grain loads.
918    ///
919    /// Each quantity's error is relative to RocketPy's value, except where the value itself goes
920    /// to zero (thrust, flow, and the propellant's mass and inertias): there it is relative to the
921    /// peak or ignition value, and a grain height that burns away is relative to its initial
922    /// height. Positions are compared in hpr's motor frame (meters forward of the nozzle exit),
923    /// relative to the motor's length.
924    ///
925    /// Measured: every error is below 1e-4. At RocketPy's own ODE knots hpr's exact web matches
926    /// RocketPy's bore and height to about 1e-9, so the residual is RocketPy's linear interpolation
927    /// between knots. The test holds 0.1%, ten times tighter than the done-when, so a regression
928    /// shows long before 1%. Because inputs and outputs share the frame mapping, loaded and
929    /// burned-out centers are also checked against values worked by hand from the cases' layouts.
930    #[test]
931    fn matches_rocketpy_solid_motor_for_three_bundled_motors() {
932        let oracle: Oracle = serde_json::from_str(include_str!(
933            "../../../validation/fixtures/motor/rocketpy-solid-motor.json"
934        ))
935        .unwrap();
936        assert_eq!(oracle.oracle, "rocketpy 1.13.0");
937        assert_eq!(oracle.cases.len(), 3);
938        let catalog = crate::catalog::Catalog::bundled().unwrap();
939        for case in &oracle.cases {
940            let inputs = &case.inputs;
941            let (entry, curve) = catalog
942                .motors
943                .iter()
944                .find_map(|m| {
945                    m.curves
946                        .iter()
947                        .find(|c| c.file == inputs.thrust_file)
948                        .map(|c| (m, c))
949                })
950                .unwrap();
951            // The oracle read the same bytes the crate bundles.
952            assert_eq!(curve.sha256, inputs.thrust_file_sha256, "{}", case.name);
953            let text = crate::catalog::bundled_curve_text(&curve.file).unwrap();
954            let thrust = entry.thrust_curve(curve, text).unwrap();
955
956            // RocketPy positions are along its own axis from its own origin; hpr's run forward
957            // from the nozzle exit.
958            let sign = match inputs.coordinate_system_orientation.as_str() {
959                "nozzle_to_combustion_chamber" => 1.0,
960                "combustion_chamber_to_nozzle" => -1.0,
961                other => panic!("unknown orientation {other}"),
962            };
963            let to_hpr = |z: f64| sign * (z - inputs.nozzle_position);
964            let grains = BatesGrains {
965                count: inputs.grain_number,
966                density_kg_m3: inputs.grain_density,
967                outer_radius_m: inputs.grain_outer_radius,
968                initial_inner_radius_m: inputs.grain_initial_inner_radius,
969                initial_height_m: inputs.grain_initial_height,
970                separation_m: inputs.grain_separation,
971                center_m: to_hpr(inputs.grains_center_of_mass_position),
972                inhibited_ends: inputs.only_radial_burn,
973            };
974            let dry = MassElement {
975                mass_kg: inputs.dry_mass,
976                cg_m: to_hpr(inputs.center_of_dry_mass_position),
977                axial_inertia_kg_m2: inputs.dry_inertia[2],
978                transverse_inertia_kg_m2: inputs.dry_inertia[0],
979            };
980            let nozzle = Nozzle {
981                exit_radius_m: inputs.nozzle_radius,
982                throat_radius_m: Some(inputs.throat_radius),
983                reference_pressure_pa: Some(STANDARD_SEA_LEVEL_PRESSURE_PA),
984            };
985            let motor =
986                SolidMotor::new(thrust, Propellant::Grains(grains), dry, Some(nozzle)).unwrap();
987            // The fixture must describe the model compared: linear thrust, the whole curve.
988            assert_eq!(inputs.interpolation_method, "linear");
989            assert_eq!(inputs.burn_time, None);
990            // Centers in hpr's frame, worked by hand from each case's layout (meters forward of
991            // the nozzle exit): the dry mass and grains at loaded, the dry mass at burnout.
992            let hand = match case.name.as_str() {
993                // Nozzle exit at 0: dry 0.2086 kg at 0.11, grains 0.2289391 kg at 0.13.
994                "cti-411i175-38mm-radial-burnout" => (0.2086, 0.11, 0.13),
995                // Axis from the forward closure to the nozzle exit at 0.404: dry at 0.25 and grains
996                // at 0.19 from the closure are 0.154 and 0.214 forward of the exit.
997                "cti-1633k940-54mm-axial-burnout-chamber-to-nozzle" => (0.5985, 0.154, 0.214),
998                // Nozzle exit at 0: dry 1.731 kg at 0.50, grains at 0.56.
999                "loki-m1378lr-54mm-inhibited-ends" => (1.731, 0.50, 0.56),
1000                other => panic!("no hand values for {other}"),
1001            };
1002            let (dry_kg, dry_z, grains_z) = hand;
1003            let m_p = case.scalars.propellant_initial_mass_kg;
1004            let loaded_cg = (dry_kg * dry_z + m_p * grains_z) / (dry_kg + m_p);
1005            assert!(
1006                (motor.state(0.0).total.cg_m - loaded_cg).abs() < 1e-12,
1007                "{}",
1008                case.name
1009            );
1010            assert!(
1011                (motor.state(motor.burnout_time_s()).total.cg_m - dry_z).abs() < 1e-12,
1012                "{}",
1013                case.name
1014            );
1015
1016            let scalars = &case.scalars;
1017            let close = |a: f64, b: f64| (a - b).abs() <= 1e-9 * b.abs();
1018            assert!(
1019                close(motor.curve().total_impulse_ns(), scalars.total_impulse_ns),
1020                "{}",
1021                case.name
1022            );
1023            assert!(
1024                close(motor.burnout_time_s(), scalars.burn_out_time_s),
1025                "{}",
1026                case.name
1027            );
1028            assert!(close(
1029                motor.propellant_initial_mass_kg(),
1030                scalars.propellant_initial_mass_kg
1031            ));
1032            assert!(close(
1033                motor.exhaust_velocity_m_s(),
1034                scalars.exhaust_velocity_mps
1035            ));
1036
1037            let series = &case.series;
1038            let length = entry.length_mm * 1e-3;
1039            let mut worst: Vec<(&str, f64)> = Vec::new();
1040            let mut record = |what: &'static str, error: f64| match worst
1041                .iter_mut()
1042                .find(|(name, _)| *name == what)
1043            {
1044                Some((_, max)) => *max = max.max(error),
1045                None => worst.push((what, error)),
1046            };
1047            let loaded = motor.state(0.0);
1048            let peak_flow = motor.curve().peak_thrust_n() / motor.exhaust_velocity_m_s();
1049            for (i, &t) in series.time_s.iter().enumerate() {
1050                let state = motor.state(t);
1051                let shape = grains.shape(state.propellant.mass_kg);
1052                let relative = |ours: f64, theirs: f64| (ours - theirs).abs() / theirs.abs();
1053                let scaled = |ours: f64, theirs: f64, scale: f64| (ours - theirs).abs() / scale;
1054                record(
1055                    "thrust",
1056                    scaled(
1057                        state.thrust_n,
1058                        series.thrust[i],
1059                        motor.curve().peak_thrust_n(),
1060                    ),
1061                );
1062                record(
1063                    "mass flow",
1064                    scaled(state.mass_flow_kg_s, -series.mass_flow_rate[i], peak_flow),
1065                );
1066                record(
1067                    "propellant mass",
1068                    scaled(
1069                        state.propellant.mass_kg,
1070                        series.propellant_mass[i],
1071                        scalars.propellant_initial_mass_kg,
1072                    ),
1073                );
1074                record(
1075                    "total mass",
1076                    relative(state.total.mass_kg, series.total_mass[i]),
1077                );
1078                record(
1079                    "propellant center",
1080                    scaled(
1081                        state.propellant.cg_m,
1082                        to_hpr(series.center_of_propellant_mass[i]),
1083                        length,
1084                    ),
1085                );
1086                record(
1087                    "center of mass",
1088                    scaled(state.total.cg_m, to_hpr(series.center_of_mass[i]), length),
1089                );
1090                record(
1091                    "grain bore radius",
1092                    relative(shape.inner_radius_m, series.grain_inner_radius[i]),
1093                );
1094                record(
1095                    "grain height",
1096                    scaled(
1097                        shape.height_m,
1098                        series.grain_height[i],
1099                        inputs.grain_initial_height,
1100                    ),
1101                );
1102                record(
1103                    "propellant I_11",
1104                    scaled(
1105                        state.propellant.transverse_inertia_kg_m2,
1106                        series.propellant_i_11[i],
1107                        loaded.propellant.transverse_inertia_kg_m2,
1108                    ),
1109                );
1110                record(
1111                    "propellant I_33",
1112                    scaled(
1113                        state.propellant.axial_inertia_kg_m2,
1114                        series.propellant_i_33[i],
1115                        loaded.propellant.axial_inertia_kg_m2,
1116                    ),
1117                );
1118                record(
1119                    "motor I_11",
1120                    relative(state.total.transverse_inertia_kg_m2, series.i_11[i]),
1121                );
1122                record(
1123                    "motor I_33",
1124                    relative(state.total.axial_inertia_kg_m2, series.i_33[i]),
1125                );
1126            }
1127            eprintln!("{}: largest errors against RocketPy", case.name);
1128            for (what, error) in &worst {
1129                eprintln!("  {what:<18} {:.3e}", error);
1130                assert!(
1131                    *error <= 1e-3,
1132                    "{}: {what} differs from RocketPy by {:.4}%",
1133                    case.name,
1134                    100.0 * error
1135                );
1136            }
1137        }
1138    }
1139
1140    #[test]
1141    fn serde_round_trips_and_rechecks() {
1142        let motor = SolidMotor::from_envelope(curve(), 0.038, 0.25, 0.3, 0.6).unwrap();
1143        let json = serde_json::to_string(&motor).unwrap();
1144        assert_eq!(serde_json::from_str::<SolidMotor>(&json).unwrap(), motor);
1145        let broken = json.replace(
1146            "\"mass_kg\":0.3,\"center_m\"",
1147            "\"mass_kg\":-0.3,\"center_m\"",
1148        );
1149        assert_ne!(broken, json);
1150        assert!(serde_json::from_str::<SolidMotor>(&broken).is_err());
1151    }
1152}