Skip to main content

hpr_motor/
catalog.rs

1//! An offline motor catalog: motor metadata, thrust curves, and each curve's provenance and
2//! license.
3//!
4//! [`Catalog::bundled`] is the catalog compiled into the crate: 32 curve files that ThrustCurve.org
5//! marks public domain (the decision record on solid motors and the bundled catalog,
6//! [ADR-005][adr-005]; how they were chosen, and the curves left out, are in
7//! `docs/research/thrustcurve-data.md`). Other curves are fetched and cached by `hpr-net`, the
8//! online layer that starts at [M5.1][m5-1].
9//!
10//! Every figure in the bundled index comes from its curve file alone, written by
11//! `cargo xtask motor-catalog` (issue #295: ThrustCurve.org states no terms for its API's motor
12//! records, so none of their figures is copied). Size, masses and delays are the file header's;
13//! total impulse, average and peak thrust and burn time are worked out from the curve by
14//! [`ThrustCurve`]'s own methods, the ones the simulator flies (the trapezoid integral and the
15//! NFPA 1125 burn window; their definitions and sources are on [`crate::curve`]). Only the names
16//! that identify a motor (ThrustCurve.org's id for it, designation, maker, single-use or reload,
17//! case, propellant) and the file's provenance (its ThrustCurve.org data-file id, who produced the
18//! data, its license and the download date) are written by hand, in the command's table.
19//!
20//! The index keeps the units motor files and catalogs use (mm, g, N·s, N, s), in fields named for
21//! them. [`CatalogMotor::motor`] converts to SI and takes the envelope (diameter, length and
22//! masses) from the index rather than the curve file's header. In the bundled index the two are
23//! the same; another index (from [`Catalog::from_json`]) can correct a header that is wrong
24//! ([Loft lesson L43][l43]: one said 75 mm for a 54 mm motor).
25//!
26//! [adr-005]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-005-solid-motors-statistics-consumption-grains-file-models-and-the-bundled-catalog-2026-09-17
27//! [l43]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l43
28//! [m5-1]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m5-1
29
30use serde::{Deserialize, Serialize};
31
32use crate::bundled;
33use crate::class::ImpulseClass;
34use crate::curve::ThrustCurve;
35use crate::delay::DelayList;
36use crate::error::MotorError;
37use crate::motor::SolidMotor;
38use crate::{eng, rse};
39
40/// A motor catalog.
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42pub struct Catalog {
43    /// Where the curve files come from.
44    pub source: String,
45    /// Where and when the curve files were downloaded.
46    pub snapshot: Snapshot,
47    /// The rule that chose the curves.
48    pub selection: String,
49    /// The motors.
50    pub motors: Vec<CatalogMotor>,
51}
52
53/// Where and when a catalog's curve files were downloaded. Each file's own URL and SHA-256 are on
54/// its [`CatalogCurve`].
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct Snapshot {
57    /// The site the files were downloaded from.
58    pub files_url: String,
59    /// The download date, `YYYY-MM-DD` (the latest, if the files were downloaded on several).
60    pub captured: String,
61}
62
63/// Whether a motor is used once or reloaded into a reusable case.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
65#[non_exhaustive]
66pub enum MotorType {
67    /// A single-use motor (ThrustCurve `SU`).
68    #[serde(rename = "SU")]
69    SingleUse,
70    /// A reload for a reusable case (ThrustCurve `reload`).
71    #[serde(rename = "reload")]
72    Reload,
73    /// A hybrid motor (ThrustCurve `hybrid`). ThrustCurve lists them; hpr doesn't model them (COTS
74    /// solids only), and the bundled catalog has none.
75    #[serde(rename = "hybrid")]
76    Hybrid,
77}
78
79/// One motor's catalog entry, in the units motor files use.
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct CatalogMotor {
82    /// ThrustCurve.org's id for the motor.
83    pub motor_id: String,
84    /// Manufacturer name.
85    pub manufacturer: String,
86    /// Manufacturer abbreviation.
87    pub manufacturer_abbrev: String,
88    /// Full designation, such as `1266J760-19A`.
89    pub designation: String,
90    /// Common name, such as `J760`.
91    pub common_name: String,
92    /// Impulse class letter.
93    pub impulse_class: ImpulseClass,
94    /// Single-use or reload.
95    pub motor_type: MotorType,
96    /// The case a reload fits, such as `Pro54-3G`.
97    pub case_info: Option<String>,
98    /// The propellant.
99    pub prop_info: Option<String>,
100    /// Available delays as written (`6,10,14`, `4-6`, `P`; the bundled index copies the curve
101    /// file's header); see [`CatalogMotor::delays`].
102    pub delays: Option<String>,
103    /// Diameter, mm.
104    pub diameter_mm: f64,
105    /// Length, mm.
106    pub length_mm: f64,
107    /// Total impulse, N·s ([`ThrustCurve::total_impulse_ns`] in the bundled index).
108    pub total_impulse_ns: f64,
109    /// Average thrust, N ([`ThrustCurve::average_thrust_n`] in the bundled index).
110    pub average_thrust_n: f64,
111    /// Peak thrust, N ([`ThrustCurve::peak_thrust_n`] in the bundled index).
112    pub max_thrust_n: Option<f64>,
113    /// Burn time, s ([`ThrustCurve::burn_time_s`], the NFPA 1125 window, in the bundled index).
114    pub burn_time_s: f64,
115    /// Propellant mass, g.
116    pub propellant_mass_g: Option<f64>,
117    /// Loaded mass, g.
118    pub total_mass_g: Option<f64>,
119    /// The motor's curves.
120    pub curves: Vec<CatalogCurve>,
121}
122
123/// Where a thrust curve came from and under what terms.
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct CatalogCurve {
126    /// ThrustCurve's simfile id.
127    pub simfile_id: String,
128    /// The file format.
129    pub format: CurveFormat,
130    /// Who produced the data.
131    pub source: CurveSource,
132    /// The data license.
133    #[serde(default)]
134    pub license: CurveLicense,
135    /// The file's path relative to the catalog index.
136    pub file: String,
137    /// SHA-256 of the file's bytes, hex.
138    pub sha256: String,
139    /// Where the file was downloaded from.
140    pub url: String,
141    /// ThrustCurve's page for the file.
142    pub info_url: Option<String>,
143    /// The download date, `YYYY-MM-DD`.
144    pub retrieved: String,
145}
146
147/// A thrust-curve file format.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
149#[non_exhaustive]
150pub enum CurveFormat {
151    /// RASP `.eng`.
152    #[serde(rename = "RASP")]
153    Rasp,
154    /// RockSim `.rse`.
155    #[serde(rename = "RockSim")]
156    RockSim,
157}
158
159/// Who produced a curve (ThrustCurve's `source`).
160#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
161#[non_exhaustive]
162pub enum CurveSource {
163    /// A certification body's test data.
164    #[serde(rename = "cert")]
165    Certification,
166    /// The manufacturer.
167    #[serde(rename = "mfr")]
168    Manufacturer,
169    /// A ThrustCurve user.
170    #[serde(rename = "user")]
171    User,
172}
173
174/// A curve's data license, as ThrustCurve records it. ThrustCurve's API leaves the key out when no
175/// license is recorded, which reads as [`CurveLicense::Unknown`].
176#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
177#[non_exhaustive]
178pub enum CurveLicense {
179    /// Public domain.
180    #[serde(rename = "PD")]
181    PublicDomain,
182    /// "Free": by ThrustCurve's definition this includes GPL and Creative Commons terms.
183    #[serde(rename = "free")]
184    Free,
185    /// Other, restricted terms.
186    #[serde(rename = "other")]
187    Other,
188    /// No license recorded.
189    #[default]
190    #[serde(rename = "unknown", alias = "")]
191    Unknown,
192}
193
194impl CurveLicense {
195    /// Whether a curve under this license may be bundled in this `MIT OR Apache-2.0` crate: only
196    /// public domain. "Free" can mean GPL, and the others are restricted or unknown.
197    pub fn is_bundleable(self) -> bool {
198        self == Self::PublicDomain
199    }
200}
201
202impl Catalog {
203    /// The catalog compiled into this crate.
204    ///
205    /// # Errors
206    ///
207    /// [`MotorError::Catalog`] if the bundled index doesn't deserialize, which a test rules out.
208    pub fn bundled() -> Result<Self, MotorError> {
209        Self::from_json(bundled::CATALOG_JSON)
210    }
211
212    /// Reads a catalog index.
213    ///
214    /// # Errors
215    ///
216    /// [`MotorError::Catalog`] if the text isn't a catalog index.
217    pub fn from_json(text: &str) -> Result<Self, MotorError> {
218        serde_json::from_str(text).map_err(|error| MotorError::Catalog(error.to_string()))
219    }
220
221    /// The motors whose designation or common name matches `name`, ignoring ASCII case, spaces
222    /// and hyphens (`j760`, `1266J760-19A`, `K 940`).
223    pub fn find<'a>(&'a self, name: &str) -> impl Iterator<Item = &'a CatalogMotor> + 'a {
224        let key = normalize(name);
225        self.motors.iter().filter(move |motor| {
226            normalize(&motor.designation) == key || normalize(&motor.common_name) == key
227        })
228    }
229}
230
231/// Lowercase, without spaces or hyphens.
232fn normalize(name: &str) -> String {
233    name.chars()
234        .filter(|c| !c.is_whitespace() && *c != '-')
235        .map(|c| c.to_ascii_lowercase())
236        .collect()
237}
238
239/// A curve file's thrust curve and its header's propellant and loaded masses, kg.
240fn read_curve_file(
241    curve: &CatalogCurve,
242    text: &str,
243) -> Result<(ThrustCurve, f64, f64), MotorError> {
244    let one = |count: usize| {
245        if count == 1 {
246            Ok(())
247        } else {
248            Err(MotorError::Inconsistent(format!(
249                "the curve file {} holds {count} motors, not one",
250                curve.file
251            )))
252        }
253    };
254    match curve.format {
255        CurveFormat::Rasp => {
256            let file = eng::parse(text)?.value;
257            one(file.entries.len())?;
258            let entry = &file.entries[0];
259            Ok((
260                entry.thrust_curve()?,
261                entry.propellant_mass_kg,
262                entry.total_mass_kg,
263            ))
264        }
265        CurveFormat::RockSim => {
266            let file = rse::parse(text)?.value;
267            one(file.engines.len())?;
268            let engine = &file.engines[0];
269            Ok((
270                engine.thrust_curve()?,
271                engine.propellant_mass_g * 1e-3,
272                engine.initial_mass_g * 1e-3,
273            ))
274        }
275    }
276}
277
278/// The text of a bundled curve file, by its path in the bundled index.
279pub fn bundled_curve_text(file: &str) -> Option<&'static str> {
280    bundled::CURVE_FILES
281        .iter()
282        .find(|(path, _)| *path == file)
283        .map(|(_, text)| *text)
284}
285
286impl CatalogMotor {
287    /// The delay settings read from [`CatalogMotor::delays`]; empty when none are recorded.
288    pub fn delays(&self) -> DelayList {
289        DelayList::parse(self.delays.as_deref().unwrap_or(""))
290    }
291
292    /// Reads a curve of this motor from its file's text.
293    ///
294    /// # Errors
295    ///
296    /// - [`MotorError::Inconsistent`] if `curve` is not one of this motor's curves, or the file
297    ///   doesn't hold exactly one motor.
298    /// - The format's parse error ([`eng::parse`], [`rse::parse`]).
299    /// - [`ThrustCurve::new`]'s errors.
300    pub fn thrust_curve(
301        &self,
302        curve: &CatalogCurve,
303        text: &str,
304    ) -> Result<ThrustCurve, MotorError> {
305        self.check_own(curve)?;
306        read_curve_file(curve, text).map(|(thrust, _, _)| thrust)
307    }
308
309    /// Refuses a curve listed under another motor, which would pair this motor's masses and
310    /// envelope with the wrong thrust.
311    fn check_own(&self, curve: &CatalogCurve) -> Result<(), MotorError> {
312        if self.curves.contains(curve) {
313            Ok(())
314        } else {
315            Err(MotorError::Inconsistent(format!(
316                "curve {} is not one of {}'s curves",
317                curve.simfile_id, self.designation
318            )))
319        }
320    }
321
322    /// The motor with the curve read from `text`, built with [`SolidMotor::from_envelope`] from
323    /// this entry's diameter, length and masses. The curve file's header is used only for a mass
324    /// the metadata lacks.
325    ///
326    /// # Errors
327    ///
328    /// As [`CatalogMotor::thrust_curve`] and [`SolidMotor::from_envelope`], and
329    /// [`MotorError::Inconsistent`] for a hybrid (hpr models solids only) or when neither the
330    /// metadata nor the file gives the masses. The text is not checked against the curve's SHA-256:
331    /// pass the file the curve names.
332    pub fn motor(&self, curve: &CatalogCurve, text: &str) -> Result<SolidMotor, MotorError> {
333        if self.motor_type == MotorType::Hybrid {
334            return Err(MotorError::Inconsistent(format!(
335                "{} is a hybrid; hpr models solid motors only",
336                self.designation
337            )));
338        }
339        self.check_own(curve)?;
340        let (thrust, header_propellant_kg, header_total_kg) = read_curve_file(curve, text)?;
341        let propellant_kg = self
342            .propellant_mass_g
343            .map_or(header_propellant_kg, |g| g * 1e-3);
344        let total_kg = self.total_mass_g.map_or(header_total_kg, |g| g * 1e-3);
345        if !(propellant_kg > 0.0 && total_kg > 0.0) {
346            return Err(MotorError::Inconsistent(format!(
347                "{} has no propellant and loaded masses",
348                self.designation
349            )));
350        }
351        SolidMotor::from_envelope(
352            thrust,
353            self.diameter_mm * 1e-3,
354            self.length_mm * 1e-3,
355            propellant_kg,
356            total_kg,
357        )
358    }
359
360    /// The motor with its first bundled curve.
361    ///
362    /// # Errors
363    ///
364    /// [`MotorError::Inconsistent`] if the entry has no bundled curve file, and
365    /// [`CatalogMotor::motor`]'s errors.
366    pub fn bundled_motor(&self) -> Result<SolidMotor, MotorError> {
367        let (curve, text) = self
368            .curves
369            .iter()
370            .find_map(|curve| bundled_curve_text(&curve.file).map(|text| (curve, text)))
371            .ok_or_else(|| {
372                MotorError::Inconsistent(format!("{} has no bundled curve", self.designation))
373            })?;
374        self.motor(curve, text)
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use sha2::{Digest, Sha256};
381
382    use super::*;
383    use crate::delay::Delay;
384
385    fn bundled() -> Catalog {
386        Catalog::bundled().unwrap()
387    }
388
389    /// Loft lesson L41: Loft's bundled curves were 45 public domain, 38 with no license, 22
390    /// unknown and 3 "free" (which can include GPL).
391    #[test]
392    fn bundled_curves_have_permissive_license() {
393        let catalog = bundled();
394        assert!(!catalog.motors.is_empty());
395        let mut files = 0;
396        for motor in &catalog.motors {
397            for curve in &motor.curves {
398                assert!(
399                    curve.license.is_bundleable(),
400                    "{} {}: {:?}",
401                    motor.designation,
402                    curve.simfile_id,
403                    curve.license
404                );
405                assert!(bundled_curve_text(&curve.file).is_some(), "{}", curve.file);
406                files += 1;
407            }
408        }
409        // Every compiled-in file is indexed, so nothing unlicensed rides along.
410        assert_eq!(files, bundled::CURVE_FILES.len());
411        // Each file is byte for byte what was downloaded from its recorded URL.
412        for motor in &catalog.motors {
413            for curve in &motor.curves {
414                let text = bundled_curve_text(&curve.file).unwrap();
415                let digest: String = Sha256::digest(text.as_bytes())
416                    .iter()
417                    .map(|byte| format!("{byte:02x}"))
418                    .collect();
419                assert_eq!(digest, curve.sha256, "{}", curve.file);
420                assert!(curve.url.contains(&curve.simfile_id));
421            }
422        }
423        assert!(!CurveLicense::Free.is_bundleable());
424        assert!(!CurveLicense::Unknown.is_bundleable());
425    }
426
427    /// Loft lesson L42: Loft's impulse checks allowed −8% to +8%, and a mis-sourced curve flew
428    /// about 26% high until caught. Every figure the bundled index states is its curve's own, bit
429    /// for bit: `cargo xtask motor-catalog` works them out with the methods the simulator flies
430    /// (#295), so what `hpr motors` lists is what flies. Each is also within 1% of what
431    /// ThrustCurve.org's own code works out from the same file (M1.3's bound; the fixture
432    /// [`every_bundled_curve_matches_thrustcurve_statistics_code`] holds to 1e-12).
433    #[test]
434    fn every_bundled_curve_impulse_within_1pct_of_thrustcurve() {
435        let oracle: AnalyzeOracle = serde_json::from_str(include_str!(
436            "../../../validation/fixtures/motor/thrustcurve-analyze-stats.json"
437        ))
438        .unwrap();
439        let catalog = bundled();
440        let mut checked = 0;
441        for motor in &catalog.motors {
442            for curve in &motor.curves {
443                let text = bundled_curve_text(&curve.file).unwrap();
444                let thrust = motor.thrust_curve(curve, text).unwrap();
445                let theirs = oracle
446                    .curves
447                    .iter()
448                    .find(|c| c.file == curve.file)
449                    .unwrap_or_else(|| panic!("{}: not in the oracle record", curve.file));
450                for (stated, computed, by_thrustcurve, what) in [
451                    (
452                        motor.total_impulse_ns,
453                        thrust.total_impulse_ns(),
454                        theirs.total_impulse_ns,
455                        "total impulse",
456                    ),
457                    (
458                        motor.average_thrust_n,
459                        thrust.average_thrust_n(),
460                        theirs.average_thrust_n,
461                        "average thrust",
462                    ),
463                    (
464                        motor.burn_time_s,
465                        thrust.burn_time_s(),
466                        theirs.burn_time_s,
467                        "burn time",
468                    ),
469                    (
470                        motor.max_thrust_n.unwrap(),
471                        thrust.peak_thrust_n(),
472                        theirs.max_thrust_n,
473                        "peak thrust",
474                    ),
475                ] {
476                    assert_eq!(
477                        stated.to_bits(),
478                        computed.to_bits(),
479                        "{} ({}): the index's {what} {stated} is not its curve's {computed}",
480                        motor.designation,
481                        curve.simfile_id,
482                    );
483                    let error = (stated - by_thrustcurve) / by_thrustcurve;
484                    assert!(
485                        error.abs() <= 0.01,
486                        "{} ({}): {what} {stated} against ThrustCurve's code's {by_thrustcurve} \
487                         ({:+.2}%)",
488                        motor.designation,
489                        curve.simfile_id,
490                        100.0 * error
491                    );
492                }
493                assert_eq!(
494                    ImpulseClass::from_total_impulse(motor.total_impulse_ns).unwrap(),
495                    motor.impulse_class,
496                    "{}",
497                    motor.designation
498                );
499                checked += 1;
500            }
501        }
502        assert_eq!(checked, 32);
503    }
504
505    /// Every bundled entry's size, masses and delays are its curve file header's (#295): the
506    /// masses to the microgram, as `cargo xtask motor-catalog` writes a `.eng` header's kilograms
507    /// in grams.
508    #[test]
509    fn every_bundled_size_mass_and_delay_is_the_header_s() {
510        let catalog = bundled();
511        for motor in &catalog.motors {
512            let [curve] = &motor.curves[..] else {
513                panic!("{}: {} curves", motor.designation, motor.curves.len());
514            };
515            let text = bundled_curve_text(&curve.file).unwrap();
516            let (diameter_mm, length_mm, delays, propellant_g, total_g) = match curve.format {
517                CurveFormat::Rasp => {
518                    let entry = eng::parse(text).unwrap().value.entries.remove(0);
519                    (
520                        entry.diameter_mm,
521                        entry.length_mm,
522                        Some(entry.delays),
523                        entry.propellant_mass_kg * 1e3,
524                        entry.total_mass_kg * 1e3,
525                    )
526                }
527                CurveFormat::RockSim => {
528                    let engine = rse::parse(text).unwrap().value.engines.remove(0);
529                    (
530                        engine.diameter_mm,
531                        engine.length_mm,
532                        engine.delays,
533                        engine.propellant_mass_g,
534                        engine.initial_mass_g,
535                    )
536                }
537            };
538            let name = &motor.designation;
539            assert_eq!(motor.diameter_mm, diameter_mm, "{name}");
540            assert_eq!(motor.length_mm, length_mm, "{name}");
541            assert_eq!(motor.delays, delays, "{name}");
542            assert!(
543                (motor.propellant_mass_g.unwrap() - propellant_g).abs() <= 5e-7,
544                "{name}"
545            );
546            assert!(
547                (motor.total_mass_g.unwrap() - total_g).abs() <= 5e-7,
548                "{name}"
549            );
550        }
551    }
552
553    /// The bundled index copies nothing that only ThrustCurve.org's API records state (#295): no
554    /// certifying body, update date, availability or adjustable-delay flag, and no API capture.
555    #[test]
556    fn bundled_index_holds_no_thrustcurve_record_fields() {
557        for key in [
558            "cert_org",
559            "updated_on",
560            "availability",
561            "delay_adjustable",
562            "motors_url",
563            "api/v1",
564        ] {
565            assert!(
566                !bundled::CATALOG_JSON.contains(key),
567                "the bundled index holds {key}"
568            );
569        }
570        let catalog = bundled();
571        assert_eq!(
572            catalog.snapshot.files_url,
573            "https://www.thrustcurve.org/simfiles/"
574        );
575        for curve in catalog.motors.iter().flat_map(|m| &m.curves) {
576            assert!(curve.url.starts_with(&catalog.snapshot.files_url));
577        }
578    }
579
580    /// Loft lesson L43: a catalog's envelope overrides the curve file's header; one header said
581    /// 75 mm for a 54 mm motor. The bundled index now takes its envelope from the headers
582    /// themselves (#295; `cargo xtask motor-catalog` refuses a reload whose case names another
583    /// diameter), so the override is shown on an index entry made to differ from its header: an
584    /// invented 1000 mm and 12,000 g for the N3300R, whose file says 1060 mm.
585    #[test]
586    fn metadata_overrides_header_envelope() {
587        let catalog = bundled();
588        let bundled_entry = catalog.find("N3300R").next().unwrap();
589        let curve = &bundled_entry.curves[0];
590        let text = bundled_curve_text(&curve.file).unwrap();
591        let header = &eng::parse(text).unwrap().value.entries[0];
592        assert_eq!(header.length_mm, 1060.0);
593        assert_eq!(bundled_entry.length_mm, 1060.0);
594        let motor = CatalogMotor {
595            length_mm: 1000.0,
596            total_mass_g: Some(12000.0),
597            ..bundled_entry.clone()
598        };
599        let built = motor.motor(curve, text).unwrap();
600        // The envelope default centers the dry mass on the index's length, not the header's.
601        assert!((built.dry().cg_m - 0.500).abs() < 1e-12);
602        let loaded = built.state(0.0).total;
603        assert!((loaded.mass_kg - 12.000).abs() < 1e-9);
604        assert!((bundled_entry.bundled_motor().unwrap().dry().cg_m - 0.530).abs() < 1e-12);
605        // With no index masses, the header's are used.
606        let bare = CatalogMotor {
607            propellant_mass_g: None,
608            total_mass_g: None,
609            ..motor.clone()
610        };
611        let fallback = bare.motor(curve, text).unwrap();
612        assert!((fallback.state(0.0).total.mass_kg - header.total_mass_kg).abs() < 1e-12);
613        assert!((fallback.dry().cg_m - 0.500).abs() < 1e-12);
614    }
615
616    /// M1.3's done-when: parse-write-parse round trips are identical, on every bundled file.
617    #[test]
618    fn every_bundled_file_round_trips_bit_for_bit() {
619        let catalog = bundled();
620        for motor in &catalog.motors {
621            for curve in &motor.curves {
622                let text = bundled_curve_text(&curve.file).unwrap();
623                match curve.format {
624                    CurveFormat::Rasp => {
625                        let first = eng::parse(text).unwrap().value;
626                        let written = eng::write(&first).unwrap();
627                        let second = eng::parse(&written).unwrap().value;
628                        assert_eq!(second, first, "{}", curve.file);
629                        assert_eq!(eng::tests::bits(&second), eng::tests::bits(&first));
630                        assert_eq!(eng::write(&second).unwrap(), written);
631                    }
632                    CurveFormat::RockSim => {
633                        let first = rse::parse(text).unwrap().value;
634                        let written = rse::write(&first).unwrap();
635                        let second = rse::parse(&written).unwrap().value;
636                        assert_eq!(second, first, "{}", curve.file);
637                        assert_eq!(rse::tests::bits(&second), rse::tests::bits(&first));
638                        assert_eq!(rse::write(&second).unwrap(), written);
639                    }
640                }
641            }
642        }
643    }
644
645    #[test]
646    fn finds_motors_and_reads_delays() {
647        let catalog = bundled();
648        let j760: Vec<_> = catalog.find("j760").collect();
649        assert_eq!(j760.len(), 1);
650        assert_eq!(j760[0].designation, "1266J760-19A");
651        assert_eq!(catalog.find("1266J760-19A").count(), 1);
652        let plugged = catalog.find("L3200").next().unwrap();
653        assert_eq!(plugged.delays().delays, [Delay::Plugged]);
654        assert!(catalog.find("no such motor").next().is_none());
655        // Every bundled motor builds.
656        for motor in &catalog.motors {
657            let built = motor.bundled_motor().unwrap();
658            assert!(
659                built.propellant_initial_mass_kg() > 0.0,
660                "{}",
661                motor.designation
662            );
663        }
664    }
665
666    #[test]
667    fn catalog_json_reads_thrustcurve_license_values_and_rejects_others() {
668        assert!(matches!(
669            Catalog::from_json("{}"),
670            Err(MotorError::Catalog(_))
671        ));
672        let with = |license: &str| {
673            Catalog::from_json(&bundled::CATALOG_JSON.replacen("\"license\": \"PD\",", license, 1))
674        };
675        let first_license = |catalog: Catalog| catalog.motors[0].curves[0].license;
676        assert!(with("\"license\": \"GPL\",").is_err());
677        assert_eq!(
678            first_license(with("\"license\": \"free\",").unwrap()),
679            CurveLicense::Free
680        );
681        // ThrustCurve's API leaves the key out, or blank, when no license is recorded.
682        assert_eq!(first_license(with("").unwrap()), CurveLicense::Unknown);
683        assert_eq!(
684            first_license(with("\"license\": \"\",").unwrap()),
685            CurveLicense::Unknown
686        );
687        let hybrid = bundled::CATALOG_JSON.replacen(
688            "\"motor_type\": \"SU\"",
689            "\"motor_type\": \"hybrid\"",
690            1,
691        );
692        let hybrid = Catalog::from_json(&hybrid).unwrap();
693        assert_eq!(hybrid.motors[0].motor_type, MotorType::Hybrid);
694        assert!(hybrid.motors[0].bundled_motor().is_err());
695    }
696
697    #[test]
698    fn a_curve_of_another_motor_is_refused() {
699        let catalog = Catalog::bundled().unwrap();
700        let (this, other) = (&catalog.motors[0], &catalog.motors[1]);
701        let curve = &other.curves[0];
702        let text = bundled_curve_text(&curve.file).unwrap();
703        assert!(other.motor(curve, text).is_ok());
704        assert!(matches!(
705            this.motor(curve, text),
706            Err(MotorError::Inconsistent(message)) if message.contains(&curve.simfile_id)
707        ));
708        assert!(this.thrust_curve(curve, text).is_err());
709    }
710
711    #[derive(Debug, serde::Deserialize)]
712    struct AnalyzeOracle {
713        oracle: String,
714        curves: Vec<AnalyzeCurve>,
715    }
716
717    #[derive(Debug, serde::Deserialize)]
718    struct AnalyzeCurve {
719        file: String,
720        total_impulse_ns: f64,
721        burn_time_s: f64,
722        average_thrust_n: f64,
723        max_thrust_n: f64,
724        burn_start_s: f64,
725        burn_end_s: f64,
726    }
727
728    /// hpr's statistics against ThrustCurve.org's own code (`simulate/analyze/analyze.js`, run
729    /// unchanged by `validation/oracles/thrustcurve/analyze_stats.js`) on every bundled curve.
730    /// Unlike the 1% check against the stored values, which chose the bundle, this can fail: it
731    /// checks that hpr computes what ThrustCurve computes, to rounding.
732    #[test]
733    fn every_bundled_curve_matches_thrustcurve_statistics_code() {
734        let oracle: AnalyzeOracle = serde_json::from_str(include_str!(
735            "../../../validation/fixtures/motor/thrustcurve-analyze-stats.json"
736        ))
737        .unwrap();
738        assert!(oracle.oracle.contains("analyze.js at commit 577afa6"));
739        let catalog = bundled();
740        assert_eq!(oracle.curves.len(), bundled::CURVE_FILES.len());
741        let mut worst = 0.0f64;
742        for expected in &oracle.curves {
743            let (motor, curve) = catalog
744                .motors
745                .iter()
746                .find_map(|m| {
747                    m.curves
748                        .iter()
749                        .find(|c| c.file == expected.file)
750                        .map(|c| (m, c))
751                })
752                .unwrap();
753            let thrust = motor
754                .thrust_curve(curve, bundled_curve_text(&curve.file).unwrap())
755                .unwrap();
756            let (start, end) = thrust.burn_window_s();
757            for (ours, theirs, what) in [
758                (
759                    thrust.total_impulse_ns(),
760                    expected.total_impulse_ns,
761                    "total impulse",
762                ),
763                (thrust.burn_time_s(), expected.burn_time_s, "burn time"),
764                (
765                    thrust.average_thrust_n(),
766                    expected.average_thrust_n,
767                    "average thrust",
768                ),
769                (thrust.peak_thrust_n(), expected.max_thrust_n, "peak thrust"),
770                (start, expected.burn_start_s, "burn start"),
771                (end, expected.burn_end_s, "burn end"),
772            ] {
773                let error = (ours - theirs).abs() / theirs.abs().max(1e-3);
774                worst = worst.max(error);
775                assert!(
776                    error <= 1e-12,
777                    "{}: {what} {ours} against {theirs}",
778                    expected.file
779                );
780            }
781        }
782        eprintln!("largest relative difference from ThrustCurve's code: {worst:.2e}");
783    }
784
785    /// OpenRocket 24.12's record of the same files, written by
786    /// `validation/oracles/openrocket/motors.py`.
787    #[derive(Debug, serde::Deserialize)]
788    struct OpenRocketRecord {
789        openrocket: String,
790        inputs_sha256: std::collections::BTreeMap<String, String>,
791        jar_sha256: String,
792        curves: Vec<OpenRocketCurve>,
793    }
794
795    #[derive(Debug, serde::Deserialize)]
796    struct OpenRocketCurve {
797        file: String,
798        sha256: String,
799        #[serde(default)]
800        motors: Vec<OpenRocketMotor>,
801        #[serde(default)]
802        driver_error: Option<String>,
803    }
804
805    #[derive(Debug, serde::Deserialize)]
806    struct OpenRocketMotor {
807        common_name: String,
808        total_impulse_ns: f64,
809        max_thrust_n: f64,
810        average_thrust_n: f64,
811        burn_time_s: f64,
812        burn_time_estimate_s: f64,
813        first_time_s: f64,
814        points: usize,
815    }
816
817    /// hpr's total impulse against OpenRocket 24.12's own estimate for the same file, on every
818    /// bundled curve (M2.2c1). Both integrate the file's points with the trapezoid rule, so the
819    /// milestone's 0.1% is met with room to spare; the check is the bound the milestone names, and
820    /// the run prints the spread.
821    ///
822    /// Three more of OpenRocket's numbers are the same quantity as one of hpr's, so they are held
823    /// too: `getMaxThrustEstimate` is the largest *listed* thrust, which is
824    /// [`ThrustCurve::peak_thrust_n`] on every bundled curve (none lists a sample a step skips),
825    /// `getBurnTimeEstimate` is the 5%-of-peak window ([`ThrustCurve::burn_time_s`], the NFPA 1125
826    /// rule), and `getBurnTime` is the curve's whole duration ([`ThrustCurve::end_time_s`]), not a
827    /// window. OpenRocket prepends an origin to a file whose first point is after ignition, exactly
828    /// as hpr does, so the point counts are held equal as well.
829    ///
830    /// One quantity is a real difference, printed here and written down with its size in ADR-066
831    /// rather than held: OpenRocket's average thrust divides the impulse *inside* the window by the
832    /// window, where hpr (with ThrustCurve.org's code) divides the whole curve's impulse by it, so
833    /// hpr's is the higher on every curve.
834    #[test]
835    fn openrocket_s_total_impulse_matches_every_bundled_curve() {
836        let record: OpenRocketRecord = serde_json::from_str(include_str!(
837            "../../../validation/fixtures/motor/openrocket-curve-stats.json"
838        ))
839        .unwrap();
840        assert_eq!(record.openrocket, "24.12");
841        // The record moves only when its script runs, never by hand (Loft lesson L76).
842        assert_eq!(
843            record.inputs_sha256["motors.py"],
844            "1049b94c2c0da896dd84887a06b5ff8fd72865350aa98a2f9e1c8b7e14d5e3e7",
845        );
846        assert_eq!(
847            record.jar_sha256,
848            "4959b72f52f5f607941e9722abbb7b7f0c4a38ebbbf84204a329db9f31c4f897",
849        );
850        assert_eq!(record.curves.len(), bundled::CURVE_FILES.len());
851        let catalog = bundled();
852        let mut worst_impulse = 0.0f64;
853        let mut worst_peak = 0.0f64;
854        let mut average = Vec::new();
855        let mut identical = true;
856        let mut files = std::collections::BTreeSet::new();
857        for entry in &record.curves {
858            let file = entry
859                .file
860                .strip_prefix("crates/hpr-motor/data/thrustcurve/")
861                .expect("the record names the bundled file");
862            assert_eq!(entry.driver_error, None, "{file}");
863            let [theirs] = &entry.motors[..] else {
864                panic!("{file}: OpenRocket read {} motors", entry.motors.len());
865            };
866            let (motor, curve) = catalog
867                .motors
868                .iter()
869                .find_map(|m| m.curves.iter().find(|c| c.file == file).map(|c| (m, c)))
870                .unwrap_or_else(|| panic!("{file} is not a bundled curve"));
871            // The record is tied to the bytes OpenRocket read, which are the bytes hpr reads.
872            assert_eq!(curve.sha256, entry.sha256, "{file}");
873            assert!(files.insert(file.to_owned()), "{file} twice");
874            // The two catalogs spell a designation differently (ThrustCurve's `131G84-10A` is
875            // OpenRocket's `131-G84-GR-10A`, read from the file's own header), so the common name
876            // is what can be compared; the file's SHA-256 is what ties the record to the motor.
877            assert_eq!(
878                normalize(&motor.common_name),
879                normalize(&theirs.common_name),
880                "{file}"
881            );
882            let text = bundled_curve_text(file).unwrap();
883            let thrust = motor.thrust_curve(curve, text).unwrap();
884            for (ours, theirs, what, bound) in [
885                (
886                    thrust.total_impulse_ns(),
887                    theirs.total_impulse_ns,
888                    "total impulse",
889                    1e-3,
890                ),
891                (
892                    thrust.peak_thrust_n(),
893                    theirs.max_thrust_n,
894                    "peak thrust",
895                    1e-12,
896                ),
897                (
898                    thrust.burn_time_s(),
899                    theirs.burn_time_estimate_s,
900                    "burn time",
901                    1e-12,
902                ),
903                (
904                    thrust.end_time_s(),
905                    theirs.burn_time_s,
906                    "curve duration",
907                    1e-12,
908                ),
909            ] {
910                assert!(theirs > 0.0, "{file}: {what} {theirs}");
911                let error = (ours - theirs).abs() / theirs;
912                if what == "total impulse" {
913                    worst_impulse = worst_impulse.max(error);
914                } else {
915                    worst_peak = worst_peak.max(error);
916                }
917                assert!(error <= bound, "{file}: {what} {ours} against {theirs}");
918                // Each is the same quantity computed twice, and each comes out bit for bit equal,
919                // which is what the guide and ADR-066 claim.
920                identical &= ours == theirs;
921            }
922            // Both readers prepend `(0, 0)` to a file whose first point is after ignition (29 of
923            // the 32), so both count that point and neither starts late.
924            assert_eq!(thrust.times_s()[0], 0.0, "{file}");
925            assert_eq!(theirs.first_time_s, 0.0, "{file}");
926            assert_eq!(thrust.times_s().len(), theirs.points, "{file}");
927            // The one definition that differs, measured rather than held, and signed: hpr's
928            // whole-curve numerator makes it the higher on every curve.
929            average.push(
930                (thrust.average_thrust_n() - theirs.average_thrust_n) / theirs.average_thrust_n,
931            );
932        }
933        assert_eq!(
934            files.len(),
935            bundled::CURVE_FILES.len(),
936            "a file twice or missing"
937        );
938        assert!(
939            identical,
940            "impulse, peak thrust, burn time and duration are no longer bit for bit OpenRocket's"
941        );
942        average.sort_by(f64::total_cmp);
943        let mid = average.len() / 2;
944        let median = if average.len().is_multiple_of(2) {
945            0.5 * (average[mid - 1] + average[mid])
946        } else {
947            average[mid]
948        };
949        let (low, high) = (average[0] * 100.0, average[average.len() - 1] * 100.0);
950        assert!(low > 0.0, "hpr's average thrust is no longer the higher");
951        eprintln!(
952            "against OpenRocket 24.12 on {} curves: total impulse, peak thrust, the 5% burn-time \
953             window and the curve's duration are all bit for bit equal (worst relative differences \
954             {worst_impulse:.2e} and {worst_peak:.2e}); hpr's average thrust is higher by \
955             {low:+.4}% to {high:+.4}% (median {:+.4}%), because its numerator is the whole curve's \
956             impulse and OpenRocket's is the window's",
957            average.len(),
958            median * 100.0,
959        );
960    }
961}