Skip to main content

hpr_validate/
openrocket.rs

1//! hpr held to OpenRocket's own answers ([M2.2][m2-2]): the tests, and one rule of OpenRocket's
2//! that `cargo xtask ork` needs to explain a gap, [`openrocket_fin_set_roll_kg_m2`].
3//!
4//! `validation/fixtures/ork/openrocket-conventions.json` is OpenRocket 24.12 reading small probe
5//! designs, each asking one question a `.ork` leaves to its reader. What does a shoulder written
6//! with no wall weigh? What is a part that names no material made of? Where is a center of gravity
7//! override measured from? Which override wins when a part and the parts inside it both have one?
8//! The record is written by `validation/oracles/openrocket/conventions.py`, which runs OpenRocket
9//! and never reads it ([ADR-061][adr-061]). These tests read the same documents with `hpr_io::ork`
10//! and hold hpr's structure to OpenRocket's. Where hpr keeps a rule of its own, a *departure*, the
11//! test pins how far apart the two are, so a change on either side shows.
12//!
13//! [m2-2]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m2-2
14//! [adr-061]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-061-what-a-ork-leaves-unsaid-read-as-openrocket-reads-it-overrides-measured-two-departures-kept-2026-09-21
15
16use hpr_design::fins::FinSet;
17
18/// The roll inertia OpenRocket 24.12 gives a fin set about its own center of mass, kg·m², **inferred
19/// from its output, not taken from its source** (which is GPL and not read). For two or more fins,
20/// whose center is on the rocket's axis, it is the set's mass `m` spread evenly along a thin rod
21/// from the body at `R` out to `R + hₑ`,
22///
23/// ```text
24/// I = m (R² + R hₑ + hₑ²/3),   hₑ² = A h / c_r,
25/// ```
26///
27/// where `A` is one fin's planform area, `h` its span and `c_r` its root chord: a rectangle's own
28/// span, shorter for a fin that narrows outward. For one fin it is the same rod about its own
29/// middle, `m hₑ²/12`. `validation/oracles/openrocket/conventions.py` measured it on rectangles of
30/// two chords, two spans and two body radii, a trapezoid, a triangle, an ellipse (OpenRocket's
31/// 30-sided polygon), one fin, and fins with a rounded or airfoil section, a tab or fillets: every
32/// one to 1e-12 on the probe's structure but the ellipse (by its polygon) and a cant (2.66e-5). A
33/// tab's mass takes the planform's value, and the section and thickness play no part. hpr's own is
34/// the exact integral of `r²` over the fin ([`FinSet::mass_properties`]); the two agree on a
35/// rectangle without a tab, but for the thickness ([ADR-062][adr-062]).
36///
37/// `mass_kg` is the set's, all fins together. `None` for a planform `hpr-design` refuses, a count
38/// of zero, or a radius or mass that is negative or not finite.
39///
40/// [adr-062]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-062-fins-and-rail-buttons-against-openrocket-roll-inertia-explained-2026-09-21
41#[must_use]
42pub fn openrocket_fin_set_roll_kg_m2(
43    fins: &FinSet,
44    body_radius_m: f64,
45    mass_kg: f64,
46) -> Option<f64> {
47    let area_m2 = fins.planform.geometry().ok()?.area_m2;
48    rod_roll_kg_m2(
49        fins.count,
50        area_m2,
51        fins.planform.span_m(),
52        fins.planform.root_chord_m(),
53        body_radius_m,
54        mass_kg,
55    )
56}
57
58/// [`openrocket_fin_set_roll_kg_m2`] from the numbers it uses, so that a test can give it
59/// OpenRocket's polygon for an ellipse.
60fn rod_roll_kg_m2(
61    count: u32,
62    area_m2: f64,
63    span_m: f64,
64    root_m: f64,
65    body_radius_m: f64,
66    mass_kg: f64,
67) -> Option<f64> {
68    let usable = |x: f64| x.is_finite() && x >= 0.0;
69    if count == 0
70        || !(usable(area_m2) && usable(span_m) && usable(body_radius_m) && usable(mass_kg))
71        || !(root_m.is_finite() && root_m > 0.0)
72    {
73        return None;
74    }
75    let reach2 = area_m2 * span_m / root_m;
76    if count == 1 {
77        return Some(mass_kg * reach2 / 12.0);
78    }
79    let r = body_radius_m;
80    Some(mass_kg * (r * r + r * reach2.sqrt() + reach2 / 3.0))
81}
82
83#[cfg(test)]
84mod tests {
85    use hpr_design::tree::{Layout, Part};
86    use hpr_io::ork::{self, StoredReferenceExclusion, StoredSimulation};
87    use serde_json::Value;
88
89    fn record() -> Value {
90        let text = include_str!("../../../validation/fixtures/ork/openrocket-conventions.json");
91        serde_json::from_str(text).expect("the committed record is JSON")
92    }
93
94    fn probe<'a>(record: &'a Value, question: &str) -> &'a Value {
95        let probe = &record["probes"][question];
96        assert!(probe.is_object(), "no probe asks {question:?}");
97        probe
98    }
99
100    /// hpr's layout of a probe, and the warnings it raised reading it.
101    fn hpr(probe: &Value) -> (Layout, Vec<String>) {
102        let document = probe["document"].as_str().expect("the probe's document");
103        let read = ork::read(document.as_bytes()).expect("a probe reads");
104        let spine = ork::rocket(&read.value.document);
105        let layout = spine.value.layout().expect("a probe lays out");
106        let warnings = spine.warnings.iter().map(|w| w.message.clone()).collect();
107        (layout, warnings)
108    }
109
110    /// Mass (kg), the center of mass's station aft of the tip (m), and the roll and pitch inertias
111    /// about it (kg·m²): hpr's and OpenRocket's. Pitch is the mean of the two inertias across the
112    /// axis, and OpenRocket's `ixx` is roll: both as `cargo xtask ork` measured on its probe tube
113    /// (ADR-060).
114    fn both(probe: &Value) -> ([f64; 4], [f64; 4], Vec<String>) {
115        let (layout, warnings) = hpr(probe);
116        let s = &layout.structure;
117        let i = s.inertia_kg_m2;
118        let ours = [
119            s.mass_kg,
120            // hpr's `+z` points at the nose from the tip, so a station aft of it is `-z`.
121            -s.cg_m.z,
122            i.z_axis.z,
123            (i.x_axis.x + i.y_axis.y) / 2.0,
124        ];
125        let or = &probe["structure"];
126        let number = |key: &str| or[key].as_f64().expect("a number");
127        let theirs = [
128            number("mass_kg"),
129            number("cm_x_m"),
130            number("ixx"),
131            (number("iyy") + number("izz")) / 2.0,
132        ];
133        (ours, theirs, warnings)
134    }
135
136    fn relative(ours: f64, theirs: f64) -> f64 {
137        (ours - theirs) / theirs
138    }
139
140    fn stored(status: &str, results_xml: Option<&str>) -> StoredSimulation {
141        let xml = format!(
142            r#"<openrocket version="1.10"><rocket><name>R</name></rocket><simulations>
143              <simulation status="{status}"><name>stored</name><simulator>RK4Simulator</simulator><calculator>BarrowmanCalculator</calculator>{results}</simulation>
144            </simulations></openrocket>"#,
145            results = results_xml.unwrap_or_default(),
146        );
147        ork::design(
148            &ork::read(xml.as_bytes())
149                .expect("stored fixture reads")
150                .value,
151        )
152        .value
153        .simulations
154        .into_iter()
155        .next()
156        .expect("stored simulation")
157    }
158
159    fn plausible_results_xml() -> &'static str {
160        r#"<flightdata maxaltitude="100" maxvelocity="80" maxacceleration="120" maxmach="0.23" timetoapogee="5" flighttime="20"/>"#
161    }
162
163    fn stored_with_provenance(
164        simulator: Option<&str>,
165        calculator: Option<&str>,
166    ) -> StoredSimulation {
167        let simulator = simulator.map_or_else(String::new, |value| {
168            format!("<simulator>{value}</simulator>")
169        });
170        let calculator = calculator.map_or_else(String::new, |value| {
171            format!("<calculator>{value}</calculator>")
172        });
173        let xml = format!(
174            r#"<openrocket version="1.10"><rocket><name>R</name></rocket><simulations>
175              <simulation status="uptodate"><name>stored</name>{simulator}{calculator}{results}</simulation>
176            </simulations></openrocket>"#,
177            results = plausible_results_xml(),
178        );
179        ork::design(
180            &ork::read(xml.as_bytes())
181                .expect("stored fixture reads")
182                .value,
183        )
184        .value
185        .simulations
186        .into_iter()
187        .next()
188        .expect("stored simulation")
189    }
190
191    fn stored_with_results(results_xml: &str) -> StoredSimulation {
192        stored("uptodate", Some(results_xml))
193    }
194
195    fn results_with_branches(apogee: &str, branches: &str) -> String {
196        format!(
197            r#"<flightdata maxaltitude="{apogee}" maxvelocity="80" maxacceleration="120" maxmach="0.23" timetoapogee="5" flighttime="20">{branches}</flightdata>"#
198        )
199    }
200
201    /// Stored results remain readable, but stale, missing and physically contradictory values are
202    /// not allowed to become validation references or inflate a reference census.
203    #[test]
204    fn stale_and_implausible_stored_results_are_excluded_from_gates_and_census() {
205        let cases = [
206            (stored("uptodate", Some(plausible_results_xml())), None),
207            (
208                stored(
209                    "uptodate",
210                    Some(
211                        r#"<flightdata maxaltitude="100" maxvelocity="80" maxacceleration="120" maxmach="0.23" timetoapogee="5" flighttime="20"><databranch name="Sustainer" types="Time,Altitude"><datapoint>1,1</datapoint><datapoint>2,1</datapoint></databranch></flightdata>"#,
212                    ),
213                ),
214                Some(StoredReferenceExclusion::InconsistentResults),
215            ),
216            (
217                stored(
218                    "uptodate",
219                    Some(
220                        r#"<flightdata maxaltitude="100" maxvelocity="80" maxacceleration="120" maxmach="0.23" timetoapogee="5" flighttime="20"><databranch name="Sustainer" types="Time,Altitude"><datapoint>1,100</datapoint><datapoint>2,100.05</datapoint></databranch></flightdata>"#,
221                    ),
222                ),
223                None,
224            ),
225            (
226                stored(
227                    "uptodate",
228                    Some(
229                        r#"<flightdata maxaltitude="100" maxvelocity="80" maxacceleration="120" maxmach="0.23" timetoapogee="5" flighttime="20"><databranch name="Sustainer" types="Time,Altitude"><datapoint>1,100</datapoint><datapoint>2,100.2</datapoint></databranch></flightdata>"#,
230                    ),
231                ),
232                Some(StoredReferenceExclusion::InconsistentResults),
233            ),
234            (
235                stored("outdated", Some(plausible_results_xml())),
236                Some(StoredReferenceExclusion::Outdated),
237            ),
238            (
239                stored("notsimulated", Some(plausible_results_xml())),
240                Some(StoredReferenceExclusion::NotSimulated),
241            ),
242            (
243                stored("uptodate", None),
244                Some(StoredReferenceExclusion::MissingResults),
245            ),
246            (
247                stored(
248                    "uptodate",
249                    Some(
250                        r#"<flightdata maxaltitude="-1" maxvelocity="80" maxacceleration="120" maxmach="0.23" timetoapogee="5" flighttime="20"/>"#,
251                    ),
252                ),
253                Some(StoredReferenceExclusion::ImpossibleSummary),
254            ),
255            (
256                stored(
257                    "uptodate",
258                    Some(
259                        r#"<flightdata maxaltitude="NaN" maxvelocity="80" maxacceleration="120" maxmach="0.23" timetoapogee="5" flighttime="20"/>"#,
260                    ),
261                ),
262                Some(StoredReferenceExclusion::MissingSummary),
263            ),
264            (
265                stored(
266                    "uptodate",
267                    Some(
268                        r#"<flightdata maxaltitude="100" maxvelocity="80" maxacceleration="120" maxmach="0.23" timetoapogee="5" flighttime="20"><databranch name="Sustainer" types="Time,Altitude"><datapoint>2,10</datapoint><datapoint>1,20</datapoint></databranch></flightdata>"#,
269                    ),
270                ),
271                Some(StoredReferenceExclusion::InconsistentResults),
272            ),
273            (
274                stored(
275                    "uptodate",
276                    Some(
277                        r#"<flightdata maxaltitude="100" maxvelocity="80" maxacceleration="120" maxmach="0.23" timetoapogee="5" flighttime="20"><databranch name="Sustainer" types="Time,Altitude"><datapoint>1,-1</datapoint><datapoint>2,20</datapoint></databranch></flightdata>"#,
278                    ),
279                ),
280                Some(StoredReferenceExclusion::ImpossibleSummary),
281            ),
282        ];
283        let classified = cases
284            .iter()
285            .filter(|(simulation, exclusion)| simulation.reference_exclusion() == *exclusion)
286            .count();
287        assert_eq!(classified, cases.len());
288        assert_eq!(
289            cases
290                .iter()
291                .filter(|(_, exclusion)| exclusion.is_none())
292                .count(),
293            2
294        );
295        assert_eq!(
296            StoredReferenceExclusion::Outdated.reason(),
297            "status-outdated"
298        );
299        assert_eq!(
300            StoredReferenceExclusion::MissingResults.reason(),
301            "missing-results"
302        );
303        assert_eq!(
304            stored_with_provenance(None, Some("BarrowmanCalculator")).reference_exclusion(),
305            Some(StoredReferenceExclusion::MissingSimulator)
306        );
307        assert_eq!(
308            stored_with_provenance(Some("OtherSimulator"), Some("BarrowmanCalculator"))
309                .reference_exclusion(),
310            Some(StoredReferenceExclusion::UnsupportedSimulator)
311        );
312        assert_eq!(
313            stored_with_provenance(Some("RK4Simulator"), None).reference_exclusion(),
314            Some(StoredReferenceExclusion::MissingCalculator)
315        );
316        assert_eq!(
317            stored_with_provenance(Some("RK4Simulator"), Some("OtherCalculator"))
318                .reference_exclusion(),
319            Some(StoredReferenceExclusion::UnsupportedCalculator)
320        );
321    }
322
323    /// The apogee summary maps to OpenRocket's primary branch, not whichever branch happens to
324    /// have the numerically closest maximum. Secondary stages may also record apogee events.
325    #[test]
326    fn stored_apogee_uses_the_first_apogee_branch() {
327        let branches = r#"
328          <databranch name="Sustainer" types="Time,Altitude">
329            <event time="5" type="apogee"/><datapoint>0,0</datapoint><datapoint>5,100</datapoint>
330          </databranch>
331          <databranch name="Booster" types="Time,Altitude">
332            <event time="4" type="apogee"/><datapoint>0,0</datapoint><datapoint>4,99.9</datapoint>
333          </databranch>"#;
334        let simulation = stored_with_results(&results_with_branches("100", branches));
335        assert_eq!(simulation.reference_exclusion(), None);
336    }
337
338    /// A single altitude-bearing branch identifies the summary without an apogee event, but two
339    /// such branches do not give enough information to identify which series owns it.
340    #[test]
341    fn stored_apogee_branch_requires_unambiguous_branch_evidence() {
342        let single = r#"<databranch name="Sustainer" types="Time,Altitude">
343          <datapoint>0,0</datapoint><datapoint>5,100</datapoint>
344        </databranch>"#;
345        assert_eq!(
346            stored_with_results(&results_with_branches("100", single)).reference_exclusion(),
347            None
348        );
349        let multiple = format!(
350            r#"{single}<databranch name="Booster" types="Time,Altitude"><datapoint>0,0</datapoint><datapoint>5,99</datapoint></databranch>"#
351        );
352        assert_eq!(
353            stored_with_results(&results_with_branches("100", &multiple)).reference_exclusion(),
354            Some(StoredReferenceExclusion::UninspectableSeries)
355        );
356    }
357
358    /// A stored maximum matches at the exact allowance boundary; just beyond it does not. The
359    /// relative allowance takes over at 1 m, where it equals the fixed 1 mm allowance.
360    #[test]
361    fn stored_apogee_comparison_uses_the_two_sided_allowance() {
362        let check = |apogee: &str, series: &str| {
363            let branch = format!(
364                r#"<databranch name="Sustainer" types="Time,Altitude"><datapoint>0,0</datapoint><datapoint>5,{series}</datapoint></databranch>"#
365            );
366            stored_with_results(&results_with_branches(apogee, &branch)).reference_exclusion()
367        };
368        assert_eq!(check("100", "100.1"), None);
369        assert_eq!(
370            check("100", "100.1000001"),
371            Some(StoredReferenceExclusion::InconsistentResults)
372        );
373        assert_eq!(check("1", "1.001"), None);
374        assert_eq!(
375            check("1", "1.0010001"),
376            Some(StoredReferenceExclusion::InconsistentResults)
377        );
378        assert_eq!(
379            check("0.5", "0.501"),
380            Some(StoredReferenceExclusion::InconsistentResults)
381        );
382        assert_eq!(check("0.5", "0.5009999"), None);
383        assert_eq!(check("100", "99.9"), None);
384        assert_eq!(
385            check("100", "99.8999999"),
386            Some(StoredReferenceExclusion::InconsistentResults)
387        );
388    }
389
390    #[test]
391    fn stored_times_agree_with_flight_and_apogee_summaries() {
392        let branch = |last_time: &str, apogee_time: &str| {
393            format!(
394                r#"<databranch name="Sustainer" types="Time,Altitude"><event time="{apogee_time}" type="apogee"/><datapoint>0,0</datapoint><datapoint>{last_time},100</datapoint></databranch>"#
395            )
396        };
397        let run = |last_time: &str, apogee_time: &str| {
398            stored_with_results(&results_with_branches(
399                "100",
400                &branch(last_time, apogee_time),
401            ))
402            .reference_exclusion()
403        };
404        assert_eq!(run("20.0009", "5"), None);
405        assert_eq!(run("20", "5.0009"), None);
406        assert_eq!(
407            run("20.0011", "5"),
408            Some(StoredReferenceExclusion::InconsistentResults)
409        );
410        assert_eq!(
411            run("20", "5.0011"),
412            Some(StoredReferenceExclusion::InconsistentResults)
413        );
414    }
415
416    #[test]
417    fn non_finite_event_times_exclude_stored_results() {
418        let mut simulation = stored_with_results(&results_with_branches(
419            "100",
420            r#"<databranch name="Sustainer" types="Time,Altitude"><event time="5" type="launch"/><datapoint>0,0</datapoint><datapoint>5,100</datapoint></databranch>"#,
421        ));
422        simulation.results.as_mut().unwrap().branches[0].events[0].time_s = f64::NAN;
423        assert_eq!(
424            simulation.reference_exclusion(),
425            Some(StoredReferenceExclusion::ImpossibleSummary)
426        );
427    }
428
429    #[test]
430    fn fatal_events_and_events_outside_the_flight_exclude_stored_results() {
431        let with_event = |time: &str, kind: &str| {
432            let branch = format!(
433                r#"<databranch name="Sustainer" types="Time,Altitude"><event time="{time}" type="{kind}"/><datapoint>0,0</datapoint><datapoint>5,100</datapoint></databranch>"#
434            );
435            stored_with_results(&results_with_branches("100", &branch)).reference_exclusion()
436        };
437        assert_eq!(
438            with_event("6", "SIM_ABORT"),
439            Some(StoredReferenceExclusion::FatalEvent)
440        );
441        assert_eq!(
442            with_event("6", "Simulation-Abort"),
443            Some(StoredReferenceExclusion::FatalEvent)
444        );
445        assert_eq!(
446            with_event("-0.1", "launch"),
447            Some(StoredReferenceExclusion::ImpossibleSummary)
448        );
449        assert_eq!(
450            with_event("21", "simulationend"),
451            Some(StoredReferenceExclusion::InconsistentResults)
452        );
453    }
454
455    /// The override probes, each with how far hpr's center of mass is from OpenRocket's, in meters
456    /// (hpr's station less OpenRocket's). A zero is agreement; the last two are the departure
457    /// ADR-061 keeps for a mass override that covers the parts inside and states no center.
458    const PRECEDENCE: [(&str, f64); 12] = [
459        (
460            "a centre of gravity override on a nose with a shoulder",
461            0.0,
462        ),
463        (
464            "a centre of gravity override on a transition with a fore shoulder",
465            0.0,
466        ),
467        ("a mass override on a nose with a shoulder", 0.0),
468        ("a mass override on a tube, not the part inside", 0.0),
469        ("a mass override on the part inside only", 0.0),
470        (
471            "a centre of gravity override on a tube, not the part inside",
472            0.0,
473        ),
474        (
475            "a centre of gravity override on a tube and the part inside",
476            0.0,
477        ),
478        ("a mass override on the stage", 0.0),
479        ("both overrides on the stage", 0.0),
480        ("a stage override over a part's own", 0.0),
481        ("a mass override on a tube and the part inside", -0.003686),
482        (
483            "a mass override on a tube and the part inside, which has its own",
484            -0.019690,
485        ),
486    ];
487
488    /// Both overrides on one part, with flags that disagree, and the center's distance as above.
489    const DISAGREEING: [(&str, f64); 2] = [
490        (
491            "both overrides on a tube, the centre covering the part inside and the mass not",
492            0.004672,
493        ),
494        (
495            "both overrides on a tube, the mass covering the part inside and the centre not",
496            0.0,
497        ),
498    ];
499
500    /// Loft lesson L51: Loft's rule for which center-of-gravity override wins came from
501    /// OpenRocket's source and was unsettled by up to 133 mm. Here it is measured.
502    ///
503    /// On every override probe hpr's mass is OpenRocket's, so the winning mass override is the
504    /// same one: a parent's override that covers its children wins over a child's own, and a
505    /// stage's over everything in it. A center of gravity override is measured from the part's
506    /// front, not its shoulder's, and moves the shoulder with the part. A center of gravity override
507    /// alone that covers the parts inside sets the assembly's center in both programs (how each
508    /// places the parts inside differs, which shows only in the inertia: see
509    /// `inertia_under_an_override_departs_as_written`).
510    ///
511    /// Two cases are hpr's own rule, written in ADR-061 and pinned here in meters:
512    ///
513    /// - a mass override that covers the parts inside and states no center: OpenRocket puts the
514    ///   center at the overriding part's own, leaving out the parts inside; hpr keeps the
515    ///   assembly's, as its parts lay it out;
516    /// - both overrides, with flags that disagree: hpr states one scope and takes the mass's, and
517    ///   says so.
518    #[test]
519    fn override_precedence_matches_oracle() {
520        let record = record();
521        for (question, apart_m) in PRECEDENCE {
522            let (ours, theirs, warnings) = both(probe(&record, question));
523            assert!(
524                relative(ours[0], theirs[0]).abs() < 1e-5,
525                "{question}: mass {ours:?} {theirs:?}"
526            );
527            assert!(
528                (ours[1] - theirs[1] - apart_m).abs() < 1e-6,
529                "{question}: center {} m apart, not {apart_m}",
530                ours[1] - theirs[1]
531            );
532            assert!(warnings.is_empty(), "{question}: {warnings:?}");
533        }
534        // Flags that disagree cannot be said in `hpr-design`; the mass flag decides, out loud.
535        // Where the mass covers the parts inside, the center lands on OpenRocket's anyway.
536        for (question, apart_m) in DISAGREEING {
537            let (ours, theirs, warnings) = both(probe(&record, question));
538            assert!(relative(ours[0], theirs[0]).abs() < 1e-9, "{question}");
539            assert!(
540                (ours[1] - theirs[1] - apart_m).abs() < 1e-6,
541                "{question}: center {} m apart, not {apart_m}",
542                ours[1] - theirs[1]
543            );
544            assert_eq!(warnings.len(), 1, "{question}: {warnings:?}");
545            assert!(
546                warnings[0].contains("the mass flag was taken"),
547                "{warnings:?}"
548            );
549        }
550    }
551
552    /// The single subcomponent-override flag written before schema 1.9, and how far hpr's center
553    /// of mass is from OpenRocket's, in meters, on each probe of it.
554    const OLD_FLAG: [(&str, f64); 11] = [
555        (
556            "the old flag on both of a stage's overrides, schema 1.4",
557            0.0,
558        ),
559        (
560            "the old flag on a tube's mass override, schema 1.4",
561            -0.003686,
562        ),
563        (
564            "the old flag on a tube's mass override, schema 1.8",
565            -0.003686,
566        ),
567        (
568            "the old flag, false, on a tube's mass override, schema 1.8",
569            0.0,
570        ),
571        (
572            "the old flag on a tube's mass override, schema 1.10",
573            -0.003686,
574        ),
575        ("the old flag after a mass flag that says false", -0.003686),
576        ("the old flag before a mass flag that says false", 0.0),
577        ("the old flag before a drag flag that says false", -0.003686),
578        ("the old flag after a drag flag that says false", -0.003686),
579        (
580            "the old flag, false, after a centre flag that says true",
581            0.0,
582        ),
583        (
584            "the old flag, false, before a centre flag that says true",
585            0.0,
586        ),
587    ];
588
589    /// The element at or under `element` whose `<id>` is `id`.
590    fn with_id<'a>(element: &'a ork::Element, id: &str) -> Option<&'a ork::Element> {
591        if element
592            .child("id")
593            .is_some_and(|own| own.text().trim() == id)
594        {
595            return Some(element);
596        }
597        element.elements().find_map(|child| with_id(child, id))
598    }
599
600    /// OpenRocket 24.12 reads the single flag the three per-quantity ones replaced before schema
601    /// 1.9 as setting all three, in schema 1.4, 1.8 and 1.10 alike, and where a part writes both
602    /// forms the later one wins, quantity by quantity. hpr reads it so (M2.2e6): part by part, the
603    /// three flags are OpenRocket's, nothing is warned of, the mass is OpenRocket's, and the center
604    /// is OpenRocket's but where a mass override covering the parts inside states no center, which
605    /// is ADR-061's departure, the same 3.686 mm as the per-quantity flag's probe above.
606    #[test]
607    fn the_old_subcomponent_flag_reads_as_openrocket_reads_it() {
608        let record = record();
609        for (question, apart_m) in OLD_FLAG {
610            let probe = probe(&record, question);
611            let (ours, theirs, warnings) = both(probe);
612            assert!(
613                relative(ours[0], theirs[0]).abs() < 1e-9,
614                "{question}: mass {ours:?} {theirs:?}"
615            );
616            assert!(
617                (ours[1] - theirs[1] - apart_m).abs() < 1e-6,
618                "{question}: center {} m apart, not {apart_m}",
619                ours[1] - theirs[1]
620            );
621            assert!(warnings.is_empty(), "{question}: {warnings:?}");
622
623            let document = probe["document"].as_str().expect("the probe's document");
624            let parsed = ork::read(document.as_bytes()).expect("a probe reads");
625            let flags = probe["override_flags"].as_object().expect("the flags");
626            assert!(flags.len() >= 3, "{question}: {flags:?}");
627            for (id, recorded) in flags {
628                let element = with_id(&parsed.value.document.root, id).expect("the part");
629                let mut part_warnings = Vec::new();
630                let overrides = ork::Values::new(element, "probe", &mut part_warnings).overrides();
631                let flags_ours = [
632                    overrides.subcomponents_mass,
633                    overrides.subcomponents_cg,
634                    overrides.subcomponents_cd,
635                ]
636                .map(Option::unwrap_or_default);
637                let flags_theirs =
638                    ["mass", "cg", "cd"].map(|k| recorded[k].as_bool().expect("a flag"));
639                assert_eq!(flags_ours, flags_theirs, "{question}: {id}");
640                assert!(part_warnings.is_empty(), "{question}: {part_warnings:?}");
641            }
642        }
643    }
644
645    /// The probes whose inertias are compared, with hpr's roll and pitch relative to OpenRocket's.
646    const INERTIA: [(&str, [f64; 2]); 11] = [
647        ("a mass override on a nose with a shoulder", [0.0, 0.0]),
648        ("a mass override on a tube, not the part inside", [0.0, 0.0]),
649        ("a mass override on the part inside only", [0.0, 0.0]),
650        (
651            "a mass override on a tube and the part inside",
652            [-0.0693, -0.0667],
653        ),
654        (
655            "a mass override on a tube and the part inside, which has its own",
656            [-0.3713, -0.3724],
657        ),
658        (
659            "a centre of gravity override on a tube and the part inside",
660            [0.0, -0.0265],
661        ),
662        (
663            "both overrides on a tube, the centre covering the part inside and the mass not",
664            [0.0, -0.0010],
665        ),
666        (
667            "both overrides on a tube, the mass covering the part inside and the centre not",
668            [-0.0693, -0.0818],
669        ),
670        ("a mass override on the stage", [4.0050, 4.0050]),
671        ("both overrides on the stage", [4.0050, 3.4526]),
672        ("a stage override over a part's own", [1.4756, 1.4756]),
673    ];
674
675    /// Inertia under an override is hpr's own rule, a departure written in ADR-061. hpr scales a
676    /// mass override's inertia with its mass, over everything the override covers, so the extra
677    /// mass sits where the parts' does; OpenRocket scales only the overriding part's own inertia,
678    /// keeps the inertias of the parts inside (a stage has none of its own, so nothing is scaled),
679    /// and leaves the covered parts' masses out. For a center of gravity override that covers the
680    /// parts inside, hpr moves the assembly whole; OpenRocket moves the part alone and adds the
681    /// parts inside where they were. On a lone part both scale, and agree. The relative
682    /// differences, hpr's to OpenRocket's, are pinned here as roll and pitch.
683    #[test]
684    fn inertia_under_an_override_departs_as_written() {
685        let record = record();
686        for (question, pinned) in INERTIA {
687            let (ours, theirs, _) = both(probe(&record, question));
688            let found = [2, 3].map(|k| (relative(ours[k], theirs[k]) * 1e4).round() / 1e4);
689            assert_eq!(found, pinned, "{question}");
690        }
691    }
692
693    /// The probes whose readings hpr takes from OpenRocket, where no rule of hpr's own is in play.
694    const READINGS: [&str; 18] = [
695        "a nose with no shoulder",
696        "a nose with a walled shoulder",
697        "a nose whose shoulder has no wall",
698        "a nose whose shoulder has no wall, capped",
699        "a filled nose whose shoulder has no wall",
700        "a filled nose with a walled shoulder",
701        "a transition whose shoulders have no wall",
702        "a nose of no wall",
703        "a transition of no wall",
704        "a tube of no wall",
705        "a tube holding an inner tube, a coupler and a lug of no wall",
706        "a mass override on a nose of no wall",
707        "a nose, a shoulder and a tube that write no thickness at all",
708        "a narrower nose and tube that write no thickness at all",
709        "a transition that writes no thickness at all",
710        "a nose and a tube that name no material, with one part of each kind inside",
711        "a transition, and more kinds inside a tube, that name no material",
712        // Not a reading hpr takes: pinned below.
713        "a tube holding an inner tube, a coupler and a lug that write no thickness",
714    ];
715
716    /// The gaps a reading probe shows, each pinned rather than hidden: a part's class, and how far
717    /// hpr's is from OpenRocket's, as its mass relative to OpenRocket's and its station in meters.
718    ///
719    /// - An elliptical fin set: hpr's planform is the exact ellipse, `π c h / 4`; OpenRocket's
720    ///   weighs 0.18% less, which matches a 30-sided polygon inscribed at equal angles,
721    ///   `(π/30) / sin(π/30) − 1`, to 13 digits: an inference from its output, not its source
722    ///   (M2.2b2, with the fins).
723    const PART_GAPS: [(&str, f64, f64); 1] = [("EllipticalFinSet", 0.001830, 0.0)];
724
725    /// hpr reads OpenRocket's words for walls, shoulders and materials as OpenRocket 24.12 does
726    /// (ADR-061): a wall or a shoulder of no thickness weighs nothing, capped or not, on a filled
727    /// nose or a hollow one, and so does an inner tube, coupler or lug of no wall; a nose,
728    /// transition or tube that writes no thickness has a 2 mm wall, and a shoulder that writes none
729    /// has no wall; a part that names no material is made of OpenRocket's cardboard, ripstop nylon,
730    /// elastic cord or (a rail button) Delrin; and a mass override on a part that weighs nothing is
731    /// a point mass at the middle of its length, unless the part is packed (ADR-063, below). So the
732    /// mass and the center of mass agree, part by part as well as whole, and nothing is warned of;
733    /// where no fin, rail button or recovery part is in the probe, the inertias agree as well.
734    ///
735    /// The worst mass is a transition's, 2.9e-6 from OpenRocket's (hpr measures its wall normal to
736    /// the surface); the worst center, 1.5e-7 m. The bounds are a few times those. The two gaps in
737    /// [`PART_GAPS`] are pinned, and so is the one reading hpr does not take: an inner tube or lug
738    /// that writes no thickness, which OpenRocket gives a wall of its own (0.5 mm on the 20 mm
739    /// inner tube, 1 mm on the 5 mm lug, none on the coupler) and hpr reads as none, with a warning.
740    #[test]
741    fn walls_shoulders_and_unnamed_materials_read_as_openrocket_does() {
742        let record = record();
743        for question in READINGS {
744            let probe = probe(&record, question);
745            let unwritten = question.ends_with("that write no thickness");
746            let (ours, theirs, warnings) = both(probe);
747            if unwritten {
748                assert_eq!(warnings.len(), 3, "{question}: {warnings:?}");
749            } else {
750                assert!(warnings.is_empty(), "{question}: {warnings:?}");
751            }
752            if theirs[0] == 0.0 {
753                // A structure that weighs nothing has no center to compare.
754                assert_eq!(ours[0], 0.0, "{question}");
755                assert!(ours[1].is_finite(), "{question}");
756                continue;
757            }
758            let (layout, _) = hpr(probe);
759            // The whole is the parts: whatever the parts' gaps add up to, and nothing more.
760            let mut gap_kg = 0.0;
761            let mut gap_moment = 0.0;
762            let mut plain = true;
763            for part in probe["parts"].as_array().expect("parts") {
764                let class = part["class"].as_str().expect("a class");
765                if matches!(class, "Rocket" | "AxialStage") {
766                    continue; // they weigh nothing of their own, and hpr lays out no part for them
767                }
768                let id = part["id"].as_str().expect("an id");
769                let (_, placed) = layout
770                    .find(id)
771                    .unwrap_or_else(|| panic!("{question}: no part {id} in hpr"));
772                let mass = part["mass_kg"].as_f64().expect("a mass");
773                let station = part["cm_x_m"].as_f64().expect("a station");
774                plain &= !matches!(
775                    class,
776                    "TrapezoidFinSet"
777                        | "EllipticalFinSet"
778                        | "FreeformFinSet"
779                        | "RailButton"
780                        | "Parachute"
781                        | "Streamer"
782                        | "ShockCord"
783                );
784                let (mass_gap, station_gap) =
785                    if unwritten && matches!(class, "InnerTube" | "LaunchLug") {
786                        // OpenRocket's own wall, which hpr does not give: all of the part's mass.
787                        (-1.0, 0.0)
788                    } else {
789                        PART_GAPS
790                            .iter()
791                            .find(|(kind, _, _)| *kind == class)
792                            .map_or((0.0, 0.0), |(_, m, z)| (*m, *z))
793                    };
794                if mass == 0.0 {
795                    assert_eq!(placed.own.mass_kg, 0.0, "{question}: {id}");
796                    continue;
797                }
798                let found = relative(placed.own.mass_kg, mass);
799                assert!(
800                    (found - mass_gap).abs() < 1e-5,
801                    "{question}: {id} is {found} from OpenRocket's mass, not {mass_gap}"
802                );
803                if placed.own.mass_kg > 0.0 {
804                    let apart_m = -placed.own.cg_m.z - station;
805                    assert!(
806                        (apart_m - station_gap).abs() < 1e-6,
807                        "{question}: {id} is {apart_m} m from OpenRocket's station"
808                    );
809                }
810                gap_kg += placed.own.mass_kg - mass;
811                gap_moment += placed.own.mass_kg * (-placed.own.cg_m.z) - mass * station;
812            }
813            assert!(
814                (ours[0] - theirs[0] - gap_kg).abs() < 1e-5 * theirs[0],
815                "{question}: mass {ours:?} {theirs:?}"
816            );
817            let expected_m = (theirs[0] * theirs[1] + gap_moment) / (theirs[0] + gap_kg);
818            assert!(
819                (ours[1] - expected_m).abs() < 1e-6,
820                "{question}: center {ours:?} {theirs:?}"
821            );
822            if plain && !unwritten {
823                for k in [2, 3] {
824                    let (a, b) = (ours[k], theirs[k]);
825                    assert!(
826                        (a - b).abs() <= 1e-5 * b.abs().max(1e-12),
827                        "{question}: inertia {ours:?} {theirs:?}"
828                    );
829                }
830            }
831        }
832    }
833
834    /// The probes of one tube and one part (M2.2b2, [ADR-062][adr-062]), each with how far hpr's
835    /// structure is from OpenRocket's: the mass (relative), the center of mass (meters, hpr's less
836    /// OpenRocket's), the roll inertia (relative) with OpenRocket's fin rule on OpenRocket's own fin
837    /// mass in place of hpr's ([`super::openrocket_fin_set_roll_kg_m2`]), and the pitch inertia
838    /// (relative). Each is pinned to three figures, and a zero to 1e-12. The tube alone is
839    /// OpenRocket's, so each row is its one part's:
840    ///
841    /// - A bulkhead, centering ring, inner tube, mass component, parachute (under an override too),
842    ///   shock cord and streamer: OpenRocket's in all four.
843    /// - A fin set: its roll is OpenRocket's rule exactly, whatever its outline, section, tab or
844    ///   fillets, but for an ellipse (OpenRocket's is a 30-sided polygon) and a cant (−2.66e-5).
845    ///   Its mass is OpenRocket's but for a rounded or airfoil section
846    ///   ([`a_fin_section_is_weighed_as_pinned`]), an ellipse, and a cant (−4.19e-5, not traced).
847    ///   Its pitch inertia is apart by up to 0.11% where the masses agree (0.406% on a single fin,
848    ///   below): OpenRocket's pitch rule for fins is not measured here.
849    /// - Fillets (M2.2e7, [ADR-096][adr-096]): OpenRocket's in mass and center to 1e-15 (held
850    ///   here to 1e-12), in their own material or cardboard's when none is named, on any outline,
851    ///   count or tube; before, hpr left them out (−0.808% and −2.79% of the probe's mass at 5 and
852    ///   10 mm). The pitch inertia is apart by −0.0077% to −0.638%, largest with the 30 mm fillets,
853    ///   and +0.425% on a single fin: hpr's is the exact prism, and OpenRocket's pitch rule for fins
854    ///   is not measured.
855    /// - A rail button, one or a row, from any end: OpenRocket's in mass and center (#151), its
856    ///   inertias apart by 7.16e-6 and 9.23e-5 (one), 1.43e-5 and 4.99e-4 (two).
857    /// - A launch lug: its pitch inertia is apart by 3.13e-4.
858    /// - A packed part that writes no packed size, or only half of one: OpenRocket packs it 25 mm
859    ///   long and 12.5 mm in radius, whatever the tube, and so does hpr now (ADR-063; before, hpr
860    ///   read zero and was 0.611 mm off in the center, −0.167% in roll).
861    /// - A packed part that weighs nothing, under a mass override: OpenRocket spreads the override
862    ///   over its packing, `m r²/2` in roll, and so does hpr now (ADR-063; before, a point mass,
863    ///   −0.805%).
864    /// - A single fin: the rule about the fin's own center, `m hₑ²/12`, agrees; its pitch is
865    ///   apart by 0.406%.
866    /// - A 3-ring clustered inner tube: hpr reads all three tubes (M1.9b), so the mass and center
867    ///   agree; the roll inertia is +5.11% and the pitch +0.273%, the tubes' parallel-axis terms
868    ///   `3 m d²` and half of it, which OpenRocket leaves out (ADR-075). Before M1.9b hpr read one
869    ///   tube: −12.85% mass, +5.95 mm center, −2.43% roll and −3.68% pitch.
870    ///
871    /// [adr-062]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-062-fins-and-rail-buttons-against-openrocket-roll-inertia-explained-2026-09-21
872    /// [adr-064]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-064-clusters-fillets-and-unread-parts-remain-visible-departures-2026-09-22
873    /// [adr-096]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-096-fin-fillets-and-an-automatic-radius-inside-a-nose-cone-read-as-openrocket-reads-them-2026-09-28
874    const ALONE: [(&str, [f64; 4]); 50] = [
875        ("a tube and a bulkhead", [0.0, 0.0, 0.0, 0.0]),
876        (
877            "a tube and a canted fin set",
878            [-4.19e-5, -4.54e-6, -2.66e-5, 7.58e-5],
879        ),
880        ("a tube and a centering ring", [0.0, 0.0, 0.0, 0.0]),
881        (
882            "a tube and a fin set of airfoil section",
883            [-0.0165, 0.000935, 0.0, -0.00488],
884        ),
885        (
886            "a tube and a fin set of rounded section",
887            [0.00014, -1.24e-5, 0.0, 7.37e-5],
888        ),
889        (
890            "a tube and a fin set of square section",
891            [0.0, 0.0, 0.0, 4.57e-5],
892        ),
893        ("a tube and a fin set with a tab", [0.0, 0.0, 0.0, -0.00109]),
894        (
895            "a tube and a fin set with fillets",
896            [0.0, 0.0, 0.0, -0.00035],
897        ),
898        (
899            "a tube and a fin set with wider fillets",
900            [0.0, 0.0, 0.0, -0.00129],
901        ),
902        (
903            "a tube and a fin set with fillets of 30 mm",
904            [0.0, 0.0, 0.0, -0.00638],
905        ),
906        (
907            "a tube and a fin set with fillets of their own material",
908            [0.0, 0.0, 0.0, -0.000749],
909        ),
910        (
911            "a tube and a fin set with fillets that name no material",
912            [0.0, 0.0, 0.0, -0.000223],
913        ),
914        (
915            "a tube and a single fin with fillets",
916            [0.0, 0.0, 7.78e-6, 0.00425],
917        ),
918        (
919            "a tube and four fins of rounded section with fillets",
920            [0.000179, -1.52e-5, 0.0, -0.000428],
921        ),
922        (
923            "a tube and a freeform fin set with fillets",
924            [0.0, 0.0, 0.0, -7.74e-5],
925        ),
926        (
927            "a wider tube and a fin set with fillets",
928            [0.0, 0.0, 0.0, -0.00123],
929        ),
930        ("a tube and a freeform fin set", [0.0, 0.0, 0.0, 4.57e-5]),
931        ("a tube and a launch lug", [0.0, 0.0, 0.0, 0.000313]),
932        ("a tube and a mass component", [0.0, 0.0, 0.0, 0.0]),
933        ("a tube and a parachute", [0.0, 0.0, 0.0, 0.0]),
934        (
935            "a tube and a parachute that writes no packed size",
936            [0.0, 0.0, 0.0, 0.0],
937        ),
938        (
939            "a tube and a parachute that writes only a packed length",
940            [0.0, 0.0, 0.0, 0.0],
941        ),
942        (
943            "a tube and a parachute that writes only a packed radius",
944            [0.0, 0.0, 0.0, 0.0],
945        ),
946        (
947            "a tube and a streamer that writes no packed size",
948            [0.0, 0.0, 0.0, 0.0],
949        ),
950        (
951            "a tube and a shock cord that writes no packed size",
952            [0.0, 0.0, 0.0, 0.0],
953        ),
954        (
955            "a tube and a mass component that writes no packed size",
956            [0.0, 0.0, 0.0, 0.0],
957        ),
958        (
959            "a tube and a mass component of no mass, under a mass override",
960            [0.0, 0.0, 0.0, 0.0],
961        ),
962        (
963            "a tube and a shock cord of no length, under a mass override",
964            [0.0, 0.0, 0.0, 0.0],
965        ),
966        (
967            "a wider tube and a parachute that writes no packed size",
968            [0.0, 0.0, 0.0, 0.0],
969        ),
970        (
971            "a narrow tube and a parachute that writes no packed size",
972            [0.0, 0.0, 0.0, 0.0],
973        ),
974        (
975            "a tube and a parachute with a mass override",
976            [0.0, 0.0, 0.0, 0.0],
977        ),
978        ("a tube and a rail button", [0.0, 0.0, 7.16e-6, 9.23e-5]),
979        (
980            "a tube and a rail button from the bottom",
981            [0.0, 0.0, 7.16e-6, 9.23e-5],
982        ),
983        (
984            "a tube and a rail button from the middle",
985            [0.0, 0.0, 7.16e-6, 9.24e-5],
986        ),
987        (
988            "a tube and a row of two rail buttons from the bottom",
989            [0.0, 0.0, 1.43e-5, 0.000497],
990        ),
991        (
992            "a tube and a row of two rail buttons from the middle",
993            [0.0, 0.0, 1.43e-5, 0.000499],
994        ),
995        (
996            "a tube and a row of two rail buttons from the top",
997            [0.0, 0.0, 1.43e-5, 0.000499],
998        ),
999        ("a tube and a shock cord", [0.0, 0.0, 0.0, 0.0]),
1000        ("a tube and a streamer", [0.0, 0.0, 0.0, 0.0]),
1001        (
1002            "a tube and a thicker fin set of airfoil section",
1003            [-0.0305, 0.00155, 0.0, -0.00842],
1004        ),
1005        (
1006            "a tube and an elliptical fin set",
1007            [0.000188, -1.69e-5, 0.000122, -0.000328],
1008        ),
1009        ("a tube and an inner tube", [0.0, 0.0, 0.0, 0.0]),
1010        (
1011            "a tube and a clustered inner tube",
1012            [0.0, 0.0, 0.0511, 0.00273],
1013        ),
1014        ("a tube and rectangular fins", [0.0, 0.0, 0.0, 2.3e-6]),
1015        (
1016            "a tube and rectangular fins of twice the chord",
1017            [0.0, 0.0, 0.0, 4.49e-6],
1018        ),
1019        (
1020            "a tube and rectangular fins of twice the span",
1021            [0.0, 0.0, 0.0, 4.2e-6],
1022        ),
1023        ("a tube and a single fin", [0.0, 0.0, 0.0, 0.00406]),
1024        (
1025            "a tube and a parachute of no canopy, under a mass override",
1026            [0.0, 0.0, 0.0, 0.0],
1027        ),
1028        ("a tube and triangular fins", [0.0, 0.0, 0.0, 0.00028]),
1029        ("a wider tube and rectangular fins", [0.0, 0.0, 0.0, 1e-6]),
1030    ];
1031
1032    #[test]
1033    fn each_part_alone_is_openrocket_s_or_pinned() {
1034        let record = record();
1035        for (question, pinned) in ALONE {
1036            let probe = probe(&record, question);
1037            let (ours, theirs, warnings) = both(probe);
1038            assert!(warnings.is_empty(), "{question}: {warnings:?}");
1039            let (layout, _) = hpr(probe);
1040            let mut roll = ours[2];
1041            for part in probe["parts"].as_array().expect("parts") {
1042                let Some((_, placed)) = part["id"].as_str().and_then(|id| layout.find(id)) else {
1043                    continue;
1044                };
1045                if let Part::FinSet(fins) = &placed.part {
1046                    let mass_kg = part["mass_kg"].as_f64().expect("a mass");
1047                    let radius_m = placed.body_radius_m.expect("fins sit on a tube");
1048                    let rule = super::openrocket_fin_set_roll_kg_m2(fins, radius_m, mass_kg)
1049                        .expect("three fins");
1050                    roll += rule - placed.own.inertia_kg_m2.z_axis.z;
1051                }
1052            }
1053            let found = [
1054                relative(ours[0], theirs[0]),
1055                ours[1] - theirs[1],
1056                relative(roll, theirs[2]),
1057                relative(ours[3], theirs[3]),
1058            ];
1059            for (k, (found, pinned)) in found.iter().zip(pinned).enumerate() {
1060                let bound = if pinned == 0.0 {
1061                    1e-12
1062                } else {
1063                    5e-3 * pinned.abs()
1064                };
1065                assert!(
1066                    (found - pinned).abs() <= bound,
1067                    "{question}: quantity {k} is {found:e}, not {pinned:e}"
1068                );
1069            }
1070        }
1071    }
1072
1073    /// hpr's own fin roll inertia against OpenRocket's, set by set: the departure
1074    /// [ADR-062][adr-062] keeps. OpenRocket's set is its structure's roll inertia less everything
1075    /// else, which is hpr's (every other part of these probes is OpenRocket's to 1e-15). Pinned to
1076    /// three figures: the guide's table quotes them.
1077    ///
1078    /// [adr-062]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-062-fins-and-rail-buttons-against-openrocket-roll-inertia-explained-2026-09-21
1079    #[test]
1080    fn hpr_s_own_fin_roll_departs_as_pinned() {
1081        let record = record();
1082        let departures = [
1083            ("a tube and rectangular fins", 0.000_129),
1084            ("a tube and a fin set of square section", 0.0241),
1085            ("a tube and triangular fins", -0.0214),
1086            ("a tube and a fin set with a tab", -0.0512),
1087        ];
1088        for (question, pinned) in departures {
1089            let probe = probe(&record, question);
1090            let (ours, theirs, _) = both(probe);
1091            let (layout, _) = hpr(probe);
1092            let fins = layout
1093                .components
1094                .iter()
1095                .find(|placed| matches!(placed.part, Part::FinSet(_)))
1096                .expect("a fin set");
1097            let hpr_fins = fins.own.inertia_kg_m2.z_axis.z;
1098            let their_fins = theirs[2] - (ours[2] - hpr_fins);
1099            let found = relative(hpr_fins, their_fins);
1100            assert!(
1101                (found - pinned).abs() <= 5e-3 * pinned.abs(),
1102                "{question}: {found:e}, not {pinned:e}"
1103            );
1104        }
1105    }
1106
1107    /// OpenRocket weighs a fin set as its outline times its thickness times a factor for its
1108    /// section, whatever the thickness: 1 for square, 0.99 for rounded and 0.85 for airfoil (read from
1109    /// its output, at 3 mm and 6 mm for the airfoil). hpr integrates the section: a rounded edge is a
1110    /// semicircle, and an airfoil is NACA's four-digit section, `0.6851 t c` (Abbott and von
1111    /// Doenhoff). hpr keeps its own ([ADR-062][adr-062]); the ratios are pinned.
1112    ///
1113    /// [adr-062]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-062-fins-and-rail-buttons-against-openrocket-roll-inertia-explained-2026-09-21
1114    #[test]
1115    fn a_fin_section_is_weighed_as_pinned() {
1116        let record = record();
1117        let fins = |question: &str| -> (f64, f64) {
1118            let probe = probe(&record, question);
1119            let (layout, _) = hpr(probe);
1120            let theirs = probe["parts"]
1121                .as_array()
1122                .expect("parts")
1123                .iter()
1124                .find(|part| part["class"] == "TrapezoidFinSet")
1125                .expect("a fin set");
1126            let id = theirs["id"].as_str().expect("an id");
1127            let (_, ours) = layout.find(id).expect("hpr's fin set");
1128            (
1129                ours.own.mass_kg,
1130                theirs["mass_kg"].as_f64().expect("a mass"),
1131            )
1132        };
1133        let square = fins("a tube and a fin set of square section");
1134        assert!((square.0 - square.1).abs() < 1e-15, "{square:?}");
1135        let sections = [
1136            (
1137                "a tube and a fin set of rounded section",
1138                1.0,
1139                0.99,
1140                0.991_416,
1141            ),
1142            (
1143                "a tube and a fin set of airfoil section",
1144                1.0,
1145                0.85,
1146                0.685_083,
1147            ),
1148            (
1149                "a tube and a thicker fin set of airfoil section",
1150                2.0,
1151                0.85,
1152                0.685_083,
1153            ),
1154        ];
1155        for (question, thickness, openrocket, ours) in sections {
1156            let (hpr, theirs) = fins(question);
1157            let slab = thickness * square.1;
1158            assert!(
1159                (theirs / slab - openrocket).abs() < 1e-12,
1160                "{question}: {}",
1161                theirs / slab
1162            );
1163            assert!(
1164                (hpr / slab - ours).abs() < 1e-6,
1165                "{question}: {}",
1166                hpr / slab
1167            );
1168        }
1169    }
1170
1171    /// Loft's `demo-boattail.ork`, whose roll inertia is 2.64% from OpenRocket's, 0.093% with
1172    /// OpenRocket's fin rule in place of hpr's (`cargo xtask ork`). Its fins are elliptical, and
1173    /// OpenRocket's ellipse is a 30-sided polygon, of `sin(π/30) / (π/30)` the area and so the
1174    /// mass: with that in the rule too, the two are 1.5e-6 apart. The design and OpenRocket's
1175    /// record of it are both committed (ADR-060).
1176    #[test]
1177    fn the_loft_boattail_s_roll_is_the_rule_on_openrocket_s_ellipse() {
1178        let text = include_str!("../../../validation/fixtures/ork/openrocket-mass-loft-demo.json");
1179        let record: Value = serde_json::from_str(text).expect("the committed record is JSON");
1180        let file = "validation/fixtures/ork/loft-demo/demo-boattail.ork";
1181        let design = record["designs"]
1182            .as_array()
1183            .expect("designs")
1184            .iter()
1185            .find(|design| design["file"] == file)
1186            .expect("the boattail's record");
1187        let theirs = design["structure"]["ixx"].as_f64().expect("a roll inertia");
1188        let bytes = include_bytes!("../../../validation/fixtures/ork/loft-demo/demo-boattail.ork");
1189        let read = ork::read(bytes.as_slice()).expect("the design reads");
1190        let layout = ork::design(&read.value)
1191            .value
1192            .rocket
1193            .layout()
1194            .expect("it lays out");
1195        let polygon = (std::f64::consts::PI / 30.0).sin() / (std::f64::consts::PI / 30.0);
1196        let mut ours = layout.structure.inertia_kg_m2.z_axis.z;
1197        let mut fins = 0;
1198        for placed in &layout.components {
1199            let Part::FinSet(set) = &placed.part else {
1200                continue;
1201            };
1202            assert!(matches!(
1203                set.planform,
1204                hpr_design::fins::FinPlanform::Elliptical { .. }
1205            ));
1206            // The rule on the polygon: its mass, and its area in `hₑ² = A h / c_r`.
1207            let rule = super::rod_roll_kg_m2(
1208                set.count,
1209                set.planform.geometry().expect("an outline").area_m2 * polygon,
1210                set.planform.span_m(),
1211                set.planform.root_chord_m(),
1212                placed.body_radius_m.expect("fins sit on a tube"),
1213                placed.own.mass_kg * polygon,
1214            )
1215            .expect("three fins");
1216            ours += rule - placed.own.inertia_kg_m2.z_axis.z;
1217            fins += 1;
1218        }
1219        assert_eq!(fins, 1);
1220        let apart = relative(ours, theirs);
1221        assert!(apart.abs() < 2e-6, "{apart:e}");
1222    }
1223
1224    fn pods() -> Value {
1225        let text = include_str!("../../../validation/fixtures/ork/openrocket-pods.json");
1226        serde_json::from_str(text).expect("the committed record is JSON")
1227    }
1228
1229    /// Every part inside every pod OpenRocket 24.12 was asked about sits where OpenRocket puts it:
1230    /// each copy across the axis to 1e-15 m, with OpenRocket's `(y, z)` read as hpr's `(x, y)`,
1231    /// and along it to 1e-15 m. That holds the reader's distance from the axis for each
1232    /// `radiusoffset` method (`relative`, `surface`, `free`), a pod whose widest part is in its
1233    /// middle, one, two and three pods, a negative angle, every angle method, and a pod set placed
1234    /// from its tube's bottom (M1.13b). A pod of no length holds its fins and its lug on a tube of
1235    /// its own radius, most often none, turned with the pod: each fin's root and the lug's axis are
1236    /// where OpenRocket puts them, matched as sets, since OpenRocket lists a fin set's fins across
1237    /// its pods in an order of its own. On a tube 10 mm in radius, two pods at 30° with three fins
1238    /// each at 20° show every fin turned with its pod (M1.13b2).
1239    #[test]
1240    fn every_pod_is_where_openrocket_puts_it() {
1241        let record = pods();
1242        let probes = record["probes"].as_object().expect("probes");
1243        assert_eq!(probes.len(), 18);
1244        for (question, probe) in probes {
1245            let (layout, warnings) = hpr(probe);
1246            if question.ends_with("its mass overridden to 0.1 kg") {
1247                assert_eq!(warnings.len(), 1, "{question}: {warnings:?}");
1248                assert!(warnings[0].contains("at the rocket's tip"), "{warnings:?}");
1249            } else {
1250                assert!(warnings.is_empty(), "{question}: {warnings:?}");
1251            }
1252            let mut inside = 0;
1253            for (id, theirs) in probe["components"].as_object().expect("components") {
1254                let Some((_, placed)) = layout.find(id) else {
1255                    continue;
1256                };
1257                if !in_a_pod(&layout, id) {
1258                    continue;
1259                }
1260                inside += 1;
1261                let theirs: Vec<[f64; 3]> = theirs["locations_m"]
1262                    .as_array()
1263                    .expect("locations")
1264                    .iter()
1265                    .map(|l| {
1266                        let at = |k: usize| l[k].as_f64().expect("a number");
1267                        [at(1), at(2), at(0)]
1268                    })
1269                    .collect();
1270                let ours = where_hpr_puts(placed);
1271                assert_eq!(ours.len(), theirs.len(), "{question}: {id}");
1272                let near = |a: &[f64; 3], b: &[f64; 3]| {
1273                    (a[0] - b[0]).hypot(a[1] - b[1]) <= 1e-15 && (a[2] - b[2]).abs() <= 1e-15
1274                };
1275                if matches!(placed.part, Part::FinSet(_)) {
1276                    let mut left = ours.clone();
1277                    for place in &theirs {
1278                        let k = left.iter().position(|o| near(o, place));
1279                        let k = k.unwrap_or_else(|| panic!("{question}: {id} {place:?} {ours:?}"));
1280                        left.swap_remove(k);
1281                    }
1282                } else {
1283                    for (o, t) in ours.iter().zip(&theirs) {
1284                        assert!(near(o, t), "{question}: {id} {o:?} vs {t:?}");
1285                    }
1286                }
1287            }
1288            let least = if question.starts_with("an empty") {
1289                0
1290            } else {
1291                2
1292            };
1293            assert!(inside >= least, "{question}: {inside} parts in the pod");
1294        }
1295    }
1296
1297    /// Whether the part `id` hangs, at any depth, from a pod set.
1298    fn in_a_pod(layout: &Layout, id: &str) -> bool {
1299        let mut at = layout.find(id).expect("the part").1.parent;
1300        while let Some(index) = at {
1301            let parent = &layout.components[index];
1302            if matches!(parent.part, Part::PodSet(_)) {
1303                return true;
1304            }
1305            at = parent.parent;
1306        }
1307        false
1308    }
1309
1310    /// Where hpr puts a part in a pod, as OpenRocket's component locations name it: `[x, y]`
1311    /// across the axis and the station along it, for every copy. A body component is its fore
1312    /// end on its pod's axis; a fin is its root's leading edge, on its tube's surface at its own
1313    /// angle; a lug is its axis's fore end, its own radius out from its tube's surface. What a pod
1314    /// holds turns with its pod.
1315    fn where_hpr_puts(placed: &hpr_design::tree::PlacedComponent) -> Vec<[f64; 3]> {
1316        let fore = placed.fore_station_m;
1317        let out = |offset: [f64; 2], radius: f64, angle: f64| {
1318            [
1319                offset[0] + radius * angle.cos(),
1320                offset[1] + radius * angle.sin(),
1321                fore,
1322            ]
1323        };
1324        let body = placed.body_radius_m.unwrap_or_default();
1325        let mut places = Vec::new();
1326        for copy in &placed.copies {
1327            match &placed.part {
1328                Part::FinSet(set) => {
1329                    let step = std::f64::consts::TAU / f64::from(set.count);
1330                    for k in 0..set.count {
1331                        let angle = copy.roll_rad + set.base_angle_rad + step * f64::from(k);
1332                        places.push(out(copy.offset_m, body, angle));
1333                    }
1334                }
1335                Part::LaunchLug(lug) => {
1336                    let angle = copy.roll_rad + lug.angle_rad;
1337                    places.push(out(copy.offset_m, body + lug.outer_radius_m, angle));
1338                }
1339                _ => places.push(out(copy.offset_m, 0.0, 0.0)),
1340            }
1341        }
1342        places
1343    }
1344
1345    /// A design with pods weighs what OpenRocket 24.12 says, on every probe.
1346    ///
1347    /// - **Part by part.** Every part OpenRocket weighs in a pod, and the pod set itself, is found
1348    ///   in hpr with OpenRocket's mass, all its copies together, and its center along the axis: a
1349    ///   tube, a fin set or a lug within 2e-15, relative (9.5e-16 at worst), so each pod is
1350    ///   weighed where it sits (ADR-089), a pod of no length and an empty pod set weigh nothing,
1351    ///   and a pod's fins and lug weigh what OpenRocket's do. A pod's nose cone is 8.3e-8 from
1352    ///   OpenRocket's in mass and 4.0e-9 in its center, held within 1e-7 and 5e-9. OpenRocket
1353    ///   weighs a nose cone differently from hpr: the probes' own nose, on the airframe, is
1354    ///   5.10e-7 apart in mass and −4.64e-7 in its center, pinned here to 0.5%, and the
1355    ///   airframe's tube agrees to 2e-15.
1356    /// - **The whole.** The empty pod set's probe is the bare airframe: 1.17e-7 from OpenRocket's
1357    ///   in mass and −1.125e-7 in the center, relative, pinned. No probe's mass
1358    ///   or center is further from OpenRocket's than that, so no pod here adds a gap of its own.
1359    ///   The mass is within 1.2e-7 on every probe; the center within 1.1e-7 where the pods have
1360    ///   a length (M1.13b1's), and within 1.13e-7 where they have none and weigh little.
1361    /// - **Roll**, within 2e-9, with each fin set in a pod given OpenRocket's own roll inertia
1362    ///   about its pod's axis ([`super::openrocket_fin_set_roll_kg_m2`], ADR-062) in place of hpr's.
1363    /// - **Pitch.** OpenRocket gives one pitch inertia, the same number as `iyy` and `izz`. Where
1364    ///   the pods hold no fins it is hpr's inertia about `x_B` (OpenRocket's `y`) within 6.1e-7,
1365    ///   and so the mean across the axis only where the pods leave the two equal (three pods):
1366    ///   with one or two of M1.13b1's pods, OpenRocket's `izz` is not the inertia about its `z`
1367    ///   axis, and hpr's is, 0.3% to 1.1% apart. Where they hold fins, the gap about `x_B` is
1368    ///   pinned to 0.5% of itself, as on the airframe: OpenRocket's pitch rule for fins is not
1369    ///   measured.
1370    /// - **An override on an empty pod set.** OpenRocket adds its mass at the rocket's tip, on the
1371    ///   axis: its structure is the bare airframe's but 0.1 kg heavier, with the same first
1372    ///   moment and roll inertia. hpr's reader drops the override, with a warning, so hpr's is the
1373    ///   bare airframe exactly.
1374    #[test]
1375    fn pods_weigh_as_openrocket_s() {
1376        // Mass and center, relative: every tube, fin set and lug in a pod; a pod's nose cone.
1377        const PART: (f64, f64) = (2e-15, 2e-15);
1378        const NOSE: (f64, f64) = (1e-7, 5e-9);
1379        // hpr's pitch about `x_B` less OpenRocket's, relative, where the pods hold fins.
1380        const FIN_PITCH: [(&str, f64); 4] = [
1381            (
1382                "one pod of no length 0.01 in radius, relative 0.02, two fins at 90",
1383                3.35e-5,
1384            ),
1385            (
1386                "one pod of no length, relative 0.03, bottom 0.0, two fins at 90",
1387                9.35e-6,
1388            ),
1389            (
1390                "two pods of no length 0.01 in radius, relative 0.02, at 30, three fins at 20",
1391                2.58e-7,
1392            ),
1393            (
1394                "two pods of no length, relative 0.02, at 45, three fins",
1395                -9.05e-7,
1396            ),
1397        ];
1398        let pinned = |got: f64, want: f64| (got - want).abs() <= 0.005 * want.abs();
1399        let record = pods();
1400        let probes = record["probes"].as_object().expect("probes");
1401        let empty = &probes["an empty pod set, relative 0.004"];
1402        let (ours, theirs, _) = both(empty);
1403        let bare = [relative(ours[0], theirs[0]), relative(ours[1], theirs[1])];
1404        assert!(pinned(bare[0], 1.17e-7), "{bare:?}");
1405        assert!(pinned(bare[1], -1.125e-7), "{bare:?}");
1406        let (layout, _) = hpr(empty);
1407        let nose = &empty["parts"][0];
1408        assert_eq!(nose["class"], "NoseCone");
1409        let (_, placed) = layout.find(conventions_id(1)).expect("the nose");
1410        let apart = relative(
1411            placed.own.mass_kg,
1412            nose["mass_kg"].as_f64().expect("a mass"),
1413        );
1414        assert!(pinned(apart, 5.10e-7), "{apart:e}");
1415        let apart = relative(
1416            -placed.own.cg_m.z,
1417            nose["cm_x_m"].as_f64().expect("a station"),
1418        );
1419        assert!(pinned(apart, -4.64e-7), "{apart:e}");
1420        // The tube is OpenRocket's, so the nose is all of the bare airframe's gap.
1421        let tube = &empty["parts"][1];
1422        assert_eq!(tube["class"], "BodyTube");
1423        let (_, placed) = layout.find(conventions_id(2)).expect("the tube");
1424        let apart = relative(
1425            placed.own.mass_kg,
1426            tube["mass_kg"].as_f64().expect("a mass"),
1427        );
1428        assert!(apart.abs() < PART.0, "{apart:e}");
1429
1430        for (question, probe) in probes {
1431            let (ours, theirs, _) = both(probe);
1432            let (layout, _) = hpr(probe);
1433            if question.ends_with("its mass overridden to 0.1 kg") {
1434                let (bare_ours, bare_theirs, _) = both(empty);
1435                assert_eq!(ours, bare_ours, "{question}");
1436                let mass = theirs[0] - bare_theirs[0];
1437                assert!((mass - 0.1).abs() < 1e-15, "{question}: {mass:e}");
1438                let moment = theirs[0] * theirs[1] - bare_theirs[0] * bare_theirs[1];
1439                assert!(moment.abs() < 1e-15, "{question}: {moment:e}");
1440                assert_eq!(theirs[2], bare_theirs[2], "{question}");
1441                continue;
1442            }
1443            let airframe = [conventions_id(1), conventions_id(2)];
1444            let mut weighed = 0;
1445            let mut in_pods = 0;
1446            for part in probe["parts"].as_array().expect("parts") {
1447                let id = part["id"].as_str().expect("an id");
1448                let class = part["class"].as_str().expect("a class");
1449                if matches!(class, "Rocket" | "AxialStage") || airframe.contains(&id) {
1450                    continue;
1451                }
1452                in_pods += 1;
1453                let (_, placed) = layout
1454                    .find(id)
1455                    .unwrap_or_else(|| panic!("{question}: no part {id} in hpr"));
1456                assert!(
1457                    in_a_pod(&layout, id) || matches!(placed.part, Part::PodSet(_)),
1458                    "{question}: {id}"
1459                );
1460                weighed += 1;
1461                let mass = part["mass_kg"].as_f64().expect("a mass");
1462                let own = placed.own;
1463                if mass == 0.0 {
1464                    assert_eq!(own.mass_kg, 0.0, "{question}: {id}");
1465                    continue;
1466                }
1467                let (mass_bound, center_bound) = if matches!(placed.part, Part::NoseCone(_)) {
1468                    NOSE
1469                } else {
1470                    PART
1471                };
1472                let apart = relative(own.mass_kg, mass);
1473                assert!(apart.abs() < mass_bound, "{question}: {id} mass {apart:e}");
1474                let station = part["cm_x_m"].as_f64().expect("a station");
1475                let apart = relative(-own.cg_m.z, station);
1476                assert!(
1477                    apart.abs() < center_bound,
1478                    "{question}: {id} center {apart:e}"
1479                );
1480            }
1481            assert_eq!(weighed, in_pods, "{question}");
1482            let holds_nothing = question.starts_with("an empty");
1483            let least = if holds_nothing { 1 } else { 3 };
1484            assert!(in_pods >= least, "{question}: {in_pods}");
1485
1486            let of_length = !(holds_nothing || question.contains("of no length"));
1487            let center_bound = if of_length { 1.1e-7 } else { 1.13e-7 };
1488            for (k, bound) in [(0, 1.2e-7), (1, center_bound)] {
1489                let apart = relative(ours[k], theirs[k]);
1490                assert!(apart.abs() < bound, "{question}: quantity {k}, {apart:e}");
1491                assert!(
1492                    apart.abs() <= bare[k].abs(),
1493                    "{question}: quantity {k}, {apart:e}"
1494                );
1495            }
1496
1497            let mut roll = ours[2];
1498            for placed in &layout.components {
1499                let Part::FinSet(set) = &placed.part else {
1500                    continue;
1501                };
1502                let radius = placed.body_radius_m.expect("fins sit on a tube");
1503                let one = placed
1504                    .part
1505                    .mass_properties(Some(radius))
1506                    .expect("a fin set");
1507                let copies = f64::from(u32::try_from(placed.copies.len()).expect("a few pods"));
1508                let rule = super::openrocket_fin_set_roll_kg_m2(set, radius, one.mass_kg)
1509                    .expect("two or more fins");
1510                roll += copies * (rule - one.inertia_kg_m2.z_axis.z);
1511            }
1512            let apart = relative(roll, theirs[2]);
1513            assert!(apart.abs() < 2e-9, "{question}: roll {apart:e}");
1514
1515            let i = layout.structure.inertia_kg_m2;
1516            let pitch = probe["structure"]["iyy"].as_f64().expect("a number");
1517            assert_eq!(
1518                probe["structure"]["izz"].as_f64(),
1519                Some(pitch),
1520                "{question}"
1521            );
1522            let about_x = relative(i.x_axis.x, pitch);
1523            if let Some((_, want)) = FIN_PITCH.iter().find(|(q, _)| q == question) {
1524                assert!(pinned(about_x, *want), "{question}: {about_x:e}");
1525                continue;
1526            }
1527            assert!(
1528                !layout
1529                    .components
1530                    .iter()
1531                    .any(|c| matches!(c.part, Part::FinSet(_))),
1532                "{question}: fins not pinned"
1533            );
1534            assert!(about_x.abs() < 6.1e-7, "{question}: {about_x:e}");
1535            let about_y = relative(i.y_axis.y, pitch);
1536            if question.starts_with("three") {
1537                assert!(about_y.abs() < 6.1e-7, "{question}: {about_y:e}");
1538            } else if of_length {
1539                assert!((0.002..0.011).contains(&about_y), "{question}: {about_y:e}");
1540            }
1541        }
1542    }
1543
1544    /// The probes' fixed ids, as `conventions.py` writes them.
1545    fn conventions_id(n: u32) -> &'static str {
1546        match n {
1547            1 => "00000000-0000-4000-8000-000000000001",
1548            2 => "00000000-0000-4000-8000-000000000002",
1549            _ => unreachable!("only the probes' nose and tube are asked for"),
1550        }
1551    }
1552
1553    fn clusters() -> Value {
1554        let text = include_str!("../../../validation/fixtures/ork/openrocket-clusters.json");
1555        serde_json::from_str(text).expect("the committed record is JSON")
1556    }
1557
1558    /// Every tube of every cluster OpenRocket 24.12 was asked about sits where OpenRocket puts it,
1559    /// to 1e-15 m: each of its fourteen patterns at scale 1, a scale, a rotation, a radial offset,
1560    /// and all three at once, with OpenRocket's `(y, z)` read as hpr's `(x, y)` (ADR-075). A
1561    /// pattern OpenRocket has no name for is one tube, as OpenRocket reads it, with a warning.
1562    #[test]
1563    fn every_tube_of_a_cluster_is_where_openrocket_puts_it() {
1564        let record = clusters();
1565        let patterns = record["patterns"].as_object().expect("patterns");
1566        assert_eq!(patterns.len(), 14);
1567        for name in patterns.keys() {
1568            assert!(
1569                record["probes"][format!("{name} at scale 1")].is_object(),
1570                "no probe of {name}"
1571            );
1572        }
1573        for (question, probe) in record["probes"].as_object().expect("probes") {
1574            let (layout, warnings) = hpr(probe);
1575            let unnamed = question == "a pattern OpenRocket has no name for";
1576            assert_eq!(
1577                warnings.len(),
1578                usize::from(unnamed),
1579                "{question}: {warnings:?}"
1580            );
1581            if unnamed {
1582                assert!(
1583                    warnings[0].contains("is not one of OpenRocket's cluster patterns"),
1584                    "{warnings:?}"
1585                );
1586            }
1587            for (id, tube) in probe["tubes"].as_object().expect("tubes") {
1588                let (_, placed) = layout.find(id).expect("the tube");
1589                let Part::InnerTube(inner) = &placed.part else {
1590                    panic!("{question}: {id} is not an inner tube");
1591                };
1592                let [x, y] = placed.part.axis_offset_m();
1593                let ours: Vec<[f64; 2]> = inner
1594                    .tubes_m()
1595                    .expect("finite")
1596                    .iter()
1597                    .map(|[u, v]| [x + u, y + v])
1598                    .collect();
1599                let theirs: Vec<[f64; 2]> = tube["instance_offsets_m"]
1600                    .as_array()
1601                    .expect("offsets")
1602                    .iter()
1603                    .map(|c| [c[1].as_f64().expect("y"), c[2].as_f64().expect("z")])
1604                    .collect();
1605                assert_eq!(ours.len(), theirs.len(), "{question}");
1606                assert_eq!(
1607                    tube["count"].as_u64(),
1608                    Some(ours.len() as u64),
1609                    "{question}"
1610                );
1611                for (ours, theirs) in ours.iter().zip(&theirs) {
1612                    let apart = (ours[0] - theirs[0]).hypot(ours[1] - theirs[1]);
1613                    assert!(apart <= 1e-15, "{question}: {ours:?} vs {theirs:?}");
1614                }
1615            }
1616        }
1617    }
1618
1619    /// A cluster weighs what OpenRocket 24.12 says, but for the spread of its own tubes about the
1620    /// cluster's axis. The mass and center agree to 1e-12 on every probe: every tube counted, an
1621    /// engine block inside counted once in each tube, a mass override on the cluster its whole
1622    /// mass, and an automatic ring's bore the tube's own radius, as if the cluster were one tube on
1623    /// the axis. OpenRocket weighs a cluster's tubes stacked on its axis (a 3-ring at scale 1 and at
1624    /// 1.5 have the same inertias), while hpr places each where it is: so on every cluster on the
1625    /// body's axis, hpr's roll inertia is OpenRocket's plus `Σ m |c|²` over the tubes, `c` each
1626    /// tube's offset and `m` its mass, and the pitch inertia (the mean across the axis) plus half
1627    /// of it, to 1e-12, worked here by hand (ADR-075). Off the axis the roll rule still holds for a
1628    /// cluster, but three departures are left, pinned as measured, less the spread: a lone tube
1629    /// 10 mm off the axis, whose offset OpenRocket leaves out of both inertias, and the pitch of
1630    /// two clusters off the axis, 0.015% and 0.021%, which hpr has not traced.
1631    #[test]
1632    fn a_cluster_weighs_as_openrocket_s_but_for_its_tubes_spread() {
1633        const OFF_AXIS: [(&str, [f64; 2]); 3] = [
1634            (
1635                "an unclustered tube 10 mm off the axis at 30",
1636                [0.00303, 0.000164],
1637            ),
1638            ("a 2-row 10 mm off the axis at 30", [0.0, 0.00015]),
1639            (
1640                "a 3-ring 10 mm off the axis at 30, turned 20",
1641                [0.0, 0.000206],
1642            ),
1643        ];
1644        let record = clusters();
1645        for (question, probe) in record["probes"].as_object().expect("probes") {
1646            let (ours, theirs, _) = both(probe);
1647            assert!(
1648                relative(ours[0], theirs[0]).abs() <= 1e-12,
1649                "{question}: mass"
1650            );
1651            assert!((ours[1] - theirs[1]).abs() <= 1e-12, "{question}: center");
1652            let (layout, _) = hpr(probe);
1653            let mut spread = 0.0;
1654            for id in probe["tubes"].as_object().expect("tubes").keys() {
1655                let (_, placed) = layout.find(id).expect("the tube");
1656                let Part::InnerTube(inner) = &placed.part else {
1657                    panic!("{question}: {id} is not an inner tube");
1658                };
1659                let tubes = inner.tubes_m().expect("finite");
1660                let each_kg = placed.own.mass_kg / tubes.len() as f64;
1661                spread += tubes
1662                    .iter()
1663                    .map(|[u, v]| each_kg * (u * u + v * v))
1664                    .sum::<f64>();
1665            }
1666            // Roll and pitch less the spread, against OpenRocket's.
1667            let found = [
1668                relative(ours[2] - spread, theirs[2]),
1669                relative(ours[3] - spread / 2.0, theirs[3]),
1670            ];
1671            let pinned = OFF_AXIS
1672                .iter()
1673                .find(|(q, _)| q == question)
1674                .map_or([0.0, 0.0], |(_, pinned)| *pinned);
1675            for (k, (found, pinned)) in found.iter().zip(pinned).enumerate() {
1676                let bound = if pinned == 0.0 {
1677                    1e-12
1678                } else {
1679                    5e-3 * pinned.abs()
1680                };
1681                assert!(
1682                    (found - pinned).abs() <= bound,
1683                    "{question}: inertia {k} less the spread is {found:e}, not {pinned:e}"
1684                );
1685            }
1686        }
1687    }
1688
1689    /// The cluster record was written by the script it names, from OpenRocket 24.12, and every
1690    /// probe in it is one the tests above read, which read them all.
1691    #[test]
1692    fn the_cluster_record_is_openrocket_s() {
1693        let record = clusters();
1694        assert_eq!(record["openrocket"], "24.12");
1695        assert_eq!(
1696            record["source"],
1697            "validation/oracles/openrocket/clusters.py"
1698        );
1699        assert!(record["probes"].as_object().expect("probes").len() >= 22);
1700    }
1701
1702    /// The probes of an automatic outer radius inside a nose cone or transition, and of an inner
1703    /// tube written `auto` (M2.2e7).
1704    const BORES: [&str; 13] = [
1705        "a nose holding a coupler of automatic radius at its bottom",
1706        "an ogive nose holding a coupler of automatic radius at its bottom",
1707        "a nose holding a coupler of automatic radius past its base",
1708        "a nose with a shoulder, holding a coupler of automatic radius past its base",
1709        "a nose holding a long coupler of automatic radius from its middle",
1710        "a nose holding an engine block of automatic radius",
1711        "a nose holding a centering ring and a bulkhead of automatic radius",
1712        "a nose holding a coupler of automatic radius and a wall thicker than its bore",
1713        "a nose holding a coupler of automatic radius with a mass inside",
1714        "a transition holding a coupler of automatic radius",
1715        "a tube holding a coupler of automatic radius and a wall thicker than its bore",
1716        "a tube holding an inner tube of automatic radius",
1717        "a nose holding an inner tube of automatic radius",
1718    ];
1719
1720    /// The one bore probe hpr refuses: a coupler at the tip, where the cone's wall meets the axis.
1721    const BORE_REFUSED: &str = "a nose holding a coupler of automatic radius at its tip";
1722
1723    /// M2.2e7 ([ADR-096][adr-096]): inside a hollow nose cone or transition, an automatic outer
1724    /// radius is the parent's outer radius at the part's narrower end less its wall, the shoulder
1725    /// left out; a wall thicker than that is the tube solid, in a body tube too; and an
1726    /// `innertube` written `auto` keeps 9.5 mm, with no warning, as OpenRocket reads it. So every
1727    /// coupler, engine block, inner tube, ring and bulkhead weighs OpenRocket's mass to 1e-14 at
1728    /// OpenRocket's station to 1e-15.
1729    ///
1730    /// The nose cones and the transition carry the gaps their walls already had: 5.1e-7 of the
1731    /// cone's mass, 3.9e-5 of the ogive's and 2.8e-6 of the transition's, and centers within
1732    /// 2.5e-6 m (hpr measures a wall normal to the surface). One mass component is pinned apart: packed with an
1733    /// automatic radius inside the coupler, OpenRocket shortens it to 8.49 mm, keeping the volume
1734    /// of its 12.5 mm by 50 mm default, and hpr keeps its written 50 mm, 20.75 mm further aft of
1735    /// its center ([#186][i186]). A coupler at the tip, where the wall meets the axis, has no
1736    /// radius: OpenRocket weighs it as nothing, and hpr refuses the design.
1737    ///
1738    /// [adr-096]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-096-fin-fillets-and-an-automatic-radius-inside-a-nose-cone-read-as-openrocket-reads-them-2026-09-28
1739    /// [i186]: https://github.com/nrdptel/hpr-sim/issues/186
1740    #[test]
1741    fn an_automatic_radius_inside_a_nose_reads_as_openrocket_does() {
1742        let record = record();
1743        for question in BORES {
1744            let probe = probe(&record, question);
1745            let (layout, warnings) = hpr(probe);
1746            assert!(warnings.is_empty(), "{question}: {warnings:?}");
1747            for part in probe["parts"].as_array().expect("parts") {
1748                let class = part["class"].as_str().expect("a class");
1749                if matches!(class, "Rocket" | "AxialStage") {
1750                    continue;
1751                }
1752                let id = part["id"].as_str().expect("an id");
1753                let (_, placed) = layout
1754                    .find(id)
1755                    .unwrap_or_else(|| panic!("{question}: no part {id} in hpr"));
1756                let mass = part["mass_kg"].as_f64().expect("a mass");
1757                let station = part["cm_x_m"].as_f64().expect("a station");
1758                let (mass_bound, station_gap, station_bound) = match class {
1759                    "NoseCone" | "Transition" => (5e-5, 0.0, 5e-6),
1760                    "MassComponent" => (1e-15, 0.020755, 1e-6),
1761                    _ => (1e-14, 0.0, 1e-15),
1762                };
1763                let found = relative(placed.own.mass_kg, mass);
1764                assert!(
1765                    found.abs() <= mass_bound,
1766                    "{question}: {class} {id} is {found:e} from OpenRocket's mass"
1767                );
1768                let apart_m = -placed.own.cg_m.z - station;
1769                assert!(
1770                    (apart_m - station_gap).abs() <= station_bound,
1771                    "{question}: {class} {id} is {apart_m:e} m from OpenRocket's station"
1772                );
1773            }
1774        }
1775
1776        let probe = probe(&record, BORE_REFUSED);
1777        let coupler = probe["parts"]
1778            .as_array()
1779            .expect("parts")
1780            .iter()
1781            .find(|part| part["class"] == "TubeCoupler")
1782            .expect("the coupler");
1783        assert_eq!(coupler["mass_kg"].as_f64(), Some(0.0));
1784        let document = probe["document"].as_str().expect("the probe's document");
1785        let read = ork::read(document.as_bytes()).expect("a probe reads");
1786        let error = ork::rocket(&read.value.document)
1787            .value
1788            .layout()
1789            .expect_err("a tube of no radius");
1790        assert!(
1791            matches!(
1792                &error,
1793                hpr_design::DesignError::InComponent { source, .. }
1794                    if matches!(
1795                        **source,
1796                        hpr_design::DesignError::Domain { what: "outer radius", value }
1797                            if value == 0.0
1798                    )
1799            ),
1800            "{error:?}"
1801        );
1802    }
1803
1804    /// M2.2e8's probes: tube fin sets on a 50 mm or a 20 mm tube, most written `auto` (ADR-098).
1805    const TUBE_FINS: [&str; 19] = [
1806        "a tube and 1 tube fins of automatic radius",
1807        "a tube and 2 tube fins of automatic radius",
1808        "a tube and 3 tube fins of automatic radius",
1809        "a tube and 4 tube fins of automatic radius",
1810        "a tube and 5 tube fins of automatic radius",
1811        "a tube and 6 tube fins of automatic radius",
1812        "a tube and 8 tube fins of automatic radius",
1813        "a tube and 9 tube fins of automatic radius",
1814        "a tube and 12 tube fins of automatic radius",
1815        "a tube and 20 tube fins of automatic radius",
1816        "a tube and 6 tube fins of a stated radius",
1817        "a tube and 6 tube fins of automatic radius and a wall thicker than it",
1818        "a tube and 6 tube fins of a stated radius, offset from the body",
1819        "a tube of automatic radius and 4 tube fins of automatic radius",
1820        "a tube and 12 tube fins of a stated radius",
1821        "a tube and 100 tube fins of automatic radius",
1822        "a 20 mm tube and 1 tube fins of automatic radius",
1823        "a 20 mm tube and 2 tube fins of automatic radius",
1824        "a 20 mm tube and 5 tube fins of automatic radius",
1825    ];
1826
1827    /// A tube fin set written `auto` has the radius OpenRocket 24.12 works out, the ring closed
1828    /// around the body for three tubes or more and the body's radius for one or two; a wall thicker
1829    /// than it is cut to it; more than 8 tubes are 8; and the tubes weigh what OpenRocket's do,
1830    /// centered where its are (ADR-098). A written radial offset moves nothing in OpenRocket, and
1831    /// hpr does not read one. Two inertias depart, and hpr keeps its hollow tubes at `R_b + r`:
1832    /// OpenRocket's roll exceeds what any mass inside the ring can have, `(R_b + 2r)²` a unit of
1833    /// mass, and its pitch is `N` times one tube's own, with no term for the ring's spread.
1834    #[test]
1835    fn a_tube_fin_sets_automatic_radius_reads_as_openrocket_does() {
1836        let record = record();
1837        for question in TUBE_FINS {
1838            let probe = probe(&record, question);
1839            let (layout, warnings) = hpr(probe);
1840            // More than 8 tubes are read as 8 out loud; an offset from the body is read as none,
1841            // out loud, which is also what OpenRocket's numbers show.
1842            let capped = ["9", "12", "20", "100"]
1843                .iter()
1844                .any(|n| question.contains(&format!(" and {n} tube fins")));
1845            let offset = question.contains("offset");
1846            assert_eq!(
1847                warnings.len(),
1848                usize::from(capped || offset),
1849                "{question}: {warnings:?}"
1850            );
1851            let said = if offset {
1852                "read sitting on it"
1853            } else {
1854                "reads at most 8"
1855            };
1856            assert!(
1857                warnings.iter().all(|w| w.contains(said)),
1858                "{question}: {warnings:?}"
1859            );
1860            for part in probe["parts"].as_array().expect("parts") {
1861                let class = part["class"].as_str().expect("a class");
1862                if matches!(class, "Rocket" | "AxialStage") {
1863                    continue;
1864                }
1865                let id = part["id"].as_str().expect("an id");
1866                let (_, placed) = layout
1867                    .find(id)
1868                    .unwrap_or_else(|| panic!("{question}: no part {id} in hpr"));
1869                // A nose cone's shell is the bore probes' bound (the nose is only on one probe).
1870                let (mass_bound, station_bound) = match class {
1871                    "NoseCone" => (5e-5, 5e-6),
1872                    _ => (1e-14, 1e-15),
1873                };
1874                let mass = part["mass_kg"].as_f64().expect("a mass");
1875                let found = relative(placed.own.mass_kg, mass);
1876                assert!(
1877                    found.abs() <= mass_bound,
1878                    "{question}: {class} is {found:e} from OpenRocket's mass"
1879                );
1880                let apart_m = -placed.own.cg_m.z - part["cm_x_m"].as_f64().expect("a station");
1881                assert!(
1882                    apart_m.abs() <= station_bound,
1883                    "{question}: {class} is {apart_m:e} m from OpenRocket's"
1884                );
1885            }
1886            let (id, theirs) = probe["tube_fins"]
1887                .as_object()
1888                .and_then(|sets| sets.iter().next())
1889                .expect("one tube fin set");
1890            let (_, placed) = layout.find(id).expect("the tube fin set");
1891            let Part::TubeFinSet(ours) = &placed.part else {
1892                panic!("{question}: {:?}", placed.part);
1893            };
1894            let number = |key: &str| theirs[key].as_f64().expect(key);
1895            assert_eq!(
1896                theirs["automatic"].as_bool(),
1897                Some(!question.contains("stated"))
1898            );
1899            assert_eq!(
1900                Some(u64::from(ours.count)),
1901                theirs["count"].as_u64(),
1902                "{question}"
1903            );
1904            let r = number("outer_radius_m");
1905            assert!(
1906                relative(ours.outer_radius_m, r).abs() <= 1e-15,
1907                "{question}: radius {} against OpenRocket's {r}",
1908                ours.outer_radius_m
1909            );
1910            assert!(
1911                relative(ours.thickness_m, number("thickness_m")).abs() <= 1e-15,
1912                "{question}: wall {} against {}",
1913                ours.thickness_m,
1914                number("thickness_m")
1915            );
1916            let body_radius_m = number("body_radius_m");
1917            assert_eq!(placed.body_radius_m, Some(body_radius_m), "{question}");
1918            // Per unit mass, about the set's center: one tube's own inertias, across and about its
1919            // axis, and how far its axis is from the body's.
1920            let (r_i, length_m) = (number("inner_radius_m"), 0.1);
1921            let across = (r * r + r_i * r_i) / 4.0 + length_m * length_m / 12.0;
1922            let about = (r * r + r_i * r_i) / 2.0;
1923            let d2 = (body_radius_m + r).powi(2);
1924            let unit = |i: f64| i / placed.own.mass_kg;
1925            let inertia = &placed.own.inertia_kg_m2;
1926            // OpenRocket's pitch is each tube's own inertia across it, times the count, with no term
1927            // for the tubes' distance from the axis: a second departure (ADR-098).
1928            let their_pitch = number("longitudinal_unit_inertia_m2");
1929            let n = f64::from(ours.count);
1930            assert!(
1931                relative(their_pitch, n * across).abs() <= 1e-14,
1932                "{question}: {their_pitch}"
1933            );
1934            if ours.count == 1 {
1935                // One tube: its center `R_b + r` off the axis, and its own roll, in both codes.
1936                let [_, y, z] =
1937                    [0, 1, 2].map(|k| theirs["component_cg_xyz_m"][k].as_f64().expect("a center"));
1938                let off = y.hypot(z);
1939                assert!(
1940                    (off - (body_radius_m + r)).abs() <= 1e-15,
1941                    "{question}: {off}"
1942                );
1943                let ours_off = placed.own.cg_m.x.hypot(placed.own.cg_m.y);
1944                assert!(
1945                    (ours_off - off).abs() <= 1e-15,
1946                    "{question}: hpr's center {ours_off}"
1947                );
1948                let roll = number("rotational_unit_inertia_m2");
1949                assert!(relative(roll, about).abs() <= 1e-15, "{question}: {roll}");
1950                assert!(
1951                    relative(unit(inertia.z_axis.z), roll).abs() <= 1e-14,
1952                    "{question}"
1953                );
1954                // And its own pitch, about its own center: the one count where the two agree.
1955                assert!(
1956                    relative(unit(inertia.x_axis.x), across).abs() <= 1e-13,
1957                    "{question}"
1958                );
1959                assert!(relative(their_pitch, across).abs() <= 1e-14, "{question}");
1960            } else {
1961                // A ring: hpr's tubes at `R_b + r`, which OpenRocket's roll exceeds past any mass
1962                // inside `R_b + 2r`.
1963                assert!(
1964                    relative(unit(inertia.z_axis.z), about + d2).abs() <= 1e-13,
1965                    "{question}"
1966                );
1967                let bound = (body_radius_m + 2.0 * r).powi(2);
1968                let roll = number("rotational_unit_inertia_m2");
1969                assert!(
1970                    roll > bound,
1971                    "{question}: OpenRocket's {roll} within {bound}"
1972                );
1973                if ours.count >= 3 {
1974                    let pitch = unit(inertia.x_axis.x);
1975                    assert!(
1976                        relative(pitch, across + d2 / 2.0).abs() <= 1e-13,
1977                        "{question}: {pitch}"
1978                    );
1979                    assert!(
1980                        relative(unit(inertia.y_axis.y), pitch).abs() <= 1e-13,
1981                        "{question}"
1982                    );
1983                    assert!(
1984                        their_pitch > pitch * 1.2,
1985                        "{question}: {their_pitch} against hpr's {pitch}"
1986                    );
1987                }
1988            }
1989        }
1990    }
1991
1992    /// The record was written by the script it names, from OpenRocket 24.12 with no default
1993    /// materials saved in its preferences, and every probe it holds is one a test here reads: a
1994    /// probe added to the script and not to a test would be a question nobody checks the answer
1995    /// to.
1996    #[test]
1997    fn every_probe_is_checked() {
1998        let record = record();
1999        assert_eq!(record["openrocket"], "24.12");
2000        assert_eq!(
2001            record["source"],
2002            "validation/oracles/openrocket/conventions.py"
2003        );
2004        assert_eq!(record["saved_default_materials"], serde_json::json!({}));
2005        let mut read: Vec<&str> = READINGS.to_vec();
2006        read.extend(PRECEDENCE.iter().map(|(question, _)| *question));
2007        read.extend(DISAGREEING.iter().map(|(question, _)| *question));
2008        read.extend(INERTIA.iter().map(|(question, _)| *question));
2009        read.extend(ALONE.iter().map(|(question, _)| *question));
2010        read.extend(OLD_FLAG.iter().map(|(question, _)| *question));
2011        read.extend(BORES);
2012        read.push(BORE_REFUSED);
2013        read.extend(TUBE_FINS);
2014        let probes: Vec<&str> = record["probes"]
2015            .as_object()
2016            .expect("probes")
2017            .keys()
2018            .map(String::as_str)
2019            .collect();
2020        for question in &probes {
2021            assert!(
2022                read.contains(question),
2023                "no test reads the probe {question:?}"
2024            );
2025        }
2026        for question in &read {
2027            assert!(probes.contains(question), "no probe asks {question:?}");
2028        }
2029    }
2030
2031    /// Where hpr puts a tube fin set, against OpenRocket 24.12 (Loft lesson L19, ADR-102).
2032    struct TubeFinsSeen {
2033        probe: String,
2034        mach: f64,
2035        /// OpenRocket's set slope over hpr's.
2036        slope_ratio: f64,
2037        /// OpenRocket's set slope over `N π d²/A_ref`, the long-ring limit of `N` isolated rings.
2038        over_long_ring_limit: f64,
2039        /// hpr's rocket center of pressure less OpenRocket's, calibres.
2040        gap_cal: f64,
2041        /// How far hpr's center moves, calibres, with the tubes at OpenRocket's center alone, and
2042        /// with OpenRocket's slope alone.
2043        center_only_cal: f64,
2044        slope_only_cal: f64,
2045    }
2046
2047    /// `openrocket-tube-fin-aero.json`, held to the scripts that wrote it.
2048    fn tube_fin_record() -> Value {
2049        let text = include_str!("../../../validation/fixtures/ork/openrocket-tube-fin-aero.json");
2050        let record: Value = serde_json::from_str(text).expect("the committed record is JSON");
2051        for (name, bytes) in [
2052            (
2053                "tube_fin_aero.py",
2054                include_bytes!("../../../validation/oracles/openrocket/tube_fin_aero.py")
2055                    .as_slice(),
2056            ),
2057            (
2058                "automatic_radius.py",
2059                include_bytes!("../../../validation/oracles/openrocket/automatic_radius.py")
2060                    .as_slice(),
2061            ),
2062        ] {
2063            assert_eq!(
2064                record["inputs_sha256"][name],
2065                crate::real_flight::sha256_hex(bytes).as_str(),
2066                "{name} changed since the record was written: rerun it"
2067            );
2068        }
2069        record
2070    }
2071
2072    /// Every probe of `record` at each Mach number below the tube-fin model's limit, with the
2073    /// checks that hold whatever the two codes' models: the same geometry, the same nose and body,
2074    /// and OpenRocket's rule as its record shows it.
2075    fn tube_fins_seen(record: &Value) -> Vec<TubeFinsSeen> {
2076        let number = |value: &Value| value.as_f64().expect("a number");
2077        let mut seen = Vec::new();
2078        let probes = record["probes"].as_object().expect("probes");
2079        assert_eq!(probes.len(), 14);
2080        for (name, probe) in probes {
2081            let document = probe["document"].as_str().expect("the probe's document");
2082            let read = ork::read(document.as_bytes()).expect("a probe reads");
2083            let layout = ork::rocket(&read.value.document)
2084                .value
2085                .layout()
2086                .expect("a probe lays out");
2087            let model = hpr_aero::AeroModel::new(&layout).expect("hpr models the probe");
2088            let [set] = model.tube_fin_sets() else {
2089                panic!("{name}: not one tube fin set in hpr");
2090            };
2091            let mut slopes = Vec::new();
2092            for asked in probe["machs"].as_array().expect("machs") {
2093                let mach = number(&asked["mach"]);
2094                let cal = number(&asked["reference_length_m"]);
2095                let components = asked["components"].as_object().expect("components");
2096                let of = |class: &str| {
2097                    let mut found = components.values().filter(|c| c["class"] == class);
2098                    let one = found.next().expect("the component");
2099                    assert!(found.next().is_none(), "{name}: two of {class}");
2100                    one
2101                };
2102                let rocket = of("Rocket");
2103                let tubes = &components[&set.id];
2104                assert_eq!(tubes["class"], "TubeFinSet", "{name}");
2105                // The same rocket: reference area, the tubes' count, radii, leading edge and
2106                // length.
2107                let (outer, inner) = (
2108                    number(&tubes["outer_radius_m"]),
2109                    number(&tubes["inner_radius_m"]),
2110                );
2111                assert!(
2112                    (set.mean_diameter_m - (outer + inner)).abs() <= 1e-12,
2113                    "{name}"
2114                );
2115                let axis = number(&tubes["body_radius_m"]) + outer;
2116                assert!((set.axis_radius_m - axis).abs() <= 1e-12, "{name}");
2117                assert!(
2118                    (model.reference_area_m2() / number(&asked["reference_area_m2"]) - 1.0).abs()
2119                        <= 1e-12,
2120                    "{name}"
2121                );
2122                assert_eq!(
2123                    tubes["count"].as_u64(),
2124                    Some(u64::from(set.count)),
2125                    "{name}"
2126                );
2127                let fore_m = number(&tubes["leading_edge_x_m"]);
2128                let length_m = number(&tubes["length_m"]);
2129                assert!((set.fore_station_m - fore_m).abs() <= 1e-12, "{name}");
2130                assert!((set.length_m - length_m).abs() <= 1e-12, "{name}");
2131                // OpenRocket's rule, as its record shows it: one slope at every Mach number, and
2132                // the center a quarter of the length aft of the leading edge up to Mach 0.5, at
2133                // the leading edge from Mach 0.6.
2134                let (theirs, their_x) = (number(&tubes["cna_per_rad"]), number(&tubes["cp_x_m"]));
2135                slopes.push(theirs);
2136                let quarter = if mach <= 0.5 { 0.25 } else { 0.0 };
2137                assert!(
2138                    ((their_x - fore_m) / length_m - quarter).abs() <= 1e-9,
2139                    "{name} at Mach {mach}"
2140                );
2141                if mach >= hpr_aero::tube_fins::TUBE_FIN_MACH_LIMIT {
2142                    continue;
2143                }
2144                let (ours, our_x) = set.loading(mach).expect("below the limit");
2145                let whole = model
2146                    .normal_force(&hpr_aero::Flow::new(mach, 0.0, 0.0))
2147                    .expect("hpr's normal force");
2148                let our_cp = whole.cp_station_m.expect("a center of pressure");
2149                let (their_whole, their_cp) =
2150                    (number(&rocket["cna_per_rad"]), number(&rocket["cp_x_m"]));
2151                // The rest of the rocket, the nose and the body, is nearly the same in both
2152                // codes: OpenRocket gives the body tube a slope of 0.003 at its middle, hpr none,
2153                // which puts their center 0.015 calibres aft of hpr's nose's.
2154                let rest = whole.slope_per_rad - ours;
2155                let rest_moment = whole.moment_slope_m - ours * our_x;
2156                let (their_rest, their_rest_moment) = (
2157                    their_whole - theirs,
2158                    their_whole * their_cp - theirs * their_x,
2159                );
2160                assert!(
2161                    (rest - their_rest).abs() <= 0.005,
2162                    "{name}: {rest} {their_rest}"
2163                );
2164                assert!(
2165                    (rest_moment / rest - their_rest_moment / their_rest).abs() <= 0.02 * cal,
2166                    "{name}"
2167                );
2168                let center = |slope: f64, x: f64| (rest_moment + slope * x) / (rest + slope);
2169                // So the gap is the tubes': hpr with OpenRocket's slope and center for them is
2170                // OpenRocket's rocket.
2171                assert!(
2172                    (center(theirs, their_x) - their_cp).abs() <= 0.01 * cal,
2173                    "{name} at Mach {mach}: {}",
2174                    (center(theirs, their_x) - their_cp) / cal
2175                );
2176                let pi_d2 = std::f64::consts::PI * set.mean_diameter_m * set.mean_diameter_m;
2177                seen.push(TubeFinsSeen {
2178                    probe: name.clone(),
2179                    mach,
2180                    slope_ratio: theirs / ours,
2181                    over_long_ring_limit: theirs
2182                        / (f64::from(set.count) * pi_d2 / model.reference_area_m2()),
2183                    gap_cal: (our_cp - their_cp) / cal,
2184                    center_only_cal: (center(ours, their_x) - our_cp) / cal,
2185                    slope_only_cal: (center(theirs, our_x) - our_cp) / cal,
2186                });
2187            }
2188            assert!(
2189                slopes.iter().all(|s| (s / slopes[0] - 1.0).abs() <= 1e-12),
2190                "{name}: {slopes:?}"
2191            );
2192        }
2193        seen
2194    }
2195
2196    /// hpr's rocket center of pressure less OpenRocket's on each probe, calibres, at Mach 0.05,
2197    /// 0.3, 0.5, 0.6 and 0.75, as measured when ADR-102 was written.
2198    const TUBE_FIN_GAPS_CAL: [(&str, [f64; 5]); 14] = [
2199        (
2200            "3 tubes of 6 mm radius",
2201            [-2.7269, -2.7278, -2.7294, -2.2099, -2.2126],
2202        ),
2203        (
2204            "4 tubes of 6 mm radius",
2205            [-2.5611, -2.5624, -2.5648, -2.0028, -2.0068],
2206        ),
2207        (
2208            "5 tubes of 6 mm radius",
2209            [-2.3960, -2.3976, -2.4007, -1.8097, -1.8147],
2210        ),
2211        (
2212            "6 tubes of 6 mm radius",
2213            [-2.2474, -2.2493, -2.2529, -1.6410, -1.6468],
2214        ),
2215        (
2216            "8 tubes of 6 mm radius",
2217            [-2.0048, -2.0070, -2.0115, -1.3712, -1.3784],
2218        ),
2219        (
2220            "six touching tubes",
2221            [-1.0427, -1.0501, -1.0647, -0.3634, -0.3867],
2222        ),
2223        (
2224            "six touching tubes 0.025 m long",
2225            [-0.4262, -0.4237, -0.4197, -0.1844, -0.1834],
2226        ),
2227        (
2228            "six touching tubes 0.05 m long",
2229            [-0.7755, -0.7816, -0.7937, -0.3301, -0.3502],
2230        ),
2231        (
2232            "six touching tubes 0.15 m long",
2233            [-1.7202, -1.7285, -1.7448, -0.3262, -0.3518],
2234        ),
2235        (
2236            "six touching tubes 0.3 m long",
2237            [-2.9728, -2.9815, -2.9984, -0.1432, -0.1699],
2238        ),
2239        (
2240            "six touching tubes with a 0.001 m wall",
2241            [-1.0252, -1.0323, -1.0463, -0.3486, -0.3710],
2242        ),
2243        (
2244            "six touching tubes with a 0.003 m wall",
2245            [-0.8964, -0.9026, -0.9150, -0.2324, -0.2521],
2246        ),
2247        (
2248            "six tubes of 0.004 m radius",
2249            [-2.8034, -2.8039, -2.8048, -2.3076, -2.3091],
2250        ),
2251        (
2252            "six tubes of 0.008 m radius",
2253            [-1.7089, -1.7124, -1.7194, -1.0576, -1.0688],
2254        ),
2255    ];
2256
2257    /// Loft lesson L19: Loft put a tube fin set's center of pressure about 0.9 calibres forward of
2258    /// OpenRocket's. hpr's is further forward still, and the lesson's bar, a quarter calibre, is
2259    /// not met: this test measures the gap on OpenRocket 24.12's 14 probes, at five Mach numbers,
2260    /// and on its *Tube fin rocket*, and pins every one, so a change to either code shows
2261    /// (ADR-102).
2262    ///
2263    /// The whole gap is the tubes': the probes' geometry, nose and body agree, and with
2264    /// OpenRocket's slope and center for the tubes hpr's rocket is OpenRocket's. Both of
2265    /// OpenRocket's differ from hpr's cited ring wing (ADR-099). Its slope is 1.26 to 1.86 times
2266    /// hpr's, and 1.20 to 1.86 times the long-ring limit of as many isolated thin rings,
2267    /// `N π d²/A_ref`; its center is a quarter of the tube's length aft of the leading edge up to
2268    /// Mach 0.5, and the leading edge from Mach 0.6, a jump its maintainers call a bug and fixed
2269    /// after 24.12 (openrocket#3235).
2270    #[test]
2271    fn tube_fin_cp_against_the_oracle_measured_and_pinned() {
2272        let record = tube_fin_record();
2273        let seen = tube_fins_seen(&record);
2274        let close = |found: f64, pinned: f64, what: &str| {
2275            assert!(
2276                (found - pinned).abs() <= 5e-4,
2277                "{what}: {found} against {pinned}"
2278            );
2279        };
2280        // Every gap, probe by probe and Mach number by Mach number.
2281        assert_eq!(seen.len(), TUBE_FIN_GAPS_CAL.len() * 5);
2282        for (probe, gaps) in TUBE_FIN_GAPS_CAL {
2283            let found: Vec<&TubeFinsSeen> = seen.iter().filter(|s| s.probe == probe).collect();
2284            assert_eq!(found.len(), gaps.len(), "{probe}");
2285            for (s, gap) in found.iter().zip(gaps) {
2286                close(s.gap_cal, gap, &format!("{probe} at Mach {}", s.mach));
2287            }
2288        }
2289        let range = |values: Vec<f64>| {
2290            let least = values.iter().copied().fold(f64::INFINITY, f64::min);
2291            (
2292                least,
2293                values.iter().copied().fold(f64::NEG_INFINITY, f64::max),
2294            )
2295        };
2296        // OpenRocket's slope against hpr's, and against the long-ring limit of isolated rings.
2297        let (least, most) = range(seen.iter().map(|s| s.slope_ratio).collect());
2298        close(least, 1.2631, "the least slope ratio");
2299        close(most, 1.8638, "the largest slope ratio");
2300        let (least, most) = range(seen.iter().map(|s| s.over_long_ring_limit).collect());
2301        close(least, 1.2038, "the least share of the long-ring limit");
2302        close(most, 1.8556, "the largest share of the long-ring limit");
2303        // OpenRocket's slope per tube does not change with the count: it models no interference
2304        // that does (ADR-102).
2305        let per_tube: Vec<f64> = [3, 4, 5, 6, 8]
2306            .iter()
2307            .map(|count| {
2308                let asked = &record["probes"][format!("{count} tubes of 6 mm radius")]["machs"][0];
2309                let tubes = asked["components"]
2310                    .as_object()
2311                    .and_then(|all| all.values().find(|c| c["class"] == "TubeFinSet"))
2312                    .expect("the tubes");
2313                tubes["cna_per_rad"].as_f64().expect("a slope") / f64::from(*count)
2314            })
2315            .collect();
2316        assert!(
2317            per_tube
2318                .iter()
2319                .all(|s| (s / per_tube[0] - 1.0).abs() <= 1e-15),
2320            "{per_tube:?}"
2321        );
2322        // On the probe built like the *Tube fin rocket*, at Mach 0.05, what OpenRocket's center
2323        // alone and its slope alone would move hpr's by.
2324        let touching = seen
2325            .iter()
2326            .find(|s| s.probe == "six touching tubes")
2327            .expect("the probe");
2328        close(
2329            touching.center_only_cal,
2330            0.4952,
2331            "OpenRocket's center alone",
2332        );
2333        close(touching.slope_only_cal, 0.5319, "OpenRocket's slope alone");
2334        // Up to Mach 0.5, where OpenRocket takes the quarter length, no probe is within L19's
2335        // bar; from Mach 0.6, where it takes the leading edge, five of the 28 are. That jump is a
2336        // bug in 24.12, fixed after it (openrocket#3235).
2337        let within: Vec<(&str, f64)> = seen
2338            .iter()
2339            .filter(|s| s.gap_cal.abs() <= 0.25)
2340            .map(|s| (s.probe.as_str(), s.mach))
2341            .collect();
2342        assert_eq!(
2343            within,
2344            [
2345                ("six touching tubes 0.025 m long", 0.6),
2346                ("six touching tubes 0.025 m long", 0.75),
2347                ("six touching tubes 0.3 m long", 0.6),
2348                ("six touching tubes 0.3 m long", 0.75),
2349                ("six touching tubes with a 0.003 m wall", 0.6),
2350            ]
2351        );
2352        // OpenRocket's own *Tube fin rocket*: the probe record's example and the flight record's
2353        // are the same OpenRocket answer, and hpr's center, in the flight record, is 1.07
2354        // calibres forward of it at rod clearance.
2355        let flights: Value = serde_json::from_str(include_str!(
2356            "../../../validation/reports/openrocket-flights.json"
2357        ))
2358        .expect("the flight record");
2359        let flight = flights["flights"]
2360            .as_array()
2361            .and_then(|all| all.iter().find(|f| f["design"] == "Tube fin rocket"))
2362            .expect("the Tube fin rocket's flight");
2363        let at = &flight["at_rod_clearance"];
2364        let number = |value: &Value| value.as_f64().expect("a number");
2365        let example = &record["example"]["machs"][0];
2366        assert_eq!(number(&example["mach"]), 0.05);
2367        let rocket = example["components"]
2368            .as_object()
2369            .and_then(|all| all.values().find(|c| c["class"] == "Rocket"))
2370            .expect("the example's rocket");
2371        let theirs = number(&at["openrocket"]["cp_from_nose_m"]);
2372        // The flight record's is OpenRocket's flight data at rod clearance; it is 1.4e-5 m from
2373        // its calculator's answer asked directly.
2374        assert!((number(&rocket["cp_x_m"]) - theirs).abs() <= 1e-4);
2375        assert!(
2376            number(&at["mach"]) < 0.5,
2377            "OpenRocket's quarter-length center"
2378        );
2379        let gap = (number(&at["hpr"]["cp_from_nose_m"]) - theirs)
2380            / number(&at["openrocket"]["reference_length_m"]);
2381        close(gap, -1.0747, "the Tube fin rocket at rod clearance");
2382    }
2383
2384    /// `openrocket-drag-override.json`, held to the scripts that wrote it.
2385    fn drag_override_record() -> Value {
2386        let text = include_str!("../../../validation/fixtures/ork/openrocket-drag-override.json");
2387        let record: Value = serde_json::from_str(text).expect("the committed record is JSON");
2388        for (name, bytes) in [
2389            (
2390                "drag_override.py",
2391                include_bytes!("../../../validation/oracles/openrocket/drag_override.py")
2392                    .as_slice(),
2393            ),
2394            (
2395                "automatic_radius.py",
2396                include_bytes!("../../../validation/oracles/openrocket/automatic_radius.py")
2397                    .as_slice(),
2398            ),
2399        ] {
2400            assert_eq!(
2401                record["inputs_sha256"][name],
2402                crate::real_flight::sha256_hex(bytes).as_str(),
2403                "{name} changed since the record was written: rerun it"
2404            );
2405        }
2406        record
2407    }
2408
2409    /// hpr's zero-lift drag coefficient for a probe document at `mach`, at sea level.
2410    fn probe_drag(document: &str, mach: f64) -> f64 {
2411        // `V/ν` at sea level in the US Standard Atmosphere (a = 340.294 m/s, ν = 1.4607e-5 m²/s).
2412        let reynolds_per_m = mach * 340.294 / 1.4607e-5;
2413        let read = ork::read(document.as_bytes()).expect("a probe reads");
2414        let layout = ork::rocket(&read.value.document)
2415            .value
2416            .layout()
2417            .expect("a probe lays out");
2418        hpr_aero::AeroModel::new(&layout)
2419            .expect("hpr models the probe")
2420            .drag(
2421                &hpr_aero::Flow::axial(mach),
2422                &hpr_aero::DragConditions::coasting(reynolds_per_m),
2423            )
2424            .expect("hpr's drag")
2425            .zero_lift_coefficient
2426    }
2427
2428    /// M4.5h (#165, ADR-167): a part's stated drag coefficient changes hpr's drag as it changes
2429    /// OpenRocket 24.12's, on every probe of `drag_override.py`: the change from the same airframe
2430    /// with nothing stated, hpr's against OpenRocket's.
2431    ///
2432    /// The stated coefficient is the same in both codes, so what is left is the difference between
2433    /// the two codes' own drag of the parts it replaces, and OpenRocket's raising the friction of
2434    /// the parts left when an aft part is stated (ADR-167). Below Mach 0.6, where hpr's buildup and
2435    /// OpenRocket's agree closely on bodies and fins, that is at most 0.0059 here (the probes stating
2436    /// the flare), and no more at any Mach number for all but the nose's and the boattail's;
2437    /// a step's base drag charged to the wrong part would move it by 0.043 or more (the `step`
2438    /// airframe's, the smallest), and a fin set's coefficient counted once by 1.0. The nose's and
2439    /// the boattail's probes are held only up to Mach 0.6: faster, the two codes' own drag of
2440    /// those parts differs by up to 0.21 and 0.061, the buildups' difference, not the setting's.
2441    ///
2442    /// hpr's lugs and buttons drag more than OpenRocket's (0.036 and 0.032 more for the two of
2443    /// each here), so the probes that state them are held to 0.075; one instance counted wrong
2444    /// would move them by 0.5. The tube covering its children is held as a body is, once the lugs'
2445    /// and buttons' own gaps, from those probes, are taken off: a lug or a button left uncovered
2446    /// would move it by 0.03 or more. The stage covering its children is the whole drag in both
2447    /// codes: exactly 0.5. A stage's coefficient alone, and one on a part inside the body, change
2448    /// both codes' drag exactly as much at every Mach number, and the fins' agree within 1e-4.
2449    #[test]
2450    fn a_stated_drag_coefficient_moves_the_drag_as_openrocket_s() {
2451        let record = drag_override_record();
2452        let variants = record["variants"].as_object().expect("variants");
2453        assert_eq!(variants.len(), 25);
2454        // Each probe's Mach numbers, with hpr's drag and the change in hpr's and OpenRocket's.
2455        let mut changes = std::collections::BTreeMap::new();
2456        for (name, variant) in variants {
2457            let airframe = &variants[variant["airframe"].as_str().expect("an airframe")];
2458            let document = variant["document"].as_str().expect("a document");
2459            let plain = airframe["document"].as_str().expect("a document");
2460            let mut rows = Vec::new();
2461            for (asked, plain_asked) in variant["machs"]
2462                .as_array()
2463                .expect("machs")
2464                .iter()
2465                .zip(airframe["machs"].as_array().expect("machs"))
2466            {
2467                let mach = asked["mach"].as_f64().expect("a Mach number");
2468                let cd = asked["rocket"]["cd"].as_f64().expect("a coefficient");
2469                let theirs = cd - plain_asked["rocket"]["cd"].as_f64().expect("a coefficient");
2470                let ours_cd = probe_drag(document, mach);
2471                rows.push((mach, ours_cd, cd, ours_cd - probe_drag(plain, mach), theirs));
2472            }
2473            changes.insert(name.as_str(), rows);
2474        }
2475        let gap_of = |name: &str, at: usize| {
2476            let (_, _, _, ours, theirs) = changes[name][at];
2477            ours - theirs
2478        };
2479        let mut largest_body_gap: f64 = 0.0;
2480        for (name, rows) in &changes {
2481            let exact = ["the stage at 0.5", "the inner tube", "the ring", "the mass"]
2482                .iter()
2483                .any(|part| name.ends_with(&format!("{part} at 0.5")) || name.ends_with(part));
2484            let lugs_or_buttons = name.contains("the lugs") || name.contains("the buttons");
2485            let subsonic_only = name.contains("the nose") || name.contains("the boattail at");
2486            for (at, &(mach, ours_cd, theirs_cd, ours, theirs)) in rows.iter().enumerate() {
2487                let mut gap = ours - theirs;
2488                let what =
2489                    format!("{name} at Mach {mach}: hpr {ours:+.5}, OpenRocket {theirs:+.5}");
2490                if *name == "flare, the stage at 0.5 with its children" {
2491                    assert!((ours_cd - 0.5).abs() <= 1e-12, "{what}: hpr {ours_cd}");
2492                    assert!(
2493                        (theirs_cd - 0.5).abs() <= 1e-12,
2494                        "{what}: OpenRocket {theirs_cd}"
2495                    );
2496                    continue;
2497                }
2498                if *name == "flare, the tube at 0.5 with its children" {
2499                    gap -= gap_of("flare, the lugs at 0.5", at)
2500                        + gap_of("flare, the buttons at 0.5", at);
2501                }
2502                if exact {
2503                    assert!(gap.abs() <= 1e-9, "{what}");
2504                } else if name.contains("the fins") {
2505                    assert!(gap.abs() <= 1e-4, "{what}");
2506                } else if lugs_or_buttons {
2507                    assert!(gap.abs() <= 0.075, "{what}");
2508                } else if mach <= 0.6 || !subsonic_only {
2509                    assert!(gap.abs() <= 0.0065, "{what}");
2510                    largest_body_gap = largest_body_gap.max(gap.abs());
2511                }
2512            }
2513        }
2514        assert!(
2515            (largest_body_gap - 0.0059).abs() < 1e-4,
2516            "{largest_body_gap}"
2517        );
2518    }
2519}