Skip to main content

hpr_design/
config.rs

1//! Motor mounts, configurations and the assembled rocket: the structure with its motors placed,
2//! and its mass properties through the burn.
3//!
4//! **Placement.** A motor's axis runs forward from its nozzle exit ([`hpr_motor::mass`]). In a
5//! mount whose axial extent ends at station `s_aft`, the nozzle exit sits at station
6//! `s_aft + overhang`, on the mount's axis (an inner tube's radial offset and angle, or the body
7//! axis). A motor element at `z_m` along the motor axis is then at body
8//! `(x_mount, y_mount, −(s_aft + overhang) + z_m)` ([`MassProperties::from_motor_element`]).
9//!
10//! **Composition.** The rocket at time `t` is the structure ([`Layout::structure`]) combined with
11//! each motor's loaded, burning or spent mass properties ([`SolidMotor::state`]) at its own time
12//! since ignition. [`Assembly::mass_properties`] lights every motor at `t = 0`;
13//! [`Assembly::mass_properties_lit`] takes each motor's ignition time ([`Ignition`], resolved by
14//! [`Assembly::ignition_times_s`]), and a motor not yet lit is loaded. This is RocketPy's
15//! composition (`Rocket.total_mass`, `center_of_mass`, and the inertias of
16//! `rocketpy/rocket/rocket.py`), checked against RocketPy 1.13.0 in the tests.
17//!
18//! See `docs/physics/design.md`.
19
20use std::collections::BTreeSet;
21use std::sync::Arc;
22
23use hpr_core::DVec3;
24use hpr_motor::{Delay, MassElement, SolidMotor};
25use serde::{Deserialize, Serialize};
26
27use crate::error::DesignError;
28use crate::mass::MassProperties;
29use crate::shapes::check_dimension;
30use crate::tree::{Layout, Rocket, StageMasses};
31
32/// Makes a body tube or inner tube a motor mount.
33#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
34#[serde(deny_unknown_fields)]
35pub struct MotorMount {
36    /// How far the nozzle exit sits aft of the mount's aft end, m (negative when recessed).
37    #[serde(default)]
38    pub overhang_m: f64,
39}
40
41/// A set of motors to fly with: at most one per mount. A mount that is a cluster
42/// ([`InnerTube::cluster_m`](crate::InnerTube::cluster_m)) takes its motor in every tube.
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
44#[serde(deny_unknown_fields)]
45pub struct Configuration {
46    /// Unique id among the configurations.
47    pub id: String,
48    /// Name.
49    #[serde(default)]
50    pub name: String,
51    /// The motors.
52    pub motors: Vec<MountedMotor>,
53}
54
55/// When a motor lights, on the flight's clock: `t = 0` is launch, when the motors that light at
56/// launch ignite. A delay is counted from its event (the decision record on staging,
57/// [ADR-074][adr-074]).
58///
59/// [adr-074]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-074-ignition-times-and-powered-staging-the-sustainer-flies-on-as-a-rigid-body-2026-09-25
60#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
61#[serde(rename_all = "snake_case", deny_unknown_fields)]
62#[non_exhaustive]
63pub enum Ignition {
64    /// At launch, `t = 0`.
65    #[default]
66    Launch,
67    /// At a time after launch: an air start on a timer.
68    Time {
69        /// The time after launch, s.
70        time_s: f64,
71    },
72    /// A delay after another mount's motor burns out: a sustainer lit by the booster's burnout,
73    /// or by its ejection charge with the charge's delay.
74    Burnout {
75        /// The id of the mount whose motor's burnout lights this one.
76        mount: String,
77        /// The delay after that burnout, s.
78        delay_s: f64,
79    },
80    /// A delay after the stage aft of this motor's stage separates from it (the flight gives the
81    /// separation). A motor whose stage is never freed never lights; one in the last stage, with
82    /// nothing aft of it to separate, is refused.
83    Separation {
84        /// The delay after the separation, s.
85        delay_s: f64,
86    },
87    /// Never: the motor stays loaded and gives no thrust all flight, as if every tube of its mount
88    /// were listed in [`MountedMotor::failed_tubes`]. A `.ork` design can set a motor so, or light
89    /// it at an event that never comes, and OpenRocket then flies it unlit.
90    Never,
91}
92
93impl Ignition {
94    /// Whether this is [`Ignition::Launch`].
95    #[must_use]
96    pub fn is_launch(&self) -> bool {
97        *self == Self::Launch
98    }
99}
100
101/// A motor in a mount.
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
103#[serde(deny_unknown_fields)]
104pub struct MountedMotor {
105    /// The id of the mount component.
106    pub mount: String,
107    /// Designation, for display.
108    #[serde(default)]
109    pub designation: String,
110    /// Case outer diameter, m.
111    pub diameter_m: f64,
112    /// Case length, m.
113    pub length_m: f64,
114    /// The motor.
115    pub motor: SolidMotor,
116    /// The ejection delay chosen, if any.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub delay: Option<Delay>,
119    /// When it lights.
120    #[serde(default, skip_serializing_if = "Ignition::is_launch")]
121    pub ignition: Ignition,
122    /// The tubes whose motor fails to light, by index into the mount's tubes (a cluster's in the
123    /// order of [`InnerTube::cluster_m`](crate::InnerTube::cluster_m), `0` for a single tube; a
124    /// cluster inside another cluster counts the outer copies first, each with all its tubes; a
125    /// mount in a pod counts the pods, in the order of [`PodSet::pods`](crate::PodSet::pods)): a
126    /// motor out. Each is carried loaded and gives no thrust. An ignition on the mount's burnout
127    /// takes its first motor that lights, but a recovery device or separation triggered by one
128    /// motor's index waits on that motor alone: point it at a tube that lights, or it never fires.
129    /// Empty (the default) when every motor lights.
130    #[serde(default, skip_serializing_if = "Vec::is_empty")]
131    pub failed_tubes: Vec<usize>,
132}
133
134/// A motor placed in the rocket: one per tube of its mount, so a cluster's mount gives several,
135/// one after another in the order of its tubes.
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137pub struct PlacedMotor {
138    /// The mount's id.
139    pub mount: String,
140    /// Index of the mount's stage.
141    pub stage: usize,
142    /// The nozzle exit's position in body axes, m.
143    pub nozzle_m: DVec3,
144    /// The motor as configured.
145    pub mounted: MountedMotor,
146    /// Which of the mount's tubes it is in (`0` for a single tube).
147    #[serde(default)]
148    pub tube: usize,
149    /// Whether it never lights: its tube fails ([`MountedMotor::failed_tubes`]), or the motor is
150    /// set never to light ([`Ignition::Never`]).
151    #[serde(default)]
152    pub fails: bool,
153}
154
155impl PlacedMotor {
156    /// Station of the nozzle exit, m aft of the nose tip.
157    pub fn nozzle_station_m(&self) -> f64 {
158        -self.nozzle_m.z
159    }
160
161    /// Station of the motor case's forward end, m.
162    pub fn fore_station_m(&self) -> f64 {
163        self.nozzle_station_m() - self.mounted.length_m
164    }
165
166    /// A motor-axis mass element in body axes.
167    pub fn place(&self, element: &MassElement) -> MassProperties {
168        MassProperties::from_motor_element(element, self.nozzle_m.z).translated(DVec3::new(
169            self.nozzle_m.x,
170            self.nozzle_m.y,
171            0.0,
172        ))
173    }
174
175    /// The whole motor at `t_s` seconds after ignition, in body axes.
176    pub fn mass_properties(&self, t_s: f64) -> MassProperties {
177        self.place(&self.mounted.motor.state(t_s).total)
178    }
179
180    /// The whole motor at flight time `t_s` when it lit at `ignition_s` (`None`: it never lights),
181    /// in body axes: loaded before it lights.
182    pub fn mass_properties_lit(&self, t_s: f64, ignition_s: Option<f64>) -> MassProperties {
183        let since_s = ignition_s.map_or(0.0, |ignition_s| (t_s - ignition_s).max(0.0));
184        self.mass_properties(since_s)
185    }
186
187    /// The motor's dry mass (case, closures, nozzle) in body axes.
188    pub fn dry_mass_properties(&self) -> MassProperties {
189        self.place(&self.mounted.motor.dry())
190    }
191}
192
193/// A rocket with one configuration's motors in place.
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195pub struct Assembly {
196    /// The configuration's id.
197    pub configuration: String,
198    /// The resolved structure.
199    pub layout: Layout,
200    /// The motors.
201    pub motors: Vec<PlacedMotor>,
202}
203
204impl Assembly {
205    /// The rocket `t_s` seconds after ignition: the structure and every motor, but for a motor
206    /// that fails ([`PlacedMotor::fails`]), which stays loaded. Every other motor is taken as lit,
207    /// even one waiting on a mount whose every motor fails; [`Self::mass_properties_lit`] with
208    /// [`Self::ignition_times_s`] gives the flight's.
209    pub fn mass_properties(&self, t_s: f64) -> MassProperties {
210        self.motors
211            .iter()
212            .fold(self.layout.structure, |sum, motor| {
213                let ignition = (!motor.fails).then_some(0.0);
214                MassProperties::combine([&sum, &motor.mass_properties_lit(t_s, ignition)])
215            })
216    }
217
218    /// The rocket at flight time `t_s`, each motor lit at its `ignition_s`, one per motor in
219    /// order ([`Self::ignition_times_s`]): `None` for one that never lights, which stays loaded.
220    /// A motor past the end of `ignition_s` is taken as lit at launch, as
221    /// [`Self::mass_properties`] lights them all.
222    pub fn mass_properties_lit(&self, t_s: f64, ignition_s: &[Option<f64>]) -> MassProperties {
223        self.mass_properties_lit_on(self.layout.structure, t_s, ignition_s)
224    }
225
226    /// As [`Self::mass_properties_lit`], with `structure` in place of the layout's: the
227    /// structure with a part taken out, say ([`MassProperties::without_part`]).
228    pub fn mass_properties_lit_on(
229        &self,
230        structure: MassProperties,
231        t_s: f64,
232        ignition_s: &[Option<f64>],
233    ) -> MassProperties {
234        self.motors
235            .iter()
236            .enumerate()
237            .fold(structure, |sum, (index, motor)| {
238                let ignition = ignition_s.get(index).copied().unwrap_or(Some(0.0));
239                MassProperties::combine([&sum, &motor.mass_properties_lit(t_s, ignition)])
240            })
241    }
242
243    /// Each motor's ignition time on the flight's clock, in order: `None` for one that never
244    /// lights. `separated_s(stage)` gives the time at which the stage aft of `stage` came away, if
245    /// it has; [`Ignition::Separation`] counts from it.
246    ///
247    /// A motor lit by another mount's burnout lights when one of that mount's motors that lights
248    /// has a known ignition; a chain that never reaches a known time (a cycle is refused by
249    /// [`Layout::place_motors`], so only a separation that never comes, or a mount whose every
250    /// motor fails) leaves it unlit. A motor that fails ([`PlacedMotor::fails`]) never lights.
251    #[must_use]
252    pub fn ignition_times_s(&self, separated_s: impl Fn(usize) -> Option<f64>) -> Vec<Option<f64>> {
253        ignition_times_s(&self.motors, separated_s, true)
254    }
255
256    /// The rocket with every motor spent: the structure and the motors' dry masses, but for a
257    /// motor that fails ([`PlacedMotor::fails`]), which is carried loaded.
258    pub fn dry_mass_properties(&self) -> MassProperties {
259        self.motors
260            .iter()
261            .fold(self.layout.structure, |sum, motor| {
262                let motor = if motor.fails {
263                    motor.mass_properties_lit(0.0, None)
264                } else {
265                    motor.dry_mass_properties()
266                };
267                MassProperties::combine([&sum, &motor])
268            })
269    }
270}
271
272/// As [`Assembly::ignition_times_s`], over `motors`; with `failures` false, as if every motor
273/// lit.
274fn ignition_times_s(
275    motors: &[PlacedMotor],
276    separated_s: impl Fn(usize) -> Option<f64>,
277    failures: bool,
278) -> Vec<Option<f64>> {
279    let fails = |motor: &PlacedMotor| failures && motor.fails;
280    let mut times: Vec<Option<f64>> = motors
281        .iter()
282        .map(|motor| match &motor.mounted.ignition {
283            _ if fails(motor) => None,
284            Ignition::Launch => Some(0.0),
285            Ignition::Time { time_s } => Some(*time_s),
286            Ignition::Separation { delay_s } => separated_s(motor.stage).map(|t| t + delay_s),
287            Ignition::Burnout { .. } => None,
288            // Taken as lit only for the check of a cycle of burnouts, where every motor is.
289            Ignition::Never => (!failures).then_some(0.0),
290        })
291        .collect();
292    // Each pass resolves at least one more link of every chain that can resolve, so as many
293    // passes as there are motors settle them all.
294    for _ in 0..motors.len() {
295        let mut changed = false;
296        for (index, motor) in motors.iter().enumerate() {
297            let Ignition::Burnout { mount, delay_s } = &motor.mounted.ignition else {
298                continue;
299            };
300            if times[index].is_some() || fails(motor) {
301                continue;
302            }
303            // Every motor of a mount lights together, so the first that lights gives the burnout.
304            let lit = motors
305                .iter()
306                .zip(&times)
307                .filter(|(other, _)| other.mount == *mount)
308                .find_map(|(other, time)| time.map(|t| t + other.mounted.motor.burnout_time_s()));
309            if let Some(burnout_s) = lit {
310                times[index] = Some(burnout_s + delay_s);
311                changed = true;
312            }
313        }
314        if !changed {
315            break;
316        }
317    }
318    times
319}
320
321impl Layout {
322    /// Places `configuration`'s motors in their mounts in this layout.
323    ///
324    /// # Errors
325    ///
326    /// - [`DesignError::UnknownId`] for a mount that doesn't exist.
327    /// - [`DesignError::Tree`] for a mount that isn't a motor mount, or two motors in one mount.
328    /// - [`DesignError::InComponent`] (with the mount's id) wrapping [`DesignError::Domain`] for a
329    ///   non-positive or non-finite motor diameter or length, or a non-finite overhang, or an
330    ///   ignition time or delay that is negative or not finite.
331    /// - [`DesignError::Tree`] for an ignition by the burnout of a mount with no motor in this
332    ///   configuration, or by a chain of burnouts that comes back to the motor itself.
333    /// - [`DesignError::InComponent`] (with the mount's id) wrapping [`DesignError::Domain`] for a
334    ///   failed tube the mount doesn't have, or one named twice, or a cluster offset that is not
335    ///   finite.
336    pub fn place_motors(
337        &self,
338        configuration: &Configuration,
339    ) -> Result<Vec<PlacedMotor>, DesignError> {
340        let mut mounts = BTreeSet::new();
341        let mut motors = Vec::with_capacity(configuration.motors.len());
342        for mounted in &configuration.motors {
343            let (_, mount) = self
344                .find(&mounted.mount)
345                .ok_or_else(|| DesignError::UnknownId {
346                    what: "motor mount",
347                    id: mounted.mount.clone(),
348                })?;
349            let in_mount = |error| DesignError::InComponent {
350                id: mount.id.clone(),
351                source: Box::new(error),
352            };
353            check_dimension("motor diameter (m)", mounted.diameter_m, false).map_err(in_mount)?;
354            check_dimension("motor length (m)", mounted.length_m, false).map_err(in_mount)?;
355            let Some(spec) = mount.motor_mount else {
356                return Err(DesignError::Tree {
357                    id: mount.id.clone(),
358                    message: format!(
359                        "configuration {} puts a motor here, but it is not a motor mount",
360                        configuration.id
361                    ),
362                });
363            };
364            if !mounts.insert(mount.id.as_str()) {
365                return Err(DesignError::Tree {
366                    id: mount.id.clone(),
367                    message: format!(
368                        "configuration {} puts two motors in this mount",
369                        configuration.id
370                    ),
371                });
372            }
373            if !spec.overhang_m.is_finite() {
374                return Err(in_mount(DesignError::Domain {
375                    what: "motor overhang (m)",
376                    value: spec.overhang_m,
377                }));
378            }
379            match &mounted.ignition {
380                Ignition::Launch => {}
381                Ignition::Time { time_s: value } => {
382                    check_ignition("ignition time after launch (s)", *value).map_err(in_mount)?;
383                }
384                Ignition::Burnout { delay_s: value, .. } => {
385                    check_ignition("ignition delay (s)", *value).map_err(in_mount)?;
386                }
387                Ignition::Separation { delay_s: value } => {
388                    check_ignition("ignition delay (s)", *value).map_err(in_mount)?;
389                    if mount.stage + 1 >= self.stages.len() {
390                        return Err(DesignError::Tree {
391                            id: mount.id.clone(),
392                            message: format!(
393                                "configuration {} lights this motor at its stage's separation, \
394                                 but no stage is aft of it to separate",
395                                configuration.id
396                            ),
397                        });
398                    }
399                }
400                Ignition::Never => {}
401            }
402            let tubes = mount.contents_copies().map_err(in_mount)?;
403            for (k, &tube) in mounted.failed_tubes.iter().enumerate() {
404                if tube >= tubes.len() || mounted.failed_tubes[..k].contains(&tube) {
405                    return Err(in_mount(DesignError::Domain {
406                        what: "failed tube (index into the mount's tubes, each named once)",
407                        value: tube as f64,
408                    }));
409                }
410            }
411            let axis = mount.part.axis_offset_m();
412            let z = -(mount.aft_station_m() + spec.overhang_m);
413            for (tube, place) in tubes.into_iter().enumerate() {
414                let [x, y] = place.point(axis);
415                motors.push(PlacedMotor {
416                    mount: mount.id.clone(),
417                    stage: mount.stage,
418                    nozzle_m: DVec3::new(x, y, z),
419                    mounted: mounted.clone(),
420                    tube,
421                    fails: mounted.ignition == Ignition::Never
422                        || mounted.failed_tubes.contains(&tube),
423                });
424            }
425        }
426        for motor in &motors {
427            if let Ignition::Burnout { mount, .. } = &motor.mounted.ignition
428                && !motors.iter().any(|other| other.mount == *mount)
429            {
430                return Err(DesignError::Tree {
431                    id: motor.mount.clone(),
432                    message: format!(
433                        "configuration {} lights this motor at the burnout of mount {mount}, \
434                         which holds no motor in it",
435                        configuration.id
436                    ),
437                });
438            }
439        }
440        // With every separation at once and every motor lit, only a cycle of burnouts is left
441        // unlit.
442        let lit = ignition_times_s(&motors, |_| Some(0.0), false);
443        if let Some(index) = lit.iter().position(Option::is_none) {
444            return Err(DesignError::Tree {
445                id: motors[index].mount.clone(),
446                message: format!(
447                    "configuration {} lights this motor by a chain of burnouts that comes back \
448                     to it",
449                    configuration.id
450                ),
451            });
452        }
453        Ok(motors)
454    }
455}
456
457/// An ignition time or delay: finite and not negative.
458fn check_ignition(what: &'static str, value: f64) -> Result<(), DesignError> {
459    if value.is_finite() && value >= 0.0 {
460        Ok(())
461    } else {
462        Err(DesignError::Domain { what, value })
463    }
464}
465
466impl Rocket {
467    /// The configuration with id `id`.
468    pub fn configuration(&self, id: &str) -> Option<&Configuration> {
469        self.configurations.iter().find(|c| c.id == id)
470    }
471
472    /// Checks that configuration ids are unique and non-empty.
473    ///
474    /// # Errors
475    ///
476    /// [`DesignError::DuplicateId`] for the first empty or repeated id.
477    pub fn check_configuration_ids(&self) -> Result<(), DesignError> {
478        let mut ids = BTreeSet::new();
479        for configuration in &self.configurations {
480            if configuration.id.is_empty() || !ids.insert(configuration.id.as_str()) {
481                return Err(DesignError::DuplicateId(configuration.id.clone()));
482            }
483        }
484        Ok(())
485    }
486
487    /// Resolves the tree and places the motors of configuration `configuration_id`.
488    ///
489    /// # Errors
490    ///
491    /// - As [`Rocket::layout`] and [`Layout::place_motors`].
492    /// - [`DesignError::DuplicateId`] for an empty or repeated configuration id.
493    /// - [`DesignError::UnknownId`] for a configuration that doesn't exist.
494    pub fn assemble(&self, configuration_id: &str) -> Result<Assembly, DesignError> {
495        self.check_configuration_ids()?;
496        let configuration = self.configuration_or_error(configuration_id)?;
497        let layout = self.layout()?;
498        let motors = layout.place_motors(configuration)?;
499        Ok(Assembly {
500            configuration: configuration.id.clone(),
501            layout,
502            motors,
503        })
504    }
505
506    /// Lays the design out once ([`Rocket::layout`]), to check it ([`LaidOut::check`]) and
507    /// assemble it ([`LaidOut::into_assembly`]) without laying it out again: the layout is most
508    /// of the cost of either, and a Monte Carlo run builds a flight thousands of times.
509    ///
510    /// # Errors
511    ///
512    /// As [`Rocket::check_configuration_ids`] and [`Rocket::layout`].
513    pub fn lay_out(&self) -> Result<LaidOut, DesignError> {
514        self.clone().into_laid_out()
515    }
516
517    /// As [`Rocket::lay_out`], keeping this copy of the design.
518    fn into_laid_out(self) -> Result<LaidOut, DesignError> {
519        self.check_configuration_ids()?;
520        let (components, masses) = self.placed_components()?;
521        Ok(LaidOut {
522            layout: self.staged_layout(components, &masses)?,
523            rocket: self,
524            masses: Arc::new(masses),
525        })
526    }
527
528    /// Configuration `configuration_id`, or [`DesignError::UnknownId`].
529    fn configuration_or_error(
530        &self,
531        configuration_id: &str,
532    ) -> Result<&Configuration, DesignError> {
533        self.configuration(configuration_id)
534            .ok_or_else(|| DesignError::UnknownId {
535                what: "configuration",
536                id: configuration_id.to_owned(),
537            })
538    }
539}
540
541/// A copy of a design and its layout, laid out once by [`Rocket::lay_out`], to check it and
542/// assemble it ([`LaidOut::into_assembly`]) without laying it out again. Its fields are private,
543/// so the layout is always the design's own.
544#[derive(Debug, Clone)]
545pub struct LaidOut {
546    rocket: Rocket,
547    layout: Layout,
548    /// What the layout's stages are made from, for [`LaidOut::relay`]; shared by the designs
549    /// relaid from it.
550    masses: Arc<StageMasses>,
551}
552
553impl LaidOut {
554    /// The design.
555    pub fn rocket(&self) -> &Rocket {
556        &self.rocket
557    }
558
559    /// The layout ([`Rocket::layout`]).
560    pub fn layout(&self) -> &Layout {
561        &self.layout
562    }
563
564    /// As [`Rocket::assemble`], on this layout, which it takes rather than copies, with the design
565    /// given back.
566    ///
567    /// # Errors
568    ///
569    /// - As [`Layout::place_motors`].
570    /// - [`DesignError::UnknownId`] for a configuration that doesn't exist.
571    pub fn into_assembly(self, configuration_id: &str) -> Result<(Rocket, Assembly), DesignError> {
572        let configuration = self.rocket.configuration_or_error(configuration_id)?;
573        let motors = self.layout.place_motors(configuration)?;
574        let assembly = Assembly {
575            configuration: configuration.id.clone(),
576            layout: self.layout,
577            motors,
578        };
579        Ok((self.rocket, assembly))
580    }
581
582    /// `rocket` laid out, as [`Rocket::lay_out`] lays it out. Where `rocket` differs from this
583    /// design only in its stages' overrides (mass, center of mass, inertia) and its
584    /// configurations (motors), this layout's parts are kept and only the stages are done again,
585    /// which gives the same layout bit for bit when the rest of the two designs is bitwise
586    /// equal, as a copy's is (two values equal under `==` can still differ in a zero's sign):
587    /// placing the parts is most of a layout's cost, and a Monte Carlo sample changes nothing
588    /// else. Any other difference lays `rocket` out from the start.
589    ///
590    /// # Errors
591    ///
592    /// As [`Rocket::lay_out`].
593    pub fn relay(&self, rocket: Rocket) -> Result<LaidOut, DesignError> {
594        if !self.same_but_overrides_and_motors(&rocket) {
595            return rocket.into_laid_out();
596        }
597        rocket.check_configuration_ids()?;
598        let layout = rocket.staged_layout(self.layout.components.clone(), &self.masses)?;
599        Ok(LaidOut {
600            rocket,
601            layout,
602            masses: Arc::clone(&self.masses),
603        })
604    }
605
606    /// Whether `rocket` is this design but for its stages' overrides and its configurations.
607    /// Every other field is compared, so a field added to either type has to be added here.
608    fn same_but_overrides_and_motors(&self, rocket: &Rocket) -> bool {
609        let Rocket {
610            name,
611            stages,
612            reference_diameter,
613            configurations: _,
614        } = rocket;
615        *name == self.rocket.name
616            && *reference_diameter == self.rocket.reference_diameter
617            && stages.len() == self.rocket.stages.len()
618            && stages.iter().zip(&self.rocket.stages).all(|(stage, ours)| {
619                let crate::Stage {
620                    id,
621                    name,
622                    components,
623                    overrides: _,
624                    drag_override: _,
625                    parallel,
626                } = stage;
627                *id == ours.id
628                    && *name == ours.name
629                    && *components == ours.components
630                    && *parallel == ours.parallel
631            })
632    }
633}
634
635#[cfg(test)]
636mod tests {
637    use hpr_core::DMat3;
638
639    use super::*;
640    use crate::testing::{motor, three_fin_rocket};
641
642    /// Laying a design out once checks and assembles it as [`crate::checks::check`] and
643    /// [`Rocket::assemble`] do, and refuses an unknown configuration the same way.
644    #[test]
645    fn a_design_laid_out_once_checks_and_assembles_as_twice() {
646        let design = three_fin_rocket();
647        let laid_out = design.lay_out().unwrap();
648        assert_eq!(laid_out.layout(), &design.layout().unwrap());
649        assert_eq!(
650            laid_out.check().unwrap(),
651            crate::checks::check(&design).unwrap()
652        );
653        let id = design.configurations[0].id.clone();
654        assert_eq!(
655            laid_out.clone().into_assembly(&id).unwrap(),
656            (design.clone(), design.assemble(&id).unwrap())
657        );
658        assert!(matches!(
659            laid_out.into_assembly("no such configuration"),
660            Err(DesignError::UnknownId { what: "configuration", ref id })
661                if id == "no such configuration"
662        ));
663    }
664
665    /// A design relaid from another's layout is the design laid out alone, bit for bit: with
666    /// other stage overrides and motors, the parts are kept; with another part, it starts over.
667    #[test]
668    fn a_relaid_design_is_laid_out_as_alone() {
669        let nominal = three_fin_rocket();
670        let laid_out = nominal.lay_out().unwrap();
671        let id = nominal.configurations[0].id.clone();
672        let same = |relaid: &LaidOut, design: &Rocket| {
673            let alone = design.lay_out().unwrap();
674            assert_eq!(relaid.rocket(), design);
675            assert_eq!(relaid.layout(), alone.layout());
676            assert_eq!(relaid.check().unwrap(), alone.check().unwrap());
677            assert_eq!(
678                relaid.clone().into_assembly(&id).unwrap(),
679                alone.into_assembly(&id).unwrap()
680            );
681        };
682        let mut overridden = nominal.clone();
683        let stage = &mut overridden.stages[0].overrides;
684        stage.mass_kg = Some(1.234_567);
685        stage.cg_aft_m = Some(0.456_789);
686        stage.cg_xy_m = Some([0.001, -0.002]);
687        let mounted = &mut overridden.configurations[0].motors[0];
688        mounted.motor = motor(&mounted.mount, mounted.diameter_m, 0.25).motor;
689        assert!(laid_out.same_but_overrides_and_motors(&overridden));
690        let relaid = laid_out.relay(overridden.clone()).unwrap();
691        same(&relaid, &overridden);
692        assert_ne!(relaid.layout(), laid_out.layout());
693        // Relaid again, back to the nominal design.
694        same(&relaid.relay(nominal.clone()).unwrap(), &nominal);
695        // Another part: laid out from the start.
696        let mut longer = nominal.clone();
697        let crate::Part::BodyTube(tube) = &mut longer.stages[0].components[1].part else {
698            panic!("the test rocket's second component is its airframe");
699        };
700        tube.length_m += 0.1;
701        assert!(!laid_out.same_but_overrides_and_motors(&longer));
702        let relaid = laid_out.relay(longer.clone()).unwrap();
703        same(&relaid, &longer);
704        // Another stage id, or a stage more: laid out from the start too.
705        let mut renamed = nominal.clone();
706        renamed.stages[0].id = "renamed".to_owned();
707        assert!(!laid_out.same_but_overrides_and_motors(&renamed));
708        same(&laid_out.relay(renamed.clone()).unwrap(), &renamed);
709        let mut staged = nominal.clone();
710        let mut booster = staged.stages[0].clone();
711        booster.id = "booster".to_owned();
712        for (index, component) in booster.components.iter_mut().enumerate() {
713            component.id = format!("booster-{index}");
714            component.children.clear();
715        }
716        booster
717            .components
718            .retain(|c| matches!(c.part, crate::Part::BodyTube(_)));
719        staged.stages.push(booster);
720        assert!(!laid_out.same_but_overrides_and_motors(&staged));
721        let relaid = laid_out.relay(staged.clone()).unwrap();
722        same(&relaid, &staged);
723        assert_eq!(relaid.layout().stages.len(), 2);
724        // Two stages, the second's mass and center overridden: its parts are kept.
725        let mut heavier = staged.clone();
726        heavier.stages[1].overrides.mass_kg = Some(0.75);
727        heavier.stages[1].overrides.cg_aft_m = Some(0.3);
728        assert!(relaid.same_but_overrides_and_motors(&heavier));
729        let twice = relaid.relay(heavier.clone()).unwrap();
730        same(&twice, &heavier);
731        assert_ne!(twice.layout().stages[1], relaid.layout().stages[1]);
732        assert_eq!(twice.layout().stages[0], relaid.layout().stages[0]);
733        assert_ne!(relaid.layout().length_m, laid_out.layout().length_m);
734        // A bad override is refused as laying it out alone refuses it.
735        let mut bad = nominal;
736        bad.stages[0].overrides.mass_kg = Some(-1.0);
737        assert_eq!(
738            laid_out.relay(bad.clone()).unwrap_err().to_string(),
739            bad.lay_out().unwrap_err().to_string()
740        );
741    }
742
743    /// The sample rocket's mount spans stations 0.7 to 1.0 with 0.01 m of overhang, so the nozzle
744    /// exit is at 1.01. Its envelope motor (1 kg loaded, 0.5 kg of propellant, 0.2 m long) puts
745    /// dry mass and propellant both 0.1 m forward of the nozzle, at body z = −0.91.
746    #[test]
747    fn motor_sits_at_mount_aft_end_plus_overhang() {
748        let design = three_fin_rocket();
749        let assembly = design.assemble("main").unwrap();
750        let [placed] = &assembly.motors[..] else {
751            panic!("one motor")
752        };
753        assert!((placed.nozzle_station_m() - 1.01).abs() < 1e-15);
754        assert!((placed.fore_station_m() - 0.81).abs() < 1e-15);
755        assert_eq!(placed.stage, 0);
756        assert_eq!((placed.nozzle_m.x, placed.nozzle_m.y), (0.0, 0.0));
757
758        let structure = assembly.layout.structure;
759        let (m_s, z_s) = (structure.mass_kg, structure.cg_m.z);
760        for (t, motor_kg) in [(0.0, 1.0), (0.5, 0.75), (2.0, 0.5)] {
761            let whole = assembly.mass_properties(t);
762            let want_mass = m_s + motor_kg;
763            assert!((whole.mass_kg - want_mass).abs() < 1e-12, "{t}");
764            let want_z = (m_s * z_s + motor_kg * -0.91) / want_mass;
765            assert!((whole.cg_m.z - want_z).abs() < 1e-12, "{t}");
766            // Parallel axis about the rocket's center, from the motor's own moments at `t`. The
767            // launch lug puts the center slightly off the axis, where the motor is not.
768            let state = placed.mounted.motor.state(t).total;
769            let (dx, dy, dz) = (-whole.cg_m.x, -whole.cg_m.y, -0.91 - whole.cg_m.z);
770            let about = structure.inertia_about(whole.cg_m);
771            let transverse =
772                about.x_axis.x + state.transverse_inertia_kg_m2 + motor_kg * (dy * dy + dz * dz);
773            assert!(
774                (whole.inertia_kg_m2.x_axis.x - transverse).abs() < 1e-12 * transverse,
775                "{t}"
776            );
777            let axial = about.z_axis.z + state.axial_inertia_kg_m2 + motor_kg * (dx * dx + dy * dy);
778            assert!(
779                (whole.inertia_kg_m2.z_axis.z - axial).abs() < 1e-12 * axial,
780                "{t}"
781            );
782            assert!(whole.cg_m.x.abs() > 1e-6, "the lug is off the axis");
783        }
784        let dry = assembly.dry_mass_properties();
785        assert!((dry.mass_kg - (m_s + 0.5)).abs() < 1e-12);
786        assert_eq!(dry, assembly.mass_properties(1.0));
787    }
788
789    /// A motor in an inner tube 0.01 m off the axis at 90° sits at `y = 0.01`, and the rocket
790    /// picks up the product of inertia `I_yz = −Σ m y z` that offset makes.
791    #[test]
792    fn motor_in_an_offset_mount_is_off_axis() {
793        let mut design = three_fin_rocket();
794        let mount = &mut design.stages[0].components[1].children[0];
795        if let crate::Part::InnerTube(tube) = &mut mount.part {
796            tube.outer_radius_m = 0.0105;
797            tube.radial_offset_m = 0.01;
798            tube.angle_rad = std::f64::consts::FRAC_PI_2;
799        }
800        // Drop the on-axis centering rings.
801        design.stages[0].components[1].children.drain(1..3);
802        design.configurations[0].motors = vec![motor("mmt", 0.018, 0.1)];
803        let assembly = design.assemble("main").unwrap();
804        let placed = &assembly.motors[0];
805        assert!(placed.nozzle_m.x.abs() < 1e-18);
806        assert!((placed.nozzle_m.y - 0.01).abs() < 1e-18);
807        let m = placed.mass_properties(0.0);
808        assert!((m.cg_m.y - 0.01).abs() < 1e-18);
809        let about_origin = m.inertia_about(DVec3::ZERO);
810        let own = MassProperties {
811            cg_m: DVec3::ZERO,
812            ..m
813        };
814        let offset = about_origin - own.inertia_kg_m2;
815        let want_yz = -m.mass_kg * m.cg_m.y * m.cg_m.z;
816        assert!((offset.z_axis.y - want_yz).abs() < 1e-15, "{offset:?}");
817        // cos 90° is 6e-17, not zero.
818        assert!(offset.y_axis.x.abs() < 1e-18);
819        assert!(assembly.mass_properties(0.0).inertia_kg_m2.z_axis.y.abs() > 1e-6);
820        assert_ne!(m.inertia_kg_m2, DMat3::ZERO);
821    }
822
823    /// The sample rocket with its mount a cluster of `tubes` 18 mm tubes (their axes where
824    /// `tubes` says), holding a 0.1 m envelope motor, and no centering rings.
825    fn clustered(tubes: Vec<[f64; 2]>) -> Rocket {
826        let mut design = three_fin_rocket();
827        let mount = &mut design.stages[0].components[1].children[0];
828        if let crate::Part::InnerTube(tube) = &mut mount.part {
829            tube.outer_radius_m = 0.0095;
830            tube.cluster_m = tubes;
831        }
832        design.stages[0].components[1].children.drain(1..3);
833        design.configurations[0].motors = vec![motor("mmt", 0.018, 0.1)];
834        design
835    }
836
837    /// Three tubes on a ring of radius `d = 0.02` m, at 90°, 210° and 330°.
838    fn ring_of_three() -> Vec<[f64; 2]> {
839        [90.0_f64, 210.0, 330.0]
840            .iter()
841            .map(|a| [0.02 * a.to_radians().cos(), 0.02 * a.to_radians().sin()])
842            .collect()
843    }
844
845    /// A motor in a 3-ring cluster is three motors, one in each tube, their nozzles where the tubes
846    /// are and at the one station. The rocket weighs the structure and all three at every time,
847    /// and about the body's axis its roll inertia is the structure's plus, for each motor, its own
848    /// axial inertia and `m d²`, worked by hand.
849    #[test]
850    fn a_cluster_mount_takes_its_motor_in_every_tube() {
851        let assembly = clustered(ring_of_three()).assemble("main").unwrap();
852        assert_eq!(assembly.motors.len(), 3);
853        for (k, (placed, [x, y])) in assembly.motors.iter().zip(ring_of_three()).enumerate() {
854            assert_eq!((placed.tube, placed.fails), (k, false));
855            assert_eq!(placed.mount, "mmt");
856            assert_eq!((placed.nozzle_m.x, placed.nozzle_m.y), (x, y));
857            assert!((placed.nozzle_station_m() - 1.01).abs() < 1e-15);
858        }
859        let structure = assembly.layout.structure;
860        for (t, motor_kg) in [(0.0, 1.0), (0.5, 0.75), (2.0, 0.5)] {
861            let whole = assembly.mass_properties(t);
862            assert!((whole.mass_kg - (structure.mass_kg + 3.0 * motor_kg)).abs() < 1e-12);
863            let axis = DVec3::new(0.0, 0.0, whole.cg_m.z);
864            let own = assembly.motors[0].mounted.motor.state(t).total;
865            let want = structure.inertia_about(axis).z_axis.z
866                + 3.0 * (own.axial_inertia_kg_m2 + motor_kg * 0.02 * 0.02);
867            let got = whole.inertia_about(axis).z_axis.z;
868            assert!((got - want).abs() < 1e-12 * want, "{t}: {got} vs {want}");
869        }
870    }
871
872    /// A motor out: the tube named in `failed_tubes` keeps its motor loaded and never lights it,
873    /// the others burn. A tube the mount doesn't have, or one named twice, is refused.
874    #[test]
875    fn a_failed_tube_never_lights() {
876        let mut design = clustered(ring_of_three());
877        design.configurations[0].motors[0].failed_tubes = vec![0];
878        let assembly = design.assemble("main").unwrap();
879        let fails: Vec<bool> = assembly.motors.iter().map(|m| m.fails).collect();
880        assert_eq!(fails, [true, false, false]);
881        let lit = assembly.ignition_times_s(|_| None);
882        assert_eq!(lit, [None, Some(0.0), Some(0.0)]);
883        let spent = assembly.mass_properties_lit(2.0, &lit);
884        let structure = assembly.layout.structure.mass_kg;
885        assert!((spent.mass_kg - (structure + 1.0 + 0.5 + 0.5)).abs() < 1e-12);
886        // The loaded motor pulls the center toward its tube, at 90°: +y.
887        let all_burning = assembly.mass_properties_lit(2.0, &[Some(0.0); 3]);
888        assert!(spent.cg_m.y > all_burning.cg_m.y + 1e-4);
889        assert_eq!(assembly.mass_properties(2.0), spent);
890
891        for (failed, value) in [(vec![3], 3.0), (vec![1, 1], 1.0)] {
892            let mut design = clustered(ring_of_three());
893            design.configurations[0].motors[0].failed_tubes = failed;
894            let Err(DesignError::InComponent { id, source }) = design.assemble("main") else {
895                panic!("refused");
896            };
897            assert_eq!(id, "mmt");
898            assert!(
899                matches!(*source, DesignError::Domain { what, value: v }
900                    if what.starts_with("failed tube") && v == value),
901                "{source:?}"
902            );
903        }
904        // One tube is tube 0.
905        let mut design = three_fin_rocket();
906        design.configurations[0].motors[0].failed_tubes = vec![0];
907        let assembly = design.assemble("main").unwrap();
908        assert_eq!(assembly.ignition_times_s(|_| None), [None]);
909        // A motor that never lights is carried loaded, spent or burning as the rest may be.
910        let loaded = MassProperties::combine([
911            &assembly.layout.structure,
912            &assembly.motors[0].mass_properties(0.0),
913        ]);
914        assert_eq!(assembly.dry_mass_properties(), loaded);
915        assert_eq!(assembly.mass_properties(10.0), loaded);
916    }
917
918    #[test]
919    fn bad_configurations_are_refused() {
920        let design = three_fin_rocket();
921        assert!(matches!(
922            design.assemble("missing"),
923            Err(DesignError::UnknownId {
924                what: "configuration",
925                ..
926            })
927        ));
928
929        let mut d = design.clone();
930        d.configurations[0].motors[0].mount = "nowhere".to_owned();
931        assert!(matches!(
932            d.assemble("main"),
933            Err(DesignError::UnknownId {
934                what: "motor mount",
935                ..
936            })
937        ));
938
939        let mut d = design.clone();
940        d.configurations[0].motors[0].mount = "airframe".to_owned();
941        assert!(
942            matches!(d.assemble("main"), Err(DesignError::Tree { ref id, .. }) if id == "airframe")
943        );
944
945        let mut d = design.clone();
946        let second = d.configurations[0].motors[0].clone();
947        d.configurations[0].motors.push(second);
948        assert!(matches!(d.assemble("main"), Err(DesignError::Tree { ref id, .. }) if id == "mmt"));
949
950        let mut d = design.clone();
951        let copy = d.configurations[0].clone();
952        d.configurations.push(copy);
953        assert_eq!(
954            d.assemble("main"),
955            Err(DesignError::DuplicateId("main".to_owned()))
956        );
957
958        let mut d = design.clone();
959        d.configurations[0].motors[0].diameter_m = 0.0;
960        assert!(matches!(
961            d.assemble("main"),
962            Err(DesignError::InComponent { ref id, ref source })
963                if id == "mmt" && matches!(**source, DesignError::Domain { .. })
964        ));
965
966        let mut d = design.clone();
967        d.stages[0].components[1].children[0].motor_mount = Some(MotorMount {
968            overhang_m: f64::NAN,
969        });
970        assert!(matches!(
971            d.assemble("main"),
972            Err(DesignError::InComponent { .. })
973        ));
974
975        // A layout places any configuration handed to it, not only the rocket's own.
976        let layout = design.layout().unwrap();
977        let mut candidate = design.configurations[0].clone();
978        candidate.id = "candidate".to_owned();
979        assert_eq!(layout.place_motors(&candidate).unwrap().len(), 1);
980        candidate.motors[0].mount = "chute".to_owned();
981        assert!(matches!(
982            layout.place_motors(&candidate),
983            Err(DesignError::Tree { ref id, .. }) if id == "chute"
984        ));
985    }
986    /// The RocketPy example designs in `validation/designs/`, by oracle case name.
987    const ROCKETPY_DESIGNS: [(&str, &str); 10] = [
988        (
989            "calisto-getting-started-motor-at-minus-1.255",
990            include_str!(
991                "../../../validation/designs/rocketpy-calisto-getting-started-motor-at-minus-1.255.json"
992            ),
993        ),
994        (
995            "calisto-tests-motor-at-minus-1.373",
996            include_str!(
997                "../../../validation/designs/rocketpy-calisto-tests-motor-at-minus-1.373.json"
998            ),
999        ),
1000        (
1001            "bella-lui",
1002            include_str!("../../../validation/designs/rocketpy-bella-lui.json"),
1003        ),
1004        (
1005            "ndrt-2020-nose-to-tail",
1006            include_str!("../../../validation/designs/rocketpy-ndrt-2020-nose-to-tail.json"),
1007        ),
1008        (
1009            "valetudo",
1010            include_str!("../../../validation/designs/rocketpy-valetudo.json"),
1011        ),
1012        (
1013            "juno-iii",
1014            include_str!("../../../validation/designs/rocketpy-juno-iii.json"),
1015        ),
1016        (
1017            "cavour",
1018            include_str!("../../../validation/designs/rocketpy-cavour.json"),
1019        ),
1020        (
1021            "genesis",
1022            include_str!("../../../validation/designs/rocketpy-genesis.json"),
1023        ),
1024        (
1025            "lince",
1026            include_str!("../../../validation/designs/rocketpy-lince.json"),
1027        ),
1028        (
1029            "prometheus-2022-generic-motor",
1030            include_str!("../../../validation/designs/rocketpy-prometheus-2022-generic-motor.json"),
1031        ),
1032    ];
1033
1034    #[derive(Debug, serde::Deserialize)]
1035    struct Oracle {
1036        oracle: String,
1037        cases: Vec<OracleCase>,
1038    }
1039
1040    #[derive(Debug, serde::Deserialize)]
1041    struct OracleCase {
1042        name: String,
1043        rocket: OracleRocket,
1044        motor: serde_json::Value,
1045        geometry: serde_json::Value,
1046        scalars: OracleScalars,
1047        series: OracleSeries,
1048        knot_series: Option<OracleSeries>,
1049    }
1050
1051    #[derive(Debug, serde::Deserialize)]
1052    struct OracleRocket {
1053        mass: f64,
1054        inertia: Vec<f64>,
1055        center_of_mass_without_motor: f64,
1056        coordinate_system_orientation: String,
1057        motor_position: f64,
1058    }
1059
1060    #[derive(Debug, serde::Deserialize)]
1061    #[allow(non_snake_case, reason = "RocketPy's attribute names")]
1062    struct OracleScalars {
1063        dry_mass_kg: f64,
1064        center_of_dry_mass_position_m: f64,
1065        dry_I_11_kg_m2: f64,
1066        dry_I_33_kg_m2: f64,
1067        nozzle_position_m: f64,
1068        propellant_initial_mass_kg: f64,
1069    }
1070
1071    #[derive(Debug, serde::Deserialize)]
1072    #[allow(non_snake_case, reason = "RocketPy's attribute names")]
1073    struct OracleSeries {
1074        time_s: Vec<f64>,
1075        propellant_mass: Vec<f64>,
1076        total_mass: Vec<f64>,
1077        center_of_mass: Vec<f64>,
1078        I_11: Vec<f64>,
1079        I_33: Vec<f64>,
1080        I_11_about_cg: Vec<f64>,
1081    }
1082
1083    /// The largest error seen for each quantity, with where.
1084    #[derive(Default)]
1085    struct Worst(Vec<(&'static str, f64, String)>);
1086
1087    impl Worst {
1088        fn see(&mut self, what: &'static str, error: f64, at: impl Fn() -> String) {
1089            assert!(error.is_finite(), "{what}: {}", at());
1090            match self.0.iter_mut().find(|w| w.0 == what) {
1091                Some(w) if error > w.1 => (w.1, w.2) = (error, at()),
1092                Some(_) => {}
1093                None => self.0.push((what, error, at())),
1094            }
1095        }
1096    }
1097
1098    fn relative(got: f64, want: f64) -> f64 {
1099        ((got - want) / want).abs()
1100    }
1101
1102    fn number(v: &serde_json::Value, key: &str) -> f64 {
1103        v[key]
1104            .as_f64()
1105            .unwrap_or_else(|| panic!("fixture motor `{key}`"))
1106    }
1107
1108    /// M1.4b's done-when: mass, center of mass and inertia match RocketPy's example rockets. The
1109    /// fixture comes from `validation/oracles/rocketpy/rocket_mass.py` (RocketPy 1.13.0), which
1110    /// builds each example rocket from its own inputs, with the bundled public-domain curve nearest
1111    /// in impulse in place of the example's thrust file (whose terms are unclear; ADR-007). Its
1112    /// docstring lists the examples left out and why.
1113    ///
1114    /// The designs in `validation/designs/` are generated by `cargo xtask designs`. This test
1115    /// derives the mass inputs again from the fixture, independently of that generator: the stage
1116    /// override, the nozzle station through RocketPy's placement rule
1117    /// (`rocket.py:1113-1125`: `z = p + s·z_m`, `s` the product of the two orientation signs), and
1118    /// the motor's grains or column, dry mass and curve. Then it compares, at 103 times through the
1119    /// burn and after it and at up to 60 of RocketPy's LSODA knots: total mass, the center of mass
1120    /// (as a station, relative to the rocket's length), `I_11` about the center of dry mass
1121    /// (RocketPy's reference point), `I_11` about the center of mass, `I_33`, and the propellant
1122    /// mass, plus the dry scalars.
1123    #[test]
1124    fn matches_rocketpy_example_rockets() {
1125        let oracle: Oracle = serde_json::from_str(include_str!(
1126            "../../../validation/fixtures/design/rocketpy-rocket-mass.json"
1127        ))
1128        .unwrap();
1129        assert_eq!(oracle.oracle, "rocketpy 1.13.0");
1130        assert_eq!(oracle.cases.len(), ROCKETPY_DESIGNS.len());
1131        let catalog = hpr_motor::Catalog::bundled().unwrap();
1132        let mut worst = Worst::default();
1133        for case in &oracle.cases {
1134            let (_, text) = ROCKETPY_DESIGNS
1135                .iter()
1136                .find(|(name, _)| *name == case.name)
1137                .unwrap_or_else(|| panic!("no design for {}", case.name));
1138            let design: Rocket = serde_json::from_str(text).unwrap();
1139            let assembly = design.assemble("example").unwrap();
1140            let name = case.name.as_str();
1141
1142            // Stations aft of the nose tip, from RocketPy coordinates.
1143            let tip = case.geometry["nose"]["position"].as_f64().unwrap();
1144            let rocket_sign = match case.rocket.coordinate_system_orientation.as_str() {
1145                "tail_to_nose" => 1.0,
1146                "nose_to_tail" => -1.0,
1147                other => panic!("{other}"),
1148            };
1149            let station = |z: f64| rocket_sign * (tip - z);
1150
1151            // The stage override is the example's rocket without its motor.
1152            let overrides = design.stages[0].overrides;
1153            assert_eq!(overrides.mass_kg, Some(case.rocket.mass), "{name}");
1154            assert_eq!(overrides.cg_xy_m, Some([0.0, 0.0]), "{name}: on the axis");
1155            let cg_aft = overrides.cg_aft_m.unwrap();
1156            assert!(
1157                (cg_aft - station(case.rocket.center_of_mass_without_motor)).abs() < 1e-12,
1158                "{name}"
1159            );
1160            let [i11, i22, i33] = case.rocket.inertia[..] else {
1161                panic!("{name}: inertia")
1162            };
1163            assert_eq!(i11, i22);
1164            assert_eq!(
1165                overrides.inertia,
1166                Some(crate::InertiaOverride::axisymmetric(i33, i11)),
1167                "{name}"
1168            );
1169
1170            // The motor: RocketPy's inputs on hpr's axis, forward of the nozzle exit.
1171            let m = &case.motor;
1172            let motor_sign = match m["coordinate_system_orientation"].as_str().unwrap() {
1173                "nozzle_to_combustion_chamber" => 1.0,
1174                "combustion_chamber_to_nozzle" => -1.0,
1175                other => panic!("{other}"),
1176            };
1177            let nozzle = number(m, "nozzle_position");
1178            let to_motor_axis = |z: f64| motor_sign * (z - nozzle);
1179            let [placed] = &assembly.motors[..] else {
1180                panic!("{name}: one motor")
1181            };
1182            let rocketpy_nozzle = case.rocket.motor_position + rocket_sign * motor_sign * nozzle;
1183            assert!(
1184                (rocketpy_nozzle - case.scalars.nozzle_position_m).abs() < 1e-12,
1185                "{name}"
1186            );
1187            assert!(
1188                (placed.nozzle_station_m() - station(rocketpy_nozzle)).abs() < 1e-12,
1189                "{name}: nozzle at {} vs {}",
1190                placed.nozzle_station_m(),
1191                station(rocketpy_nozzle)
1192            );
1193            let motor = &placed.mounted.motor;
1194            let file = m["thrust_file"].as_str().unwrap();
1195            let (entry, curve) = catalog
1196                .motors
1197                .iter()
1198                .find_map(|e| e.curves.iter().find(|c| c.file == file).map(|c| (e, c)))
1199                .unwrap();
1200            assert_eq!(curve.sha256, m["thrust_file_sha256"].as_str().unwrap());
1201            let bundled = entry
1202                .thrust_curve(
1203                    curve,
1204                    hpr_motor::catalog::bundled_curve_text(&curve.file).unwrap(),
1205                )
1206                .unwrap();
1207            assert_eq!(motor.curve(), &bundled, "{name}: the bundled curve");
1208            let dry = motor.dry();
1209            // hpr's motor needs a positive dry mass: the generator gives Cavour's massless dry
1210            // parts 1e-15 kg.
1211            let example_dry = number(m, "dry_mass");
1212            let expected_dry = if example_dry == 0.0 {
1213                1e-15
1214            } else {
1215                example_dry
1216            };
1217            assert_eq!(dry.mass_kg, expected_dry, "{name}");
1218            let dry_inertia: Vec<f64> = m["dry_inertia"]
1219                .as_array()
1220                .unwrap()
1221                .iter()
1222                .map(|v| v.as_f64().unwrap())
1223                .collect();
1224            assert_eq!(dry.transverse_inertia_kg_m2, dry_inertia[0], "{name}");
1225            assert_eq!(dry.axial_inertia_kg_m2, dry_inertia[2], "{name}");
1226            match (m["motor_kind"].as_str().unwrap(), motor.propellant()) {
1227                ("solid", hpr_motor::Propellant::Grains(g)) => {
1228                    assert_eq!(
1229                        *g,
1230                        hpr_motor::BatesGrains {
1231                            count: u32::try_from(m["grain_number"].as_u64().unwrap()).unwrap(),
1232                            density_kg_m3: number(m, "grain_density"),
1233                            outer_radius_m: number(m, "grain_outer_radius"),
1234                            initial_inner_radius_m: number(m, "grain_initial_inner_radius"),
1235                            initial_height_m: number(m, "grain_initial_height"),
1236                            separation_m: number(m, "grain_separation"),
1237                            center_m: to_motor_axis(number(m, "grains_center_of_mass_position")),
1238                            inhibited_ends: m["only_radial_burn"].as_bool().unwrap(),
1239                        },
1240                        "{name}"
1241                    );
1242                    assert!(
1243                        (dry.cg_m - to_motor_axis(number(m, "center_of_dry_mass_position"))).abs()
1244                            < 1e-15,
1245                        "{name}"
1246                    );
1247                }
1248                ("generic", hpr_motor::Propellant::Column(c)) => {
1249                    // RocketPy puts an unset dry center at the chamber (motor.py:1518-1523).
1250                    assert!(m["center_of_dry_mass_position"].is_null());
1251                    let chamber = to_motor_axis(number(m, "chamber_position"));
1252                    assert_eq!(
1253                        *c,
1254                        hpr_motor::PropellantColumn {
1255                            mass_kg: number(m, "propellant_initial_mass"),
1256                            center_m: chamber,
1257                            outer_radius_m: number(m, "chamber_radius"),
1258                            inner_radius_m: 0.0,
1259                            length_m: number(m, "chamber_height"),
1260                        },
1261                        "{name}"
1262                    );
1263                    assert_eq!(dry.cg_m, chamber, "{name}");
1264                }
1265                (kind, other) => panic!("{name}: {kind} vs {other:?}"),
1266            }
1267            let m_p0 = motor.propellant_initial_mass_kg();
1268            worst.see(
1269                "initial propellant mass",
1270                relative(m_p0, case.scalars.propellant_initial_mass_kg),
1271                || name.to_owned(),
1272            );
1273
1274            // The dry rocket.
1275            let length = assembly.layout.length_m;
1276            let dry_rocket = assembly.dry_mass_properties();
1277            worst.see(
1278                "dry mass",
1279                relative(dry_rocket.mass_kg, case.scalars.dry_mass_kg),
1280                || name.to_owned(),
1281            );
1282            worst.see(
1283                "dry center (of length)",
1284                (-dry_rocket.cg_m.z - station(case.scalars.center_of_dry_mass_position_m)).abs()
1285                    / length,
1286                || name.to_owned(),
1287            );
1288            worst.see(
1289                "dry I_11",
1290                relative(
1291                    dry_rocket.inertia_kg_m2.x_axis.x,
1292                    case.scalars.dry_I_11_kg_m2,
1293                ),
1294                || name.to_owned(),
1295            );
1296            worst.see(
1297                "dry I_33",
1298                relative(
1299                    dry_rocket.inertia_kg_m2.z_axis.z,
1300                    case.scalars.dry_I_33_kg_m2,
1301                ),
1302                || name.to_owned(),
1303            );
1304
1305            // Through the burn, on the even grid and at RocketPy's own knots.
1306            let grids = [
1307                ("", Some(&case.series)),
1308                (" at knots", case.knot_series.as_ref()),
1309            ];
1310            for (grid, s) in grids.into_iter().filter_map(|(g, s)| Some((g, s?))) {
1311                let label = |what: &'static str| -> &'static str {
1312                    match (what, grid.is_empty()) {
1313                        (_, true) => what,
1314                        ("total mass", false) => "total mass at knots",
1315                        ("center of mass (of length)", false) => "center of mass at knots",
1316                        ("I_11 about the dry center", false) => {
1317                            "I_11 about the dry center at knots"
1318                        }
1319                        ("I_22 about the dry center", false) => {
1320                            "I_22 about the dry center at knots"
1321                        }
1322                        ("I_11 about the center of mass", false) => {
1323                            "I_11 about the center of mass at knots"
1324                        }
1325                        ("I_33", false) => "I_33 at knots",
1326                        ("products of inertia (of I_11)", false) => "products of inertia at knots",
1327                        ("propellant mass, grains (of initial)", false) => {
1328                            "propellant mass, grains, at knots"
1329                        }
1330                        (other, false) => panic!("no knot label for {other}"),
1331                    }
1332                };
1333                for (i, &t) in s.time_s.iter().enumerate() {
1334                    let at = || format!("{name} at t = {t} s");
1335                    let whole = assembly.mass_properties(t);
1336                    worst.see(
1337                        label("total mass"),
1338                        relative(whole.mass_kg, s.total_mass[i]),
1339                        at,
1340                    );
1341                    worst.see(
1342                        label("center of mass (of length)"),
1343                        (-whole.cg_m.z - station(s.center_of_mass[i])).abs() / length,
1344                        at,
1345                    );
1346                    let about_dry = whole.inertia_about(dry_rocket.cg_m);
1347                    worst.see(
1348                        label("I_11 about the dry center"),
1349                        relative(about_dry.x_axis.x, s.I_11[i]),
1350                        at,
1351                    );
1352                    worst.see(
1353                        label("I_22 about the dry center"),
1354                        relative(about_dry.y_axis.y, s.I_11[i]),
1355                        at,
1356                    );
1357                    worst.see(
1358                        label("I_11 about the center of mass"),
1359                        relative(whole.inertia_kg_m2.x_axis.x, s.I_11_about_cg[i]),
1360                        at,
1361                    );
1362                    worst.see(
1363                        label("I_33"),
1364                        relative(whole.inertia_kg_m2.z_axis.z, s.I_33[i]),
1365                        at,
1366                    );
1367                    let products = [
1368                        whole.inertia_kg_m2.y_axis.x,
1369                        whole.inertia_kg_m2.z_axis.x,
1370                        whole.inertia_kg_m2.z_axis.y,
1371                    ];
1372                    worst.see(
1373                        label("products of inertia (of I_11)"),
1374                        products.iter().fold(0.0f64, |a, v| a.max(v.abs())) / s.I_11[i],
1375                        at,
1376                    );
1377                    let what = match motor.propellant() {
1378                        hpr_motor::Propellant::Column(_) => "propellant mass, column (of initial)",
1379                        _ => "propellant mass, grains (of initial)",
1380                    };
1381                    worst.see(
1382                        label(what),
1383                        (motor.propellant_mass_kg(t) - s.propellant_mass[i]).abs() / m_p0,
1384                        at,
1385                    );
1386                }
1387            }
1388        }
1389        // Measured worst (RocketPy 1.13.0), and each tolerance's margin over it:
1390        //
1391        // - Exact quantities: dry mass, center and inertias, initial propellant mass and the
1392        //   column's propellant mass agree to 2.4e-16 (tolerance 1e-12). Products of inertia are
1393        //   exactly zero (1e-15).
1394        // - At RocketPy's LSODA knots, where its grain geometry holds computed values: total mass
1395        //   3.3e-10, center 1.4e-10 of the length, `I_11` 8.0e-10, `I_33` 1.8e-10, grain propellant
1396        //   mass 2.4e-9 of its initial value. That is the solver's own accuracy (rtol 1e-11); the
1397        //   tolerance is 1e-8, a margin of 4 to 70.
1398        // - On the even grid, between knots: total mass 1.3e-5 (Lince), center 3.6e-6, `I_11` 2.6e-5,
1399        //   `I_33` 1.4e-5, grain propellant mass 4.9e-5. `SolidMotor` interpolates its grain
1400        //   volumes linearly between LSODA knots (`solid_motor.py:375-383`, `:603-630`) and
1401        //   `GenericMotor` samples its inertias at the thrust knots, while hpr's are exact for a
1402        //   piecewise-linear curve. Tolerances: 5e-5 (margin 3.8), 2e-5 (5.6), 1e-4 (3.9 and 7) and
1403        //   2.5e-4 (5).
1404        let tolerance = |what: &str| match what {
1405            "initial propellant mass"
1406            | "dry mass"
1407            | "dry center (of length)"
1408            | "dry I_11"
1409            | "dry I_33"
1410            | "propellant mass, column (of initial)" => 1e-12,
1411            "products of inertia (of I_11)" | "products of inertia at knots" => 1e-15,
1412            "total mass at knots"
1413            | "center of mass at knots"
1414            | "I_11 about the dry center at knots"
1415            | "I_22 about the dry center at knots"
1416            | "I_11 about the center of mass at knots"
1417            | "I_33 at knots"
1418            | "propellant mass, grains, at knots" => 1e-8,
1419            "total mass" => 5e-5,
1420            "center of mass (of length)" => 2e-5,
1421            "I_11 about the dry center"
1422            | "I_22 about the dry center"
1423            | "I_11 about the center of mass"
1424            | "I_33" => 1e-4,
1425            "propellant mass, grains (of initial)" => 2.5e-4,
1426            other => panic!("no tolerance for {other}"),
1427        };
1428        for (what, error, at) in &worst.0 {
1429            println!("{what}: {error:.3e} ({at})");
1430        }
1431        assert_eq!(worst.0.len(), 22, "every quantity was compared");
1432        for (what, error, at) in &worst.0 {
1433            assert!(*error <= tolerance(what), "{what}: {error:e} at {at}");
1434        }
1435    }
1436
1437    /// The synthetic two-stage design with its sustainer lit by `ignition`: a J760 in the
1438    /// booster (motor 0), an I175 in the sustainer (motor 1).
1439    fn two_stage(ignition: Ignition) -> Rocket {
1440        let mut rocket: Rocket = serde_json::from_str(include_str!(
1441            "../../../validation/designs/synthetic-two-stage-75mm-54mm.json"
1442        ))
1443        .unwrap();
1444        rocket.configurations[0].motors[1].ignition = ignition;
1445        rocket
1446    }
1447
1448    /// A sustainer lit by a clustered booster's burnout lights at the burnout of the booster's
1449    /// first motor that lights, so one motor out doesn't hold it back; with every booster motor
1450    /// out it never lights, and that is not the cycle of burnouts assembly refuses.
1451    #[test]
1452    fn a_burnout_of_a_cluster_is_its_first_motor_that_lights() {
1453        let mut rocket = two_stage(Ignition::Burnout {
1454            mount: "booster-motor-mount".to_owned(),
1455            delay_s: 1.0,
1456        });
1457        let booster = rocket.stages[1].components[1]
1458            .children
1459            .iter_mut()
1460            .find(|c| c.id == "booster-motor-mount")
1461            .expect("the booster's mount");
1462        if let crate::Part::InnerTube(tube) = &mut booster.part {
1463            tube.cluster_m = vec![[-0.01, 0.0], [0.01, 0.0]];
1464        }
1465        let booster_burnout_s =
1466            |assembly: &Assembly| assembly.motors[0].mounted.motor.burnout_time_s();
1467        rocket.configurations[0].motors[0].failed_tubes = vec![0];
1468        let assembly = rocket.assemble("j760-i175").unwrap();
1469        let burnout_s = booster_burnout_s(&assembly);
1470        assert_eq!(
1471            assembly.ignition_times_s(|_| None),
1472            [None, Some(0.0), Some(burnout_s + 1.0)]
1473        );
1474        rocket.configurations[0].motors[0].failed_tubes = vec![1, 0];
1475        let assembly = rocket.assemble("j760-i175").unwrap();
1476        assert_eq!(assembly.ignition_times_s(|_| None), [None, None, None]);
1477    }
1478
1479    /// A motor set never to light is carried loaded to the end, as a motor out is, and a motor
1480    /// lit by its burnout never lights either; that is not the cycle of burnouts assembly refuses.
1481    #[test]
1482    fn a_motor_set_never_to_light_stays_loaded() {
1483        let assembly = two_stage(Ignition::Never).assemble("j760-i175").unwrap();
1484        assert_eq!(
1485            assembly.motors.iter().map(|m| m.fails).collect::<Vec<_>>(),
1486            [false, true]
1487        );
1488        assert_eq!(assembly.ignition_times_s(|_| Some(0.0)), [Some(0.0), None]);
1489        let loaded = assembly.motors[1].mass_properties(0.0);
1490        let structure = assembly.layout.structure;
1491        let booster = |t: f64| assembly.motors[0].mass_properties(t);
1492        let lit = assembly.ignition_times_s(|_| None);
1493        for t in [0.0, 3.0, 100.0] {
1494            // Summed motor by motor, in order, as the assembly sums them.
1495            let expected = MassProperties::combine([
1496                &MassProperties::combine([&structure, &booster(t)]),
1497                &loaded,
1498            ]);
1499            assert_eq!(assembly.mass_properties_lit(t, &lit), expected);
1500            assert_eq!(assembly.mass_properties(t), expected);
1501        }
1502        let dry = MassProperties::combine([&structure, &assembly.motors[0].dry_mass_properties()]);
1503        assert_eq!(
1504            assembly.dry_mass_properties(),
1505            MassProperties::combine([&dry, &loaded])
1506        );
1507        let mut rocket = two_stage(Ignition::Burnout {
1508            mount: "booster-motor-mount".to_owned(),
1509            delay_s: 0.0,
1510        });
1511        rocket.configurations[0].motors[0].ignition = Ignition::Never;
1512        let assembly = rocket.assemble("j760-i175").unwrap();
1513        assert_eq!(assembly.ignition_times_s(|_| Some(0.0)), [None, None]);
1514        let json = serde_json::to_string(&two_stage(Ignition::Never)).unwrap();
1515        assert!(json.contains(r#""ignition":"never""#), "{json}");
1516        assert_eq!(
1517            serde_json::from_str::<Rocket>(&json).unwrap(),
1518            two_stage(Ignition::Never)
1519        );
1520    }
1521
1522    #[test]
1523    fn ignition_times_follow_their_events() {
1524        let j760 = |assembly: &Assembly| assembly.motors[0].mounted.motor.burnout_time_s();
1525        // At launch, and at a time.
1526        let assembly = two_stage(Ignition::Launch).assemble("j760-i175").unwrap();
1527        assert_eq!(assembly.ignition_times_s(|_| None), [Some(0.0), Some(0.0)]);
1528        let assembly = two_stage(Ignition::Time { time_s: 3.5 })
1529            .assemble("j760-i175")
1530            .unwrap();
1531        assert_eq!(assembly.ignition_times_s(|_| None), [Some(0.0), Some(3.5)]);
1532        // At the booster's burnout plus a delay.
1533        let assembly = two_stage(Ignition::Burnout {
1534            mount: "booster-motor-mount".to_owned(),
1535            delay_s: 1.25,
1536        })
1537        .assemble("j760-i175")
1538        .unwrap();
1539        let burnout_s = j760(&assembly);
1540        assert!(burnout_s > 1.0, "{burnout_s}");
1541        assert_eq!(
1542            assembly.ignition_times_s(|_| None),
1543            [Some(0.0), Some(burnout_s + 1.25)]
1544        );
1545        // At the separation of the stage aft of the sustainer's (stage 0), and never without it.
1546        let assembly = two_stage(Ignition::Separation { delay_s: 0.5 })
1547            .assemble("j760-i175")
1548            .unwrap();
1549        assert_eq!(assembly.ignition_times_s(|_| None), [Some(0.0), None]);
1550        assert_eq!(
1551            assembly.ignition_times_s(|stage| (stage == 0).then_some(4.0)),
1552            [Some(0.0), Some(4.5)]
1553        );
1554        assert_eq!(
1555            assembly.ignition_times_s(|stage| (stage == 1).then_some(4.0)),
1556            [Some(0.0), None]
1557        );
1558        // A motor not yet lit is loaded; lit, it burns on its own clock.
1559        let lit = [Some(0.0), Some(4.5)];
1560        let loaded = assembly.motors[1].mass_properties(0.0).mass_kg;
1561        let spent = assembly.motors[1].dry_mass_properties().mass_kg;
1562        let at = |t: f64| assembly.mass_properties_lit(t, &lit).mass_kg;
1563        let structure = assembly.layout.structure.mass_kg;
1564        let booster = |t: f64| assembly.motors[0].mass_properties(t).mass_kg;
1565        assert_eq!(at(4.0), structure + booster(4.0) + loaded);
1566        let sustainer = assembly.motors[1].mass_properties(1.0).mass_kg;
1567        assert!(sustainer < loaded && sustainer > spent);
1568        assert!((at(5.5) - (structure + booster(5.5) + sustainer)).abs() < 1e-12);
1569        assert!((at(100.0) - assembly.dry_mass_properties().mass_kg).abs() < 1e-12);
1570    }
1571
1572    #[test]
1573    fn bad_ignitions_are_refused() {
1574        let refused = |ignition: Ignition| two_stage(ignition).assemble("j760-i175").is_err();
1575        assert!(refused(Ignition::Time { time_s: -1.0 }));
1576        assert!(refused(Ignition::Time { time_s: f64::NAN }));
1577        assert!(refused(Ignition::Separation {
1578            delay_s: f64::INFINITY
1579        }));
1580        // The booster is the last stage: nothing is aft of it to separate.
1581        let mut rocket = two_stage(Ignition::Launch);
1582        rocket.configurations[0].motors[0].ignition = Ignition::Separation { delay_s: 0.0 };
1583        let error = rocket.assemble("j760-i175").unwrap_err();
1584        assert!(matches!(error, DesignError::Tree { .. }), "{error:?}");
1585        assert!(refused(Ignition::Burnout {
1586            mount: "booster-motor-mount".to_owned(),
1587            delay_s: -0.1,
1588        }));
1589        // A mount with no motor, or no such mount.
1590        assert!(refused(Ignition::Burnout {
1591            mount: "nose".to_owned(),
1592            delay_s: 0.0,
1593        }));
1594        // Its own burnout, and a cycle through the other motor.
1595        assert!(refused(Ignition::Burnout {
1596            mount: "sustainer-motor-mount".to_owned(),
1597            delay_s: 0.0,
1598        }));
1599        let mut rocket = two_stage(Ignition::Burnout {
1600            mount: "booster-motor-mount".to_owned(),
1601            delay_s: 0.0,
1602        });
1603        rocket.configurations[0].motors[0].ignition = Ignition::Burnout {
1604            mount: "sustainer-motor-mount".to_owned(),
1605            delay_s: 0.0,
1606        };
1607        let error = rocket.assemble("j760-i175").unwrap_err();
1608        assert!(matches!(error, DesignError::Tree { .. }), "{error:?}");
1609        // A launch is written as nothing at all, so older designs read the same.
1610        let json = serde_json::to_string(&two_stage(Ignition::Launch)).unwrap();
1611        assert!(!json.contains("ignition"));
1612        let timed = serde_json::to_string(&two_stage(Ignition::Time { time_s: 2.0 })).unwrap();
1613        assert!(
1614            timed.contains(r#""ignition":{"time":{"time_s":2.0}}"#),
1615            "{timed}"
1616        );
1617    }
1618}