Skip to main content

hpr_aero/
lib.rs

1//! Aerodynamics: Barrowman normal force and center of pressure with extensions, drag buildup,
2//! compressibility and override tables.
3//!
4//! **Guide:** [Aerodynamics][guide-aero]: the models, their sources, how well they are validated
5//! and what they leave out.
6//!
7//! [guide-aero]: https://nrdptel.github.io/hpr-sim/physics/aero.html
8//! [guide-cp]: https://nrdptel.github.io/hpr-sim/physics/aero.html#your-rockets-centre-of-pressure
9//! [guide-flight]: https://nrdptel.github.io/hpr-sim/physics/flight.html#aerodynamics-in-flight
10//! [guide-roll]: https://nrdptel.github.io/hpr-sim/physics/aero.html#roll-forcing-and-damping
11//! [guide-fins-mach]: https://nrdptel.github.io/hpr-sim/physics/aero.html#fins-through-mach-1
12//! [guide-drag-mach]: https://nrdptel.github.io/hpr-sim/physics/aero.html#drag-through-mach-1
13//! [guide-override]: https://nrdptel.github.io/hpr-sim/physics/aero.html#the-normal-force-from-rasaero-ii
14//!
15//! - [`body`]: nose cones, body tubes and transitions: Barrowman's slope and center of pressure.
16//! - [`crossflow`]: body lift, the crossflow's push on a body at an angle of attack: Jorgensen's
17//!   `η C_dn` against the body's fineness and the crossflow Mach number, or Galejs's constant.
18//! - [`fins`]: fin sets: Barrowman's slope with Prandtl–Glauert, the mean aerodynamic chord,
19//!   supersonic linear theory and the transonic join between them, fin-count and roll terms, and
20//!   fin–body interference.
21//! - [`drag`]: the terms of Niskanen's zero-lift drag buildup, and axial drag at an angle of
22//!   attack.
23//! - [`nose_drag`]: the pressure drag of noses, shoulders and steps from rest through Mach 1 to
24//!   supersonic speeds, with Stoney's measured curves.
25//! - [`afterbody`]: a boattail's wave drag faster than sound, and the base pressure behind it.
26//! - [`shock_expansion`]: the second-order shock-expansion method for a pointed body faster than
27//!   sound (NACA TN 3527).
28//! - [`supersonic_boattail`]: a boattail's measured share of the normal force faster than sound
29//!   (Washington and Pettis, RD-TM-68-5).
30//! - [`table`]: override tables from another tool: the drag coefficient against Mach number, and
31//!   the normal force and center of pressure against Mach number and angle of attack, read from
32//!   RASAero II's export.
33//! - [`tube_fins`]: tube fins, each tube an annular wing: Weissinger's slope with Göthert's rule,
34//!   Fletcher's measured aerodynamic center, below Mach 0.8.
35//! - [`custom`]: drag models of your own: the [`DragModel`] trait, flown in place of the drag
36//!   buildup.
37//! - [`model`]: a rocket's terms built from a [`hpr_design::Layout`] and summed at a [`Flow`].
38//!
39//! A rocket's center of pressure is [`NormalForce::cp_station_m`], in meters aft of the nose tip,
40//! from [`AeroModel::normal_force`] at [`Flow::axial`] ([Your rocket's center of
41//! pressure][guide-cp] in the guide).
42//!
43//! Status: the normal force and center of pressure from Mach 0 to 5 (fins through the transonic
44//! region to supersonic linear theory, [Fins through Mach 1][guide-fins-mach]); the drag buildup
45//! from Mach 0 to 5 (noses, shoulders and steps through Mach 1 by Niskanen's appendix B,
46//! [Drag through Mach 1][guide-drag-mach]); drag override tables, and drag models of a
47//! program's own ([`custom`]), at any Mach number; normal-force override tables from RASAero II's
48//! export ([The normal force from RASAero II][guide-override]);
49//! the roll forcing of canted fins and the roll damping from Mach 0 to 5 ([`AeroModel::roll`],
50//! [Roll: forcing and damping][guide-roll]).
51//!
52//! - Pitch and yaw damping in a flight come only from the flight engine (`hpr_sim`) evaluating
53//!   each component in its own local flow, which includes the speed the rocket's rotation adds
54//!   there ([Rigid-body flight][guide-flight] in the guide). The crate has no pitch or yaw damping
55//!   coefficients; they would have to replace the local-flow damping, not add to it.
56//! - Only components with a normal-force slope give that damping: nose cones, transitions and fin
57//!   sets. Body tubes give none at small angles: their own slope is 0, and their body lift grows
58//!   with `sin² α`.
59
60pub mod afterbody;
61pub mod blunt_tip;
62pub mod body;
63pub mod crossflow;
64pub mod custom;
65pub mod drag;
66pub mod error;
67pub mod fins;
68pub mod model;
69pub mod nose_drag;
70pub mod shock_expansion;
71pub mod supersonic_boattail;
72pub mod table;
73pub mod tube_fins;
74
75pub use afterbody::Boattail;
76pub use body::{BODY_LIFT_K, BodyGeometry};
77pub use crossflow::BodyLift;
78pub use custom::{DragModel, DragQuery};
79pub use drag::{
80    BaseBehindBoattail, BoattailTerm, ComponentDrag, ComponentDragTerms, Drag, DragConditions,
81    MOTOR_POD_SETS, MergedBoattail, PressureDragTerm, ReliefSource, WakeTerm,
82};
83pub use error::AeroError;
84pub use fins::{
85    FinAero, FinGeometry, FinLoading, FinOutline, FinRoll, FinRollTerms, fin_count_factor,
86    interference_factor, roll_damping_interference, roll_forcing_interference, roll_sum, side_sum,
87};
88pub use model::{
89    AeroModel, BodyAero, BodyModel, ComponentNormalForce, FinSetAero, Flow, MAX_CANT_RAD,
90    NORMAL_FORCE_MACH_LIMIT, NormalForce, PodFins, PodSetAero, Roll, SUPERSONIC_JOIN_START_MACH,
91    SUPERSONIC_JOIN_WIDTH_MACH, SupersonicBoattail, SupersonicBody, SupersonicFallback,
92    SupersonicFlare,
93};
94pub use nose_drag::{PressureDragCurve, StoneyNose};
95pub use table::{
96    DragTable, NormalForceColumn, NormalForceLookup, NormalForceTable, TableReference,
97    parse_mach_csv,
98};
99pub use tube_fins::TubeFinSetAero;
100
101#[cfg(test)]
102mod testing;
103
104#[cfg(test)]
105mod tests {
106    use std::f64::consts::PI;
107
108    use hpr_design::{
109        FinPlanform, NoseShape, Overrides, Position, ReferenceDiameter, Rocket, Stage,
110    };
111    use serde::Deserialize;
112
113    use super::*;
114    use crate::testing::{body_part, committed_design, component, fin_set, nose, one_stage};
115
116    const INCH: f64 = 0.0254;
117
118    fn close(got: f64, want: f64, rel: f64, what: &str) {
119        let err = ((got - want) / want).abs();
120        assert!(
121            err <= rel,
122            "{what}: got {got}, want {want}, rel err {err:e}"
123        );
124    }
125
126    fn component_force(model: &AeroModel, id: &str) -> NormalForce {
127        model
128            .components(&Flow::axial(0.0))
129            .unwrap()
130            .into_iter()
131            .find(|c| c.id == id)
132            .unwrap()
133            .normal_force
134    }
135
136    /// Loft lesson L89: Barrowman hand values. A cone has `C_Nα = 2` and its CP at `2L/3`; a
137    /// conical transition from 20 to 40 mm radius over 0.1 m, with a 40 mm reference radius, has
138    /// `C_Nα = 1.5` and its CP 0.05556 m aft of its fore end; a boattail back to 20 mm has −1.5 at
139    /// the mirrored CP; an elliptical fin's CP is 0.28779 `c_r` aft of its root leading edge.
140    #[test]
141    fn barrowman_hand_values() {
142        let cone = one_stage(
143            vec![
144                component("nose", nose(NoseShape::Conical {}, 0.2, 0.025), None),
145                component("tube", body_part(0.6, 0.025, 0.025), None),
146            ],
147            ReferenceDiameter::Maximum {},
148        );
149        let model = AeroModel::new(&cone.layout().unwrap()).unwrap();
150        let total = model.normal_force(&Flow::axial(0.0)).unwrap();
151        close(total.slope_per_rad, 2.0, 1e-15, "cone slope");
152        close(
153            total.cp_station_m.unwrap(),
154            0.2 * 2.0 / 3.0,
155            1e-11,
156            "cone CP",
157        );
158        assert_eq!(component_force(&model, "tube").slope_per_rad, 0.0);
159        assert_eq!(component_force(&model, "tube").cp_station_m, None);
160
161        let shoulder = one_stage(
162            vec![
163                component("nose", nose(NoseShape::Conical {}, 0.1, 0.02), None),
164                component("shoulder", body_part(0.1, 0.02, 0.04), None),
165                component("tube", body_part(0.5, 0.04, 0.04), None),
166                component("boattail", body_part(0.1, 0.04, 0.02), None),
167            ],
168            ReferenceDiameter::Custom { diameter_m: 0.08 },
169        );
170        let model = AeroModel::new(&shoulder.layout().unwrap()).unwrap();
171        let s = component_force(&model, "shoulder");
172        close(s.slope_per_rad, 1.5, 1e-15, "shoulder slope");
173        close(s.cp_station_m.unwrap() - 0.1, 0.05556, 1e-4, "shoulder CP");
174        close(
175            s.cp_station_m.unwrap() - 0.1,
176            0.1 / 3.0 * (1.0 + 1.0 / 1.5),
177            1e-11,
178            "eq. 44",
179        );
180        let b = component_force(&model, "boattail");
181        close(b.slope_per_rad, -1.5, 1e-15, "boattail slope");
182        close(
183            b.cp_station_m.unwrap() - 0.7,
184            0.1 / 3.0 * (1.0 + 1.0 / 3.0),
185            1e-11,
186            "boattail CP",
187        );
188
189        let (c_r, s) = (0.1, 0.06);
190        let mut tube = component("tube", body_part(0.6, 0.025, 0.025), None);
191        tube.children = vec![component(
192            "fins",
193            fin_set(
194                4,
195                FinPlanform::Elliptical {
196                    root_chord_m: c_r,
197                    span_m: s,
198                },
199            ),
200            Some(Position::Bottom { aft_offset_m: 0.0 }),
201        )];
202        let finned = one_stage(
203            vec![
204                component("nose", nose(NoseShape::Conical {}, 0.2, 0.025), None),
205                tube,
206            ],
207            ReferenceDiameter::Maximum {},
208        );
209        let layout = finned.layout().unwrap();
210        let model = AeroModel::new(&layout).unwrap();
211        let root_le = layout.find("fins").unwrap().1.fore_station_m;
212        let fins = component_force(&model, "fins");
213        close(
214            (fins.cp_station_m.unwrap() - root_le) / c_r,
215            0.28779,
216            2e-5,
217            "elliptical fin CP",
218        );
219        close(
220            (fins.cp_station_m.unwrap() - root_le) / c_r,
221            0.5 - 2.0 / (3.0 * PI),
222            1e-12,
223            "elliptical fin CP, exact",
224        );
225    }
226
227    #[derive(Deserialize)]
228    struct Fixture {
229        tolerance_rel: f64,
230        examples: Vec<Example>,
231    }
232
233    #[derive(Deserialize)]
234    struct Example {
235        id: String,
236        reference_diameter_in: f64,
237        station_offset_in: f64,
238        nose: NoseIn,
239        body: Vec<BodyIn>,
240        fin_sets: Vec<FinsIn>,
241        printed: Vec<Printed>,
242    }
243
244    #[derive(Deserialize)]
245    struct NoseIn {
246        shape: String,
247        length_in: f64,
248        base_diameter_in: f64,
249    }
250
251    #[derive(Deserialize)]
252    struct BodyIn {
253        id: String,
254        #[serde(default)]
255        stage: Option<String>,
256        length_in: f64,
257        fore_diameter_in: f64,
258        aft_diameter_in: f64,
259    }
260
261    #[derive(Deserialize)]
262    struct FinsIn {
263        id: String,
264        on: String,
265        count: u32,
266        root_chord_in: f64,
267        tip_chord_in: f64,
268        span_in: f64,
269        sweep_in: f64,
270        root_leading_edge_station_in: f64,
271    }
272
273    #[derive(Deserialize)]
274    struct Printed {
275        what: String,
276        cn_alpha: f64,
277        cp_in: f64,
278        #[serde(default)]
279        six_fin_rule: bool,
280    }
281
282    /// The design of a worked example, and of its first stage alone.
283    fn example_rockets(example: &Example) -> (Rocket, Rocket) {
284        let offset = example.station_offset_in;
285        let shape = match example.nose.shape.as_str() {
286            "cone" => NoseShape::Conical {},
287            "tangent_ogive" => NoseShape::Ogive { radius_ratio: 1.0 },
288            other => panic!("unknown nose shape {other}"),
289        };
290        let mut stages = vec![Stage {
291            id: "sustainer".to_owned(),
292            name: String::new(),
293            components: vec![component(
294                "nose",
295                nose(
296                    shape,
297                    example.nose.length_in * INCH,
298                    0.5 * example.nose.base_diameter_in * INCH,
299                ),
300                None,
301            )],
302            overrides: Overrides::default(),
303            drag_override: None,
304            parallel: None,
305        }];
306        for b in &example.body {
307            let mut part = component(
308                &b.id,
309                body_part(
310                    b.length_in * INCH,
311                    0.5 * b.fore_diameter_in * INCH,
312                    0.5 * b.aft_diameter_in * INCH,
313                ),
314                None,
315            );
316            part.children = example
317                .fin_sets
318                .iter()
319                .filter(|f| f.on == b.id)
320                .map(|f| {
321                    component(
322                        &f.id,
323                        fin_set(
324                            f.count,
325                            FinPlanform::Trapezoidal {
326                                root_chord_m: f.root_chord_in * INCH,
327                                tip_chord_m: f.tip_chord_in * INCH,
328                                span_m: f.span_in * INCH,
329                                sweep_m: f.sweep_in * INCH,
330                            },
331                        ),
332                        Some(Position::Absolute {
333                            station_m: (f.root_leading_edge_station_in + offset) * INCH,
334                        }),
335                    )
336                })
337                .collect();
338            match &b.stage {
339                Some(stage) if stages.last().is_some_and(|s| &s.id != stage) => {
340                    stages.push(Stage {
341                        id: stage.clone(),
342                        name: String::new(),
343                        components: vec![part],
344                        overrides: Overrides::default(),
345                        drag_override: None,
346                        parallel: None,
347                    });
348                }
349                _ => stages.last_mut().unwrap().components.push(part),
350            }
351        }
352        let reference = ReferenceDiameter::Custom {
353            diameter_m: example.reference_diameter_in * INCH,
354        };
355        let whole = Rocket {
356            name: example.id.clone(),
357            stages,
358            reference_diameter: reference,
359            configurations: Vec::new(),
360        };
361        let first = Rocket {
362            stages: whole.stages[..1].to_vec(),
363            ..whole.clone()
364        };
365        (whole, first)
366    }
367
368    /// TIR-33's six-fin rule over hpr's for a six-fin set of span `s` on a body of radius `r`:
369    /// `(1 + 0.5 r/(s + r)) / (0.913 (1 + r/(s + r)))` (TIR-33 p. 25; ADR-008).
370    fn six_fin_rule_ratio(set: &FinSetAero) -> f64 {
371        let r_over = (set.interference - 1.0).max(0.0);
372        (1.0 + 0.5 * r_over) / (set.count_factor * set.interference)
373    }
374
375    /// M1.5a done-when: `C_Nα` and CP reproduce Barrowman's worked examples within 1%: Testbed II
376    /// and the Aerobee 350 (NARAM-8, 1966), and the Javelin, Recruiter and Arcon-Hi (TIR-33,
377    /// 1970), component by component and in total. Inputs and printed results are in
378    /// `validation/fixtures/aero/barrowman-worked-examples.json`, with page numbers and notes.
379    ///
380    /// The Recruiter's six-fin slopes follow TIR-33's own six-fin rule. They are checked with that
381    /// rule substituted into the slope and the CP weighting; hpr's own values are reported, and
382    /// must miss the print by about the rules' difference, which exceeds the tolerance.
383    #[test]
384    fn barrowman_worked_examples() {
385        let fixture: Fixture = serde_json::from_str(include_str!(
386            "../../../validation/fixtures/aero/barrowman-worked-examples.json"
387        ))
388        .unwrap();
389        let tolerance = fixture.tolerance_rel;
390        assert_eq!(tolerance, 0.01);
391        let flow = Flow::axial(0.0);
392        let mut worst: f64 = 0.0;
393        let mut worst_own = (0.0, String::new());
394        let mut outside_own = Vec::new();
395        assert_eq!(fixture.examples.len(), 5);
396        let printed_values: usize = fixture.examples.iter().map(|e| e.printed.len()).sum();
397        assert_eq!(printed_values, 19);
398        for example in &fixture.examples {
399            let (whole, first) = example_rockets(example);
400            let model = AeroModel::new(&whole.layout().unwrap()).unwrap();
401            let components = model.components(&flow).unwrap();
402            let six_fin: Option<&FinSetAero> = model.fin_sets().iter().find(|s| s.count == 6);
403            for printed in &example.printed {
404                let force = match printed.what.as_str() {
405                    "total" => model.normal_force(&flow).unwrap(),
406                    "sustainer-alone" => AeroModel::new(&first.layout().unwrap())
407                        .unwrap()
408                        .normal_force(&flow)
409                        .unwrap(),
410                    id => {
411                        components
412                            .iter()
413                            .find(|c| c.id == id)
414                            .unwrap_or_else(|| panic!("{}: no component {id}", example.id))
415                            .normal_force
416                    }
417                };
418                let mut slope = force.slope_per_rad;
419                let mut cp_m = force.cp_station_m.unwrap();
420                let own_cp_err = (cp_m / INCH - example.station_offset_in) / printed.cp_in - 1.0;
421                let own = (slope / printed.cn_alpha - 1.0).abs().max(own_cp_err.abs());
422                if own > worst_own.0 {
423                    worst_own = (own, format!("{} {}", example.id, printed.what));
424                }
425                if own > tolerance {
426                    outside_own.push(format!("{} {}", example.id, printed.what));
427                }
428                if printed.six_fin_rule {
429                    let set = six_fin.expect("a six-fin set");
430                    let set_slope = set
431                        .fin
432                        .geometry()
433                        .single_fin_slope(model.reference_area_m2(), 0.0)
434                        .unwrap()
435                        * roll_sum(set.count, set.base_angle_rad, 0.0)
436                        * set.count_factor
437                        * set.interference;
438                    let extra = set_slope * (six_fin_rule_ratio(set) - 1.0);
439                    let tir33_slope = slope + extra;
440                    // The two six-fin rules differ by more than the tolerance, and hpr's own slope
441                    // misses the print by about that difference.
442                    let rule_gap = slope / tir33_slope - 1.0;
443                    let printed_gap = slope / printed.cn_alpha - 1.0;
444                    assert!(
445                        rule_gap > 0.02 && (printed_gap - rule_gap).abs() < tolerance,
446                        "{} {}: rule gap {rule_gap:e}, gap to print {printed_gap:e}",
447                        example.id,
448                        printed.what
449                    );
450                    eprintln!(
451                        "{} {}: hpr C_Nα {slope:.4} is {:+.2}% from the print; \
452                         TIR-33's six-fin rule alone accounts for {:+.2}%",
453                        example.id,
454                        printed.what,
455                        100.0 * printed_gap,
456                        100.0 * rule_gap
457                    );
458                    eprintln!(
459                        "{} {}: with hpr's rule, CP {:.4} in",
460                        example.id,
461                        printed.what,
462                        cp_m / INCH - example.station_offset_in
463                    );
464                    cp_m = (cp_m * slope + extra * set.cp_station_m(0.0).unwrap()) / tir33_slope;
465                    slope = tir33_slope;
466                }
467                let cp_in = cp_m / INCH - example.station_offset_in;
468                let slope_err = slope / printed.cn_alpha - 1.0;
469                let cp_err = cp_in / printed.cp_in - 1.0;
470                eprintln!(
471                    "{} {}: C_Nα {slope:.4} vs {} ({:+.3}%), CP {cp_in:.4} in vs {} ({:+.3}%)",
472                    example.id,
473                    printed.what,
474                    printed.cn_alpha,
475                    100.0 * slope_err,
476                    printed.cp_in,
477                    100.0 * cp_err
478                );
479                assert!(
480                    slope_err.abs() <= tolerance && cp_err.abs() <= tolerance,
481                    "{} {}: C_Nα error {slope_err:e}, CP error {cp_err:e}",
482                    example.id,
483                    printed.what
484                );
485                worst = worst.max(slope_err.abs()).max(cp_err.abs());
486            }
487        }
488        eprintln!(
489            "worst relative error, hpr's own model: {:.2}% ({}); with TIR-33's six-fin rule for \
490             the Recruiter: {:.3}%",
491            100.0 * worst_own.0,
492            worst_own.1,
493            100.0 * worst
494        );
495        // With hpr's own six-fin rule (ADR-008), exactly the Recruiter's six-fin slopes miss 1%.
496        assert_eq!(outside_own, ["recruiter fins", "recruiter total"]);
497    }
498
499    #[derive(Deserialize)]
500    struct DragCurves {
501        mach: f64,
502        reynolds_per_m: f64,
503        tolerance_rel: f64,
504        cases: Vec<DragCurveCase>,
505    }
506
507    #[derive(Deserialize)]
508    struct DragCurveCase {
509        id: String,
510        design: String,
511        variant_of: Option<String>,
512        thrusting: bool,
513        curve_cd0: f64,
514        hpr_cd0: f64,
515        relative_error: f64,
516    }
517
518    /// M1.5b done-when: subsonic `C_D0` of RocketPy's example rockets against the drag curves
519    /// that ship with them, at Mach 0.3 and USSA76 sea level (RASAero II computes its exports'
520    /// Reynolds numbers at sea level). The curves have unclear terms and stay in
521    /// `refs/`; `cargo xtask aero` writes the relative errors to
522    /// `validation/fixtures/aero/rocketpy-drag-curves.json`. This test recomputes hpr's values
523    /// from the committed designs, so the recorded errors can't go stale, and checks them against
524    /// the tolerance.
525    #[test]
526    fn rocketpy_drag_curves_at_mach_0_3() {
527        let fixture: DragCurves = serde_json::from_str(include_str!(
528            "../../../validation/fixtures/aero/rocketpy-drag-curves.json"
529        ))
530        .unwrap();
531        assert_eq!(fixture.mach, 0.3);
532        assert_eq!(fixture.tolerance_rel, 0.10);
533        let air = hpr_atmos::Ussa76::standard().sample(0.0).unwrap().air;
534        close(
535            fixture.reynolds_per_m,
536            0.3 * air.speed_of_sound_m_s / air.kinematic_viscosity_m2_s(),
537            1e-12,
538            "sea-level Reynolds number per meter",
539        );
540        let mut outside = Vec::new();
541        for case in &fixture.cases {
542            let rocket = committed_design(&case.design);
543            let model = AeroModel::new(&rocket.layout().unwrap()).unwrap();
544            // Every motor's area comes off the airframe's base, as no fixture design has pods.
545            assert!(model.pod_sets().is_empty(), "{}", case.design);
546            let motor_area: f64 = if case.thrusting {
547                rocket.configurations[0]
548                    .motors
549                    .iter()
550                    .map(|m| 0.25 * PI * m.diameter_m * m.diameter_m)
551                    .sum()
552            } else {
553                0.0
554            };
555            let conditions = if case.thrusting {
556                DragConditions::thrusting(fixture.reynolds_per_m, motor_area)
557            } else {
558                DragConditions::coasting(fixture.reynolds_per_m)
559            };
560            let drag = model.drag(&Flow::axial(fixture.mach), &conditions).unwrap();
561            // A stale fixture: rerun `cargo xtask aero`.
562            close(drag.zero_lift_coefficient, case.hpr_cd0, 1e-12, &case.id);
563            let error = drag.zero_lift_coefficient / case.curve_cd0 - 1.0;
564            assert!(
565                (error - case.relative_error).abs() < 1e-12,
566                "{}: recorded error {}, recomputed {error}",
567                case.id,
568                case.relative_error
569            );
570            eprintln!(
571                "{}: C_D0 {:.4} ({:.4} friction, {:.4} pressure, {:.4} base, {:.4} parasitic), \
572                 {:+.1}% from the curve",
573                case.id,
574                drag.zero_lift_coefficient,
575                drag.friction,
576                drag.pressure,
577                drag.base,
578                drag.parasitic,
579                100.0 * case.relative_error
580            );
581            if error.abs() > fixture.tolerance_rel {
582                outside.push(case.id.as_str());
583            }
584        }
585        // Six comparisons over four rockets, and one variant (Calisto's getting-started fins on
586        // the same export).
587        assert_eq!(fixture.cases.len(), 7);
588        let variants: Vec<&str> = fixture
589            .cases
590            .iter()
591            .filter(|c| c.variant_of.is_some())
592            .map(|c| c.id.as_str())
593            .collect();
594        assert_eq!(variants, ["calisto-getting-started-power-off"]);
595        // Outside the tolerance (ADR-009): Cavour under power, where the table carries at most
596        // 0.001 of relief at Mach 0.3 and hpr's depends on the unrecorded motor diameter; and
597        // Valetudo's table, 1.44 times the OpenRocket export for the same rocket, which hpr
598        // matches to 2% with that file's inputs.
599        assert_eq!(
600            outside,
601            ["cavour-power-on", "valetudo-power-off", "valetudo-power-on"]
602        );
603    }
604
605    #[derive(Deserialize)]
606    struct NormalForceVsMach {
607        targets: Targets,
608        references: Vec<NormalForceReference>,
609    }
610
611    #[derive(Deserialize)]
612    struct Targets {
613        cp_calibers: f64,
614        cn_alpha_rel: f64,
615    }
616
617    #[derive(Deserialize)]
618    struct NormalForceReference {
619        id: String,
620        design: String,
621        reference_diameter_m: f64,
622        rows: Vec<NormalForceRow>,
623    }
624
625    #[derive(Deserialize)]
626    struct NormalForceRow {
627        mach: f64,
628        reference_cn_alpha_per_rad: f64,
629        reference_cp_m: f64,
630        hpr_cn_alpha_per_rad: f64,
631        hpr_cp_m: f64,
632        cn_alpha_error: f64,
633        cp_error_calibers: f64,
634        within_targets: bool,
635        reference_body_cn_alpha_per_rad: Option<f64>,
636        hpr_body_cn_alpha_per_rad: Option<f64>,
637    }
638
639    #[derive(Deserialize)]
640    struct WindTunnel {
641        configurations: Vec<WindTunnelConfiguration>,
642    }
643
644    #[derive(Deserialize)]
645    struct WindTunnelConfiguration {
646        id: String,
647        length_m: f64,
648        cn_alpha: Vec<WindTunnelCurve>,
649        cn_alpha_fins_off: Vec<WindTunnelCurve>,
650        cp: Vec<WindTunnelCp>,
651    }
652
653    #[derive(Deserialize)]
654    struct WindTunnelCurve {
655        mach: f64,
656        alpha_deg_c_n: Vec<[f64; 2]>,
657    }
658
659    #[derive(Deserialize)]
660    struct WindTunnelCp {
661        mach: f64,
662        percent_length: f64,
663    }
664
665    /// The least-squares slope of `ys` against `xs`, with an intercept.
666    fn lsq_slope(xs: &[f64], ys: &[f64]) -> f64 {
667        let n = xs.len() as f64;
668        let (mx, my) = (xs.iter().sum::<f64>() / n, ys.iter().sum::<f64>() / n);
669        let sxy: f64 = xs.iter().zip(ys).map(|(x, y)| (x - mx) * (y - my)).sum();
670        let sxx: f64 = xs.iter().map(|x| (x - mx) * (x - mx)).sum();
671        sxy / sxx
672    }
673
674    /// hpr's `C_N` and its moment about the nose tip at `alpha_deg`, odd in the angle, of the
675    /// whole rocket or of its bodies alone.
676    fn force_at(model: &AeroModel, mach: f64, alpha_deg: f64, bodies_only: bool) -> (f64, f64) {
677        let parts = model
678            .components(&Flow::new(mach, alpha_deg.abs().to_radians(), 0.0))
679            .unwrap();
680        let kept = if bodies_only {
681            &parts[..model.bodies().len()]
682        } else {
683            &parts[..]
684        };
685        let sign = alpha_deg.signum();
686        (
687            sign * kept.iter().map(|p| p.normal_force.coefficient).sum::<f64>(),
688            sign * kept.iter().map(|p| p.normal_force.moment_m).sum::<f64>(),
689        )
690    }
691
692    /// M1.8a done-when: hpr's `C_Nα` and CP against Mach, against RASAero II's Calisto export
693    /// (Mach 0.1–2.0; hpr's small-angle values) and NASA's Arcas Robin wind-tunnel models
694    /// (TN D-4013 and TN D-4014, Mach 0.6–4.63; hpr fitted as the plots are). `cargo xtask aero`
695    /// writes `validation/fixtures/aero/normal-force-vs-mach.json`; this test recomputes every
696    /// hpr value from the committed designs, and every wind-tunnel slope from the committed
697    /// points, so the recorded errors can't go stale, and pins the set of rows outside the
698    /// targets set before measuring (CP within 0.5 calibers, `C_Nα` within 15%). Each miss is
699    /// explained in `docs/physics/aero.md` and ADR-027.
700    #[test]
701    fn normal_force_against_mach() {
702        let fixture: NormalForceVsMach = serde_json::from_str(include_str!(
703            "../../../validation/fixtures/aero/normal-force-vs-mach.json"
704        ))
705        .unwrap();
706        let tunnel: WindTunnel = serde_json::from_str(include_str!(
707            "../../../validation/fixtures/aero/arcas-robin-wind-tunnel.json"
708        ))
709        .unwrap();
710        assert_eq!(fixture.targets.cp_calibers, 0.5);
711        assert_eq!(fixture.targets.cn_alpha_rel, 0.15);
712        let cp_angles: Vec<f64> = [-2.0_f64, -1.0, 0.0, 1.0, 2.0]
713            .iter()
714            .map(|a| a.to_radians())
715            .collect();
716        let mut misses = Vec::new();
717        for reference in &fixture.references {
718            let model =
719                AeroModel::new(&committed_design(&reference.design).layout().unwrap()).unwrap();
720            let d = (4.0 * model.reference_area_m2() / PI).sqrt();
721            close(reference.reference_diameter_m, d, 1e-15, &reference.id);
722            let configuration = tunnel.configurations.iter().find(|c| c.id == reference.id);
723            for row in &reference.rows {
724                let what = format!("{} at Mach {}", reference.id, row.mach);
725                let (cn_alpha, cp_m, hpr_cn_alpha, hpr_cp_m) = match configuration {
726                    // RASAero II's export: hpr's small-angle slope and CP.
727                    None => {
728                        let force = model.normal_force(&Flow::axial(row.mach)).unwrap();
729                        (
730                            row.reference_cn_alpha_per_rad,
731                            row.reference_cp_m,
732                            force.slope_per_rad,
733                            force.cp_station_m.unwrap(),
734                        )
735                    }
736                    // The wind tunnel: both slopes fitted at the plotted angles, the CP over
737                    // -2 to 2 degrees.
738                    Some(configuration) => {
739                        let fit = |curves: &[WindTunnelCurve], bodies_only: bool| {
740                            let curve = curves.iter().find(|c| c.mach == row.mach).unwrap();
741                            let alphas: Vec<f64> = curve
742                                .alpha_deg_c_n
743                                .iter()
744                                .map(|p| p[0].to_radians())
745                                .collect();
746                            let measured: Vec<f64> =
747                                curve.alpha_deg_c_n.iter().map(|p| p[1]).collect();
748                            let hpr: Vec<f64> = curve
749                                .alpha_deg_c_n
750                                .iter()
751                                .map(|p| force_at(&model, row.mach, p[0], bodies_only).0)
752                                .collect();
753                            (lsq_slope(&alphas, &measured), lsq_slope(&alphas, &hpr))
754                        };
755                        let (measured, hpr) = fit(&configuration.cn_alpha, false);
756                        if let Some(curve_body) = row.reference_body_cn_alpha_per_rad {
757                            let (body, hpr_body) = fit(&configuration.cn_alpha_fins_off, true);
758                            close(curve_body, body, 1e-12, &format!("{what}: body"));
759                            close(
760                                row.hpr_body_cn_alpha_per_rad.unwrap(),
761                                hpr_body,
762                                1e-12,
763                                &format!("{what}: hpr's body"),
764                            );
765                        }
766                        let cp = configuration
767                            .cp
768                            .iter()
769                            .find(|c| c.mach == row.mach)
770                            .unwrap();
771                        let forces: Vec<(f64, f64)> = [-2.0, -1.0, 0.0, 1.0, 2.0]
772                            .iter()
773                            .map(|&a| force_at(&model, row.mach, a, false))
774                            .collect();
775                        let normal: Vec<f64> = forces.iter().map(|f| f.0).collect();
776                        let moment: Vec<f64> = forces.iter().map(|f| f.1).collect();
777                        (
778                            measured,
779                            0.01 * cp.percent_length * configuration.length_m,
780                            hpr,
781                            lsq_slope(&cp_angles, &moment) / lsq_slope(&cp_angles, &normal),
782                        )
783                    }
784                };
785                // A stale fixture: rerun `cargo xtask aero`.
786                close(row.reference_cn_alpha_per_rad, cn_alpha, 1e-12, &what);
787                close(row.reference_cp_m, cp_m, 1e-12, &what);
788                close(row.hpr_cn_alpha_per_rad, hpr_cn_alpha, 1e-12, &what);
789                close(row.hpr_cp_m, hpr_cp_m, 1e-12, &what);
790                let cn_error = hpr_cn_alpha / cn_alpha - 1.0;
791                let cp_error = (hpr_cp_m - cp_m) / d;
792                assert!((row.cn_alpha_error - cn_error).abs() < 1e-12, "{what}");
793                assert!((row.cp_error_calibers - cp_error).abs() < 1e-12, "{what}");
794                let within = cn_error.abs() <= 0.15 && cp_error.abs() <= 0.5;
795                assert_eq!(row.within_targets, within, "{what}");
796                eprintln!(
797                    "{what}: C_Na {hpr_cn_alpha:.3} against {cn_alpha:.3} ({:+.1}%), CP {:+.2} \
798                     calibers",
799                    100.0 * cn_error,
800                    cp_error
801                );
802                if !within {
803                    misses.push(format!("{}@{}", reference.id, row.mach));
804                }
805            }
806        }
807        assert_eq!(fixture.references.len(), 3);
808        assert_eq!(
809            misses,
810            [
811                // Calisto against RASAero II: hpr's Prandtl-Glauert rise and aft CP near Mach 1,
812                // which RASAero II's constant subsonic slope doesn't have, and the linear join's
813                // peak at M_s = 1.28. Since M1.8e7 its von Karman nose's vertical tip flies the
814                // shock-expansion method behind a Newtonian cap, so its body carries lift on the
815                // cylinder past Mach 1.2: Mach 2 reads +8.8% (-16.8% on slender-body theory) and
816                // Mach 1.5 +13.2% (-3.1%; ADR-038).
817                "calisto-rasaero-ii@0.8",
818                "calisto-rasaero-ii@0.9",
819                "calisto-rasaero-ii@0.95",
820                "calisto-rasaero-ii@1.3",
821                // The Arcas Robin: the measured transonic dip in the fins' lift and the join's
822                // peak (Mach 0.8 to 1.2). Since M1.8e8 the committed designs fly the
823                // shock-expansion method to their base (their vertical tip behind a Newtonian cap
824                // since M1.8e7, their lip carrying nothing in the boattail's wake since e8), so
825                // every row from Mach 1.5 is within the slope's target, where the short model read
826                // -16.3% to -28.0% before. What is left on the long model at Mach 1.8 and 2.3 is
827                // the center of pressure, 0.53 and 0.52 calibers forward of the measured, where
828                // the body reads 15% to 19% high fins off (ADR-039).
829                "arcas-robin-short@0.8",
830                "arcas-robin-short@0.9",
831                "arcas-robin-short@0.95",
832                "arcas-robin-short@1.2",
833                "arcas-robin-long@0.9",
834                "arcas-robin-long@1",
835                "arcas-robin-long@1.2",
836                "arcas-robin-long@1.8",
837                "arcas-robin-long@2.3",
838            ]
839        );
840    }
841
842    #[derive(Deserialize)]
843    struct DragVsMach {
844        target_rel: f64,
845        reynolds_per_m: f64,
846        references: Vec<DragReference>,
847    }
848
849    #[derive(Deserialize)]
850    struct DragReference {
851        id: String,
852        design: String,
853        rows: Vec<DragRow>,
854    }
855
856    #[derive(Deserialize)]
857    struct DragRow {
858        mach: f64,
859        fins: String,
860        reference_forebody_c_a: f64,
861        hpr_forebody_c_d: f64,
862        hpr_base: f64,
863        error: f64,
864        within_target: bool,
865    }
866
867    #[derive(Deserialize)]
868    struct AxialTunnel {
869        configurations: Vec<AxialConfiguration>,
870    }
871
872    #[derive(Deserialize)]
873    struct AxialConfiguration {
874        id: String,
875        design: String,
876        axial_force: Vec<AxialPoint>,
877    }
878
879    #[derive(Deserialize)]
880    struct AxialPoint {
881        mach: f64,
882        fins: String,
883        forebody_c_a: f64,
884        c_a: Option<f64>,
885        chamber_c_a: Option<f64>,
886        forebody_c_a_chamber_only: Option<f64>,
887    }
888
889    /// M1.8b1 done-when: hpr's forebody drag (`C_D0` less the base drag) against the Arcas Robin
890    /// wind-tunnel models' forebody axial force at every Mach number the reports give, fins on
891    /// and off (TN D-4013's `C_A,corr`, Mach 0.6–1.2; TN D-4014's `C_A` less its chamber force,
892    /// Mach 1.5–4.63). `cargo xtask aero` writes `validation/fixtures/aero/drag-vs-mach.json`;
893    /// this test recomputes every hpr value from the committed designs at the tunnels' Reynolds
894    /// number, checks every measured point is compared, and pins the rows within the 10% target
895    /// set before measuring, so every other row is a pinned miss (42 of 44). Each miss is
896    /// explained in `docs/physics/aero.md`, ADR-028 and ADR-030.
897    #[test]
898    fn drag_against_mach() {
899        let fixture: DragVsMach = serde_json::from_str(include_str!(
900            "../../../validation/fixtures/aero/drag-vs-mach.json"
901        ))
902        .unwrap();
903        let tunnel: AxialTunnel = serde_json::from_str(include_str!(
904            "../../../validation/fixtures/aero/arcas-robin-wind-tunnel.json"
905        ))
906        .unwrap();
907        assert_eq!(fixture.target_rel, 0.10);
908        assert_eq!(fixture.reynolds_per_m, 3.0e6 / 0.3048);
909        let conditions = DragConditions::coasting(fixture.reynolds_per_m);
910        let (mut within, mut rows) = (Vec::new(), 0);
911        assert_eq!(fixture.references.len(), tunnel.configurations.len());
912        for (reference, configuration) in fixture.references.iter().zip(&tunnel.configurations) {
913            assert_eq!(reference.id, configuration.id);
914            assert_eq!(reference.design, configuration.design);
915            assert_eq!(
916                reference.rows.len(),
917                configuration.axial_force.len(),
918                "{}: every measured point is compared",
919                reference.id
920            );
921            let model =
922                AeroModel::new(&committed_design(&reference.design).layout().unwrap()).unwrap();
923            let fin_ids: Vec<&str> = model.fin_sets().iter().map(|f| f.id.as_str()).collect();
924            for (row, point) in reference.rows.iter().zip(&configuration.axial_force) {
925                let what = format!("{}@{} fins {}", reference.id, row.mach, row.fins);
926                assert_eq!((row.mach, &row.fins), (point.mach, &point.fins), "{what}");
927                assert_eq!(row.reference_forebody_c_a, point.forebody_c_a, "{what}");
928                // TN D-4014's forebody is its `C_A` less the chamber's force over the whole base,
929                // `1.383 C_A,c` with 1.383 = (1.470/1.250)², to the readings' 4 decimals; the
930                // chamber-only bound is `C_A − C_A,c`.
931                if let (Some(c_a), Some(chamber)) = (point.c_a, point.chamber_c_a) {
932                    let factor = (1.470f64 / 1.250).powi(2);
933                    assert!(
934                        (point.forebody_c_a - (c_a - factor * chamber)).abs() < 1.5e-4,
935                        "{what}"
936                    );
937                    let bound = point.forebody_c_a_chamber_only.unwrap_or(f64::NAN);
938                    assert!((bound - (c_a - chamber)).abs() < 1.5e-4, "{what}");
939                }
940                let parts = model
941                    .buildup_components(&Flow::axial(row.mach), &conditions)
942                    .unwrap();
943                let kept = parts
944                    .iter()
945                    .filter(|p| row.fins == "on" || !fin_ids.contains(&p.id.as_str()));
946                let (mut forebody, mut base) = (0.0, 0.0);
947                for part in kept {
948                    forebody += part.drag.friction + part.drag.pressure + part.drag.parasitic;
949                    base += part.drag.base;
950                }
951                close(row.hpr_forebody_c_d, forebody, 1e-12, &what);
952                close(row.hpr_base, base, 1e-12, &what);
953                let error = forebody / point.forebody_c_a - 1.0;
954                assert!((row.error - error).abs() < 1e-12, "{what}");
955                assert_eq!(
956                    row.within_target,
957                    error.abs() <= fixture.target_rel,
958                    "{what}"
959                );
960                rows += 1;
961                if row.within_target {
962                    within.push(what);
963                }
964            }
965        }
966        assert_eq!(rows, 44);
967        // Since the boattail's supersonic wave drag and the lip in its wake (ADR-030): before,
968        // 8 rows were within 10%, six of them because the lip's 0.085 made up for the missing
969        // wave drag.
970        assert_eq!(
971            within,
972            ["arcas-robin-short@1 fins on", "arcas-robin-long@1 fins on",]
973        );
974    }
975
976    /// M1.8b2 and M1.8b3 (ADR-029, ADR-030): Calisto's rows within 10% of its RASAero II export
977    /// over the inputs the export doesn't record. The export's values at the sweep's Mach numbers
978    /// come back from the committed fixture (hpr's value over one plus its error), and hpr flies
979    /// the 2018 design with square, rounded and airfoil fins 2 to 6.35 mm thick, smooth or painted
980    /// (20 µm), every 0.05 from Mach 0.1 to 2.0 at sea level. Before the boattail's supersonic
981    /// wave drag no combination had rows within 10% in both the subsonic and the supersonic bands
982    /// (the test was named `calistos_supersonic_gap_survives_every_plausible_fin_and_finish`).
983    /// Now the committed inputs (square, 3 mm, smooth, ADR-009's rule) have 15, 3 and 8, and
984    /// rounded fins 4.76 mm thick, smooth, have 15, 4 and 14: most of the supersonic gap left is
985    /// within what the unrecorded inputs span. The committed design keeps ADR-009's rule.
986    #[test]
987    fn calistos_rows_by_fin_and_finish() {
988        use hpr_design::{Component, FinCrossSection, Finish, Part};
989
990        let fixture: serde_json::Value = serde_json::from_str(include_str!(
991            "../../../validation/fixtures/aero/rocketpy-drag-curves.json"
992        ))
993        .unwrap();
994        let case = fixture["cases"]
995            .as_array()
996            .unwrap()
997            .iter()
998            .find(|c| c["id"] == "calisto-power-off")
999            .unwrap();
1000        let rows: Vec<(f64, f64)> = case["sweep"]["rows"]
1001            .as_array()
1002            .unwrap()
1003            .iter()
1004            .map(|r| {
1005                let hpr = r["hpr_cd0"].as_f64().unwrap();
1006                let error = r["relative_error"].as_f64().unwrap();
1007                (r["mach"].as_f64().unwrap(), hpr / (1.0 + error))
1008            })
1009            .collect();
1010        assert_eq!(rows.len(), 39);
1011        let air = hpr_atmos::Ussa76::standard().sample(0.0).unwrap().air;
1012        fn set(components: &mut [Component], section: FinCrossSection, t: f64, finish: Finish) {
1013            for c in components {
1014                c.finish = Some(finish);
1015                if let Part::FinSet(fins) = &mut c.part {
1016                    fins.cross_section = section;
1017                    fins.thickness_m = t;
1018                }
1019                set(&mut c.children, section, t, finish);
1020            }
1021        }
1022        let (mut counts, mut ranges) = (Vec::new(), Vec::new());
1023        for section in [
1024            FinCrossSection::Square,
1025            FinCrossSection::Rounded,
1026            FinCrossSection::Airfoil,
1027        ] {
1028            for t in [0.002, 0.003, 0.00476, 0.00635] {
1029                for finish in [Finish::Mirror {}, Finish::MassProductionPaint {}] {
1030                    let mut rocket =
1031                        committed_design("rocketpy-calisto-tests-motor-at-minus-1.373.json");
1032                    set(&mut rocket.stages[0].components, section, t, finish);
1033                    let model = AeroModel::new(&rocket.layout().unwrap()).unwrap();
1034                    // Rows within 10% by band: subsonic to 0.8, transonic below 1.2, supersonic.
1035                    let mut within = [0usize; 3];
1036                    let mut errors: [Vec<f64>; 3] = Default::default();
1037                    for &(mach, curve) in &rows {
1038                        let conditions = DragConditions::coasting(
1039                            mach * air.speed_of_sound_m_s / air.kinematic_viscosity_m2_s(),
1040                        );
1041                        let drag = model.drag(&Flow::axial(mach), &conditions).unwrap();
1042                        let error = drag.zero_lift_coefficient / curve - 1.0;
1043                        let band = if mach <= 0.8 {
1044                            0
1045                        } else if mach < 1.2 {
1046                            1
1047                        } else {
1048                            2
1049                        };
1050                        if error.abs() <= 0.10 {
1051                            within[band] += 1;
1052                        }
1053                        errors[band].push(error);
1054                    }
1055                    counts.push(within);
1056                    // Each band's least and greatest error, in percent to 0.1.
1057                    let pct = |e: f64| (e * 1000.0).round() / 10.0;
1058                    ranges.push(errors.map(|band| {
1059                        let min = band.iter().copied().fold(f64::INFINITY, f64::min);
1060                        let max = band.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1061                        (pct(min), pct(max))
1062                    }));
1063                }
1064            }
1065        }
1066        // By section (square, rounded, airfoil), thickness (2, 3, 4.76, 6.35 mm) and finish
1067        // (smooth, painted): rows within 10% of 15 subsonic, 7 transonic and 17 supersonic.
1068        assert_eq!(
1069            counts,
1070            [
1071                [15, 3, 1],
1072                [7, 4, 8],
1073                [15, 3, 8],
1074                [3, 4, 15],
1075                [0, 3, 17],
1076                [0, 3, 17],
1077                [0, 3, 17],
1078                [0, 1, 2],
1079                [9, 4, 0],
1080                [14, 3, 4],
1081                [15, 3, 1],
1082                [14, 4, 9],
1083                [15, 4, 14],
1084                [12, 3, 17],
1085                [14, 4, 17],
1086                [9, 3, 17],
1087                [3, 4, 0],
1088                [14, 3, 2],
1089                [7, 3, 0],
1090                [13, 4, 7],
1091                [10, 4, 9],
1092                [13, 4, 17],
1093                [11, 4, 17],
1094                [13, 3, 17],
1095            ]
1096        );
1097        // The committed inputs (square, 3 mm, smooth) and the best of the rest (rounded,
1098        // 4.76 mm, smooth): no combination has every row within 10%.
1099        assert_eq!(ranges[2], [(3.9, 8.9), (-10.1, 16.4), (-14.9, -5.1)]);
1100        assert_eq!(ranges[12], [(-8.4, 5.8), (-8.9, 22.5), (-11.7, -3.4)]);
1101        assert!(counts.iter().all(|c| c != &[15, 7, 17]));
1102    }
1103
1104    /// M1.8b3 (ADR-030): hpr's boattail pressure drag and the base pressure behind a boattail
1105    /// against measured conical boattails, and the boattail chart against Jack's second-order
1106    /// theory. `cargo xtask aero` writes each comparison into
1107    /// `validation/fixtures/aero/drag-vs-mach.json` from the transcribed references in
1108    /// `measured-boattails.json`; this recomputes every hpr value from the rows' geometry and pins
1109    /// the ranges the guide quotes. The targets were M1.8's 10%; how far each group misses is
1110    /// explained in `docs/physics/aero.md`.
1111    #[test]
1112    fn boattails_against_measurements() {
1113        use crate::afterbody::Boattail;
1114        let references: serde_json::Value = serde_json::from_str(include_str!(
1115            "../../../validation/fixtures/aero/measured-boattails.json"
1116        ))
1117        .unwrap();
1118        let fixture: serde_json::Value = serde_json::from_str(include_str!(
1119            "../../../validation/fixtures/aero/drag-vs-mach.json"
1120        ))
1121        .unwrap();
1122        let f = |row: &serde_json::Value, key: &str| row[key].as_f64().unwrap();
1123        // The subsonic rule gives long boattails exactly 0.
1124        let same = |got: f64, want: f64, what: &str| {
1125            assert!(
1126                (got - want).abs() <= 1e-12 * want.abs().max(1e-3),
1127                "{what}: {got} against {want}"
1128            );
1129        };
1130        let boattail = |row: &serde_json::Value| {
1131            let (l, r) = (f(row, "length_ratio"), f(row, "diameter_ratio").min(1.0));
1132            Boattail::new(l, 1.0, r).unwrap()
1133        };
1134        let rows = |section: &str| {
1135            let reference = references[section]["rows"].as_array().unwrap();
1136            let compared = fixture[section]["rows"].as_array().unwrap();
1137            assert_eq!(reference.len(), compared.len(), "{section}");
1138            compared.clone()
1139        };
1140        // Errors in percent to 0.1, as the guide quotes them.
1141        let range = |errors: &[f64]| {
1142            let pct = |e: f64| (e * 1000.0).round() / 10.0;
1143            let min = errors.iter().copied().fold(f64::INFINITY, f64::min);
1144            let max = errors.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1145            (errors.len(), pct(min), pct(max))
1146        };
1147
1148        // Measured boattail pressure drag, grouped by angle and speed: attached boattails of 12°
1149        // and gentler under Niskanen's rule (to Mach 0.8), in the straight-line rise (to Mach 1),
1150        // where the Mach 1.2 value is held (to 1.2), and faster; Cubbage's 16° ones; and his
1151        // separated 30° and 45° ones. Compton's points near Mach 1 that his report calls
1152        // questionable are a group of their own.
1153        let mut groups: std::collections::BTreeMap<&str, Vec<f64>> = Default::default();
1154        let mut absolute: Vec<f64> = Vec::new();
1155        for row in rows("boattails") {
1156            let b = boattail(&row);
1157            let mach = f(&row, "mach");
1158            let hpr = b.pressure_drag_coefficient(mach).unwrap();
1159            same(f(&row, "hpr"), hpr, "boattail");
1160            let error = hpr / f(&row, "cd") - 1.0;
1161            assert!((f(&row, "error") - error).abs() < 1e-12);
1162            let deg = b.half_angle_rad.to_degrees();
1163            let group = if row["flow"] == "separated" {
1164                "separated"
1165            } else if row["questionable"] == true {
1166                "questionable"
1167            } else if deg > 12.0 {
1168                if mach >= 1.0 {
1169                    "steep"
1170                } else {
1171                    "steep, subsonic"
1172                }
1173            } else if mach >= 1.2 {
1174                absolute.push(hpr - f(&row, "cd"));
1175                "supersonic"
1176            } else if mach >= 1.0 {
1177                "held"
1178            } else if mach > 0.8 {
1179                "rise"
1180            } else {
1181                "rule"
1182            };
1183            groups.entry(group).or_default().push(error);
1184        }
1185        let got: Vec<(&str, (usize, f64, f64))> =
1186            groups.iter().map(|(k, v)| (*k, range(v))).collect();
1187        assert_eq!(
1188            got,
1189            [
1190                ("held", (4, -18.2, -5.4)),
1191                ("questionable", (27, -46.2, 60.0)),
1192                ("rise", (28, -77.5, 7.6)),
1193                ("rule", (58, -100.0, -83.5)),
1194                ("separated", (3, -2.8, 6.6)),
1195                ("steep", (9, 26.4, 54.2)),
1196                ("steep, subsonic", (6, -30.2, 60.4)),
1197                ("supersonic", (58, -21.9, 28.3)),
1198            ]
1199        );
1200        // From Mach 1.2 the biggest misses in percent are the smallest drags, 3° and 5°
1201        // boattails of 0.01 to 0.02; in drag coefficient every row is within 0.0123.
1202        assert!(absolute.iter().all(|e| e.abs() < 0.0123));
1203
1204        // The base pressure behind a boattail: the error in base drag on the cylinder's area.
1205        let mut differences = Vec::new();
1206        for row in rows("base_pressures") {
1207            let b = boattail(&row);
1208            let k = b
1209                .base_pressure_ratio(f(&row, "mach"), b.area_ratio)
1210                .unwrap();
1211            same(f(&row, "k_hpr"), k, "base ratio");
1212            let measured = f(&row, "boattail_cp") / f(&row, "cylinder_cp");
1213            let difference = (k - measured) * -f(&row, "cylinder_cp") * b.area_ratio;
1214            assert!((f(&row, "base_cd_difference") - difference).abs() < 1e-12);
1215            differences.push(difference);
1216        }
1217        assert_eq!(differences.len(), 12);
1218        assert!(differences.iter().all(|d| d.abs() < 0.0102));
1219        assert_eq!(differences.iter().filter(|d| d.abs() <= 0.004).count(), 10);
1220
1221        // The chart, held to the 2D limit, against Jack's second-order theory.
1222        let (mut inside, mut past, mut shallow) = (Vec::new(), Vec::new(), Vec::new());
1223        for row in rows("second_order_theory") {
1224            let a = f(&row, "area_ratio");
1225            let theta = f(&row, "half_angle_deg").to_radians();
1226            let r = a.sqrt();
1227            let b = Boattail::new((1.0 - r) / (2.0 * theta.tan()), 1.0, r).unwrap();
1228            let mach = f(&row, "mach");
1229            let hpr = b.attached_pressure_drag(mach).unwrap();
1230            same(f(&row, "hpr"), hpr, "theory");
1231            let error = hpr / f(&row, "cd") - 1.0;
1232            assert!((f(&row, "error") - error).abs() < 1e-12);
1233            if a > 0.6 + 1e-9 {
1234                shallow.push(error);
1235            } else if f(&row, "x") <= 1.4 {
1236                inside.push(error);
1237            } else {
1238                past.push(error);
1239            }
1240        }
1241        assert_eq!(range(&inside), (83, -10.4, 8.0));
1242        assert_eq!(range(&past), (28, -2.7, 8.0));
1243        // The 0.7 and 0.8 curves read high against Jack past x ≈ 1.
1244        assert_eq!(range(&shallow), (40, -1.5, 32.4));
1245    }
1246
1247    #[derive(Deserialize)]
1248    struct HandbookDrag {
1249        calculations: Vec<HandbookCalculation>,
1250    }
1251
1252    #[derive(Deserialize)]
1253    struct HandbookCalculation {
1254        design: String,
1255        rows: Vec<HandbookComparison>,
1256    }
1257
1258    #[derive(Deserialize)]
1259    struct HandbookComparison {
1260        mach: f64,
1261        reynolds_per_m: f64,
1262        reference: HandbookParts,
1263        hpr: HandbookParts,
1264        compared: HandbookCompared,
1265        error: f64,
1266        within_target: bool,
1267    }
1268
1269    #[derive(Deserialize)]
1270    struct HandbookCompared {
1271        reference: f64,
1272        hpr: f64,
1273    }
1274
1275    #[derive(Deserialize)]
1276    struct HandbookParts {
1277        friction: f64,
1278        nose: f64,
1279        fins: f64,
1280        base: f64,
1281        #[serde(default)]
1282        other: f64,
1283        total: f64,
1284    }
1285
1286    #[derive(Deserialize)]
1287    struct HandbookReference {
1288        rows: Vec<HandbookRow>,
1289    }
1290
1291    #[derive(Deserialize)]
1292    struct HandbookRow {
1293        mach: f64,
1294        reynolds_per_m: f64,
1295        body_friction: f64,
1296        fin_friction: f64,
1297        friction: f64,
1298        nose_wave: Option<f64>,
1299        fin_wave: Option<f64>,
1300        fin_base: f64,
1301        fins: f64,
1302        base: f64,
1303        total_jet_off: f64,
1304    }
1305
1306    /// M1.8b2: hpr's `C_D0`, base drag included, against MIL-HDBK-762's sample drag calculation
1307    /// (Table 5-4, pp. 5-58 to 5-66) for the rocket of its Fig. 5-155, term by term, at the
1308    /// table's Reynolds numbers. A calculation with every input known, not a measurement: it
1309    /// shows which way hpr's methods lean where RASAero II's curves can't, because their inputs
1310    /// are unrecorded (ADR-029). The handbook's fins are single wedges, sharp at the leading edge,
1311    /// which hpr can't represent, so the fins' pressure drag is recorded but compared on neither
1312    /// side. `cargo xtask aero` writes the comparison to
1313    /// `validation/fixtures/aero/drag-vs-mach.json`; this checks the transcription's sums,
1314    /// recomputes hpr's terms, and pins the rows within M1.8's 10%.
1315    #[test]
1316    fn drag_against_mil_hdbk_762_sample() {
1317        let reference: HandbookReference = serde_json::from_str(include_str!(
1318            "../../../validation/fixtures/aero/mil-hdbk-762-sample-drag.json"
1319        ))
1320        .unwrap();
1321        // Each printed row's parts add up to its totals, to the table's 3 decimals.
1322        for row in &reference.rows {
1323            let what = format!("Table 5-4 at Mach {}", row.mach);
1324            assert!(
1325                (row.body_friction + row.fin_friction - row.friction).abs() < 1.5e-3,
1326                "{what}"
1327            );
1328            assert!(
1329                (row.fin_wave.unwrap_or(0.0) + row.fin_base - row.fins).abs() < 1.5e-3,
1330                "{what}"
1331            );
1332            let sum = row.friction + row.nose_wave.unwrap_or(0.0) + row.fins + row.base;
1333            assert!((sum - row.total_jet_off).abs() < 1.5e-3, "{what}");
1334            assert_eq!(row.nose_wave.is_none(), row.mach < 0.9, "{what}");
1335            assert_eq!(row.fin_wave.is_none(), row.mach < 0.95, "{what}");
1336        }
1337        let fixture: HandbookDrag = serde_json::from_str(include_str!(
1338            "../../../validation/fixtures/aero/drag-vs-mach.json"
1339        ))
1340        .unwrap();
1341        let [calculation] = fixture.calculations.as_slice() else {
1342            panic!("one calculation")
1343        };
1344        let model =
1345            AeroModel::new(&committed_design(&calculation.design).layout().unwrap()).unwrap();
1346        assert_eq!(calculation.rows.len(), reference.rows.len());
1347        let mut within = Vec::new();
1348        for (row, printed) in calculation.rows.iter().zip(&reference.rows) {
1349            let what = format!("Mach {}", row.mach);
1350            assert_eq!(
1351                (row.mach, row.reynolds_per_m),
1352                (printed.mach, printed.reynolds_per_m)
1353            );
1354            let r = &row.reference;
1355            assert_eq!(
1356                (r.friction, r.nose, r.fins, r.base, r.total),
1357                (
1358                    printed.friction,
1359                    printed.nose_wave.unwrap_or(0.0),
1360                    printed.fins,
1361                    printed.base,
1362                    printed.total_jet_off
1363                ),
1364                "{what}"
1365            );
1366            let conditions = DragConditions::coasting(printed.reynolds_per_m);
1367            let flow = Flow::axial(row.mach);
1368            let drag = model.drag(&flow, &conditions).unwrap();
1369            let parts = model.buildup_components(&flow, &conditions).unwrap();
1370            let pressure = |id: &str| {
1371                parts
1372                    .iter()
1373                    .filter(|p| p.id == id)
1374                    .map(|p| p.drag.pressure)
1375                    .sum::<f64>()
1376            };
1377            let h = &row.hpr;
1378            // To 1e-12, not bit for bit: the nose's drag goes through `powf`, `ln` and `atan`,
1379            // whose last bit differs between platforms' maths libraries.
1380            close(h.total, drag.zero_lift_coefficient, 1e-12, &what);
1381            close(h.friction, drag.friction, 1e-12, &what);
1382            close(h.base, drag.base, 1e-12, &what);
1383            close(h.nose, pressure("nose"), 1e-12, &what);
1384            close(h.fins, pressure("fins"), 1e-12, &what);
1385            assert_eq!(
1386                h.other, 0.0,
1387                "{what}: only the nose and fins have pressure drag"
1388            );
1389            assert!(
1390                (h.friction + h.nose + h.fins + h.base - h.total).abs() < 1e-12,
1391                "{what}"
1392            );
1393            let c = &row.compared;
1394            close(c.hpr, h.total - h.fins, 1e-12, &what);
1395            close(
1396                c.reference,
1397                printed.total_jet_off - printed.fins,
1398                1e-12,
1399                &what,
1400            );
1401            let error = c.hpr / c.reference - 1.0;
1402            assert!((row.error - error).abs() < 1e-12, "{what}");
1403            assert_eq!(row.within_target, error.abs() <= 0.10, "{what}");
1404            if row.within_target {
1405                within.push(row.mach);
1406            }
1407        }
1408        // Within 10% at Mach 0.7 and from 1.6 (ADR-029). From Mach 0.9 to 1.2 hpr reads 12% to
1409        // 32% high, the nose (Niskanen's ogive, 0.234 against the handbook's 0.109 at Mach 1.1)
1410        // and the base (Fleeman's 0.25 against 0.183 at Mach 1.0). From Mach 1.6 it reads 6% to
1411        // 10% low: friction (hpr's body form factor 1.02 against the handbook's 1.15) and base
1412        // drag (0.125 against 0.147 at Mach 2). At Mach 0.5 it is 10.3% low, the friction.
1413        assert_eq!(within, [0.7, 1.6, 2.0, 2.4, 2.8, 3.2]);
1414    }
1415
1416    /// The Arcas Robin comparison's two input choices (validation audit): of the 44 rows, none
1417    /// within 10% with the square section and the default 20 µm finish, none with the airfoil
1418    /// section, none with a polished finish, and 2 with both (the committed designs); before the
1419    /// boattail's supersonic wave drag (ADR-030) these were 3, 5, 6 and 8. Taking the chamber's
1420    /// force over the chamber alone changes none of the four. Allowing each reading its
1421    /// uncertainty and the reports' ±0.004, neither of the committed design's 2 could fall the
1422    /// other side of 10%.
1423    #[test]
1424    fn drag_against_mach_depends_on_the_fins_and_finish() {
1425        use hpr_design::{FinCrossSection, Finish, Part};
1426        let tunnel: serde_json::Value = serde_json::from_str(include_str!(
1427            "../../../validation/fixtures/aero/arcas-robin-wind-tunnel.json"
1428        ))
1429        .unwrap();
1430        let conditions = DragConditions::coasting(3.0e6 / 0.3048);
1431        let mut counts = Vec::new();
1432        for (section, finish) in [
1433            (FinCrossSection::Square, None),
1434            (FinCrossSection::Airfoil, None),
1435            (FinCrossSection::Square, Some(Finish::Polished {})),
1436            (FinCrossSection::Airfoil, Some(Finish::Polished {})),
1437        ] {
1438            let (mut within, mut chamber_only, mut fragile) = (0, 0, 0);
1439            for configuration in tunnel["configurations"].as_array().unwrap() {
1440                let mut rocket = committed_design(configuration["design"].as_str().unwrap());
1441                for component in &mut rocket.stages[0].components {
1442                    component.finish = finish;
1443                    for child in &mut component.children {
1444                        child.finish = finish;
1445                        if let Part::FinSet(set) = &mut child.part {
1446                            set.cross_section = section;
1447                        }
1448                    }
1449                }
1450                let model = AeroModel::new(&rocket.layout().unwrap()).unwrap();
1451                let fin_ids: Vec<&str> = model.fin_sets().iter().map(|f| f.id.as_str()).collect();
1452                for point in configuration["axial_force"].as_array().unwrap() {
1453                    let mach = point["mach"].as_f64().unwrap();
1454                    let fins = point["fins"] == "on";
1455                    let hpr: f64 = model
1456                        .buildup_components(&Flow::axial(mach), &conditions)
1457                        .unwrap()
1458                        .iter()
1459                        .filter(|part| fins || !fin_ids.contains(&part.id.as_str()))
1460                        .map(|part| part.drag.friction + part.drag.pressure + part.drag.parasitic)
1461                        .sum();
1462                    let measured = point["forebody_c_a"].as_f64().unwrap();
1463                    let inside = |reference: f64| (hpr / reference - 1.0).abs() <= 0.10;
1464                    within += usize::from(inside(measured));
1465                    let bound = point["forebody_c_a_chamber_only"]
1466                        .as_f64()
1467                        .unwrap_or(measured);
1468                    chamber_only += usize::from(inside(bound));
1469                    let spread = point["uncertainty"].as_f64().unwrap() + 0.004;
1470                    fragile += usize::from(
1471                        inside(measured)
1472                            && !(inside(measured - spread) && inside(measured + spread)),
1473                    );
1474                }
1475            }
1476            assert_eq!(within, chamber_only, "{section:?}, {finish:?}");
1477            counts.push((within, fragile));
1478        }
1479        assert_eq!(counts.iter().map(|c| c.0).collect::<Vec<_>>(), [0, 0, 0, 2]);
1480        assert_eq!(counts[3].1, 0);
1481    }
1482
1483    /// M1.8c: hpr's roll forcing against the Arcas Robin's measured roll effectiveness (TN D-4014
1484    /// Fig. 14) and its roll damping against the Basic Finner's (Barrowman 1967 Fig. 5-7), as
1485    /// `cargo xtask aero` writes them: every row recomputed from the committed designs and
1486    /// references, and the ranges the guide quotes.
1487    #[test]
1488    fn roll_against_mach() {
1489        use serde_json::Value;
1490        let fixture: Value = serde_json::from_str(include_str!(
1491            "../../../validation/fixtures/aero/roll-vs-mach.json"
1492        ))
1493        .unwrap();
1494        let tunnel: Value = serde_json::from_str(include_str!(
1495            "../../../validation/fixtures/aero/arcas-robin-wind-tunnel.json"
1496        ))
1497        .unwrap();
1498        let finner: Value = serde_json::from_str(include_str!(
1499            "../../../validation/fixtures/aero/basic-finner-roll-damping.json"
1500        ))
1501        .unwrap();
1502        let f = |v: &Value| v.as_f64().unwrap();
1503        let mut errors: Vec<(f64, f64)> = Vec::new();
1504        for configuration in fixture["arcas_robin"].as_array().unwrap() {
1505            let design = configuration["design"].as_str().unwrap();
1506            let model = AeroModel::new(&committed_design(design).layout().unwrap()).unwrap();
1507            let set = &model.fin_sets()[0];
1508            let measured = tunnel["configurations"]
1509                .as_array()
1510                .unwrap()
1511                .iter()
1512                .find(|c| c["design"] == design)
1513                .unwrap();
1514            for row in configuration["rows"].as_array().unwrap() {
1515                let mach = f(&row["mach"]);
1516                let what = format!("{design} at Mach {mach}");
1517                let fin = set
1518                    .fin
1519                    .roll(mach, set.body_radius_m, model.reference_diameter_m())
1520                    .unwrap();
1521                let hpr =
1522                    f64::from(set.count) * fin.forcing_per_rad * set.roll_forcing_interference * PI
1523                        / 180.0;
1524                close(f(&row["hpr_c_l_delta_per_deg"]), hpr, 1e-12, &what);
1525                let reading = measured["roll_effectiveness"]
1526                    .as_array()
1527                    .unwrap()
1528                    .iter()
1529                    .find(|c| f(&c["mach"]) == mach)
1530                    .unwrap()["alpha_deg_c_l_delta_per_deg"]
1531                    .as_array()
1532                    .unwrap()
1533                    .iter()
1534                    .find(|p| f(&p[0]).abs() < 0.5)
1535                    .map(|p| f(&p[1]))
1536                    .unwrap();
1537                assert_eq!(f(&row["reference_c_l_delta_per_deg"]), reading, "{what}");
1538                close(f(&row["error"]), hpr / reading - 1.0, 1e-12, &what);
1539                errors.push((mach, hpr / reading - 1.0));
1540            }
1541        }
1542        // The guide's summary: from Mach 2.3, all 8 within 5.3%; below, at Mach 1.5 and 1.8, 3
1543        // rows 14.3% to 47.8% high.
1544        assert_eq!(errors.len(), 11);
1545        let high: Vec<f64> = errors.iter().filter(|e| e.0 >= 2.3).map(|e| e.1).collect();
1546        let low: Vec<f64> = errors.iter().filter(|e| e.0 < 2.3).map(|e| e.1).collect();
1547        assert_eq!(high.len(), 8);
1548        assert!(high.iter().all(|e| e.abs() < 0.0535));
1549        let (lo, hi) = low
1550            .iter()
1551            .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), &e| {
1552                (a.min(e), b.max(e))
1553            });
1554        assert!(
1555            (lo - 0.143).abs() < 5e-4 && (hi - 0.478).abs() < 5e-4,
1556            "{lo} {hi}"
1557        );
1558
1559        // The Basic Finner: four square fins on a body one diameter across.
1560        let basic = &fixture["basic_finner"];
1561        let d = 1.0;
1562        let fin = FinAero::new(
1563            &FinPlanform::Trapezoidal {
1564                root_chord_m: d,
1565                tip_chord_m: d,
1566                span_m: d,
1567                sweep_m: 0.0,
1568            },
1569            0.25 * PI * d * d,
1570        )
1571        .unwrap();
1572        let k_r = roll_damping_interference(d, 0.5 * d, 1.0).unwrap();
1573        close(f(&basic["roll_damping_interference"]), k_r, 1e-15, "k_R(B)");
1574        for (key, source) in [
1575            ("wind_tunnel", "wind_tunnel_c_lp"),
1576            ("barrowman_theory", "barrowman_theory_c_lp"),
1577        ] {
1578            let rows = basic[key].as_array().unwrap();
1579            let readings = finner[source].as_array().unwrap();
1580            assert_eq!(rows.len(), readings.len());
1581            for (row, reading) in rows.iter().zip(readings) {
1582                let mach = f(&row["mach"]);
1583                assert_eq!(mach, f(&reading["mach"]));
1584                assert_eq!(f(&row["reference_c_lp"]), f(&reading["c_lp"]));
1585                let hpr = 4.0 * fin.roll(mach, 0.5 * d, d).unwrap().damping * k_r;
1586                close(f(&row["hpr_c_lp"]), hpr, 1e-12, key);
1587                close(
1588                    f(&row["error"]),
1589                    hpr / f(&reading["c_lp"]) - 1.0,
1590                    1e-12,
1591                    key,
1592                );
1593            }
1594        }
1595        // The guide's summary: 5.9% to 16.2% low against the wind tunnel, 2.0% against
1596        // Barrowman's own computed value.
1597        let tunnel_errors: Vec<f64> = basic["wind_tunnel"]
1598            .as_array()
1599            .unwrap()
1600            .iter()
1601            .map(|r| f(&r["error"]))
1602            .collect();
1603        let (lo, hi) = tunnel_errors
1604            .iter()
1605            .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), &e| {
1606                (a.min(e), b.max(e))
1607            });
1608        assert!(
1609            (lo + 0.162).abs() < 5e-4 && (hi + 0.059).abs() < 5e-4,
1610            "{lo} {hi}"
1611        );
1612        let theory = f(&basic["barrowman_theory"][0]["error"]);
1613        assert!((theory + 0.020).abs() < 5e-4, "{theory}");
1614    }
1615
1616    /// The whole rocket's roll sums its fin sets, each with its cant, count and body factors: two
1617    /// sets canted each way at Mach 0.5 to 4.5; a positive cant rolls toward `−z_B`; Mach 5 is
1618    /// refused; no cant, no steady roll.
1619    #[test]
1620    fn a_rocket_rolls_by_the_sum_of_its_fin_sets() {
1621        let fins = |count: u32, cant: f64, planform: FinPlanform| {
1622            let mut part = fin_set(count, planform);
1623            if let hpr_design::Part::FinSet(set) = &mut part {
1624                set.cant_rad = cant;
1625            }
1626            part
1627        };
1628        let rocket = |cants: [f64; 2]| {
1629            let mut tube = component("tube", body_part(0.8, 0.03, 0.03), None);
1630            tube.children = vec![
1631                component(
1632                    "fore",
1633                    fins(
1634                        3,
1635                        cants[0],
1636                        FinPlanform::Trapezoidal {
1637                            root_chord_m: 0.05,
1638                            tip_chord_m: 0.02,
1639                            span_m: 0.03,
1640                            sweep_m: 0.02,
1641                        },
1642                    ),
1643                    Some(Position::Top { aft_offset_m: 0.1 }),
1644                ),
1645                component(
1646                    "aft",
1647                    fins(
1648                        4,
1649                        cants[1],
1650                        FinPlanform::Elliptical {
1651                            root_chord_m: 0.08,
1652                            span_m: 0.05,
1653                        },
1654                    ),
1655                    Some(Position::Bottom { aft_offset_m: 0.0 }),
1656                ),
1657            ];
1658            one_stage(
1659                vec![
1660                    component(
1661                        "nose",
1662                        nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, 0.03),
1663                        None,
1664                    ),
1665                    tube,
1666                ],
1667                ReferenceDiameter::Maximum {},
1668            )
1669        };
1670        let (a, b) = (0.02, -0.01);
1671        let model = AeroModel::new(&rocket([a, b]).layout().unwrap()).unwrap();
1672        let d = model.reference_diameter_m();
1673        for mach in [0.5, 0.9, 1.1, 2.0, 4.5] {
1674            let roll = model.roll(mach).unwrap();
1675            let (mut forcing, mut damping) = (0.0, 0.0);
1676            for set in model.fin_sets() {
1677                let fin = set.fin.roll(mach, set.body_radius_m, d).unwrap();
1678                let n = f64::from(set.count);
1679                forcing -= n * fin.forcing_per_rad * set.roll_forcing_interference * set.cant_rad;
1680                damping += n * fin.damping * set.roll_damping_interference;
1681            }
1682            close(roll.forcing, forcing, 1e-13, "forcing");
1683            close(roll.damping, damping, 1e-13, "damping");
1684            assert!(roll.damping < 0.0);
1685        }
1686        let one = AeroModel::new(&rocket([a, 0.0]).layout().unwrap()).unwrap();
1687        assert!(one.roll(0.5).unwrap().forcing < 0.0);
1688        assert!(model.roll(5.0).is_err());
1689        let still = AeroModel::new(&rocket([0.0, 0.0]).layout().unwrap()).unwrap();
1690        // Past 15° of cant, or a NaN, the model refuses.
1691        for cant in [0.3, -0.3, f64::NAN] {
1692            let refused = rocket([cant, 0.0])
1693                .layout()
1694                .map_or(true, |layout| AeroModel::new(&layout).is_err());
1695            assert!(refused, "cant {cant}");
1696        }
1697        assert_eq!(still.steady_roll_rate_rad_s(0.5, 170.0).unwrap(), 0.0);
1698        assert!(model.steady_roll_rate_rad_s(0.5, -1.0).is_err());
1699    }
1700}