Skip to main content

hpr_io/ork/
motors.rs

1//! The motors a `.ork` design flies: its motor configurations, the motor each mount holds in each
2//! of them, when that motor ignites, its ejection delay, and the thrust curve it flies on.
3//!
4//! **Where a `.ork` keeps them.** A design declares its configurations once, under `<rocket>`, as
5//! `<motorconfiguration configid="…">` elements with a name, a `default` flag and the stages that
6//! fly. The motors themselves are not there: each body tube or inner tube that is a motor mount
7//! carries a `<motormount>`, and that holds one `<motor configid="…">` per configuration it is
8//! loaded in (a manufacturer, a designation, a `digest`, a case diameter and length and a delay),
9//! plus when it ignites, as a default for the mount and an `<ignitionconfiguration>` per
10//! configuration that changes it ([the file specification][spec], *Motor Mount*). So a
11//! configuration is read by collecting every mount's motor with its id ([Loft lesson L65][l65]:
12//! Loft read only one of the two places).
13//!
14//! **The thrust curve.** A `<motor>` names a motor; it does not describe one. From schema 1.11 the
15//! archive can carry the curve itself as `thrustcurves/<digest>.rse` ([the file
16//! specification][spec], *Embedded Thrust Curve Data*). That curve is used first: it is the one
17//! the design was saved with, named by the file's own digest ([Loft lesson L57][l57]: Loft threw
18//! such curves away). Next come any curves the caller supplies by digest ([`SuppliedCurves`],
19//! [`design_with`](super::design_with)), such as OpenRocket's own motor database: a digest names
20//! the curve exactly, so a supplied curve is the one the design was saved with too. Last, the
21//! motor is looked up in the bundled catalog by manufacturer and designation. A motor found in none
22//! of these places is read with its reason, and nothing is invented for it.
23//!
24//! **What hpr flies.** A configuration whose every motor has a curve and a size, in a mount hpr
25//! reads, and lights when hpr can light it, becomes one of the rocket's configurations, with each
26//! motor's ignition and its separations read as the [`staging`]
27//! module says ([M1.9c][m1-9], decision [ADR-076][adr-076]). Every other one is kept here, whole,
28//! with the reason it is not flown.
29//!
30//! [spec]: https://openrocket.readthedocs.io/en/latest/dev_guide/file_specification.html
31//! [m1-9]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-9c
32//! [adr-076]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-076-a-ork-files-ignitions-and-one-powered-separation-flown-against-openrocket-2026-09-25
33//! [l57]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l57
34//! [l65]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l65
35
36use std::collections::{BTreeMap, BTreeSet};
37
38use hpr_design::{Configuration, MountedMotor, Rocket};
39use hpr_motor::catalog::bundled_curve_text;
40use hpr_motor::{Catalog, Delay, MotorError, SolidMotor, rse};
41use serde::{Deserialize, Serialize};
42
43use super::recovery::StageSeparation;
44use super::staging::{self, Staging};
45
46use super::component::subcomponents;
47use super::container::Attachment;
48use super::document::Element;
49use super::value::Values;
50use super::warning::{Warning, WarningKind};
51
52/// Where a `<motor>` element's thrust curve came from.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
54#[serde(tag = "source", rename_all = "snake_case")]
55#[non_exhaustive]
56#[serde(deny_unknown_fields)]
57pub enum Curve {
58    /// The archive's own `thrustcurves/<digest>.rse` entry.
59    Embedded {
60        /// The archive entry, such as `thrustcurves/<digest>.rse`.
61        entry: String,
62        /// The motor built from it.
63        motor: Box<SolidMotor>,
64    },
65    /// A curve the caller supplied for the motor's digest ([`SuppliedCurves`]).
66    Supplied {
67        /// The digest the design records, which the curve was supplied for.
68        digest: String,
69        /// Where the supplied curves came from ([`SuppliedCurves::source`]).
70        from: String,
71        /// The motor supplied.
72        motor: Box<SolidMotor>,
73    },
74    /// The bundled ThrustCurve.org catalog ([`Catalog::bundled`]).
75    Catalog {
76        /// ThrustCurve.org's motor id.
77        motor_id: String,
78        /// ThrustCurve.org's id of the curve file flown.
79        simfile_id: String,
80        /// The motor built from it.
81        motor: Box<SolidMotor>,
82    },
83    /// No curve, and why.
84    Unresolved {
85        /// Why no curve was found.
86        why: NoCurve,
87        /// The same, in words.
88        reason: String,
89    },
90}
91
92/// Why a motor has no thrust curve.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
94#[serde(rename_all = "snake_case")]
95#[non_exhaustive]
96pub enum NoCurve {
97    /// A hybrid: hpr flies commercial solid motors only.
98    Hybrid,
99    /// The `<motor>` names no designation to look up.
100    NoDesignation,
101    /// Neither an embedded curve, a supplied one nor the bundled catalog has it.
102    NotFound,
103    /// More than one motor in the bundled catalog has its manufacturer and designation.
104    Ambiguous,
105    /// A curve was found and could not be used: an embedded `.rse` that does not read, or a
106    /// catalog curve that fails.
107    Unusable,
108}
109
110/// Thrust curves the caller supplies, each for the OpenRocket digest a `<motor>` records.
111///
112/// A `.ork` motor records a *digest*: OpenRocket's fingerprint (a hash) of its curve's data, which
113/// "uniquely identifies the functional characteristics" of the curve ([OpenRocket's GitHub
114/// wiki][wiki], file format 1.2), though OpenRocket 24.12's own database holds a few digests that
115/// two different motors share. Most designs do not embed the curve itself: OpenRocket finds it
116/// in the motor database its program ships. hpr's reader does no I/O and bundles only a small
117/// catalog, so a caller holding such a database hands its curves in here: `cargo xtask ork`
118/// supplies OpenRocket's own, for the milestone that measured the design library's curves
119/// ([M2.2c2][m2-2c2]).
120///
121/// A curve is used only for its own digest, never by name, which can match several curves. Supply
122/// solid motors only: hpr flies commercial solid motors, and a [`SolidMotor`] carries no motor
123/// type to refuse a hybrid by. A `<motor>` whose own `<type>` says `hybrid` is refused before any
124/// curve is looked up.
125///
126/// [wiki]: https://github.com/openrocket/openrocket/wiki/File-format
127/// [m2-2c2]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m2-2c2
128#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
129pub struct SuppliedCurves {
130    source: String,
131    by_digest: BTreeMap<String, SuppliedCurve>,
132}
133
134/// The case a supplied curve describes: where its motor's size comes from, to compare with the
135/// size the design gives the motor.
136#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
137pub struct CaseSize {
138    /// The case diameter, m.
139    pub diameter_m: f64,
140    /// The case length, m.
141    pub length_m: f64,
142}
143
144/// One supplied curve: the motor built from it and the case it describes.
145#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146struct SuppliedCurve {
147    case: CaseSize,
148    motor: SolidMotor,
149}
150
151impl SuppliedCurves {
152    /// No curves yet, from `source`: words a reason can quote, such as `OpenRocket 24.12's motor
153    /// database`.
154    pub fn new(source: impl Into<String>) -> Self {
155        Self {
156            source: source.into(),
157            by_digest: BTreeMap::new(),
158        }
159    }
160
161    /// Supplies `motor`, whose case is `case`, for `digest`; returns the motor it replaces, if any.
162    ///
163    /// # Errors
164    ///
165    /// [`MotorError::Domain`] when the case's diameter or length is not finite and positive; the
166    /// curves are left as they were.
167    pub fn insert(
168        &mut self,
169        digest: impl Into<String>,
170        case: CaseSize,
171        motor: SolidMotor,
172    ) -> Result<Option<SolidMotor>, MotorError> {
173        for (value, what) in [
174            (case.diameter_m, "supplied case diameter (m)"),
175            (case.length_m, "supplied case length (m)"),
176        ] {
177            if !(value.is_finite() && value > 0.0) {
178                return Err(MotorError::Domain { what, value });
179            }
180        }
181        Ok(self
182            .by_digest
183            .insert(digest.into(), SuppliedCurve { case, motor })
184            .map(|replaced| replaced.motor))
185    }
186
187    /// Where the curves came from, as given to [`SuppliedCurves::new`]; `the supplied curves`
188    /// when none was given.
189    pub fn source(&self) -> &str {
190        if self.source.is_empty() {
191            "the supplied curves"
192        } else {
193            &self.source
194        }
195    }
196
197    /// The motor supplied for `digest`.
198    pub fn get(&self, digest: &str) -> Option<&SolidMotor> {
199        self.by_digest.get(digest).map(|curve| &curve.motor)
200    }
201
202    /// How many digests have a curve.
203    pub fn len(&self) -> usize {
204        self.by_digest.len()
205    }
206
207    /// Whether no curve is supplied.
208    pub fn is_empty(&self) -> bool {
209        self.by_digest.is_empty()
210    }
211}
212
213impl Curve {
214    /// The motor, when a curve was found.
215    pub fn motor(&self) -> Option<&SolidMotor> {
216        match self {
217            Self::Embedded { motor, .. }
218            | Self::Supplied { motor, .. }
219            | Self::Catalog { motor, .. } => Some(motor),
220            Self::Unresolved { .. } => None,
221        }
222    }
223}
224
225/// When a motor ignites, as `<ignitionevent>` names it.
226///
227/// OpenRocket's file-format page shows only `automatic`. The five values here are the ones
228/// OpenRocket 24.12 writes, each measured by setting it and saving; the meanings are its own
229/// labels, and for `automatic` its FAQ ("How do I create a staged rocket?"). Anything else is kept
230/// as written.
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
232#[serde(rename_all = "snake_case")]
233#[non_exhaustive]
234pub enum IgnitionEvent {
235    /// `automatic`, "Automatic (launch or ejection charge)": the lowest stage at launch, and each
236    /// stage above it at the ejection charge of the stage below.
237    Automatic,
238    /// `launch`: at launch.
239    Launch,
240    /// `ejectioncharge`: at the first ejection charge of the stage below.
241    EjectionCharge,
242    /// `burnout`: at the first burnout of the stage below.
243    Burnout,
244    /// `never`.
245    Never,
246    /// A value not listed here, kept as written.
247    Other(String),
248}
249
250impl IgnitionEvent {
251    /// Reads an `<ignitionevent>`'s text.
252    pub fn parse(text: &str) -> Self {
253        match text.trim() {
254            "automatic" => Self::Automatic,
255            "launch" => Self::Launch,
256            "ejectioncharge" => Self::EjectionCharge,
257            "burnout" => Self::Burnout,
258            "never" => Self::Never,
259            other => Self::Other(other.to_owned()),
260        }
261    }
262
263    /// The word the file wrote.
264    pub fn as_str(&self) -> &str {
265        match self {
266            Self::Automatic => "automatic",
267            Self::Launch => "launch",
268            Self::EjectionCharge => "ejectioncharge",
269            Self::Burnout => "burnout",
270            Self::Never => "never",
271            Self::Other(text) => text,
272        }
273    }
274}
275
276/// When a motor ignites: an event and a delay after it.
277#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
278#[serde(deny_unknown_fields)]
279// Named apart from `hpr_design::Ignition` in the design format's schema.
280#[schemars(rename = "OrkIgnition")]
281pub struct Ignition {
282    /// The event.
283    pub event: IgnitionEvent,
284    /// Seconds after the event, s.
285    pub delay_s: f64,
286}
287
288impl Default for Ignition {
289    /// `automatic`, at no delay: what a mount says when it says nothing.
290    fn default() -> Self {
291        Self {
292            event: IgnitionEvent::Automatic,
293            delay_s: 0.0,
294        }
295    }
296}
297
298/// A motor in a mount, in one configuration: what the `<motor>` element says, when it ignites in
299/// that configuration, and the curve it flies on.
300#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
301#[non_exhaustive]
302#[serde(deny_unknown_fields)]
303pub struct OrkMotor {
304    /// The id of the mount component in the rocket.
305    pub mount: String,
306    /// The index of the mount's stage in [`Rocket::stages`].
307    pub stage: usize,
308    /// `<type>` as written: `single`, `reload` or `hybrid`.
309    pub kind: Option<String>,
310    /// `<manufacturer>`.
311    pub manufacturer: String,
312    /// `<designation>`, such as `H148R`.
313    pub designation: String,
314    /// `<digest>`: OpenRocket's key for the thrust curve, which names an embedded curve.
315    pub digest: Option<String>,
316    /// `<diameter>`: the case diameter, m.
317    pub diameter_m: Option<f64>,
318    /// `<length>`: the case length, m.
319    pub length_m: Option<f64>,
320    /// `<delay>`: `none` is a plugged motor, with no ejection charge; a number is the seconds from
321    /// burnout to the charge, and `0` fires it at burnout (OpenRocket's technical documentation,
322    /// pages 8 and 10).
323    pub delay: Option<Delay>,
324    /// When it ignites in this configuration: the configuration's `<ignitionconfiguration>` where
325    /// the mount has one, the mount's own default where it does not.
326    pub ignition: Ignition,
327    /// The thrust curve.
328    pub curve: Curve,
329}
330
331/// A `<motor>` inside a part hpr does not read, such as a pod's mount.
332#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
333#[non_exhaustive]
334#[serde(deny_unknown_fields)]
335pub struct UnreadMotor {
336    /// Where its mount is in the file.
337    pub at: String,
338    /// `<designation>`.
339    pub designation: String,
340    /// The tag of the part that was not read: the outermost `podset` or `parallelstage` around
341    /// the mount, or else the mount's own tag.
342    pub inside: String,
343    /// Why its mount was not read, in words.
344    pub reason: String,
345}
346
347/// A motor configuration: what the rocket declares, and every motor the mounts put in it.
348#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
349#[non_exhaustive]
350#[serde(deny_unknown_fields)]
351pub struct MotorConfiguration {
352    /// `configid`.
353    pub id: String,
354    /// `<name>`, which may be empty.
355    pub name: String,
356    /// Whether the file marks it `default="true"`.
357    pub default: bool,
358    /// Whether `<rocket>` declares it; one only a mount names is read all the same.
359    pub declared: bool,
360    /// The `<stage number>`s the configuration marks `active="false"`; `None` for one whose number
361    /// is missing or not a count, which is switched off all the same.
362    pub inactive_stages: Vec<Option<u32>>,
363    /// Its motors, in the order their mounts appear in the file.
364    pub motors: Vec<OrkMotor>,
365    /// Its motors in parts hpr does not read.
366    pub unread: Vec<UnreadMotor>,
367    /// Why it is not among the rocket's configurations, or `None` when it is.
368    pub left_out: Option<LeftOut>,
369    /// The separation it flies first, for one among the rocket's configurations that has one
370    /// ([`staging`]): powered, or with nothing ahead of it left to burn, its only one. The rocket's configuration does not carry it: give it to the flight
371    /// (`hpr::ork::separations` maps [`Self::stagings`] onto the flight's), with a recovery device
372    /// on each part.
373    pub staging: Option<Staging>,
374    /// The powered separations it flies after [`Self::staging`], in the order they fire, each
375    /// further forward: a three-stage rocket's middle stage dropping as its sustainer lights.
376    /// Empty for one with one separation or none.
377    #[serde(default, skip_serializing_if = "Vec::is_empty")]
378    pub later_stagings: Vec<Staging>,
379}
380
381impl MotorConfiguration {
382    /// Every separation it flies, in the order they fire: [`Self::staging`], then
383    /// [`Self::later_stagings`].
384    pub fn stagings(&self) -> impl Iterator<Item = &Staging> {
385        self.staging.iter().chain(&self.later_stagings)
386    }
387}
388
389/// Why a configuration is not among the rocket's.
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
391#[non_exhaustive]
392#[serde(deny_unknown_fields)]
393pub struct LeftOut {
394    /// The reason.
395    pub why: NotFlown,
396    /// The same, in words, naming the motor.
397    pub message: String,
398}
399
400/// The reasons a configuration cannot be flown as written, in the order they are checked; each is
401/// checked across every motor before the next, so the one given is the first on this list that
402/// applies.
403#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
404#[serde(rename_all = "snake_case")]
405#[non_exhaustive]
406pub enum NotFlown {
407    /// A motor is in a part hpr does not read, such as a pod.
408    UnreadMotor,
409    /// It holds no motor.
410    NoMotor,
411    /// It marks a stage inactive, and hpr flies every stage.
412    InactiveStage,
413    /// A motor has no thrust curve.
414    NoCurve,
415    /// A motor has no case diameter or length.
416    NoSize,
417    /// A motor lights when hpr can't light it: at a word hpr does not know, after a negative
418    /// delay, at the ejection charge of a motor below that states no delay, or at an event of a
419    /// stage below that holds no motor, or motors in more than one mount; or no motor of the
420    /// configuration lights at all ([`staging`]). A motor that never lights beside one that does is flown unlit.
421    IgnitionNotFlown,
422    /// The airframe or a motor mount was not read exactly as written: reading it raised a
423    /// warning. A part was left out (a pod set hpr cannot lay out, a parallel stage, a part hpr
424    /// could not give a shape), a value was dropped or simplified (a rail button's screw
425    /// head, a material that could not be read), or something was assumed (a shape hpr does not know read
426    /// as a cone). Flying it would fly a
427    /// rocket the design may not be.
428    AirframeNotAsWritten,
429    /// Its stages come apart in a way hpr doesn't fly: one with no motor ahead of it still burning
430    /// or yet to light when it fires, beside another; one with a motor behind it not yet spent;
431    /// one at or after apogee beside another; one that comes before the
432    /// separation behind it; one at launch, or at the ignition of a motor that never lights; a
433    /// negative delay; or an event hpr has no trigger for ([`staging`]).
434    SeparationNotFlown,
435}
436
437/// Every motor configuration a `.ork` design holds.
438#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
439#[non_exhaustive]
440#[serde(deny_unknown_fields)]
441pub struct Motors {
442    /// The configurations: those `<rocket>` declares in its order, then any only a mount names.
443    pub configurations: Vec<MotorConfiguration>,
444}
445
446impl Motors {
447    /// The configuration the file marks as its default, if any.
448    pub fn default_configuration(&self) -> Option<&MotorConfiguration> {
449        self.configurations.iter().find(|c| c.default)
450    }
451}
452
453/// A `<motor>` element, read.
454#[derive(Debug, Clone, PartialEq)]
455struct MotorRead {
456    kind: Option<String>,
457    manufacturer: String,
458    designation: String,
459    digest: Option<String>,
460    diameter_m: Option<f64>,
461    length_m: Option<f64>,
462    delay: Option<Delay>,
463}
464
465/// A `<motormount>`, read while its component is: the walk that builds the rocket hands these to
466/// [`read`] with the id it gave the component.
467#[derive(Debug, Clone, PartialEq)]
468pub(super) struct MountRead {
469    /// Where the mount component is in the file.
470    pub at: String,
471    /// `<overhang>`, m.
472    pub overhang_m: f64,
473    /// The mount's own ignition, for a configuration with no override.
474    default_ignition: Ignition,
475    /// `<ignitionconfiguration>`s: an event and a delay, either of which may be missing.
476    ignitions: BTreeMap<String, (Option<IgnitionEvent>, Option<f64>)>,
477    /// `<motor>`s, by configuration id, in file order.
478    motors: Vec<(String, MotorRead)>,
479    /// Second motors for a configuration that already has one here, which cannot be placed.
480    doubled: Vec<(String, UnreadMotor)>,
481    /// The tube's `clusterconfiguration`, when it is a cluster rather than one tube.
482    cluster: Option<String>,
483}
484
485/// Reads the `<motormount>` of `element`, a body tube or inner tube at `at`, if it has one.
486pub(super) fn mount(element: &Element, at: &str, warnings: &mut Vec<Warning>) -> Option<MountRead> {
487    super::reads::note(element, "motormount");
488    super::reads::note(element, "clusterconfiguration");
489    let mount = element.child("motormount")?;
490    let here = format!("{at}/motormount");
491    let mut values = Values::new(mount, &here, warnings);
492    let overhang_m = values.number(&["overhang"]).unwrap_or_default();
493    let default_ignition = Ignition {
494        event: values
495            .word(&["ignitionevent"])
496            .map_or(IgnitionEvent::Automatic, |text| IgnitionEvent::parse(&text)),
497        delay_s: values.number(&["ignitiondelay"]).unwrap_or_default(),
498    };
499    let mut ignitions = BTreeMap::new();
500    let mut motors = Vec::new();
501    let mut doubled = Vec::new();
502    super::reads::note(mount, "motor");
503    super::reads::note(mount, "ignitionconfiguration");
504    for child in mount.elements() {
505        if !matches!(child.name.as_str(), "motor" | "ignitionconfiguration") {
506            continue;
507        }
508        let at = format!("{here}/{}", child.name);
509        let Some(config) = configid(child) else {
510            warnings.push(Warning::new(
511                at,
512                WarningKind::Dropped,
513                format!(
514                    "a `{}` with no `configid` belongs to no configuration; it was ignored",
515                    child.name
516                ),
517            ));
518            continue;
519        };
520        if child.name == "motor" && motors.iter().any(|(c, _)| *c == config) {
521            // Which of the two OpenRocket would fly is not known, so neither flies: the second is
522            // kept as a motor this reader could not place, and its configuration is left out.
523            warnings.push(Warning::new(
524                at.clone(),
525                WarningKind::Dropped,
526                format!(
527                    "a second motor for configuration `{config}` in one mount, which holds one; \
528                     the configuration is not flown"
529                ),
530            ));
531            doubled.push((
532                config,
533                UnreadMotor {
534                    at,
535                    designation: child
536                        .child("designation")
537                        .map(|d| d.text().trim().to_owned())
538                        .unwrap_or_default(),
539                    inside: element.name.clone(),
540                    reason: "a second motor for this configuration in the same mount".to_owned(),
541                },
542            ));
543            continue;
544        }
545        match child.name.as_str() {
546            "ignitionconfiguration" => {
547                let mut values = Values::new(child, &at, warnings);
548                let event = values
549                    .word(&["ignitionevent"])
550                    .map(|text| IgnitionEvent::parse(&text));
551                let delay_s = values.number(&["ignitiondelay"]);
552                ignitions.insert(config, (event, delay_s));
553            }
554            "motor" => motors.push((config, motor(child, &at, warnings))),
555            _ => {}
556        }
557    }
558    let cluster = element
559        .child("clusterconfiguration")
560        .map(|c| c.text().trim().to_owned())
561        // A pattern of one tube, or a name OpenRocket doesn't know (read as one tube, with a
562        // warning), is no cluster.
563        .filter(|c| super::attached::cluster_pattern(c).is_some_and(|tubes| tubes.len() > 1));
564    Some(MountRead {
565        at: at.to_owned(),
566        overhang_m,
567        default_ignition,
568        ignitions,
569        motors,
570        doubled,
571        cluster,
572    })
573}
574
575/// An element's `configid`, when it has one that is not blank.
576fn configid(element: &Element) -> Option<String> {
577    element
578        .attribute("configid")
579        .map(str::trim)
580        .filter(|id| !id.is_empty())
581        .map(str::to_owned)
582}
583
584/// Reads one `<motor>`.
585fn motor(element: &Element, at: &str, warnings: &mut Vec<Warning>) -> MotorRead {
586    let mut values = Values::new(element, at, warnings);
587    let delay = match values.word(&["delay"]) {
588        None => None,
589        Some(text) => delay(&text, &mut values),
590    };
591    MotorRead {
592        kind: values.word(&["type"]),
593        manufacturer: values.word(&["manufacturer"]).unwrap_or_default(),
594        designation: values.word(&["designation"]).unwrap_or_default(),
595        digest: values.word(&["digest"]).filter(|digest| !digest.is_empty()),
596        diameter_m: values.number(&["diameter"]),
597        length_m: values.number(&["length"]),
598        delay,
599    }
600}
601
602/// A `<delay>`: `none` is a plugged motor, a number the seconds to the ejection charge.
603fn delay(text: &str, values: &mut Values<'_>) -> Option<Delay> {
604    if text.eq_ignore_ascii_case("none") {
605        return Some(Delay::Plugged);
606    }
607    match text.parse::<f64>() {
608        Ok(seconds) if seconds.is_finite() && seconds >= 0.0 => Some(Delay::Seconds(seconds)),
609        _ => {
610            values.warn_at(
611                WarningKind::Dropped,
612                format!(
613                    "`delay` says `{text}`, which is neither `none` nor a delay; it was ignored"
614                ),
615            );
616            values.forget(&["delay"]);
617            None
618        }
619    }
620}
621
622/// Reads every motor configuration: the ones `<rocket>` declares, filled with the motors in
623/// `mounts` (each with the id the rocket gave its component), plus the motors in parts the rocket
624/// does not read, found in `rocket_element`. Curves come from `attachments`, `supplied` or the
625/// bundled catalog.
626///
627/// Every configuration whose motors can all be flown as written is added to `rocket`.
628pub(super) fn read(
629    rocket_element: &Element,
630    rocket: &mut Rocket,
631    airframe: Airframe<'_>,
632    mounts: &[(String, MountRead)],
633    attachments: &[Attachment],
634    supplied: &SuppliedCurves,
635    warnings: &mut Vec<Warning>,
636) -> Motors {
637    let at = "openrocket/rocket";
638    let mut configurations: Vec<MotorConfiguration> = Vec::new();
639    super::reads::note(rocket_element, "motorconfiguration");
640    for element in rocket_element.children_named("motorconfiguration") {
641        let Some(id) = configid(element) else {
642            warnings.push(Warning::new(
643                format!("{at}/motorconfiguration"),
644                WarningKind::Dropped,
645                "a motor configuration with no `configid` can hold no motor; it was ignored",
646            ));
647            continue;
648        };
649        if configurations.iter().any(|c| c.id == id) {
650            warnings.push(Warning::new(
651                format!("{at}/motorconfiguration"),
652                WarningKind::Dropped,
653                format!("motor configuration `{id}` is declared twice; the first was kept"),
654            ));
655            continue;
656        }
657        super::reads::note(element, "stage");
658        super::reads::note(element, "name");
659        let inactive_stages = element
660            .children_named("stage")
661            .filter(|stage| stage.attribute("active") == Some("false"))
662            .map(|stage| {
663                stage
664                    .attribute("number")
665                    .and_then(|n| n.trim().parse().ok())
666            })
667            .collect();
668        configurations.push(MotorConfiguration {
669            id,
670            name: element
671                .child("name")
672                .map(|name| name.text().trim().to_owned())
673                .unwrap_or_default(),
674            default: element.attribute("default") == Some("true"),
675            declared: true,
676            inactive_stages,
677            motors: Vec::new(),
678            unread: Vec::new(),
679            left_out: None,
680            staging: None,
681            later_stagings: Vec::new(),
682        });
683    }
684
685    let catalog = match Catalog::bundled() {
686        Ok(catalog) => Some(catalog),
687        Err(error) => {
688            warnings.push(Warning::new(
689                at,
690                WarningKind::Unusual,
691                format!("the bundled motor catalog could not be read ({error}); no motor was looked up in it"),
692            ));
693            None
694        }
695    };
696    let mut placed: BTreeSet<&str> = BTreeSet::new();
697    for (mount_id, mount) in mounts {
698        // A mount the rocket does not hold is left to the scan for unread motors below, which
699        // says so, rather than being given a stage it is not in (Loft lesson L65).
700        let Some(stage) = stage_of(rocket, mount_id) else {
701            continue;
702        };
703        placed.insert(mount.at.as_str());
704        for (config, motor) in &mount.doubled {
705            let index = configuration(&mut configurations, config, at, warnings);
706            configurations[index].unread.push(motor.clone());
707        }
708        for (config, read) in &mount.motors {
709            let index = configuration(&mut configurations, config, at, warnings);
710            let ignition = match mount.ignitions.get(config) {
711                Some((event, delay_s)) => Ignition {
712                    event: event
713                        .clone()
714                        .unwrap_or_else(|| mount.default_ignition.event.clone()),
715                    delay_s: delay_s.unwrap_or(mount.default_ignition.delay_s),
716                },
717                None => mount.default_ignition.clone(),
718            };
719            let curve = curve(
720                read,
721                attachments,
722                supplied,
723                catalog.as_ref(),
724                &mount.at,
725                warnings,
726            );
727            configurations[index].motors.push(OrkMotor {
728                mount: mount_id.clone(),
729                stage,
730                kind: read.kind.clone(),
731                manufacturer: read.manufacturer.clone(),
732                designation: read.designation.clone(),
733                digest: read.digest.clone(),
734                diameter_m: read.diameter_m,
735                length_m: read.length_m,
736                delay: read.delay,
737                ignition,
738                curve,
739            });
740        }
741    }
742
743    let mut unread = Vec::new();
744    for (index, stage) in subcomponents(rocket_element).enumerate() {
745        let path = format!("{at}/{}[{index}]", stage.name);
746        unread_motors(stage, &path, None, &placed, &mut unread, warnings);
747    }
748    for (config, motor) in unread {
749        let index = configuration(&mut configurations, &config, at, warnings);
750        configurations[index].unread.push(motor);
751    }
752
753    // The stage each parallel stage hangs on; `None` for a stage on the axis.
754    let hung_on: Vec<Option<usize>> = rocket
755        .stages
756        .iter()
757        .map(|stage| {
758            let parallel = stage.parallel.as_ref()?;
759            stage_of(rocket, &parallel.on)
760        })
761        .collect();
762    for configuration in &mut configurations {
763        match flyable(configuration, &hung_on, airframe) {
764            Ok((lit, stagings)) => {
765                rocket.configurations.push(flown(configuration, lit));
766                let mut stagings = stagings.into_iter();
767                configuration.staging = stagings.next();
768                configuration.later_stagings = stagings.collect();
769            }
770            Err(out) => configuration.left_out = Some(out),
771        }
772    }
773    Motors { configurations }
774}
775
776/// The index of configuration `id`, adding it, with a warning, when `<rocket>` does not declare it.
777fn configuration(
778    configurations: &mut Vec<MotorConfiguration>,
779    id: &str,
780    at: &str,
781    warnings: &mut Vec<Warning>,
782) -> usize {
783    if let Some(index) = configurations.iter().position(|c| c.id == id) {
784        return index;
785    }
786    warnings.push(Warning::new(
787        at,
788        WarningKind::Unusual,
789        format!(
790            "a mount puts a motor in configuration `{id}`, which the rocket does not declare; it \
791             was read as a configuration of its own"
792        ),
793    ));
794    configurations.push(MotorConfiguration {
795        id: id.to_owned(),
796        name: String::new(),
797        default: false,
798        declared: false,
799        inactive_stages: Vec::new(),
800        motors: Vec::new(),
801        unread: Vec::new(),
802        left_out: None,
803        staging: None,
804        later_stagings: Vec::new(),
805    });
806    configurations.len() - 1
807}
808
809/// Every `<motor>` under `element` whose mount is not among `read`, with its configuration id.
810/// `inside` is the outermost part on the way down that hpr does not read, if any.
811fn unread_motors(
812    element: &Element,
813    at: &str,
814    inside: Option<&str>,
815    read: &BTreeSet<&str>,
816    found: &mut Vec<(String, UnreadMotor)>,
817    warnings: &mut Vec<Warning>,
818) {
819    let inside = inside.or(match element.name.as_str() {
820        tag @ ("podset" | "parallelstage") => Some(tag),
821        _ => None,
822    });
823    if let Some(mount) = element.child("motormount")
824        && !read.contains(at)
825    {
826        let reason = match inside {
827            Some("podset") => {
828                "its mount is inside a pod set, in a part hpr did not read".to_owned()
829            }
830            Some(_) => {
831                "its mount is inside a parallel stage, in a part hpr did not read".to_owned()
832            }
833            None => format!("its mount, a `{}`, was not read", element.name),
834        };
835        let part = inside.unwrap_or(element.name.as_str());
836        for motor in mount.children_named("motor") {
837            // A motor in no configuration flies in none, so it is no configuration's loss.
838            let Some(config) = configid(motor) else {
839                warnings.push(Warning::new(
840                    format!("{at}/motormount/motor"),
841                    WarningKind::Dropped,
842                    "a `motor` with no `configid` belongs to no configuration; it was ignored",
843                ));
844                continue;
845            };
846            found.push((
847                config,
848                UnreadMotor {
849                    at: at.to_owned(),
850                    designation: motor
851                        .child("designation")
852                        .map(|d| d.text().trim().to_owned())
853                        .unwrap_or_default(),
854                    inside: part.to_owned(),
855                    reason: reason.clone(),
856                },
857            ));
858        }
859    }
860    for (index, child) in subcomponents(element).enumerate() {
861        let path = format!("{at}/{}[{index}]", child.name);
862        unread_motors(child, &path, inside, read, found, warnings);
863    }
864}
865
866/// The index of the stage holding the component with id `id`.
867pub(super) fn stage_of(rocket: &Rocket, id: &str) -> Option<usize> {
868    fn holds(components: &[hpr_design::Component], id: &str) -> bool {
869        components
870            .iter()
871            .any(|c| c.id == id || holds(&c.children, id))
872    }
873    rocket
874        .stages
875        .iter()
876        .position(|stage| holds(&stage.components, id))
877}
878
879/// Lowercase letters and digits only, for comparing names written two ways.
880fn key(text: &str) -> String {
881    text.chars()
882        .filter(char::is_ascii_alphanumeric)
883        .map(|c| c.to_ascii_lowercase())
884        .collect()
885}
886
887/// The thrust curve for `motor`: its embedded `.rse` first, then a curve supplied for its digest,
888/// then the bundled catalog.
889fn curve(
890    motor: &MotorRead,
891    attachments: &[Attachment],
892    supplied: &SuppliedCurves,
893    catalog: Option<&Catalog>,
894    at: &str,
895    warnings: &mut Vec<Warning>,
896) -> Curve {
897    let unresolved = |why: NoCurve, reason: String| Curve::Unresolved { why, reason };
898    if motor.kind.as_deref() == Some("hybrid") {
899        return unresolved(
900            NoCurve::Hybrid,
901            "a hybrid motor; hpr flies commercial solid motors only".to_owned(),
902        );
903    }
904    if motor.designation.is_empty() {
905        return unresolved(
906            NoCurve::NoDesignation,
907            "the motor has no designation".to_owned(),
908        );
909    }
910    let mut embedded_failed = None;
911    if let Some(digest) = &motor.digest {
912        let entry = format!("thrustcurves/{digest}.rse");
913        if let Some(attachment) = attachments.iter().find(|a| a.name == entry) {
914            match embedded(attachment, motor, at, warnings) {
915                Ok(solid) => {
916                    return Curve::Embedded {
917                        entry,
918                        motor: Box::new(solid),
919                    };
920                }
921                // A hybrid is refused whichever file says so, the design or its own curve.
922                Err(Refused::Hybrid) => {
923                    return unresolved(
924                        NoCurve::Hybrid,
925                        format!(
926                            "its embedded curve {entry} is a hybrid's; hpr flies commercial solid motors only"
927                        ),
928                    );
929                }
930                Err(Refused::Unusable(reason)) => {
931                    warnings.push(Warning::new(
932                        entry.clone(),
933                        WarningKind::Dropped,
934                        format!(
935                            "the embedded curve for {} was not used: {reason}",
936                            motor.designation
937                        ),
938                    ));
939                    embedded_failed = Some(format!("its embedded curve {entry} could not be used"));
940                }
941            }
942        }
943    }
944    if let Some(digest) = &motor.digest
945        && let Some(found) = supplied.by_digest.get(digest)
946    {
947        sizes_agree(
948            motor,
949            found.case.diameter_m * 1e3,
950            found.case.length_m * 1e3,
951            supplied.source(),
952            at,
953            warnings,
954        );
955        return Curve::Supplied {
956            digest: digest.clone(),
957            from: supplied.source().to_owned(),
958            motor: Box::new(found.motor.clone()),
959        };
960    }
961    // What the supplied curves said, when there were any to look in.
962    let not_supplied = if supplied.is_empty() {
963        String::new()
964    } else if motor.digest.is_some() {
965        format!(", {} has no curve for its digest", supplied.source())
966    } else {
967        ", it records no digest to look up in the supplied curves".to_owned()
968    };
969    let Some(catalog) = catalog else {
970        return match embedded_failed {
971            Some(reason) => unresolved(NoCurve::Unusable, format!("{reason}{not_supplied}")),
972            None => unresolved(
973                NoCurve::NotFound,
974                format!("no embedded curve{not_supplied}"),
975            ),
976        };
977    };
978    let maker = key(&motor.manufacturer);
979    let matches: Vec<_> = catalog
980        .find(&motor.designation)
981        .filter(|m| key(&m.manufacturer) == maker || key(&m.manufacturer_abbrev) == maker)
982        .collect();
983    // A curve that was there and failed says more about the file than a catalog that lacks it.
984    let not_found = |kind: NoCurve, why: &str| match &embedded_failed {
985        Some(lead) => unresolved(
986            NoCurve::Unusable,
987            format!("{lead}{not_supplied}, and {why}"),
988        ),
989        None => unresolved(kind, format!("no embedded curve{not_supplied}, and {why}")),
990    };
991    match matches.as_slice() {
992        [] => not_found(
993            NoCurve::NotFound,
994            "no motor of that manufacturer and designation in the bundled catalog",
995        ),
996        [found] => {
997            let Some((curve, text)) = found
998                .curves
999                .iter()
1000                .find_map(|curve| Some((curve, bundled_curve_text(&curve.file)?)))
1001            else {
1002                return not_found(
1003                    NoCurve::NotFound,
1004                    "the bundled catalog lists it without a curve",
1005                );
1006            };
1007            sizes_agree(
1008                motor,
1009                found.diameter_mm,
1010                found.length_mm,
1011                "the bundled catalog",
1012                at,
1013                warnings,
1014            );
1015            match found.motor(curve, text) {
1016                Ok(solid) => Curve::Catalog {
1017                    motor_id: found.motor_id.clone(),
1018                    simfile_id: curve.simfile_id.clone(),
1019                    motor: Box::new(solid),
1020                },
1021                Err(error) => not_found(
1022                    NoCurve::Unusable,
1023                    &format!("the bundled catalog's curve failed: {error}"),
1024                ),
1025            }
1026        }
1027        several => not_found(
1028            NoCurve::Ambiguous,
1029            &format!(
1030                "{} motors of that manufacturer and designation are in the bundled catalog",
1031                several.len()
1032            ),
1033        ),
1034    }
1035}
1036
1037/// Warns when the case the `.ork` places differs by more than a millimeter from the one the curve
1038/// describes (`diameter_mm`, `length_mm`, from `source`): the first sets where the motor sits, the
1039/// second its mass.
1040fn sizes_agree(
1041    motor: &MotorRead,
1042    diameter_mm: f64,
1043    length_mm: f64,
1044    source: &str,
1045    at: &str,
1046    warnings: &mut Vec<Warning>,
1047) {
1048    let apart = |ork_m: Option<f64>, mm: f64| ork_m.is_some_and(|m| (m * 1e3 - mm).abs() > 1.0);
1049    if apart(motor.diameter_m, diameter_mm) || apart(motor.length_m, length_mm) {
1050        warnings.push(Warning::new(
1051            at,
1052            WarningKind::Unusual,
1053            format!(
1054                "{} is {} by {} mm in the design and {diameter_mm} by {length_mm} mm in {source}; \
1055                 the design's size places it, and the curve's gives its mass",
1056                motor.designation,
1057                motor.diameter_m.map_or(f64::NAN, |m| m * 1e3),
1058                motor.length_m.map_or(f64::NAN, |m| m * 1e3),
1059            ),
1060        ));
1061    }
1062}
1063
1064/// Why an embedded curve was not used.
1065enum Refused {
1066    /// Its own header says it is a hybrid's.
1067    Hybrid,
1068    /// It could not be read or built, and why.
1069    Unusable(String),
1070}
1071
1072/// The motor an embedded `.rse` entry describes, built as the catalog builds one
1073/// ([`SolidMotor::from_envelope`] from the file's diameter, length and masses).
1074fn embedded(
1075    attachment: &Attachment,
1076    motor: &MotorRead,
1077    at: &str,
1078    warnings: &mut Vec<Warning>,
1079) -> Result<SolidMotor, Refused> {
1080    let unusable = |reason: String| Refused::Unusable(reason);
1081    let text = std::str::from_utf8(&attachment.bytes)
1082        .map_err(|_| unusable("it is not UTF-8 text".to_owned()))?;
1083    let parsed = rse::parse(text).map_err(|error| unusable(error.to_string()))?;
1084    for warning in &parsed.warnings {
1085        warnings.push(Warning::new(
1086            attachment.name.clone(),
1087            WarningKind::Unusual,
1088            format!("line {}: {}", warning.line, warning.message),
1089        ));
1090    }
1091    let [engine] = parsed.value.engines.as_slice() else {
1092        return Err(unusable(format!(
1093            "it holds {} engines, not one",
1094            parsed.value.engines.len()
1095        )));
1096    };
1097    if engine
1098        .motor_type
1099        .as_deref()
1100        .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("hybrid"))
1101    {
1102        return Err(Refused::Hybrid);
1103    }
1104    if key(&engine.code) != key(&motor.designation) {
1105        warnings.push(Warning::new(
1106            at,
1107            WarningKind::Unusual,
1108            format!(
1109                "the embedded curve its digest names is for `{}`, and the motor says `{}`; the \
1110                 curve was used",
1111                engine.code, motor.designation
1112            ),
1113        ));
1114    }
1115    sizes_agree(
1116        motor,
1117        engine.diameter_mm,
1118        engine.length_mm,
1119        "its embedded curve",
1120        at,
1121        warnings,
1122    );
1123    let thrust = engine
1124        .thrust_curve()
1125        .map_err(|error| unusable(error.to_string()))?;
1126    SolidMotor::from_envelope(
1127        thrust,
1128        engine.diameter_mm * 1e-3,
1129        engine.length_mm * 1e-3,
1130        engine.propellant_mass_g * 1e-3,
1131        engine.initial_mass_g * 1e-3,
1132    )
1133    .map_err(|error| unusable(error.to_string()))
1134}
1135
1136/// What the rocket's reading says about flying any of its configurations.
1137#[derive(Debug, Clone, Copy)]
1138pub(super) struct Airframe<'a> {
1139    /// What was not read exactly as written, if anything: then no configuration flies.
1140    pub(super) incomplete: Option<&'a str>,
1141    /// The stages' separations.
1142    pub(super) separations: &'a [StageSeparation],
1143}
1144
1145/// Each motor's ignition and the separations `configuration` flies, in the order they
1146/// fire, or the first reason on [`NotFlown`]'s list that it can't be flown as written.
1147fn flyable(
1148    configuration: &MotorConfiguration,
1149    hung_on: &[Option<usize>],
1150    airframe: Airframe<'_>,
1151) -> Result<(Vec<hpr_design::Ignition>, Vec<Staging>), LeftOut> {
1152    if let Some(out) = left_out(configuration) {
1153        return Err(out);
1154    }
1155    let motors = &configuration.motors;
1156    let lit = motors
1157        .iter()
1158        .zip(staging::ignitions(motors, hung_on))
1159        .map(|(motor, lit)| {
1160            lit.map_err(|why| LeftOut {
1161                why: NotFlown::IgnitionNotFlown,
1162                message: format!(
1163                    "{}, in stage {}, can't be lit as written: {why}",
1164                    motor.designation, motor.stage
1165                ),
1166            })
1167        })
1168        .collect::<Result<Vec<_>, _>>()?;
1169    // A motor lit by one that never lights never lights either: say so, so every reader of the
1170    // ignitions agrees. A configuration with no motor that lights would not leave the pad.
1171    let lit = staging::never_when_waiting_on_never(motors, lit).map_err(|why| LeftOut {
1172        why: NotFlown::IgnitionNotFlown,
1173        message: why,
1174    })?;
1175    // Part of the airframe left out: no configuration of the rocket flies, whatever its motors.
1176    if let Some(what) = airframe.incomplete {
1177        return Err(LeftOut {
1178            why: NotFlown::AirframeNotAsWritten,
1179            message: format!("the airframe was not read exactly as written: {what}"),
1180        });
1181    }
1182    let staging = staging::staging(
1183        &configuration.id,
1184        motors,
1185        &lit,
1186        airframe.separations,
1187        hung_on,
1188    )
1189    .map_err(|why| LeftOut {
1190        why: NotFlown::SeparationNotFlown,
1191        message: format!("its stages can't be flown apart as written: {why}"),
1192    })?;
1193    Ok((lit, staging))
1194}
1195
1196/// Why `configuration`'s motors cannot be flown as written, or `None` when they can: every motor
1197/// read, with a curve and a size, and every stage flying.
1198fn left_out(configuration: &MotorConfiguration) -> Option<LeftOut> {
1199    let out = |why: NotFlown, message: String| Some(LeftOut { why, message });
1200    if let Some(unread) = configuration.unread.first() {
1201        return out(
1202            NotFlown::UnreadMotor,
1203            format!(
1204                "{} of its motors is in a part hpr does not read ({}: {})",
1205                configuration.unread.len(),
1206                unread.designation,
1207                unread.reason
1208            ),
1209        );
1210    }
1211    if configuration.motors.is_empty() {
1212        return out(NotFlown::NoMotor, "it holds no motor".to_owned());
1213    }
1214    if !configuration.inactive_stages.is_empty() {
1215        return out(
1216            NotFlown::InactiveStage,
1217            format!(
1218                "it flies without stage {:?}, and hpr flies every stage",
1219                configuration.inactive_stages
1220            ),
1221        );
1222    }
1223    let motors = &configuration.motors;
1224    if let Some((motor, reason)) = motors.iter().find_map(|m| match &m.curve {
1225        Curve::Unresolved { reason, .. } => Some((m, reason)),
1226        _ => None,
1227    }) {
1228        return out(
1229            NotFlown::NoCurve,
1230            format!("no thrust curve for {}: {reason}", motor.designation),
1231        );
1232    }
1233    if let Some(motor) = motors
1234        .iter()
1235        .find(|m| !m.diameter_m.is_some_and(|d| d > 0.0) || !m.length_m.is_some_and(|l| l > 0.0))
1236    {
1237        return out(
1238            NotFlown::NoSize,
1239            format!("{} has no case diameter and length", motor.designation),
1240        );
1241    }
1242    None
1243}
1244
1245/// `configuration` as a [`Configuration`] for [`Rocket::assemble`], each motor lit at `lit`; every
1246/// motor has a curve and a size, which [`left_out`] checked.
1247fn flown(configuration: &MotorConfiguration, lit: Vec<hpr_design::Ignition>) -> Configuration {
1248    Configuration {
1249        id: configuration.id.clone(),
1250        name: configuration.name.clone(),
1251        motors: configuration
1252            .motors
1253            .iter()
1254            .zip(lit)
1255            .filter_map(|(motor, ignition)| {
1256                Some(MountedMotor {
1257                    mount: motor.mount.clone(),
1258                    designation: motor.designation.clone(),
1259                    diameter_m: motor.diameter_m?,
1260                    length_m: motor.length_m?,
1261                    motor: motor.curve.motor()?.clone(),
1262                    delay: motor.delay,
1263                    ignition,
1264                    failed_tubes: Vec::new(),
1265                })
1266            })
1267            .collect(),
1268    }
1269}