Skip to main content

hpr_aero/
drag.rs

1//! Drag: the terms of Niskanen's zero-lift drag buildup and the angle-of-attack scaling of axial
2//! drag, as functions of their inputs. [`crate::AeroModel::drag`] sums them over a rocket.
3//!
4//! The zero-lift drag coefficient on the reference area is (Niskanen 2009 eq. 3.75, 3.97)
5//! `C_D0 = C_D,friction + Σ (A_T/A_ref)(C_D•)_T`, each pressure, base and parasitic term `T` taken
6//! on its own area `A_T`:
7//!
8//! - **Skin friction** ([`skin_friction_coefficient`]): a fully turbulent boundary layer (Niskanen
9//!   §3.4.1) with the Reynolds number `R = V L/ν` on the rocket's length, limited by roughness
10//!   (eq. 3.78–3.81) and corrected for compressibility (eq. 3.82–3.84). Wetted areas are weighted
11//!   by the body form factor `1 + 1/(2 f_B)` and the fin thickness factor `1 + 2t/c̄` (eq. 3.85).
12//! - **Body pressure drag**: noses, shoulders and steps up in radius from `0.8 sin² φ` at rest
13//!   (eq. 3.86) through Mach 1 to appendix B's wave drag ([`crate::nose_drag`]); boattails by the
14//!   boattail rule (eq. 3.88, [`boattail_factor`]) to Mach 0.8 and their supersonic wave drag
15//!   from Mach 1 ([`crate::afterbody`]); a lip in a boattail's wake loses a share of its own.
16//! - **Base drag** ([`base_drag_coefficient`], eq. 3.94) on the aft base, less the thrusting
17//!   motors' area, relieved behind a boattail faster than sound ([`crate::afterbody`]).
18//! - **Fin pressure drag** ([`fin_pressure_drag_coefficient`], eq. 3.89–3.93) on the fins' frontal
19//!   area `N t s`.
20//! - **Parasitic drag** of launch lugs and rail buttons ([`launch_lug_drag`], eq. 3.95–3.96, and
21//!   Niskanen's rail-pin rule, p. 52).
22//! - **Angle of attack** ([`axial_drag_alpha_factor`], §3.4.7): `C_A = C_D0 f(α)`.
23//!
24//! Interference drag and fin-tip vortices are neglected, as in Niskanen p. 41.
25//!
26//! Every term has its transonic and supersonic branch, and the buildup covers Mach 0 to 5
27//! ([`BUILDUP_MACH_LIMIT`]).
28//!
29//! See `docs/physics/aero.md` and the decision records on subsonic drag and drag override tables,
30//! [ADR-009][adr-009], on drag through Mach 1, [ADR-028][adr-028], and on the afterbody faster
31//! than sound, [ADR-030][adr-030].
32//!
33//! [adr-009]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-009-subsonic-drag-buildup-surface-finishes-and-drag-override-tables-2026-09-17
34//! [adr-028]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-028-drag-through-mach-1-niskanens-appendix-b-stoneys-curves-and-the-arcas-robins-axial-force-2026-09-18
35//! [adr-030]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-030-the-afterbody-faster-than-sound-a-boattails-wave-drag-the-base-behind-it-and-a-lip-in-its-wake-2026-09-18
36
37use hpr_core::interp::Lookup;
38use hpr_design::{
39    FinCrossSection, FinSet, LaunchLug, NoseShape, PlacedComponent, RailButton, TubeFinSet,
40};
41use serde::{Deserialize, Serialize};
42
43use crate::afterbody::Boattail;
44use crate::body::BodyGeometry;
45use crate::error::{AeroError, check_dimension, check_mach};
46use crate::fins::FinGeometry;
47use crate::nose_drag::PressureDragCurve;
48use crate::tube_fins::TUBE_FIN_MACH_LIMIT;
49
50/// Reynolds number below which the friction formulas no longer hold and the coefficient is held
51/// at its value there (Niskanen 2009 p. 44).
52pub const LOW_REYNOLDS: f64 = 1.0e4;
53
54/// The skin-friction coefficient below [`LOW_REYNOLDS`] (Niskanen 2009 eq. 3.81).
55pub const LOW_REYNOLDS_FRICTION: f64 = 1.48e-2;
56
57/// The top of the subsonic region, Mach 0.8 (Niskanen 2009 Table 3.1, p. 19), where Niskanen's
58/// semi-empirical transonic method starts (p. 47): the lower bound `M_L` of a step's and a blunt
59/// face's transonic method ([`crate::nose_drag::PressureDragCurve::step`]).
60pub const SUBSONIC_MACH_LIMIT: f64 = 0.8;
61
62/// The top of the buildup's range, which it doesn't reach: Mach 5, where the hypersonic region
63/// begins (Niskanen 2009 Table 3.1, p. 19), as for the normal force. Niskanen expects the
64/// simulation "to be reasonably accurate to at least Mach 1.5" (p. 94); how far it holds against
65/// measurements is in `docs/physics/aero.md`.
66pub const BUILDUP_MACH_LIMIT: f64 = 5.0;
67
68/// Checks a Mach number of any speed regime: finite and non-negative.
69pub(crate) fn check_mach_any(mach: f64) -> Result<(), AeroError> {
70    if mach.is_finite() && mach >= 0.0 {
71        Ok(())
72    } else {
73        Err(AeroError::Domain {
74            what: "Mach number",
75            value: mach,
76        })
77    }
78}
79
80/// Smooth fully turbulent skin friction `C_f = 1/(1.50 ln R − 5.6)²` (Niskanen 2009 eq. 3.78;
81/// Barrowman 1967 eq. 4-4 in base-10 logarithms), held at [`LOW_REYNOLDS_FRICTION`] below
82/// [`LOW_REYNOLDS`] (eq. 3.81). Incompressible.
83fn turbulent_friction(reynolds: f64) -> f64 {
84    if reynolds < LOW_REYNOLDS {
85        LOW_REYNOLDS_FRICTION
86    } else {
87        let d = 1.50 * reynolds.ln() - 5.6;
88        1.0 / (d * d)
89    }
90}
91
92/// The roughness-limited critical Reynolds number `R_crit = 51 (R_s/L)^−1.039` (Niskanen 2009
93/// eq. 3.79; Barrowman 1967 eq. 4-7), infinite for a perfectly smooth surface.
94///
95/// # Errors
96///
97/// [`AeroError::Domain`] for a negative or non-finite relative roughness.
98pub fn critical_reynolds(relative_roughness: f64) -> Result<f64, AeroError> {
99    check_dimension("relative roughness", relative_roughness, true)?;
100    Ok(51.0 * relative_roughness.powf(-1.039))
101}
102
103/// Incompressible skin-friction coefficient of a fully turbulent boundary layer at Reynolds
104/// number `reynolds` on a surface of relative roughness `R_s/L` (Niskanen 2009 eq. 3.81):
105///
106/// ```text
107/// C_f = 1.48e-2                     R < 1e4
108///     = 1/(1.50 ln R − 5.6)²        1e4 ≤ R < R_crit     (eq. 3.78)
109///     = 0.032 (R_s/L)^0.2           R ≥ R_crit, R ≥ 1e4  (eq. 3.80)
110/// ```
111///
112/// As printed, the piecewise form jumps at `R_crit`: eq. 3.79 is not where eq. 3.78 and 3.80
113/// cross (about +9% for 60 µm on 1 m). hpr keeps the published form.
114///
115/// # Errors
116///
117/// [`AeroError::Domain`] for a negative or non-finite Reynolds number or relative roughness.
118pub fn incompressible_skin_friction(
119    reynolds: f64,
120    relative_roughness: f64,
121) -> Result<f64, AeroError> {
122    check_dimension("Reynolds number", reynolds, true)?;
123    Ok(if roughness_limited(reynolds, relative_roughness)? {
124        0.032 * relative_roughness.powf(0.2)
125    } else {
126        turbulent_friction(reynolds)
127    })
128}
129
130/// Whether eq. 3.81 takes the roughness-limited branch: from `R_crit`, and never below `1e4`,
131/// where the low-Reynolds value applies first.
132fn roughness_limited(reynolds: f64, relative_roughness: f64) -> Result<bool, AeroError> {
133    Ok(reynolds >= LOW_REYNOLDS && reynolds >= critical_reynolds(relative_roughness)?)
134}
135
136/// Skin-friction coefficient with compressibility (Niskanen 2009 eq. 3.82–3.84; Barrowman 1967
137/// eq. 4-12, 4-13):
138///
139/// - `M < 1`: `C_fc = C_f (1 − 0.1 M²)` on either branch of [`incompressible_skin_friction`];
140/// - `M ≥ 1`, turbulent: `C_fc = C_f/(1 + 0.15 M²)^0.58`;
141/// - `M ≥ 1`, roughness-limited: `C_fc = C_f/(1 + 0.18 M²)`, but never below the turbulent
142///   value at the same Reynolds number.
143///
144/// The subsonic and supersonic corrections differ at `M = 1` (0.900 against 0.922 of `C_f`
145/// turbulent, 0.847 roughness-limited), as published; no transonic blend is given.
146///
147/// # Errors
148///
149/// As [`incompressible_skin_friction`], and [`AeroError::Domain`] for a negative or non-finite
150/// Mach number.
151pub fn skin_friction_coefficient(
152    reynolds: f64,
153    relative_roughness: f64,
154    mach: f64,
155) -> Result<f64, AeroError> {
156    check_mach_any(mach)?;
157    let cf = incompressible_skin_friction(reynolds, relative_roughness)?;
158    let m2 = mach * mach;
159    if mach < 1.0 {
160        return Ok(cf * (1.0 - 0.1 * m2));
161    }
162    let turbulent = turbulent_friction(reynolds) / (1.0 + 0.15 * m2).powf(0.58);
163    Ok(if roughness_limited(reynolds, relative_roughness)? {
164        (cf / (1.0 + 0.18 * m2)).max(turbulent)
165    } else {
166        turbulent
167    })
168}
169
170/// Body friction form factor `1 + 1/(2 f_B)` for a body of fineness ratio `f_B` = body length
171/// over maximum body diameter (Niskanen 2009 eq. 3.85; Barrowman 1967 eq. 4-16).
172///
173/// # Errors
174///
175/// [`AeroError::Domain`] for a non-positive or non-finite fineness ratio.
176pub fn body_friction_form_factor(fineness_ratio: f64) -> Result<f64, AeroError> {
177    check_dimension("body fineness ratio", fineness_ratio, false)?;
178    Ok(1.0 + 0.5 / fineness_ratio)
179}
180
181/// Fin friction thickness factor `1 + 2t/c̄`, with `t` the fin thickness and `c̄` the mean
182/// aerodynamic chord (Niskanen 2009 eq. 3.85).
183///
184/// # Errors
185///
186/// [`AeroError::Domain`] for a negative thickness or a non-positive chord.
187pub fn fin_friction_thickness_factor(
188    thickness_m: f64,
189    mac_length_m: f64,
190) -> Result<f64, AeroError> {
191    check_dimension("fin thickness", thickness_m, true)?;
192    check_dimension("fin mean aerodynamic chord", mac_length_m, false)?;
193    Ok(1.0 + 2.0 * thickness_m / mac_length_m)
194}
195
196/// Stagnation-pressure ratio `q_stag/q` (Niskanen 2009 eq. B.1, after Hoerner pp. 15-2, 16-3):
197/// `1 + M²/4 + M⁴/40` below Mach 1 and `1.84 − 0.76/M² + 0.166/M⁴ + 0.035/M⁶` from Mach 1 (1.275
198/// and 1.281 at `M = 1`).
199///
200/// # Errors
201///
202/// [`AeroError::Domain`] for a negative or non-finite Mach number.
203pub fn stagnation_pressure_ratio(mach: f64) -> Result<f64, AeroError> {
204    check_mach_any(mach)?;
205    Ok(stagnation_ratio(mach))
206}
207
208/// [`stagnation_pressure_ratio`] at a Mach number already checked.
209pub(crate) fn stagnation_ratio(mach: f64) -> f64 {
210    let m2 = mach * mach;
211    if mach < 1.0 {
212        1.0 + 0.25 * m2 + m2 * m2 / 40.0
213    } else {
214        let i2 = 1.0 / m2;
215        1.84 - 0.76 * i2 + 0.166 * i2 * i2 + 0.035 * i2 * i2 * i2
216    }
217}
218
219/// Pressure drag of a blunt circular cylinder face, `(C_D•)_stag = 0.85 q_stag/q` on its frontal
220/// area (Niskanen 2009 eq. B.2).
221///
222/// # Errors
223///
224/// As [`stagnation_pressure_ratio`].
225pub fn stagnation_drag_coefficient(mach: f64) -> Result<f64, AeroError> {
226    Ok(0.85 * stagnation_pressure_ratio(mach)?)
227}
228
229/// Base drag `(C_D•)_base` on the base area: `0.12 + 0.13 M²` below Mach 1 and `0.25/M` from
230/// Mach 1, continuous at 0.25 (Niskanen 2009 eq. 3.94, after Fleeman).
231///
232/// # Errors
233///
234/// [`AeroError::Domain`] for a negative or non-finite Mach number.
235pub fn base_drag_coefficient(mach: f64) -> Result<f64, AeroError> {
236    check_mach_any(mach)?;
237    Ok(if mach < 1.0 {
238        0.12 + 0.13 * mach * mach
239    } else {
240        0.25 / mach
241    })
242}
243
244/// Pressure drag at rest of a nose cone or shoulder, `(C_D•)_p,0 = 0.8 sin² φ` on its frontal
245/// area (a nose's base area, or a shoulder's increase in area), with `φ` the joint angle between
246/// the surface and the body axis at the aft joint (Niskanen 2009 eq. 3.86, after NAVWEPS 1488
247/// p. 237). A smooth joint (`φ = 0`) has none; a bare step (`φ = π/2`) has 0.8.
248///
249/// It holds "only at low subsonic velocities"; eq. 3.87 carries it to the transonic method
250/// ([`crate::nose_drag`]).
251///
252/// # Errors
253///
254/// [`AeroError::Domain`] for a joint angle outside `[0, π/2]`.
255pub fn joint_pressure_drag_coefficient(joint_angle_rad: f64) -> Result<f64, AeroError> {
256    if !(0.0..=std::f64::consts::FRAC_PI_2).contains(&joint_angle_rad) {
257        return Err(AeroError::Domain {
258            what: "joint angle",
259            value: joint_angle_rad,
260        });
261    }
262    let s = joint_angle_rad.sin();
263    Ok(0.8 * s * s)
264}
265
266/// The boattail rule's share of base drag (Niskanen 2009 eq. 3.88), from the length ratio
267/// `γ = l/(d₁ − d₂)`: 1 for `γ ≤ 1`, `(3 − γ)/2` between 1 and 3, and 0 from 3. A boattail's
268/// pressure drag is this factor times the base drag coefficient on the boattail's decrease in
269/// area, so a zero-length boattail drags like the base it uncovers.
270///
271/// # Errors
272///
273/// [`AeroError::Domain`] for a negative or non-finite length, or diameters that don't decrease.
274pub fn boattail_factor(
275    length_m: f64,
276    fore_diameter_m: f64,
277    aft_diameter_m: f64,
278) -> Result<f64, AeroError> {
279    check_dimension("boattail length", length_m, true)?;
280    check_dimension("boattail aft diameter", aft_diameter_m, true)?;
281    if !(fore_diameter_m.is_finite() && fore_diameter_m > aft_diameter_m) {
282        return Err(AeroError::Domain {
283            what: "boattail fore diameter",
284            value: fore_diameter_m,
285        });
286    }
287    let gamma = length_m / (fore_diameter_m - aft_diameter_m);
288    Ok(if gamma <= 1.0 {
289        1.0
290    } else if gamma < 3.0 {
291        0.5 * (3.0 - gamma)
292    } else {
293        0.0
294    })
295}
296
297/// Pressure drag of a fin set on its frontal area `N t s` (Niskanen 2009 eq. 3.89–3.93): the
298/// leading edge's `(C_D•)_LE⊥ cos² Γ_L` plus the trailing edge's share of base drag.
299///
300/// | cross-section | leading edge, `(C_D•)_LE⊥` | trailing edge |
301/// |---|---|---|
302/// | square | blunt face, [`stagnation_drag_coefficient`] (eq. 3.90) | base drag (eq. 3.92) |
303/// | rounded | cylinder in crossflow (eq. 3.89) | half the base drag |
304/// | airfoil | cylinder in crossflow (eq. 3.89) | none |
305///
306/// with eq. 3.89 (after Barrowman 1967 eq. 4-17–4-19): `(1 − M²)^−0.417 − 1` below Mach 0.9,
307/// `1 − 1.785 (M − 0.9)` to Mach 1, and `1.214 − 0.502/M² + 0.1095/M⁴` above. `Γ_L` is the
308/// leading-edge sweep, averaged over the span for curved edges (eq. 3.91).
309///
310/// # Errors
311///
312/// [`AeroError::Domain`] for a negative or non-finite Mach number or a
313/// sweep outside `(−π/2, π/2)`, and [`AeroError::Unsupported`] for a cross-section this model
314/// doesn't know.
315pub fn fin_pressure_drag_coefficient(
316    cross_section: FinCrossSection,
317    leading_edge_sweep_rad: f64,
318    mach: f64,
319) -> Result<f64, AeroError> {
320    check_mach_any(mach)?;
321    let half_pi = std::f64::consts::FRAC_PI_2;
322    if leading_edge_sweep_rad.is_nan() || leading_edge_sweep_rad.abs() >= half_pi {
323        return Err(AeroError::Domain {
324            what: "fin leading-edge sweep",
325            value: leading_edge_sweep_rad,
326        });
327    }
328    let rounded = || {
329        if mach < 0.9 {
330            (1.0 - mach * mach).powf(-0.417) - 1.0
331        } else if mach < 1.0 {
332            1.0 - 1.785 * (mach - 0.9)
333        } else {
334            let i2 = 1.0 / (mach * mach);
335            1.214 - 0.502 * i2 + 0.1095 * i2 * i2
336        }
337    };
338    let base = base_drag_coefficient(mach)?;
339    let (leading, trailing) = match cross_section {
340        FinCrossSection::Square => (stagnation_drag_coefficient(mach)?, base),
341        FinCrossSection::Rounded => (rounded(), 0.5 * base),
342        FinCrossSection::Airfoil => (rounded(), 0.0),
343        // A new cross-section needs a drag decision.
344        _ => {
345            return Err(AeroError::Unsupported(
346                "this fin cross-section (no drag model)".to_owned(),
347            ));
348        }
349    };
350    let c = leading_edge_sweep_rad.cos();
351    Ok(leading * c * c + trailing)
352}
353
354/// Parasitic drag of a launch lug (Niskanen 2009 eq. 3.95–3.96): the coefficient
355/// `max{1.3 − 0.3 l/d, 1} (C_D•)_stag` on the area `π r_ext² − π r_int² max{1 − l/d, 0}`, returned
356/// as `(coefficient, area_m2)`.
357///
358/// A short lug blocks only its wall's annulus and drags like a wire (Hoerner's 1.1, the 1.3
359/// factor); a lug longer than its diameter blocks its whole face like a solid protrusion. `d` is
360/// taken as the outer diameter `2 r_ext`: Niskanen's rail pin, a solid lug "with a length equal to
361/// its diameter", only reads that way.
362///
363/// # Errors
364///
365/// [`AeroError::Domain`] for a negative or non-finite Mach number, or for a
366/// negative length, a non-positive outer radius, or an inner radius outside `[0, r_ext]`.
367pub fn launch_lug_drag(
368    length_m: f64,
369    outer_radius_m: f64,
370    inner_radius_m: f64,
371    mach: f64,
372) -> Result<(f64, f64), AeroError> {
373    let (factor, area) = launch_lug_factor_and_area(length_m, outer_radius_m, inner_radius_m)?;
374    Ok((factor * stagnation_drag_coefficient(mach)?, area))
375}
376
377/// [`launch_lug_drag`]'s Mach-independent parts: the length factor `max{1.3 − 0.3 l/d, 1}` and the
378/// area, m².
379fn launch_lug_factor_and_area(
380    length_m: f64,
381    outer_radius_m: f64,
382    inner_radius_m: f64,
383) -> Result<(f64, f64), AeroError> {
384    check_dimension("launch lug length", length_m, true)?;
385    check_dimension("launch lug outer radius", outer_radius_m, false)?;
386    check_dimension("launch lug inner radius", inner_radius_m, true)?;
387    if inner_radius_m > outer_radius_m {
388        return Err(AeroError::Domain {
389            what: "launch lug inner radius",
390            value: inner_radius_m,
391        });
392    }
393    let l_over_d = length_m / (2.0 * outer_radius_m);
394    let pi = std::f64::consts::PI;
395    let area = pi * outer_radius_m * outer_radius_m
396        - pi * inner_radius_m * inner_radius_m * (1.0 - l_over_d).max(0.0);
397    Ok(((1.3 - 0.3 * l_over_d).max(1.0), area))
398}
399
400/// Parasitic drag coefficient of a rail button on its frontal area (the side profile of its base,
401/// waist and flange): Niskanen's rail-pin rule (2009 p. 52), a pin drags like a solid launch lug
402/// as long as its diameter, `(C_D•)_stag` (Hoerner p. 5-8 gives 0.80 for a pin on a wall).
403///
404/// # Errors
405///
406/// As [`stagnation_drag_coefficient`].
407pub fn rail_button_drag_coefficient(mach: f64) -> Result<f64, AeroError> {
408    stagnation_drag_coefficient(mach)
409}
410
411/// The scaling of axial drag with angle of attack, `C_A(α) = C_D0 f(α)` (Niskanen 2009 §3.4.7),
412/// with `C_A` positive along `−z_B` (toward the tail).
413///
414/// Niskanen describes, without coefficients, a two-part polynomial from `f = 1` at `α = 0` up to
415/// 1.3 at 17° and down to 0 at 90°, with zero slope at all three. hpr uses the lowest-degree
416/// polynomials that meet those conditions, a cubic on each part:
417///
418/// ```text
419/// f = 1 + 0.3 (3t² − 2t³),   t = α/17°,           0 ≤ α ≤ 17°
420/// f = 1.3 (1 − 3u² + 2u³),   u = (α − 17°)/73°,   17° ≤ α ≤ 90°
421/// ```
422///
423/// Past 90° the flow meets the tail first and drag pushes toward the nose; hpr mirrors with the
424/// sign reversed, `f(α) = −f(180° − α)` (an assumption; the source stops at 90°), so `f` is
425/// continuous through 0 at 90°. The coefficients are derived, not published (the decision record
426/// on subsonic drag, [ADR-009][adr-009]).
427///
428/// [adr-009]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-009-subsonic-drag-buildup-surface-finishes-and-drag-override-tables-2026-09-17
429///
430/// # Errors
431///
432/// [`AeroError::Domain`] for an angle outside `[0, π]`.
433pub fn axial_drag_alpha_factor(alpha_rad: f64) -> Result<f64, AeroError> {
434    if !(0.0..=std::f64::consts::PI).contains(&alpha_rad) {
435        return Err(AeroError::Domain {
436            what: "angle of attack",
437            value: alpha_rad,
438        });
439    }
440    let degrees = alpha_rad.to_degrees();
441    let (a, sign) = if degrees > 90.0 {
442        (180.0 - degrees, -1.0)
443    } else {
444        (degrees, 1.0)
445    };
446    Ok(sign
447        * if a <= 17.0 {
448            let t = a / 17.0;
449            1.0 + 0.3 * t * t * (3.0 - 2.0 * t)
450        } else {
451            let u = (a - 17.0) / 73.0;
452            1.3 * (1.0 - u * u * (3.0 - 2.0 * u))
453        })
454}
455
456/// What the drag buildup needs beyond the [`crate::Flow`].
457#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
458#[serde(deny_unknown_fields)]
459#[non_exhaustive]
460pub struct DragConditions {
461    /// Freestream Reynolds number per meter, `V/ν`, 1/m. The buildup multiplies it by the rocket's
462    /// length (Niskanen 2009 eq. 3.12, p. 42).
463    pub reynolds_per_m: f64,
464    /// Whether a motor is thrusting. It selects an override table's power-on curve.
465    pub thrusting: bool,
466    /// Total cross-section area of the motors thrusting into the aft base, m², subtracted from
467    /// the base area (Niskanen 2009 pp. 50–51). Zero while coasting. It leaves out motors in pods.
468    pub thrusting_motor_area_m2: f64,
469    /// Total cross-section area of the thrusting motors in pods, m², one total for each pod set
470    /// that holds motor mounts, in the order of [`hpr_design::Layout::motor_pod_sets`]: each pod's
471    /// share, its set's total over the set's pods, comes off its own base. Zero while none
472    /// thrusts; at most [`MOTOR_POD_SETS`] sets.
473    #[serde(default)]
474    pub thrusting_pod_motor_areas_m2: [f64; MOTOR_POD_SETS],
475}
476
477/// The most pod sets holding motor mounts that a rocket's drag tells apart
478/// ([`DragConditions::thrusting_pod_motor_areas_m2`]); a layout with more is refused. A fixed
479/// number keeps the conditions a plain value, built at every step of a flight without allocating.
480pub const MOTOR_POD_SETS: usize = 4;
481
482impl DragConditions {
483    /// Coasting at `reynolds_per_m`.
484    pub fn coasting(reynolds_per_m: f64) -> Self {
485        Self {
486            reynolds_per_m,
487            thrusting: false,
488            thrusting_motor_area_m2: 0.0,
489            thrusting_pod_motor_areas_m2: [0.0; MOTOR_POD_SETS],
490        }
491    }
492
493    /// Thrusting at `reynolds_per_m`, with motors of total cross-section `motor_area_m2` in the aft
494    /// base (zero when the area is unknown: then the base drag gets no relief).
495    pub fn thrusting(reynolds_per_m: f64, motor_area_m2: f64) -> Self {
496        Self {
497            reynolds_per_m,
498            thrusting: true,
499            thrusting_motor_area_m2: motor_area_m2,
500            thrusting_pod_motor_areas_m2: [0.0; MOTOR_POD_SETS],
501        }
502    }
503
504    /// These conditions with thrusting motors in pods, of total cross-section
505    /// `pod_motor_areas_m2[k]` in the `k`th pod set holding motor mounts, each pod's share off its
506    /// own base ([`Self::thrusting_pod_motor_areas_m2`]). The area in the aft base
507    /// ([`Self::thrusting`]) is the airframe's motors' alone.
508    #[must_use]
509    pub fn with_pod_motors(mut self, pod_motor_areas_m2: [f64; MOTOR_POD_SETS]) -> Self {
510        self.thrusting_pod_motor_areas_m2 = pod_motor_areas_m2;
511        self
512    }
513
514    /// Checks that the Reynolds number and the area are finite and non-negative, and that a
515    /// coasting rocket has no thrusting area.
516    ///
517    /// # Errors
518    ///
519    /// [`AeroError::Domain`] otherwise.
520    pub fn validate(&self) -> Result<(), AeroError> {
521        check_dimension("Reynolds number per meter", self.reynolds_per_m, true)?;
522        check_dimension("thrusting motor area", self.thrusting_motor_area_m2, true)?;
523        for area in self.thrusting_pod_motor_areas_m2 {
524            check_dimension("thrusting pod motor area", area, true)?;
525        }
526        for area in
527            std::iter::once(self.thrusting_motor_area_m2).chain(self.thrusting_pod_motor_areas_m2)
528        {
529            if !self.thrusting && area > 0.0 {
530                return Err(AeroError::Domain {
531                    what: "thrusting motor area while coasting",
532                    value: area,
533                });
534            }
535        }
536        Ok(())
537    }
538}
539
540/// A rocket's drag, or one component's share of it, at a flow condition. Coefficients are on the
541/// reference area.
542#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
543#[non_exhaustive]
544pub struct Drag {
545    /// Zero-lift drag coefficient `C_D0`: the sum of the five parts, or an override table's or a
546    /// drag model's value ([`crate::custom`]), when the five parts are zero.
547    pub zero_lift_coefficient: f64,
548    /// Axial-force coefficient `C_A = C_D0 f(α)` ([`axial_drag_alpha_factor`]), along `−z_B` when
549    /// the flow meets the nose.
550    pub axial_coefficient: f64,
551    /// Skin friction (eq. 3.85).
552    pub friction: f64,
553    /// Pressure drag of noses, shoulders, boattails, steps in radius and fins (eq. 3.86–3.93).
554    pub pressure: f64,
555    /// Base drag of the aft base (eq. 3.94).
556    pub base: f64,
557    /// Parasitic drag of launch lugs and rail buttons (eq. 3.95–3.96).
558    pub parasitic: f64,
559    /// Drag coefficients stated in place of the parts' own ([`hpr_design::DragOverride`]), each
560    /// once per instance.
561    #[serde(default)]
562    pub stated: f64,
563    /// Set when an override table gave `C_D0` (the five parts are then zero): the lookup, on the
564    /// table's own reference area, and whether it extrapolated.
565    pub table: Option<Lookup>,
566}
567
568/// One component's share of the drag buildup, at zero lift.
569#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
570#[non_exhaustive]
571pub struct ComponentDrag {
572    /// The component's id.
573    pub id: String,
574    /// Its drag. A step in radius belongs to the component aft of it, and the base to the last
575    /// body component; but a step down just behind a part whose drag is stated goes with that
576    /// part, whose aft face it is ([`ComponentDragTerms::fore_step_down_area_ratio`]). A stage's
577    /// stated drag is listed under the stage's id.
578    pub drag: Drag,
579}
580
581/// A fin set's pressure-drag inputs.
582#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
583#[non_exhaustive]
584pub struct FinPressureTerms {
585    /// The fins' cross-section.
586    pub cross_section: FinCrossSection,
587    /// Leading-edge sweep `Γ_L`, rad.
588    pub leading_edge_sweep_rad: f64,
589    /// Frontal area `N t s` over the reference area.
590    pub frontal_area_ratio: f64,
591}
592
593/// A nose's, shoulder's or step's pressure drag: its coefficient against Mach number, on an area.
594#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
595#[non_exhaustive]
596pub struct PressureDragTerm {
597    /// The coefficient on the increase in area (eq. 3.86–3.87, appendix B).
598    pub curve: PressureDragCurve,
599    /// The increase in area over the reference area.
600    pub area_ratio: f64,
601}
602
603/// A narrowing transition's pressure drag: as a boattail of its own, or, by merge weights, as its
604/// share of the boattails it may continue.
605///
606/// A share is the drag of the cone from the start of the surface it continues through its aft
607/// end, less that of the cone from the same start through its fore end; it can be below 0 where the
608/// longer cone drags less, and parts of one straight cone add up to the cone exactly. Each
609/// surface ahead that the flow may still follow has a weight: its hold of the flow behind the
610/// boattails, times 1 for a turn of up to [`MERGE_FULL_TURN_RAD`] between this part and it, 0 from
611/// [`MERGE_NONE_TURN_RAD`] (a corner), linear between, and, when either part is shallower than
612/// [`MERGE_MIN_ANGLE_RAD`], times the smaller half-angle over the larger (the larger taken as at
613/// most that), so a part narrowing by almost nothing is a tube and a straight cone of any angle
614/// merges wholly. The part drags the weights times the shares, plus its own
615/// drag times what they leave. So parts of one straight cone add up to one cone, a sharp corner
616/// keeps each part its own boattail, and the drag stays between the two.
617#[derive(Debug, Clone, PartialEq, Serialize)]
618#[non_exhaustive]
619pub struct BoattailTerm {
620    /// This transition as a boattail of its own ([`crate::afterbody`]).
621    pub own: Boattail,
622    /// Its fore area over the reference area.
623    pub own_area_ratio: f64,
624    /// Its shares of the boattails it continues, each with its part of the merge. Empty when it
625    /// continues none.
626    pub merged: Vec<MergedBoattail>,
627    /// The weight of its own drag: 1 less the merges' weights.
628    pub own_weight: f64,
629}
630
631/// A narrowing transition's share of a boattail it continues ([`BoattailTerm`]).
632#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
633#[non_exhaustive]
634pub struct MergedBoattail {
635    /// The cone from the continued surface's start through this transition's aft end.
636    pub through_aft: Boattail,
637    /// The cone from the same start through this transition's fore end.
638    pub through_fore: Boattail,
639    /// The cones' fore area, at that start, over the reference area.
640    pub area_ratio: f64,
641    /// This share's part of the merge, from 0 to 1.
642    pub weight: f64,
643}
644
645/// A lip in a boattail's wake: its step up's pressure drag is scaled by `1 − step_fraction` and its
646/// shoulder's by `1 − shoulder_fraction`.
647///
648/// NASA's Arcas Robin models end in a lip 1.3 mm long, rising 0.17 of the boattail's drop, whose
649/// effect TN D-4014 finds "masked" when the flow over the boattail separates or the boundary layer
650/// thickens (Babb and Fuller 1967, p. 6), and which RASAero II's own comparison with the tunnel
651/// left out as "buried in the boattail boundary layer" (Rogers 2022, slide 2). A flare back toward
652/// the body's full diameter is a compression surface with a drag of its own. Between the two no
653/// source gives a measure, so the fraction is a judgement: 1 while the lip's top rises no more
654/// than [`WAKE_FULL_RISE`] of the boattail's drop in diameter above the boattail's aft end, 0 from
655/// [`WAKE_NONE_RISE`], linear between; and it fades with any tube, step down or part between them
656/// over one drop in diameter. A lip may be drawn as a shoulder, as a step up, or as both, and in
657/// several parts: each takes the smallest share any top so far leaves, its step by its fore
658/// radius and its shoulder by its aft radius too. With several boattails ahead, each contributes
659/// its share of the flow; the fractions are summed and capped at 1.
660/// A step down counts as a boattail of no length, so a lip behind a plain step, such as a motor
661/// retainer behind the step down to the motor tube, is in its wake too.
662#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
663#[non_exhaustive]
664pub struct WakeTerm {
665    /// The boattail ahead that holds the most of the flow: a narrowing part's own, the cone of
666    /// the surface it continues, or a step down's corner, a boattail of no length.
667    pub boattail: Boattail,
668    /// How much of a step up's pressure drag at the lip's fore end the wake removes, from 0 to 1.
669    pub step_fraction: f64,
670    /// How much of the lip's shoulder's pressure drag the wake removes, from 0 to 1.
671    pub shoulder_fraction: f64,
672}
673
674/// A lip behind a boattail rising up to this share of the boattail's drop in diameter is wholly
675/// in its wake ([`WakeTerm`]).
676pub const WAKE_FULL_RISE: f64 = 0.25;
677
678/// A lip behind a boattail rising this share of the boattail's drop in diameter or more is not in
679/// its wake ([`WakeTerm`]).
680pub const WAKE_NONE_RISE: f64 = 0.5;
681
682/// Two narrowing parts whose half-angles differ by up to this merge wholly ([`BoattailTerm`]), a
683/// judgement for a curved boattail drawn in parts: 3°.
684pub const MERGE_FULL_TURN_RAD: f64 = 3.0 * std::f64::consts::PI / 180.0;
685
686/// Two narrowing parts whose half-angles differ by this or more don't merge ([`BoattailTerm`]),
687/// a corner: 10°.
688pub const MERGE_NONE_TURN_RAD: f64 = 10.0 * std::f64::consts::PI / 180.0;
689
690/// The aft base behind a boattail: its drag coefficient is scaled by `1 − Σ w (1 − k)` over its
691/// sources, each a boattail the base still takes relief from, with `k` that boattail's
692/// base-pressure ratio ([`Boattail::base_pressure_ratio`]) and `w` its share of the flow behind
693/// the boattails; the shares add up to at most 1.
694#[derive(Debug, Clone, PartialEq, Serialize)]
695#[non_exhaustive]
696pub struct BaseBehindBoattail {
697    /// The boattails the base takes relief from.
698    pub sources: Vec<ReliefSource>,
699}
700
701/// A boattail the aft base takes relief from ([`BaseBehindBoattail`]).
702#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
703#[non_exhaustive]
704pub struct ReliefSource {
705    /// The boattail: a narrowing part as its own, the cone of the surface it continues, or a step
706    /// down's corner, a boattail of no length, fully separated, which gives no relief.
707    pub boattail: Boattail,
708    /// The base's area over the boattail's fore area, at most 1.
709    pub area_ratio: f64,
710    /// Its share of the flow behind the boattails, faded by the parts between it and the base,
711    /// from 0 to 1.
712    pub weight: f64,
713}
714
715/// A step down's boattail of no length, for the tails behind a boattail ([`couple_afterbody`]):
716/// its length over its drop in radius. Any length this short is a fully separated corner, so the
717/// step is the limit of a closure drawn ever shorter.
718const STEP_LENGTH_RATIO: f64 = 1e-9;
719
720/// Below this half-angle a narrowing part is partly a tube: it merges with a boattail, and a later
721/// part with it, by the smaller of the two angles over the larger, the larger taken as at most
722/// this, a judgement: 1°.
723pub const MERGE_MIN_ANGLE_RAD: f64 = std::f64::consts::PI / 180.0;
724
725/// 1 with no gap, falling linearly to 0 at a gap of `scale`.
726fn gap_weight(gap_m: f64, scale_m: f64) -> f64 {
727    if scale_m > 0.0 {
728        (1.0 - gap_m / scale_m).clamp(0.0, 1.0)
729    } else {
730        0.0
731    }
732}
733
734/// How far a narrowing part of half-angle `angle_rad` merges with a boattail whose last part has
735/// `previous_angle_rad`: by their turn, 1 up to [`MERGE_FULL_TURN_RAD`], 0 from
736/// [`MERGE_NONE_TURN_RAD`], linear between; times the smaller angle over the larger, the larger
737/// taken as at most [`MERGE_MIN_ANGLE_RAD`], at most 1. So a part narrowing by nothing is a tube
738/// on either side of a boattail, and parts of one straight cone merge wholly at any angle.
739fn merge_fraction(angle_rad: f64, previous_angle_rad: f64) -> f64 {
740    let turn = (angle_rad - previous_angle_rad).abs();
741    let smooth = ((MERGE_NONE_TURN_RAD - turn) / (MERGE_NONE_TURN_RAD - MERGE_FULL_TURN_RAD))
742        .clamp(0.0, 1.0);
743    let (low, high) = (
744        angle_rad.min(previous_angle_rad),
745        angle_rad.max(previous_angle_rad),
746    );
747    let steep = if high > 0.0 {
748        (low / high.min(MERGE_MIN_ANGLE_RAD)).clamp(0.0, 1.0)
749    } else {
750        0.0
751    };
752    smooth * steep
753}
754
755/// One boattail surface the flow behind it may follow, with its share of that flow and what the
756/// parts since have left of it.
757#[derive(Clone, Copy, PartialEq)]
758struct Tail {
759    /// The start `(x, r)` of the surface a next part would continue.
760    start: (f64, f64),
761    /// The surface's aft end `(x, r)`: a lip's rise is measured from its radius.
762    end: (f64, f64),
763    /// The last narrowing part's half-angle, rad: a next part's turn is measured from it.
764    angle_rad: f64,
765    /// The surface as a boattail: a part's own, or the cone of the surface it continues.
766    cone: Boattail,
767    /// The cone's drop in diameter, m: the length every fade is measured on.
768    fall_m: f64,
769    /// Its share of the flow behind the boattails, from 0 to 1.
770    share: f64,
771    /// The length it fades over since its end, m: tubes' and parts' lengths, and steps' and
772    /// narrowing parts' drops in diameter.
773    fade_m: f64,
774    /// What the lips so far have left of the wake: the smallest share by rise of any top.
775    lip: f64,
776}
777
778impl Tail {
779    /// A narrowing part's own boattail, from its fore end `fore` to its aft end `aft`, `(x, r)`.
780    fn own(cone: Boattail, fore: (f64, f64), aft: (f64, f64), share: f64) -> Self {
781        Self {
782            start: fore,
783            end: aft,
784            angle_rad: cone.half_angle_rad,
785            cone,
786            fall_m: cone.fore_diameter_m - cone.aft_diameter_m,
787            share,
788            fade_m: 0.0,
789            lip: 1.0,
790        }
791    }
792
793    /// The share of a lip whose top is at radius `top_m` that the wake removes by its rise alone.
794    fn share_by_rise(&self, top_m: f64) -> f64 {
795        let rise = 2.0 * (top_m - self.end.1) / self.fall_m;
796        ((WAKE_NONE_RISE - rise) / (WAKE_NONE_RISE - WAKE_FULL_RISE)).clamp(0.0, 1.0)
797    }
798
799    /// What it holds of the flow: its share, faded over one fall and by the lips since.
800    fn hold(&self) -> f64 {
801        self.share * gap_weight(self.fade_m, self.fall_m) * self.lip
802    }
803
804    /// The same state as `other` but for the share: the two may be added.
805    fn same_as(&self, other: &Self) -> bool {
806        Self {
807            share: other.share,
808            ..*self
809        } == *other
810    }
811}
812
813#[cfg(test)]
814thread_local! {
815    /// The most tails [`couple_afterbody`] has held at once on this thread, for the tests.
816    static PEAK_TAILS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
817}
818
819/// Adds `tail` to `tails`, into a tail in the same state if there is one.
820fn add_tail(tails: &mut Vec<Tail>, tail: Tail) {
821    match tails.iter_mut().find(|t| t.same_as(&tail)) {
822        Some(same) => same.share += tail.share,
823        None => tails.push(tail),
824    }
825}
826
827/// The wake's fraction: the tails' holds added up, at most 1.
828fn wake_fraction(tails: &[Tail]) -> f64 {
829    tails.iter().map(Tail::hold).sum::<f64>().min(1.0)
830}
831
832/// The tail that holds the most, for [`WakeTerm::boattail`].
833fn strongest(tails: &[Tail]) -> Option<Boattail> {
834    tails
835        .iter()
836        .fold(None::<&Tail>, |best, t| match best {
837            Some(b) if b.hold() >= t.hold() => Some(b),
838            _ => Some(t),
839        })
840        .map(|t| t.cone)
841}
842
843/// Couples a rocket's afterbody terms once every body component is built: each narrowing
844/// transition's merge with the boattails before it ([`BoattailTerm`]), a lip in a boattail's
845/// wake ([`WakeTerm`]), and the aft base behind a boattail ([`BaseBehindBoattail`]). `bodies` are
846/// the body components in order, each as its index in `terms` and its geometry, end to end.
847///
848/// The flow behind the boattails is shared among their tails, the surfaces it may still follow,
849/// and each tail holds its share faded by what follows it: over one fall (its drop in diameter),
850/// by the length of each tube, lip and part, and the drop of each step down and narrowing part,
851/// and by each lip's rise. A narrowing part moves to a continuation of each tail the share it
852/// merges with ([`BoattailTerm`]), fades what it leaves as a step and a tube, and takes what no
853/// tail then holds as its own boattail; a step down does the same as a boattail of no length,
854/// fully separated. The lips and the base add the tails' holds, at most 1. Tails in the same state are one, so
855/// there are at most as many as pairs of parts. So a part narrowing by nothing is a tube, a part
856/// of no length is a step, every weight is continuous in the geometry, and a small change in a
857/// radius or a length changes the drag a little.
858pub(crate) fn couple_afterbody(
859    terms: &mut [ComponentDragTerms],
860    bodies: &[(usize, BodyGeometry)],
861    reference_area_m2: f64,
862) -> Result<(), AeroError> {
863    use std::f64::consts::PI;
864    let radius = |area: f64| (area / PI).sqrt();
865    let mut tails: Vec<Tail> = Vec::new();
866    let mut x = 0.0;
867    let mut previous_aft_radius: Option<f64> = None;
868    for (index, geometry) in bodies {
869        let (x0, x1) = (x, x + geometry.length_m);
870        let (r0, r1) = (radius(geometry.fore_area_m2), radius(geometry.aft_area_m2));
871        x = x1;
872        let before = previous_aft_radius.unwrap_or(r0);
873        previous_aft_radius = Some(r1);
874        // `index` comes from `body_terms_at`, built alongside `terms` in `AeroModel::new`.
875        let terms = &mut terms[*index];
876        let mut wake: Option<WakeTerm> = None;
877        if r0 < before {
878            // A step down: a corner the flow separates at, and a boattail of no length.
879            for t in &mut tails {
880                t.fade_m += 2.0 * (before - r0);
881            }
882            tails.retain(|t| t.hold() > 0.0);
883            let held: f64 = tails.iter().map(Tail::hold).sum();
884            if held < 1.0 {
885                let corner =
886                    Boattail::new(STEP_LENGTH_RATIO * (before - r0), 2.0 * before, 2.0 * r0)?;
887                add_tail(
888                    &mut tails,
889                    Tail::own(corner, (x0, before), (x0, r0), 1.0 - held),
890                );
891            }
892        } else if r0 > before {
893            // A step up: a lip, by its top.
894            for t in &mut tails {
895                t.lip = t.lip.min(t.share_by_rise(r0));
896            }
897            wake = strongest(&tails).map(|boattail| WakeTerm {
898                boattail,
899                step_fraction: wake_fraction(&tails),
900                shoulder_fraction: 0.0,
901            });
902        }
903        tails.retain(|t| t.hold() > 0.0);
904        if geometry.aft_area_m2 > geometry.fore_area_m2 {
905            // A shoulder: a lip, by its top; then its length fades the tails.
906            for t in &mut tails {
907                t.lip = t.lip.min(t.share_by_rise(r1));
908            }
909            if let Some(boattail) = strongest(&tails) {
910                wake = Some(WakeTerm {
911                    boattail,
912                    step_fraction: wake.map_or(0.0, |w| w.step_fraction),
913                    shoulder_fraction: wake_fraction(&tails),
914                });
915            }
916            for t in &mut tails {
917                t.fade_m += geometry.length_m;
918            }
919        } else if let Some(term) = &mut terms.boattail {
920            let own = term.own;
921            let angle = own.half_angle_rad;
922            let mut next: Vec<Tail> = Vec::with_capacity(2 * tails.len() + 1);
923            for t in &mut tails {
924                let fraction = merge_fraction(angle, t.angle_rad);
925                let weight = fraction * t.hold();
926                if weight > 0.0 && x0 > t.start.0 && t.start.1 > r0 {
927                    let through_aft = Boattail::new(x1 - t.start.0, 2.0 * t.start.1, 2.0 * r1)?;
928                    let through_fore = Boattail::new(x0 - t.start.0, 2.0 * t.start.1, 2.0 * r0)?;
929                    match term
930                        .merged
931                        .iter_mut()
932                        .find(|m| m.through_aft == through_aft && m.through_fore == through_fore)
933                    {
934                        Some(same) => same.weight += weight,
935                        None => term.merged.push(MergedBoattail {
936                            through_aft,
937                            through_fore,
938                            area_ratio: PI * t.start.1 * t.start.1 / reference_area_m2,
939                            weight,
940                        }),
941                    }
942                    add_tail(
943                        &mut next,
944                        Tail {
945                            angle_rad: angle,
946                            ..Tail::own(through_aft, t.start, (x1, r1), weight)
947                        },
948                    );
949                    t.share *= 1.0 - fraction;
950                }
951                // What it doesn't merge carries on past this part, as a step and a tube.
952                t.fade_m += geometry.length_m + 2.0 * (r0 - r1);
953            }
954            for t in tails.iter().filter(|t| t.hold() > 0.0) {
955                add_tail(&mut next, *t);
956            }
957            let merged: f64 = term.merged.iter().map(|m| m.weight).sum();
958            term.own_weight = (1.0 - merged).max(0.0);
959            let held: f64 = next.iter().map(Tail::hold).sum();
960            if held < 1.0 {
961                add_tail(&mut next, Tail::own(own, (x0, r0), (x1, r1), 1.0 - held));
962            }
963            tails = next;
964        } else {
965            // A tube, or a narrowing too small to count as a boattail: a gap and a step.
966            for t in &mut tails {
967                t.fade_m += geometry.length_m + 2.0 * (r0 - r1).max(0.0);
968            }
969        }
970        terms.in_wake_of = wake.filter(|w| w.step_fraction > 0.0 || w.shoulder_fraction > 0.0);
971        tails.retain(|t| t.hold() > 0.0);
972        #[cfg(test)]
973        PEAK_TAILS.with(|p| p.set(p.get().max(tails.len())));
974    }
975    // The aft base, if the last body component is in a boattail's tail.
976    if let Some((index, geometry)) = bodies.last()
977        && geometry.aft_area_m2 > 0.0
978        && !tails.is_empty()
979    {
980        let base = geometry.aft_area_m2;
981        let mut sources: Vec<ReliefSource> = Vec::new();
982        for t in &tails {
983            match sources.iter_mut().find(|s| s.boattail == t.cone) {
984                Some(same) => same.weight += t.hold(),
985                None => sources.push(ReliefSource {
986                    boattail: t.cone,
987                    area_ratio: (base
988                        / (0.25 * PI * t.cone.fore_diameter_m * t.cone.fore_diameter_m))
989                        .min(1.0),
990                    weight: t.hold(),
991                }),
992            }
993        }
994        terms[*index].base_behind = Some(BaseBehindBoattail { sources });
995    }
996    Ok(())
997}
998
999/// A component's precomputed drag terms, built by [`crate::AeroModel::new`]. Areas are divided by
1000/// the reference area.
1001///
1002/// Serialize-only, like [`crate::AeroModel`].
1003#[derive(Debug, Clone, PartialEq, Serialize)]
1004#[non_exhaustive]
1005pub struct ComponentDragTerms {
1006    /// The component's id.
1007    pub id: String,
1008    /// Friction area (a body's axial projection, both sides of every fin) times the body form
1009    /// factor or the fin thickness factor: the friction drag is `C_fc` times this (eq. 3.85).
1010    pub friction_area_ratio: f64,
1011    /// Relative roughness `R_s/L` of the component's finish on the rocket's length.
1012    pub relative_roughness: f64,
1013    /// Pressure drag of a step up in radius at the fore end, or of a bare front face: a flat
1014    /// face ([`PressureDragCurve::step`]).
1015    pub step: Option<PressureDragTerm>,
1016    /// Pressure drag of a nose or shoulder: its own increase in area.
1017    pub shoulder: Option<PressureDragTerm>,
1018    /// Why the buildup refuses this component, if it does: a nose or shoulder shape with no
1019    /// transonic drag data (a bulged secant ogive, a Haack series past `C = ⅓`). The model still
1020    /// builds, so the normal force and a drag table work; [`crate::AeroModel::drag`] without a
1021    /// table returns [`AeroError::Unsupported`] for it.
1022    pub unsupported: Option<String>,
1023    /// The top of the component's own Mach range, and its model's name, where it has one below
1024    /// the buildup's: tube fins' ([`crate::tube_fins::TUBE_FIN_MACH_LIMIT`]). The buildup returns
1025    /// [`AeroError::Mach`] for this component at it and above.
1026    pub mach_limit: Option<(f64, &'static str)>,
1027    /// A step down in radius at the fore end: its decrease in area, a boattail of no length
1028    /// (eq. 3.88 at `γ = 0`), times the base drag coefficient.
1029    pub boattail_area_ratio: f64,
1030    /// The part of [`Self::boattail_area_ratio`] that is a step down from the body component ahead:
1031    /// that component's aft face. A drag override on that component replaces it; one on this
1032    /// component keeps it ([`hpr_design::DragOverride`]; [ADR-167][adr-167], a part's drag
1033    /// override as OpenRocket flies it).
1034    ///
1035    /// [adr-167]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0167-a-part-s-drag-override-as-openrocket-flies-it.md
1036    pub fore_step_down_area_ratio: f64,
1037    /// A drag coefficient stated in place of this component's own drag, for one copy, every
1038    /// instance counted ([`hpr_design::DragOverride`]): zero for a part whose parent's or stage's
1039    /// override covers it. When set, the component's drag is this and its
1040    /// [`Self::fore_step_down_area_ratio`]'s, and nothing else.
1041    pub stated: Option<f64>,
1042    /// A transition that narrows over a length: its own pressure drag as a boattail, blended by
1043    /// the turn toward its share of the boattails it continues ([`BoattailTerm`]), so parts of
1044    /// one straight cone drag as the cone.
1045    pub boattail: Option<BoattailTerm>,
1046    /// A lip in a boattail's wake, drawn as a step up, a shoulder or both, with tubes, steps or
1047    /// parts between fading it: its step's and shoulder's pressure drag scaled by one less their
1048    /// fractions ([`WakeTerm`]).
1049    pub in_wake_of: Option<WakeTerm>,
1050    /// The boattails the aft base may take relief from, when a boattail lies ahead of it with
1051    /// only parts between that leave some: the base drag's factor
1052    /// ([`Boattail::base_pressure_ratio`], [`BaseBehindBoattail`]).
1053    pub base_behind: Option<BaseBehindBoattail>,
1054    /// A fin set's pressure-drag inputs, or a tube fin set's walls as square-edged fins'.
1055    pub fins: Option<FinPressureTerms>,
1056    /// Launch lugs' and rail buttons' areas (a lug's times its length factor), times the
1057    /// stagnation drag coefficient (eq. 3.95–3.96).
1058    pub parasitic_area_ratio: f64,
1059    /// Area of the aft base, m²: the last body component's aft area, zero for the rest. A pod's
1060    /// last body component has its pod's base.
1061    pub base_area_m2: f64,
1062    /// How many copies of the component fly: one pod per copy for a part in a pod set
1063    /// ([`hpr_design::PlacedComponent::copies`]), 1 for any other. Every term is one copy's, and
1064    /// the component's drag is their sum.
1065    pub copies: u32,
1066    /// Whether the component is in a pod: its base takes no part of the airframe's thrusting
1067    /// motors' area ([`DragConditions::thrusting_motor_area_m2`]).
1068    pub in_pod: bool,
1069    /// Where its pod set is among those that hold motor mounts
1070    /// ([`hpr_design::Layout::motor_pod_sets`]), if it is in one: each copy's base then takes its
1071    /// share of that set's thrusting motors' area
1072    /// ([`DragConditions::thrusting_pod_motor_areas_m2`]).
1073    pub motor_pod_set: Option<usize>,
1074}
1075
1076impl ComponentDragTerms {
1077    fn empty(component: &PlacedComponent, length_m: f64) -> Result<Self, AeroError> {
1078        Ok(Self {
1079            id: component.id.clone(),
1080            friction_area_ratio: 0.0,
1081            relative_roughness: component.finish.roughness_m()? / length_m,
1082            step: None,
1083            shoulder: None,
1084            unsupported: None,
1085            mach_limit: None,
1086            boattail_area_ratio: 0.0,
1087            fore_step_down_area_ratio: 0.0,
1088            stated: None,
1089            boattail: None,
1090            in_wake_of: None,
1091            base_behind: None,
1092            fins: None,
1093            parasitic_area_ratio: 0.0,
1094            base_area_m2: 0.0,
1095            copies: 1,
1096            in_pod: false,
1097            motor_pod_set: None,
1098        })
1099    }
1100
1101    /// A body component's terms: friction on its surface, the step in area from the previous body
1102    /// component (`None` for the first, whose fore face counts as a step up from nothing), and its
1103    /// own pressure drag. `shape` is a nose's or transition's profile shape, `None` for a tube.
1104    /// A transition that narrows over a length is a [`Boattail`] of its own until
1105    /// [`couple_afterbody`] joins it with its neighbours.
1106    ///
1107    /// A nose or shoulder's fineness ratio is its length over its rise in diameter,
1108    /// `l/(d_aft − d_fore)`: a nose's `l/d`, and for a shoulder the fineness of the nose with the
1109    /// same surface angle ([`crate::nose_drag`], ADR-028).
1110    ///
1111    /// The friction area is the surface's projection along the axis, `2π ∫ r dx = π A_plan`: the
1112    /// wall shear acts along the surface, so each element's axial share is `τ cos θ dA`. Niskanen's
1113    /// wetted area (eq. 3.85) omits the `cos θ`; the difference is small on slender noses, and
1114    /// without it a shoulder's friction would tend to a flat annulus's as its length goes to zero
1115    /// (ADR-009).
1116    pub(crate) fn body(
1117        component: &PlacedComponent,
1118        geometry: &BodyGeometry,
1119        shape: Option<NoseShape>,
1120        previous_aft_area_m2: Option<f64>,
1121        form_factor: f64,
1122        length_m: f64,
1123        reference_area_m2: f64,
1124    ) -> Result<Self, AeroError> {
1125        use std::f64::consts::PI;
1126        let mut terms = Self::empty(component, length_m)?;
1127        terms.friction_area_ratio =
1128            form_factor * PI * geometry.planform_area_m2 / reference_area_m2;
1129        let step = geometry.fore_area_m2 - previous_aft_area_m2.unwrap_or(0.0);
1130        if step > 0.0 {
1131            terms.step = Some(PressureDragTerm {
1132                curve: PressureDragCurve::step(),
1133                area_ratio: step / reference_area_m2,
1134            });
1135        } else if step < 0.0 {
1136            // A zero-length boattail: `γ = 0`.
1137            terms.boattail_area_ratio -= step;
1138            terms.fore_step_down_area_ratio = -step / reference_area_m2;
1139        }
1140        let diameter = |area: f64| 2.0 * (area / PI).sqrt();
1141        let change = geometry.aft_area_m2 - geometry.fore_area_m2;
1142        let rise = diameter(geometry.aft_area_m2) - diameter(geometry.fore_area_m2);
1143        // A widening too small to change the diameter as computed is none.
1144        if change > 0.0 && rise > 0.0 {
1145            let shape = shape.ok_or_else(|| {
1146                AeroError::Layout("a body that widens needs a profile shape".to_owned())
1147            })?;
1148            let joint = geometry.aft_angle_rad.max(0.0);
1149            match PressureDragCurve::new(shape, geometry.length_m / rise, joint) {
1150                Ok(curve) => {
1151                    terms.shoulder = Some(PressureDragTerm {
1152                        curve,
1153                        area_ratio: change / reference_area_m2,
1154                    });
1155                }
1156                Err(AeroError::Unsupported(why)) => terms.unsupported = Some(why),
1157                Err(error) => return Err(error),
1158            }
1159        } else if change < 0.0 {
1160            let (fore, aft) = (
1161                diameter(geometry.fore_area_m2),
1162                diameter(geometry.aft_area_m2),
1163            );
1164            if geometry.length_m > 0.0 && fore > aft {
1165                terms.boattail = Some(BoattailTerm {
1166                    own: Boattail::new(geometry.length_m, fore, aft)?,
1167                    own_area_ratio: geometry.fore_area_m2 / reference_area_m2,
1168                    merged: Vec::new(),
1169                    own_weight: 1.0,
1170                });
1171            } else {
1172                // No length, or a narrowing too small to change the diameter as computed: the
1173                // rule's `γ = 0`, factor 1, as a step.
1174                terms.boattail_area_ratio -= change;
1175            }
1176        }
1177        terms.boattail_area_ratio /= reference_area_m2;
1178        Ok(terms)
1179    }
1180
1181    /// A fin set's terms: friction on both sides of every fin with the thickness factor, and
1182    /// pressure drag on the frontal area.
1183    pub(crate) fn fins(
1184        component: &PlacedComponent,
1185        set: &FinSet,
1186        geometry: &FinGeometry,
1187        length_m: f64,
1188        reference_area_m2: f64,
1189    ) -> Result<Self, AeroError> {
1190        let mut terms = Self::empty(component, length_m)?;
1191        let count = f64::from(set.count);
1192        let factor = fin_friction_thickness_factor(set.thickness_m, geometry.mac_length_m)?;
1193        // Checks the cross-section and the sweep once, here.
1194        fin_pressure_drag_coefficient(set.cross_section, geometry.leading_edge_sweep_rad, 0.0)?;
1195        terms.friction_area_ratio = 2.0 * count * geometry.area_m2 * factor / reference_area_m2;
1196        terms.fins = Some(FinPressureTerms {
1197            cross_section: set.cross_section,
1198            leading_edge_sweep_rad: geometry.leading_edge_sweep_rad,
1199            frontal_area_ratio: count * set.thickness_m * geometry.span_m / reference_area_m2,
1200        });
1201        Ok(terms)
1202    }
1203
1204    /// A tube fin set's terms: friction inside and outside every tube, `2π L (r_o + r_i)` each
1205    /// with no form factor, and pressure drag on the walls' frontal annulus `π (r_o² − r_i²)`,
1206    /// as a square-edged fin's (eq. 3.90 at the leading edge, eq. 3.92 at the trailing edge), up to
1207    /// the tube-fin model's Mach limit.
1208    pub(crate) fn tube_fins(
1209        component: &PlacedComponent,
1210        set: &TubeFinSet,
1211        length_m: f64,
1212        reference_area_m2: f64,
1213    ) -> Result<Self, AeroError> {
1214        let mut terms = Self::empty(component, length_m)?;
1215        check_dimension("tube fin length", set.length_m, false)?;
1216        check_dimension("tube fin outer radius", set.outer_radius_m, false)?;
1217        check_dimension("tube fin thickness", set.thickness_m, true)?;
1218        let (outer, inner) = (set.outer_radius_m, set.outer_radius_m - set.thickness_m);
1219        check_dimension("tube fin inner radius", inner, false)?;
1220        let count = f64::from(set.count);
1221        let wetted = 2.0 * std::f64::consts::PI * set.length_m * (outer + inner);
1222        terms.friction_area_ratio = count * wetted / reference_area_m2;
1223        terms.fins = Some(FinPressureTerms {
1224            cross_section: FinCrossSection::Square,
1225            leading_edge_sweep_rad: 0.0,
1226            frontal_area_ratio: count * std::f64::consts::PI * (outer * outer - inner * inner)
1227                / reference_area_m2,
1228        });
1229        terms.mach_limit = Some((TUBE_FIN_MACH_LIMIT, "the tube-fin model"));
1230        Ok(terms)
1231    }
1232
1233    /// A row of launch lugs (eq. 3.95–3.96).
1234    pub(crate) fn launch_lugs(
1235        component: &PlacedComponent,
1236        lug: &LaunchLug,
1237        length_m: f64,
1238        reference_area_m2: f64,
1239    ) -> Result<Self, AeroError> {
1240        let mut terms = Self::empty(component, length_m)?;
1241        let (factor, area) = launch_lug_factor_and_area(
1242            lug.length_m,
1243            lug.outer_radius_m,
1244            lug.outer_radius_m - lug.thickness_m,
1245        )?;
1246        terms.parasitic_area_ratio = f64::from(lug.count) * factor * area / reference_area_m2;
1247        Ok(terms)
1248    }
1249
1250    /// A row of rail buttons, each on its side profile: the base and flange at the outer diameter
1251    /// and the waist between them at the inner diameter.
1252    pub(crate) fn rail_buttons(
1253        component: &PlacedComponent,
1254        button: &RailButton,
1255        length_m: f64,
1256        reference_area_m2: f64,
1257    ) -> Result<Self, AeroError> {
1258        let mut terms = Self::empty(component, length_m)?;
1259        check_dimension("rail button outer diameter", button.outer_diameter_m, false)?;
1260        check_dimension("rail button inner diameter", button.inner_diameter_m, true)?;
1261        let ends = button.base_height_m + button.flange_height_m;
1262        let waist = button.height_m - ends;
1263        check_dimension("rail button base and flange height", ends, true)?;
1264        check_dimension("rail button waist height", waist, true)?;
1265        let frontal = button.outer_diameter_m * ends + button.inner_diameter_m * waist;
1266        terms.parasitic_area_ratio = f64::from(button.count) * frontal / reference_area_m2;
1267        Ok(terms)
1268    }
1269
1270    /// A stage's stated drag coefficient ([`hpr_design::DragOverride`]), as a term of its own.
1271    pub(crate) fn stage(id: &str, coefficient: f64) -> Self {
1272        Self {
1273            id: id.to_owned(),
1274            friction_area_ratio: 0.0,
1275            relative_roughness: 0.0,
1276            step: None,
1277            shoulder: None,
1278            unsupported: None,
1279            mach_limit: None,
1280            boattail_area_ratio: 0.0,
1281            fore_step_down_area_ratio: 0.0,
1282            stated: Some(coefficient),
1283            boattail: None,
1284            in_wake_of: None,
1285            base_behind: None,
1286            fins: None,
1287            parasitic_area_ratio: 0.0,
1288            base_area_m2: 0.0,
1289            copies: 1,
1290            in_pod: false,
1291            motor_pod_set: None,
1292        }
1293    }
1294
1295    /// The component's drag at `mach` with the rocket's Reynolds number `reynolds` and the
1296    /// thrusting motors' area.
1297    pub(crate) fn evaluate(
1298        &self,
1299        reynolds: f64,
1300        mach: f64,
1301        conditions: &DragConditions,
1302        reference_area_m2: f64,
1303    ) -> Result<Drag, AeroError> {
1304        // A stated coefficient replaces the component's own drag, which is then never computed:
1305        // a shape the buildup has no curve for, or a speed past its own model's, doesn't matter.
1306        if let Some(stated) = self.stated {
1307            let copies = f64::from(self.copies);
1308            // The step down from the part ahead is that part's aft face (ADR-167).
1309            let pressure = copies * base_drag_coefficient(mach)? * self.fore_step_down_area_ratio;
1310            let stated = copies * stated;
1311            return Ok(Drag {
1312                zero_lift_coefficient: pressure + stated,
1313                axial_coefficient: pressure + stated,
1314                pressure,
1315                stated,
1316                ..Drag::default()
1317            });
1318        }
1319        if let Some(why) = &self.unsupported {
1320            return Err(AeroError::InComponent {
1321                id: self.id.clone(),
1322                source: Box::new(AeroError::Unsupported(why.clone())),
1323            });
1324        }
1325        if let Some((limit, model)) = self.mach_limit {
1326            check_mach(mach, limit, model).map_err(|e| AeroError::InComponent {
1327                id: self.id.clone(),
1328                source: Box::new(e),
1329            })?;
1330        }
1331        let friction = if self.friction_area_ratio > 0.0 {
1332            skin_friction_coefficient(reynolds, self.relative_roughness, mach)?
1333                * self.friction_area_ratio
1334        } else {
1335            0.0
1336        };
1337        let base_coefficient = base_drag_coefficient(mach)?;
1338        let mut pressure = base_coefficient * self.boattail_area_ratio;
1339        if let Some(term) = &self.boattail {
1340            let mut merged = 0.0;
1341            for m in &term.merged {
1342                let share = m.area_ratio
1343                    * (m.through_aft.pressure_drag_coefficient(mach)?
1344                        - m.through_fore.pressure_drag_coefficient(mach)?);
1345                // It may be below 0: extending a boattail can lower its drag. Merged wholly, the
1346                // parts' shares add up to the whole cone's drag, which is not.
1347                merged += m.weight * share;
1348            }
1349            if term.own_weight > 0.0 {
1350                merged += term.own_weight
1351                    * term.own_area_ratio
1352                    * term.own.pressure_drag_coefficient(mach)?;
1353            }
1354            pressure += merged;
1355        }
1356        // A lip in a boattail's wake keeps `1 − fraction` of its step's and shoulder's drag.
1357        let wake = self.in_wake_of;
1358        for (term, fraction) in [
1359            (&self.step, wake.map_or(0.0, |w| w.step_fraction)),
1360            (&self.shoulder, wake.map_or(0.0, |w| w.shoulder_fraction)),
1361        ] {
1362            if let Some(term) = term {
1363                pressure += (1.0 - fraction) * term.area_ratio * term.curve.coefficient(mach)?;
1364            }
1365        }
1366        if let Some(fins) = &self.fins {
1367            pressure += fins.frontal_area_ratio
1368                * fin_pressure_drag_coefficient(
1369                    fins.cross_section,
1370                    fins.leading_edge_sweep_rad,
1371                    mach,
1372                )?;
1373        }
1374        let parasitic = if self.parasitic_area_ratio > 0.0 {
1375            stagnation_drag_coefficient(mach)? * self.parasitic_area_ratio
1376        } else {
1377            0.0
1378        };
1379        // The base takes each boattail's relief by its share of the flow.
1380        let mut relief = 1.0;
1381        for source in self.base_behind.iter().flat_map(|b| &b.sources) {
1382            let k = source
1383                .boattail
1384                .base_pressure_ratio(mach, source.area_ratio)?;
1385            relief -= source.weight * (1.0 - k);
1386        }
1387        // The shares add up to at most 1, so only rounding takes it below 0; a NaN stays one.
1388        if relief < 0.0 {
1389            relief = 0.0;
1390        }
1391        // The airframe's base takes its motors' area; a pod's, its share of its set's motors'.
1392        let motor_area_m2 = match (self.in_pod, self.motor_pod_set) {
1393            (false, _) => conditions.thrusting_motor_area_m2,
1394            (true, Some(set)) if self.copies > 0 => {
1395                let total = conditions
1396                    .thrusting_pod_motor_areas_m2
1397                    .get(set)
1398                    .copied()
1399                    .ok_or(AeroError::Domain {
1400                        what: "pod set holding motor mounts, by its place",
1401                        value: set as f64,
1402                    })?;
1403                total / f64::from(self.copies)
1404            }
1405            (true, _) => 0.0,
1406        };
1407        let base = base_coefficient * relief * (self.base_area_m2 - motor_area_m2).max(0.0)
1408            / reference_area_m2;
1409        let copies = f64::from(self.copies);
1410        let (friction, pressure, base, parasitic) = (
1411            copies * friction,
1412            copies * pressure,
1413            copies * base,
1414            copies * parasitic,
1415        );
1416        let zero_lift = friction + pressure + base + parasitic;
1417        Ok(Drag {
1418            zero_lift_coefficient: zero_lift,
1419            axial_coefficient: zero_lift,
1420            friction,
1421            pressure,
1422            base,
1423            parasitic,
1424            stated: 0.0,
1425            table: None,
1426        })
1427    }
1428}
1429
1430#[cfg(test)]
1431mod tests {
1432    use std::f64::consts::{FRAC_PI_2, PI};
1433
1434    use hpr_design::{
1435        FinPlanform, Finish, LaunchLug, NoseShape, Part, Position, RailButton, ReferenceDiameter,
1436        Rocket,
1437    };
1438
1439    use super::*;
1440    use crate::table::DragTable;
1441    use crate::testing::{body_part, component, fin_set, material, nose, one_stage};
1442    use crate::{AeroModel, Flow};
1443
1444    /// A flat face's pressure drag below Mach 1, by hand: the blunt cylinder
1445    /// `0.85 (1 + M²/4 + M⁴/40)` (Niskanen 2009 eq. B.1–B.2).
1446    fn step_by_hand(mach: f64) -> f64 {
1447        let m2 = mach * mach;
1448        0.85 * (1.0 + m2 / 4.0 + m2 * m2 / 40.0)
1449    }
1450
1451    fn close(got: f64, want: f64, rel: f64, what: &str) {
1452        let err = if want == 0.0 {
1453            got.abs()
1454        } else {
1455            ((got - want) / want).abs()
1456        };
1457        assert!(
1458            err <= rel,
1459            "{what}: got {got}, want {want}, rel err {err:e}"
1460        );
1461    }
1462
1463    fn model(rocket: &Rocket) -> AeroModel {
1464        AeroModel::new(&rocket.layout().unwrap()).unwrap()
1465    }
1466
1467    /// Sea level, Mach 0.3: `V/ν` for USSA76 (a = 340.294 m/s, ν = 1.4607e-5 m²/s).
1468    const RE_PER_M: f64 = 0.3 * 340.294 / 1.4607e-5;
1469
1470    fn fins_on(
1471        tube: &str,
1472        radius: f64,
1473        length: f64,
1474        sets: Vec<hpr_design::Component>,
1475    ) -> hpr_design::Component {
1476        let mut c = component(tube, body_part(length, radius, radius), None);
1477        c.children = sets;
1478        c
1479    }
1480
1481    fn trapezoid() -> FinPlanform {
1482        FinPlanform::Trapezoidal {
1483            root_chord_m: 0.12,
1484            tip_chord_m: 0.05,
1485            span_m: 0.06,
1486            sweep_m: 0.07,
1487        }
1488    }
1489
1490    fn with_fin(
1491        id: &str,
1492        count: u32,
1493        thickness: f64,
1494        section: FinCrossSection,
1495        aft_offset: f64,
1496    ) -> hpr_design::Component {
1497        let mut part = fin_set(count, trapezoid());
1498        if let Part::FinSet(set) = &mut part {
1499            set.thickness_m = thickness;
1500            set.cross_section = section;
1501        }
1502        component(
1503            id,
1504            part,
1505            Some(Position::Bottom {
1506                aft_offset_m: aft_offset,
1507            }),
1508        )
1509    }
1510
1511    /// Loft lesson L90: skin friction follows Niskanen eq. 3.81 piecewise, including its published
1512    /// jump at `R_crit`; friction and the other Mach-dependent terms stay finite and positive to
1513    /// Mach 5; a fin set split in two drags like one set; base drag is continuous at Mach 1.
1514    #[test]
1515    fn skin_friction_follows_eq_3_81_and_drag_invariants_hold() {
1516        // Below 1e4, the constant; from 1e4 to R_crit, eq. 3.78; from R_crit, eq. 3.80.
1517        let rr = 60e-6; // 60 µm on 1 m
1518        assert_eq!(incompressible_skin_friction(9_999.0, rr).unwrap(), 1.48e-2);
1519        assert_eq!(incompressible_skin_friction(0.0, rr).unwrap(), 1.48e-2);
1520        close(
1521            incompressible_skin_friction(1e4, rr).unwrap(),
1522            1.48e-2,
1523            2e-3,
1524            "continuous at 1e4",
1525        );
1526        for r in [1e4, 3e4, 1e5, 1e6] {
1527            let d = 1.50 * f64::ln(r) - 5.6;
1528            assert_eq!(
1529                incompressible_skin_friction(r, rr).unwrap(),
1530                1.0 / (d * d),
1531                "eq. 3.78 at {r}"
1532            );
1533        }
1534        let critical = critical_reynolds(rr).unwrap();
1535        assert_eq!(critical, 51.0 * rr.powf(-1.039));
1536        close(critical, 1.242e6, 1e-3, "R_crit for 60 µm on 1 m");
1537        let rough = 0.032 * rr.powf(0.2);
1538        assert_eq!(incompressible_skin_friction(critical, rr).unwrap(), rough);
1539        assert_eq!(incompressible_skin_friction(1e9, rr).unwrap(), rough);
1540        // The published jump at R_crit: 0.00419 below, 0.00458 at it.
1541        let below = incompressible_skin_friction(critical * (1.0 - 1e-12), rr).unwrap();
1542        close(below, 0.00419, 2e-3, "turbulent just below R_crit");
1543        close(rough, 0.00458, 2e-3, "roughness-limited at R_crit");
1544        // A mirror finish never reaches the roughness limit.
1545        assert_eq!(critical_reynolds(0.0).unwrap(), f64::INFINITY);
1546        let d = 1.50 * f64::ln(1e9) - 5.6;
1547        assert_eq!(
1548            incompressible_skin_friction(1e9, 0.0).unwrap(),
1549            1.0 / (d * d)
1550        );
1551
1552        // Compressibility: 1 − 0.1 M² below Mach 1 on both branches.
1553        for (r, rr) in [(1e5, rr), (1e9, rr)] {
1554            let cf = incompressible_skin_friction(r, rr).unwrap();
1555            close(
1556                skin_friction_coefficient(r, rr, 0.8).unwrap(),
1557                cf * (1.0 - 0.064),
1558                1e-15,
1559                "eq. 3.82",
1560            );
1561        }
1562        // Supersonic: eq. 3.83 turbulent, eq. 3.84 roughness-limited but never below turbulent.
1563        let d = 1.50 * f64::ln(1e5) - 5.6;
1564        close(
1565            skin_friction_coefficient(1e5, rr, 2.0).unwrap(),
1566            1.0 / (d * d) / 1.6f64.powf(0.58),
1567            1e-15,
1568            "eq. 3.83",
1569        );
1570        close(
1571            skin_friction_coefficient(1e9, rr, 2.0).unwrap(),
1572            rough / 1.72,
1573            1e-15,
1574            "eq. 3.84",
1575        );
1576        // At Mach 5 the rough value corrected by eq. 3.84 falls below the turbulent one, which
1577        // then applies.
1578        let rr_small = 2e-6;
1579        for mach in (0..=100).map(|k| 0.05 * f64::from(k)) {
1580            for r in [0.0, 1e3, 1e4, 1e5, 1e7, 1e9] {
1581                for rr in [0.0, 2e-6, 60e-6, 1e-3] {
1582                    let cf = skin_friction_coefficient(r, rr, mach).unwrap();
1583                    assert!(
1584                        cf.is_finite() && cf > 0.0,
1585                        "C_f {cf} at M {mach}, R {r}, R_s/L {rr}"
1586                    );
1587                    if mach >= 1.0 {
1588                        let d = 1.50 * f64::ln(r.max(1e4)) - 5.6;
1589                        let smooth = if r < 1e4 { 1.48e-2 } else { 1.0 / (d * d) };
1590                        let floor = smooth / (1.0 + 0.15 * mach * mach).powf(0.58);
1591                        assert!(cf >= floor * (1.0 - 1e-15), "below turbulent at M {mach}");
1592                    }
1593                }
1594            }
1595            for f in [
1596                stagnation_drag_coefficient(mach).unwrap(),
1597                base_drag_coefficient(mach).unwrap(),
1598                fin_pressure_drag_coefficient(FinCrossSection::Rounded, 0.3, mach).unwrap(),
1599                fin_pressure_drag_coefficient(FinCrossSection::Square, 0.3, mach).unwrap(),
1600            ] {
1601                assert!(f.is_finite() && f >= 0.0, "term {f} at M {mach}");
1602            }
1603        }
1604        // At R = 1e9 on 2 µm per meter, eq. 3.84 gives 4.2e-4 at Mach 5, below the turbulent
1605        // 6.2e-4 at the same Reynolds number, which then applies.
1606        assert!(critical_reynolds(rr_small).unwrap() < 1e9);
1607        let rough_m5 = 0.032 * rr_small.powf(0.2) / (1.0 + 0.18 * 25.0);
1608        let turbulent_m5 = {
1609            let d = 1.50 * f64::ln(1e9) - 5.6;
1610            1.0 / (d * d) / (1.0f64 + 0.15 * 25.0).powf(0.58)
1611        };
1612        assert!(rough_m5 < turbulent_m5);
1613        assert_eq!(
1614            skin_friction_coefficient(1e9, rr_small, 5.0).unwrap(),
1615            turbulent_m5
1616        );
1617        // Very rough (R_crit below 1e4): the low-Reynolds value still applies below 1e4.
1618        assert!(critical_reynolds(2e-2).unwrap() < 5e3);
1619        assert_eq!(incompressible_skin_friction(5e3, 2e-2).unwrap(), 1.48e-2);
1620        assert_eq!(
1621            incompressible_skin_friction(2e4, 2e-2).unwrap(),
1622            0.032 * 2e-2f64.powf(0.2)
1623        );
1624
1625        // Base drag is continuous at Mach 1.
1626        let below = base_drag_coefficient(1.0 - 1e-12).unwrap();
1627        let at = base_drag_coefficient(1.0).unwrap();
1628        close(below, 0.25, 1e-11, "base drag below Mach 1");
1629        assert_eq!(at, 0.25);
1630
1631        // Four fins drag like two two-fin sets at the same station, 90° apart.
1632        let one_set = one_stage(
1633            vec![
1634                component(
1635                    "nose",
1636                    nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, 0.027),
1637                    None,
1638                ),
1639                fins_on(
1640                    "tail",
1641                    0.027,
1642                    0.6,
1643                    vec![with_fin("fins", 4, 0.003, FinCrossSection::Rounded, 0.0)],
1644                ),
1645            ],
1646            ReferenceDiameter::Maximum {},
1647        );
1648        let mut half_b = with_fin("fins-b", 2, 0.003, FinCrossSection::Rounded, 0.0);
1649        if let Part::FinSet(set) = &mut half_b.part {
1650            set.base_angle_rad = FRAC_PI_2;
1651        }
1652        let split = one_stage(
1653            vec![
1654                component(
1655                    "nose",
1656                    nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, 0.027),
1657                    None,
1658                ),
1659                fins_on(
1660                    "tail",
1661                    0.027,
1662                    0.6,
1663                    vec![
1664                        with_fin("fins-a", 2, 0.003, FinCrossSection::Rounded, 0.0),
1665                        half_b,
1666                    ],
1667                ),
1668            ],
1669            ReferenceDiameter::Maximum {},
1670        );
1671        let conditions = DragConditions::coasting(RE_PER_M);
1672        for alpha in [0.0, 0.1] {
1673            let flow = Flow::new(0.3, alpha, 0.0);
1674            let a = model(&one_set).drag(&flow, &conditions).unwrap();
1675            let b = model(&split).drag(&flow, &conditions).unwrap();
1676            close(
1677                b.zero_lift_coefficient,
1678                a.zero_lift_coefficient,
1679                1e-15,
1680                "split C_D0",
1681            );
1682            close(b.pressure, a.pressure, 1e-15, "split pressure drag");
1683            close(b.friction, a.friction, 1e-15, "split friction drag");
1684        }
1685    }
1686
1687    /// Loft lesson L11: fin sets are separate terms, so listing them in another order changes
1688    /// nothing.
1689    #[test]
1690    fn drag_invariant_to_fin_set_order() {
1691        let sets = || {
1692            let mut canted = with_fin("aft", 3, 0.004, FinCrossSection::Square, 0.0);
1693            if let Part::FinSet(set) = &mut canted.part {
1694                set.planform = FinPlanform::Elliptical {
1695                    root_chord_m: 0.1,
1696                    span_m: 0.05,
1697                };
1698                set.base_angle_rad = 0.2;
1699            }
1700            (
1701                with_fin("fore", 4, 0.002, FinCrossSection::Airfoil, 0.3),
1702                canted,
1703            )
1704        };
1705        let build = |sets: Vec<hpr_design::Component>| {
1706            let rocket = one_stage(
1707                vec![
1708                    component("nose", nose(NoseShape::Conical {}, 0.2, 0.027), None),
1709                    fins_on("tail", 0.027, 0.8, sets),
1710                ],
1711                ReferenceDiameter::Maximum {},
1712            );
1713            model(&rocket)
1714        };
1715        let (a, b) = sets();
1716        let forward = build(vec![a, b]);
1717        let (a, b) = sets();
1718        let reversed = build(vec![b, a]);
1719        for (mach, alpha, motor) in [(0.1, 0.0, 0.0), (0.5, 0.2, 1e-3), (0.9, 1.0, 0.0)] {
1720            let flow = Flow::new(mach, alpha, 0.0);
1721            let conditions = DragConditions::thrusting(RE_PER_M, motor);
1722            let f = forward.drag(&flow, &conditions).unwrap();
1723            let r = reversed.drag(&flow, &conditions).unwrap();
1724            close(
1725                r.zero_lift_coefficient,
1726                f.zero_lift_coefficient,
1727                1e-15,
1728                "C_D0",
1729            );
1730            close(r.axial_coefficient, f.axial_coefficient, 1e-15, "C_A");
1731            close(r.pressure, f.pressure, 1e-15, "pressure");
1732            close(r.friction, f.friction, 1e-15, "friction");
1733        }
1734    }
1735
1736    /// Loft lesson L12: the body form factor is Niskanen's `1 + 1/(2 f_B)` (1.125 at fineness 4,
1737    /// not Loft's 1.95), the fin factor `1 + 2t/c̄`, the subsonic friction correction
1738    /// `1 − 0.1 M²`, and every named roughness height is Barrowman 1967 Table 4-1's (Niskanen
1739    /// Table 3.2 reprints ten of them).
1740    #[test]
1741    fn form_factor_and_roughness_match_cited_values() {
1742        assert_eq!(body_friction_form_factor(4.0).unwrap(), 1.125);
1743        assert_eq!(body_friction_form_factor(10.0).unwrap(), 1.05);
1744        assert_eq!(fin_friction_thickness_factor(0.003, 0.1).unwrap(), 1.06);
1745        assert_eq!(fin_friction_thickness_factor(0.0, 0.1).unwrap(), 1.0);
1746        let cf = incompressible_skin_friction(1e6, 0.0).unwrap();
1747        close(
1748            skin_friction_coefficient(1e6, 0.0, 0.5).unwrap(),
1749            cf * 0.975,
1750            1e-15,
1751            "1 − 0.1 M²",
1752        );
1753
1754        // Barrowman 1967 Table 4-1, p. 46, in microns, smoothest first.
1755        let table = [
1756            0.0, 0.1, 0.5, 2.0, 5.0, 15.0, 20.0, 50.0, 50.0, 100.0, 150.0, 200.0, 250.0, 500.0,
1757            1000.0,
1758        ];
1759        for (finish, microns) in Finish::NAMED.iter().zip(table) {
1760            close(
1761                finish.roughness_m().unwrap(),
1762                microns * 1e-6,
1763                1e-15,
1764                &format!("{finish:?}"),
1765            );
1766        }
1767        // Niskanen Table 3.2's ten rows.
1768        let niskanen = [
1769            (Finish::AverageGlass {}, 0.1),
1770            (Finish::Polished {}, 0.5),
1771            (Finish::OptimumPaint {}, 5.0),
1772            (Finish::PlanedWood {}, 15.0),
1773            (Finish::MassProductionPaint {}, 20.0),
1774            (Finish::SmoothCement {}, 50.0),
1775            (Finish::DipGalvanized {}, 150.0),
1776            (Finish::PoorPaint {}, 200.0),
1777            (Finish::RawWood {}, 500.0),
1778            (Finish::Concrete {}, 1000.0),
1779        ];
1780        for (finish, microns) in niskanen {
1781            close(
1782                finish.roughness_m().unwrap(),
1783                microns * 1e-6,
1784                1e-15,
1785                &format!("{finish:?}"),
1786            );
1787        }
1788        assert_eq!(Finish::default(), Finish::MassProductionPaint {});
1789        assert_eq!(
1790            Finish::Custom { roughness_m: 6e-5 }.roughness_m().unwrap(),
1791            6e-5
1792        );
1793
1794        // In a model: a 1 m tube and 0.25 m cone, 0.05 m diameter: fineness 25, and the
1795        // component's roughness over the rocket's length.
1796        let mut tube = component("tube", body_part(1.0, 0.025, 0.025), None);
1797        tube.finish = Some(Finish::RawWood {});
1798        let rocket = one_stage(
1799            vec![
1800                component("nose", nose(NoseShape::Conical {}, 0.25, 0.025), None),
1801                tube,
1802            ],
1803            ReferenceDiameter::Maximum {},
1804        );
1805        let m = model(&rocket);
1806        let terms = &m.drag_terms()[1];
1807        close(terms.relative_roughness, 500e-6 / 1.25, 1e-15, "R_s/L");
1808        let a_ref = PI * 0.025 * 0.025;
1809        close(
1810            terms.friction_area_ratio,
1811            (1.0 + 1.0 / 50.0) * 2.0 * PI * 0.025 * 1.0 / a_ref,
1812            1e-14,
1813            "form factor × wetted area",
1814        );
1815        // A cone's friction area is its axial projection, π r L, not its slant surface.
1816        let cone = &m.drag_terms()[0];
1817        close(
1818            cone.friction_area_ratio,
1819            (1.0 + 1.0 / 50.0) * PI * 0.025 * 0.25 / a_ref,
1820            1e-12,
1821            "cone projection",
1822        );
1823    }
1824
1825    /// Loft lesson L13: under power the base drag's area is the base less the thrusting motors'
1826    /// area (Niskanen pp. 50–51), down to none when the motors fill the base.
1827    #[test]
1828    fn power_on_base_drag_subtracts_thrusting_motor_area() {
1829        let r = 0.04;
1830        let rocket = one_stage(
1831            vec![
1832                component(
1833                    "nose",
1834                    nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.3, r),
1835                    None,
1836                ),
1837                component("tube", body_part(1.2, r, r), None),
1838            ],
1839            ReferenceDiameter::Maximum {},
1840        );
1841        let m = model(&rocket);
1842        let a_base = PI * r * r;
1843        let motor = PI * 0.027 * 0.027;
1844        let flow = Flow::axial(0.5);
1845        let c_base = 0.12 + 0.13 * 0.25;
1846        let off = m.drag(&flow, &DragConditions::coasting(RE_PER_M)).unwrap();
1847        let on = m
1848            .drag(&flow, &DragConditions::thrusting(RE_PER_M, motor))
1849            .unwrap();
1850        let full = m
1851            .drag(&flow, &DragConditions::thrusting(RE_PER_M, a_base))
1852            .unwrap();
1853        let over = m
1854            .drag(&flow, &DragConditions::thrusting(RE_PER_M, 2.0 * a_base))
1855            .unwrap();
1856        close(
1857            off.base,
1858            c_base,
1859            1e-15,
1860            "coasting base drag on A_ref = A_base",
1861        );
1862        close(
1863            on.base,
1864            c_base * (a_base - motor) / a_base,
1865            1e-14,
1866            "power-on base drag",
1867        );
1868        assert_eq!(full.base, 0.0);
1869        assert_eq!(over.base, 0.0);
1870        // Nothing else changes.
1871        assert_eq!(on.friction, off.friction);
1872        assert_eq!(on.pressure, off.pressure);
1873        close(
1874            off.zero_lift_coefficient - on.zero_lift_coefficient,
1875            off.base - on.base,
1876            1e-12,
1877            "sum",
1878        );
1879        // The base belongs to the last body component.
1880        let parts = m
1881            .buildup_components(&flow, &DragConditions::thrusting(RE_PER_M, motor))
1882            .unwrap();
1883        assert_eq!(parts[0].drag.base, 0.0);
1884        assert_eq!(parts[1].drag.base, on.base);
1885
1886        // An override table switches curves on the same signal.
1887        let table = DragTable::from_csv("0,0.5\n1,0.5\n", Some("0,0.4\n1,0.4\n")).unwrap();
1888        let t = m.clone().with_drag_table(table);
1889        assert_eq!(
1890            t.drag(&flow, &DragConditions::coasting(RE_PER_M))
1891                .unwrap()
1892                .zero_lift_coefficient,
1893            0.5
1894        );
1895        assert_eq!(
1896            t.drag(&flow, &DragConditions::thrusting(RE_PER_M, motor))
1897                .unwrap()
1898                .zero_lift_coefficient,
1899            0.4
1900        );
1901    }
1902
1903    /// Loft lesson L14: a launch lug's drag is Niskanen eq. 3.95–3.96, `max{1.3 − 0.3 l/d, 1}`
1904    /// times the blunt-cylinder `0.85 q_stag/q`, on the annulus plus `max{1 − l/d, 0}` of the bore
1905    /// blocked; a rail button is a rail pin, the stagnation coefficient on its side profile.
1906    #[test]
1907    fn launch_lug_drag_matches_cited_hollow_tube_formula() {
1908        let (ro, ri) = (0.005, 0.004);
1909        let annulus = PI * (ro * ro - ri * ri);
1910        let face = PI * ro * ro;
1911        let stag = |m: f64| 0.85 * (1.0 + m * m / 4.0 + m.powi(4) / 40.0);
1912        // A ring (l = 0): 1.3 on the annulus.
1913        let (c, a) = launch_lug_drag(0.0, ro, ri, 0.0).unwrap();
1914        close(c, 1.3 * 0.85, 1e-15, "ring coefficient");
1915        close(a, annulus, 1e-15, "ring area");
1916        // Half a diameter long: 1.15 on the annulus plus half the bore.
1917        let (c, a) = launch_lug_drag(ro, ro, ri, 0.3).unwrap();
1918        close(c, 1.15 * stag(0.3), 1e-15, "l = d/2 coefficient");
1919        close(a, face - 0.5 * PI * ri * ri, 1e-15, "l = d/2 area");
1920        // A diameter or longer: 1.0 on the whole face.
1921        for l in [2.0 * ro, 0.05] {
1922            let (c, a) = launch_lug_drag(l, ro, ri, 0.7).unwrap();
1923            close(c, stag(0.7), 1e-15, "long lug coefficient");
1924            close(a, face, 1e-15, "long lug area");
1925        }
1926        // Continuous in length.
1927        let at = |l: f64| {
1928            let (c, a) = launch_lug_drag(l, ro, ri, 0.3).unwrap();
1929            c * a
1930        };
1931        close(
1932            at(2.0 * ro - 1e-12),
1933            at(2.0 * ro),
1934            1e-9,
1935            "continuous at l = d",
1936        );
1937        close(at(1e-15), at(0.0), 1e-9, "continuous at l = 0");
1938        assert!(launch_lug_drag(0.03, ro, 0.006, 0.3).is_err());
1939
1940        // In a model, a row of two 30 mm lugs and a row of two rail buttons.
1941        let mut tube = component("tube", body_part(1.0, 0.03, 0.03), None);
1942        tube.children = vec![
1943            component(
1944                "lugs",
1945                Part::LaunchLug(LaunchLug {
1946                    length_m: 0.03,
1947                    outer_radius_m: ro,
1948                    thickness_m: ro - ri,
1949                    angle_rad: 0.0,
1950                    count: 2,
1951                    spacing_m: 0.5,
1952                    material: material(),
1953                }),
1954                Some(Position::Top { aft_offset_m: 0.1 }),
1955            ),
1956            component(
1957                "buttons",
1958                Part::RailButton(RailButton {
1959                    outer_diameter_m: 0.0113,
1960                    inner_diameter_m: 0.0064,
1961                    height_m: 0.0081,
1962                    base_height_m: 0.002,
1963                    flange_height_m: 0.002,
1964                    screw_height_m: 0.0,
1965                    angle_rad: 0.0,
1966                    count: 2,
1967                    spacing_m: 0.5,
1968                    material: material(),
1969                }),
1970                Some(Position::Top { aft_offset_m: 0.1 }),
1971            ),
1972        ];
1973        let rocket = one_stage(
1974            vec![
1975                component("nose", nose(NoseShape::Conical {}, 0.2, 0.03), None),
1976                tube,
1977            ],
1978            ReferenceDiameter::Maximum {},
1979        );
1980        let m = model(&rocket);
1981        let a_ref = PI * 0.03 * 0.03;
1982        let parts = m
1983            .buildup_components(&Flow::axial(0.3), &DragConditions::coasting(RE_PER_M))
1984            .unwrap();
1985        let find = |id: &str| parts.iter().find(|p| p.id == id).unwrap().drag;
1986        close(
1987            find("lugs").parasitic,
1988            2.0 * stag(0.3) * face / a_ref,
1989            1e-14,
1990            "lugs",
1991        );
1992        let profile = 0.0113 * 0.004 + 0.0064 * 0.0041;
1993        close(
1994            find("buttons").parasitic,
1995            2.0 * stag(0.3) * profile / a_ref,
1996            1e-14,
1997            "buttons",
1998        );
1999        assert_eq!(find("lugs").friction, 0.0);
2000    }
2001
2002    /// Loft lesson L15: as a conical shoulder's length goes to zero its joint angle goes to 90°
2003    /// and its drag to a bare step's `0.8 ΔA`; a boattail's goes to the base drag of the area it
2004    /// uncovers, which a bare step down gets.
2005    #[test]
2006    fn shoulder_drag_continuous_as_transition_length_tends_to_zero() {
2007        let (small, big) = (0.02, 0.03);
2008        let rocket = |fore: f64, aft: f64, length: Option<f64>| {
2009            let mut body = vec![
2010                component(
2011                    "nose",
2012                    nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, fore),
2013                    None,
2014                ),
2015                component("fore-tube", body_part(0.4, fore, fore), None),
2016            ];
2017            if let Some(l) = length {
2018                body.push(component("change", body_part(l, fore, aft), None));
2019            }
2020            body.push(component("aft-tube", body_part(0.6, aft, aft), None));
2021            one_stage(body, ReferenceDiameter::Custom { diameter_m: 0.06 })
2022        };
2023        let a_ref = PI * 0.03 * 0.03;
2024        let delta = PI * (big * big - small * small);
2025        let conditions = DragConditions::coasting(RE_PER_M);
2026        let flow = Flow::axial(0.3);
2027        let pressure_of = |r: &Rocket| {
2028            model(r)
2029                .buildup_components(&flow, &conditions)
2030                .unwrap()
2031                .iter()
2032                .filter(|c| c.id == "change" || c.id == "aft-tube")
2033                .map(|c| c.drag.pressure)
2034                .sum::<f64>()
2035        };
2036        let total = |r: &Rocket| {
2037            model(r)
2038                .drag(&flow, &conditions)
2039                .unwrap()
2040                .zero_lift_coefficient
2041        };
2042
2043        // Shoulder: a cone of fineness l/(2Δr) and joint angle tan φ = Δr/l on ΔA, and a bare
2044        // step the flat face's curve on ΔA.
2045        let step = rocket(small, big, None);
2046        close(
2047            pressure_of(&step),
2048            step_by_hand(0.3) * delta / a_ref,
2049            1e-13,
2050            "bare step up",
2051        );
2052        // By hand at l = 0.1: fineness 5, a cone of `tan ε = 0.1`, eq. 3.87 to eq. B.5–B.6.
2053        let s5 = 0.1f64.atan().sin();
2054        let rest = 0.8 * s5 * s5;
2055        let b = 4.0 / 2.4 * (1.0 - 0.5 * s5) / (s5 - rest);
2056        close(
2057            pressure_of(&rocket(small, big, Some(0.1))),
2058            (rest + (s5 - rest) * 0.3f64.powf(b)) * delta / a_ref,
2059            1e-12,
2060            "shoulder by hand",
2061        );
2062        let mut previous = f64::INFINITY;
2063        for l in [0.1, 0.01, 1e-3, 1e-5, 1e-8] {
2064            let s = rocket(small, big, Some(l));
2065            let phi = f64::atan((big - small) / l);
2066            let curve =
2067                PressureDragCurve::new(NoseShape::Conical {}, l / (2.0 * (big - small)), phi)
2068                    .unwrap();
2069            close(
2070                pressure_of(&s),
2071                curve.coefficient(0.3).unwrap() * delta / a_ref,
2072                1e-12,
2073                "shoulder",
2074            );
2075            let gap = (total(&s) - total(&step)).abs();
2076            assert!(gap < previous, "shoulder gap {gap} at l = {l}");
2077            previous = gap;
2078        }
2079        assert!(previous < 1e-6 * total(&step));
2080
2081        // Boattail: eq. 3.88 with γ = l/(d₁ − d₂); a bare step down is γ = 0.
2082        let c_base = 0.12 + 0.13 * 0.09;
2083        let step = rocket(big, small, None);
2084        close(
2085            pressure_of(&step),
2086            c_base * delta / a_ref,
2087            1e-14,
2088            "bare step down",
2089        );
2090        for (l, factor) in [
2091            (0.01, 1.0),
2092            (0.02, 1.0),
2093            (0.04, 0.5),
2094            (0.06, 0.0),
2095            (0.1, 0.0),
2096        ] {
2097            close(
2098                pressure_of(&rocket(big, small, Some(l))),
2099                factor * c_base * delta / a_ref,
2100                1e-14,
2101                &format!("boattail at l = {l}"),
2102            );
2103        }
2104        let gap = (total(&rocket(big, small, Some(1e-8))) - total(&step)).abs();
2105        assert!(gap < 1e-6 * total(&step), "boattail gap {gap}");
2106    }
2107
2108    /// Loft lesson L16: geometry the drag model can't use is an error naming the component, and
2109    /// a large drag coefficient is returned as it is, not capped.
2110    #[test]
2111    fn malformed_geometry_is_an_error_not_a_clamped_cd() {
2112        let base = |child: hpr_design::Component| {
2113            let mut tube = component("tube", body_part(1.0, 0.03, 0.03), None);
2114            tube.children = vec![child];
2115            one_stage(
2116                vec![
2117                    component("nose", nose(NoseShape::Conical {}, 0.2, 0.03), None),
2118                    tube,
2119                ],
2120                ReferenceDiameter::Maximum {},
2121            )
2122        };
2123        // A rail button whose base and flange are taller than the button.
2124        let button = component(
2125            "buttons",
2126            Part::RailButton(RailButton {
2127                outer_diameter_m: 0.0113,
2128                inner_diameter_m: 0.0064,
2129                height_m: 0.003,
2130                base_height_m: 0.002,
2131                flange_height_m: 0.002,
2132                screw_height_m: 0.0,
2133                angle_rad: 0.0,
2134                count: 1,
2135                spacing_m: 0.0,
2136                material: material(),
2137            }),
2138            Some(Position::Top { aft_offset_m: 0.1 }),
2139        );
2140        // The design model refuses it already, and the drag terms would on their own.
2141        let rocket = base(button);
2142        assert!(rocket.layout().is_err());
2143        let mut layout = base(with_fin("fins", 3, 0.003, FinCrossSection::Square, 0.0))
2144            .layout()
2145            .unwrap();
2146        let Part::RailButton(bad) = &rocket.stages[0].components[1].children[0].part else {
2147            unreachable!("the rail button built above");
2148        };
2149        let mut placed = layout.components[0].clone();
2150        placed.id = "buttons".to_owned();
2151        placed.part = Part::RailButton(bad.clone());
2152        let err = ComponentDragTerms::rail_buttons(&placed, bad, 1.0, 1e-3).unwrap_err();
2153        assert!(matches!(err, AeroError::Domain { .. }), "{err:?}");
2154        // A layout without body components has no radius for the form factor.
2155        layout.components.clear();
2156        assert!(matches!(
2157            AeroModel::new(&layout),
2158            Err(AeroError::Domain { .. })
2159        ));
2160        // A negative custom roughness: the design refuses it, and so does the model given such a
2161        // layout directly.
2162        let mut fins = with_fin("fins", 3, 0.003, FinCrossSection::Square, 0.0);
2163        fins.finish = Some(Finish::Custom { roughness_m: -1e-6 });
2164        assert!(base(fins).layout().is_err());
2165        let mut layout = base(with_fin("fins", 3, 0.003, FinCrossSection::Square, 0.0))
2166            .layout()
2167            .unwrap();
2168        let index = layout.find("fins").unwrap().0;
2169        layout.components[index].finish = Finish::Custom { roughness_m: -1e-6 };
2170        let (id, source) = match AeroModel::new(&layout) {
2171            Err(AeroError::InComponent { id, source }) => (id, *source),
2172            other => panic!("expected an error in a component, got {other:?}"),
2173        };
2174        assert_eq!(id, "fins");
2175        assert!(matches!(source, AeroError::Design(_)), "{source:?}");
2176        // Bad conditions.
2177        let m = model(&base(with_fin(
2178            "fins",
2179            3,
2180            0.003,
2181            FinCrossSection::Square,
2182            0.0,
2183        )));
2184        let flow = Flow::axial(0.3);
2185        for conditions in [
2186            DragConditions::coasting(f64::NAN),
2187            DragConditions::coasting(-1.0),
2188            DragConditions::thrusting(RE_PER_M, f64::INFINITY),
2189            DragConditions {
2190                thrusting_motor_area_m2: 1e-3,
2191                ..DragConditions::coasting(RE_PER_M)
2192            },
2193            DragConditions::coasting(RE_PER_M).with_pod_motors([0.0, 0.0, 0.0, 1e-3]),
2194            DragConditions::thrusting(RE_PER_M, 0.0).with_pod_motors([0.0, -1e-3, 0.0, 0.0]),
2195            DragConditions::thrusting(RE_PER_M, 0.0).with_pod_motors([0.0, 0.0, f64::NAN, 0.0]),
2196        ] {
2197            assert!(m.drag(&flow, &conditions).is_err(), "{conditions:?}");
2198        }
2199        assert!(matches!(
2200            m.drag(&Flow::axial(5.0), &DragConditions::coasting(RE_PER_M)),
2201            Err(AeroError::Mach { .. })
2202        ));
2203
2204        // A blunt, stubby body on a tiny reference diameter: C_D0 far above 10, uncapped.
2205        let brick = one_stage(
2206            vec![component("block", body_part(0.1, 0.05, 0.05), None)],
2207            ReferenceDiameter::Custom { diameter_m: 0.01 },
2208        );
2209        let d = model(&brick)
2210            .drag(&flow, &DragConditions::coasting(RE_PER_M))
2211            .unwrap();
2212        let area_ratio = 100.0;
2213        close(
2214            d.pressure,
2215            step_by_hand(0.3) * area_ratio,
2216            1e-13,
2217            "flat face",
2218        );
2219        close(
2220            d.base,
2221            (0.12 + 0.13 * 0.09) * area_ratio,
2222            1e-14,
2223            "flat base",
2224        );
2225        assert!(d.zero_lift_coefficient > 90.0);
2226    }
2227
2228    /// Loft lesson L17: Loft froze the fin leading-edge drag at its Mach 1 value and gave nose and
2229    /// shoulder pressure drag no Mach term. Here the rounded leading edge follows eq. 3.89's
2230    /// supersonic branch `1.214 − 0.502/M² + 0.1095/M⁴` and the square one the stagnation
2231    /// pressure, both times `cos² Γ_L`; a cone nose rises from `0.8 sin² ε` at rest to `sin ε` at
2232    /// Mach 1 and follows eq. B.4 from Mach 1.3, in the whole rocket's buildup.
2233    #[test]
2234    fn leading_edge_and_cone_pressure_drag_have_supersonic_branches() {
2235        let sweep: f64 = 0.4;
2236        let c2 = sweep.cos().powi(2);
2237        let at_1 = fin_pressure_drag_coefficient(FinCrossSection::Airfoil, sweep, 1.0).unwrap();
2238        close(at_1, 0.8215 * c2, 1e-12, "rounded edge at Mach 1");
2239        for m in [1.5f64, 2.0, 3.0, 4.5] {
2240            let rounded = 1.214 - 0.502 / (m * m) + 0.1095 / m.powi(4);
2241            close(
2242                fin_pressure_drag_coefficient(FinCrossSection::Airfoil, sweep, m).unwrap(),
2243                rounded * c2,
2244                1e-14,
2245                "rounded, supersonic",
2246            );
2247            let i2 = 1.0 / (m * m);
2248            let stagnation = 0.85 * (1.84 - 0.76 * i2 + 0.166 * i2 * i2 + 0.035 * i2 * i2 * i2);
2249            close(
2250                fin_pressure_drag_coefficient(FinCrossSection::Square, sweep, m).unwrap(),
2251                stagnation * c2 + 0.25 / m,
2252                1e-14,
2253                "square, supersonic",
2254            );
2255            assert!((rounded * c2 - at_1).abs() > 0.05, "not frozen at Mach {m}");
2256        }
2257
2258        // A 3:1 cone on a tube: the nose's pressure drag against Mach, on its base area.
2259        let (r, l) = (0.025, 0.15);
2260        let rocket = one_stage(
2261            vec![
2262                component("nose", nose(NoseShape::Conical {}, l, r), None),
2263                component("tube", body_part(0.8, r, r), None),
2264            ],
2265            ReferenceDiameter::Maximum {},
2266        );
2267        let m = model(&rocket);
2268        let nose_pressure = |mach: f64| {
2269            m.buildup_components(&Flow::axial(mach), &DragConditions::coasting(RE_PER_M))
2270                .unwrap()[0]
2271                .drag
2272                .pressure
2273        };
2274        let s = 1.0 / 37f64.sqrt();
2275        close(nose_pressure(0.0), 0.8 * s * s, 1e-14, "at rest");
2276        close(nose_pressure(1.0), s, 1e-14, "eq. B.6 at Mach 1");
2277        let mut previous = f64::INFINITY;
2278        for mach in [1.3f64, 1.5, 2.0, 3.0, 4.9] {
2279            let b4 = 2.1 * s * s + 0.5 * s / (mach * mach - 1.0).sqrt();
2280            let got = nose_pressure(mach);
2281            close(got, b4, 1e-14, "eq. B.4");
2282            assert!(got < previous, "falls past the peak: {got} at Mach {mach}");
2283            previous = got;
2284        }
2285        assert!(nose_pressure(4.9) > 2.1 * s * s);
2286    }
2287
2288    /// Code review: a nose the drag buildup has no data for (a bulged secant ogive, a Haack series
2289    /// past `C = ⅓`) doesn't stop the model building: the normal force and a drag table work, and
2290    /// only the buildup refuses it, naming the component. A widening too small to move the diameter
2291    /// is no shoulder.
2292    #[test]
2293    fn a_shape_without_drag_data_refuses_only_the_buildup() {
2294        let (r, l) = (0.03, 0.2);
2295        for shape in [
2296            NoseShape::Ogive { radius_ratio: 0.5 },
2297            NoseShape::Haack { parameter: 0.5 },
2298        ] {
2299            let rocket = one_stage(
2300                vec![
2301                    component("nose", nose(shape, l, r), None),
2302                    component("tube", body_part(0.8, r, r), None),
2303                ],
2304                ReferenceDiameter::Maximum {},
2305            );
2306            let m = model(&rocket);
2307            assert!(m.normal_force(&Flow::axial(0.3)).is_ok(), "{shape:?}");
2308            let conditions = DragConditions::coasting(RE_PER_M);
2309            let error = m.drag(&Flow::axial(0.3), &conditions).unwrap_err();
2310            assert!(
2311                matches!(&error, AeroError::InComponent { id, source }
2312                    if id == "nose" && matches!(**source, AeroError::Unsupported(_))),
2313                "{shape:?}: {error}"
2314            );
2315            assert!(
2316                m.buildup_components(&Flow::axial(0.3), &conditions)
2317                    .is_err()
2318            );
2319            let table = DragTable::from_csv("0,0.5\n2,0.5\n", None).unwrap();
2320            let with_table = m.with_drag_table(table);
2321            assert_eq!(
2322                with_table
2323                    .drag(&Flow::axial(0.3), &conditions)
2324                    .unwrap()
2325                    .zero_lift_coefficient,
2326                0.5
2327            );
2328        }
2329        // One ulp of widening: the areas differ, the diameters as computed may not.
2330        let wider = r * (1.0 + f64::EPSILON);
2331        let rocket = one_stage(
2332            vec![
2333                component("nose", nose(NoseShape::Conical {}, l, r), None),
2334                component("tube", body_part(0.4, r, r), None),
2335                component("flare", body_part(0.05, r, wider), None),
2336                component("aft", body_part(0.4, wider, wider), None),
2337            ],
2338            ReferenceDiameter::Maximum {},
2339        );
2340        let d = model(&rocket)
2341            .drag(&Flow::axial(0.3), &DragConditions::coasting(RE_PER_M))
2342            .unwrap();
2343        assert!(d.zero_lift_coefficient.is_finite());
2344    }
2345
2346    /// The stagnation-pressure ratio: 1 at rest, the isentropic `(p₀ − p)/q` with
2347    /// `p₀/p = (1 + 0.2 M²)^3.5` and `q = 0.7 p M²` to `O(M⁶)` at low Mach, the published 1.275
2348    /// and 1.281 either side of Mach 1, and 1.84 far above it.
2349    #[test]
2350    fn stagnation_pressure_limits() {
2351        assert_eq!(stagnation_pressure_ratio(0.0).unwrap(), 1.0);
2352        for m in [0.05, 0.1, 0.2] {
2353            let isentropic = ((1.0f64 + 0.2 * m * m).powf(3.5) - 1.0) / (0.7 * m * m);
2354            let got = stagnation_pressure_ratio(m).unwrap();
2355            assert!(
2356                (got - isentropic).abs() < m.powi(6),
2357                "M {m}: {got} vs {isentropic}"
2358            );
2359        }
2360        close(
2361            stagnation_pressure_ratio(1.0 - 1e-12).unwrap(),
2362            1.275,
2363            1e-11,
2364            "below Mach 1",
2365        );
2366        close(
2367            stagnation_pressure_ratio(1.0).unwrap(),
2368            1.281,
2369            1e-14,
2370            "at Mach 1",
2371        );
2372        close(
2373            stagnation_pressure_ratio(1e6).unwrap(),
2374            1.84,
2375            1e-11,
2376            "hypersonic limit",
2377        );
2378        assert_eq!(stagnation_drag_coefficient(0.0).unwrap(), 0.85);
2379        for bad in [-0.1, f64::NAN, f64::INFINITY] {
2380            assert!(matches!(
2381                stagnation_pressure_ratio(bad),
2382                Err(AeroError::Domain { .. })
2383            ));
2384        }
2385    }
2386
2387    /// Base drag 0.12 at rest, 0.25 at Mach 1 from both sides, falling as `1/M` above; the joint
2388    /// term from 0 (smooth) to 0.8 (a step); the boattail factor's three pieces.
2389    #[test]
2390    fn base_joint_and_boattail_limits() {
2391        assert_eq!(base_drag_coefficient(0.0).unwrap(), 0.12);
2392        assert_eq!(base_drag_coefficient(2.0).unwrap(), 0.125);
2393        assert!(base_drag_coefficient(1e9).unwrap() < 1e-9);
2394
2395        assert_eq!(joint_pressure_drag_coefficient(0.0).unwrap(), 0.0);
2396        assert_eq!(joint_pressure_drag_coefficient(FRAC_PI_2).unwrap(), 0.8);
2397        close(
2398            joint_pressure_drag_coefficient(PI / 6.0).unwrap(),
2399            0.2,
2400            1e-15,
2401            "0.8 sin² 30°",
2402        );
2403        for bad in [-1e-9, FRAC_PI_2 + 1e-9, f64::NAN] {
2404            assert!(joint_pressure_drag_coefficient(bad).is_err());
2405        }
2406
2407        // γ = l/(d₁ − d₂) for a 20 mm drop in diameter.
2408        let factor = |l: f64| boattail_factor(l, 0.06, 0.04).unwrap();
2409        assert_eq!(factor(0.0), 1.0);
2410        close(factor(0.02), 1.0, 1e-15, "γ = 1");
2411        close(factor(0.03), 0.75, 1e-15, "γ = 1.5");
2412        close(factor(0.04), 0.5, 1e-15, "γ = 2");
2413        assert_eq!(factor(0.06), 0.0);
2414        assert_eq!(factor(1.0), 0.0);
2415        close(
2416            factor(0.02 * (1.0 + 1e-12)),
2417            1.0,
2418            1e-11,
2419            "continuous at γ = 1",
2420        );
2421        assert!(factor(0.06 * (1.0 - 1e-12)) < 1e-11, "continuous at γ = 3");
2422        assert!(boattail_factor(0.01, 0.04, 0.04).is_err());
2423        assert!(boattail_factor(0.01, 0.04, 0.06).is_err());
2424        assert!(boattail_factor(-0.01, 0.06, 0.04).is_err());
2425    }
2426
2427    /// Fin pressure drag by cross-section at rest; the rounded leading edge's published joins
2428    /// at Mach 0.9 (0.99876 against 1) and Mach 1 (0.8215 from both sides); `cos² Γ_L` on the
2429    /// leading edge only; refusals at a 90° sweep.
2430    #[test]
2431    fn fin_pressure_drag_limits() {
2432        let at =
2433            |section, sweep, mach| fin_pressure_drag_coefficient(section, sweep, mach).unwrap();
2434        assert_eq!(at(FinCrossSection::Square, 0.0, 0.0), 0.85 + 0.12);
2435        assert_eq!(at(FinCrossSection::Rounded, 0.0, 0.0), 0.06);
2436        assert_eq!(at(FinCrossSection::Airfoil, 0.0, 0.0), 0.0);
2437        let rounded_le = |m: f64| at(FinCrossSection::Airfoil, 0.0, m);
2438        close(rounded_le(0.9 - 1e-12), 0.998_76, 1e-5, "below Mach 0.9");
2439        assert_eq!(rounded_le(0.9), 1.0);
2440        close(rounded_le(1.0 - 1e-12), 0.8215, 1e-11, "below Mach 1");
2441        close(rounded_le(1.0), 0.8215, 1e-15, "at Mach 1");
2442        close(
2443            rounded_le(0.5),
2444            (0.75f64).powf(-0.417) - 1.0,
2445            1e-15,
2446            "eq. 3.89 at Mach 0.5",
2447        );
2448        close(rounded_le(1e6), 1.214, 1e-11, "supersonic limit");
2449        // Sweep scales the leading edge only.
2450        let sweep: f64 = 0.6;
2451        let c2 = sweep.cos() * sweep.cos();
2452        close(
2453            at(FinCrossSection::Square, sweep, 0.5),
2454            stagnation_drag_coefficient(0.5).unwrap() * c2 + base_drag_coefficient(0.5).unwrap(),
2455            1e-15,
2456            "square, swept",
2457        );
2458        close(
2459            at(FinCrossSection::Rounded, -sweep, 0.5),
2460            rounded_le(0.5) * c2 + 0.5 * base_drag_coefficient(0.5).unwrap(),
2461            1e-15,
2462            "rounded, swept forward",
2463        );
2464        for bad in [FRAC_PI_2, -FRAC_PI_2, f64::NAN] {
2465            assert!(fin_pressure_drag_coefficient(FinCrossSection::Square, bad, 0.3).is_err());
2466        }
2467    }
2468
2469    /// The angle-of-attack factor meets every condition Niskanen states (1 at 0°, 1.3 at 17°, 0 at
2470    /// 90°, zero slope at each), rises then falls, and mirrors with its sign reversed past 90°, so a
2471    /// rocket flying tail first is pushed toward its nose.
2472    #[test]
2473    fn axial_drag_alpha_factor_limits() {
2474        let f = |deg: f64| axial_drag_alpha_factor(deg.to_radians()).unwrap();
2475        assert_eq!(f(0.0), 1.0);
2476        close(f(17.0), 1.3, 1e-15, "peak");
2477        assert!(f(90.0).abs() < 1e-15);
2478        let slope = |deg: f64| {
2479            let h = 1e-6;
2480            (f((deg + h).min(180.0)) - f((deg - h).max(0.0))) / (2.0 * h)
2481        };
2482        for deg in [0.0, 17.0, 90.0] {
2483            assert!(slope(deg).abs() < 1e-5, "slope {} at {deg}°", slope(deg));
2484        }
2485        let mut previous = f(0.0);
2486        for k in 1..=170 {
2487            let deg = 0.1 * f64::from(k);
2488            assert!(f(deg) > previous, "rising at {deg}°");
2489            previous = f(deg);
2490        }
2491        for k in 171..=900 {
2492            let deg = 0.1 * f64::from(k);
2493            assert!(f(deg) < previous, "falling at {deg}°");
2494            previous = f(deg);
2495        }
2496        for deg in [5.0, 17.0, 45.0, 89.0] {
2497            close(f(180.0 - deg), -f(deg), 1e-12, "mirror");
2498        }
2499        assert_eq!(f(180.0), -1.0);
2500        // In a model at 135°: C_A points toward the nose.
2501        let rocket = one_stage(
2502            vec![
2503                component("nose", nose(NoseShape::Conical {}, 0.2, 0.03), None),
2504                component("tube", body_part(0.8, 0.03, 0.03), None),
2505            ],
2506            ReferenceDiameter::Maximum {},
2507        );
2508        let d = model(&rocket)
2509            .drag(
2510                &Flow::new(0.3, 135f64.to_radians(), 0.0),
2511                &DragConditions::coasting(RE_PER_M),
2512            )
2513            .unwrap();
2514        assert!(d.zero_lift_coefficient > 0.0 && d.axial_coefficient < 0.0);
2515        close(
2516            d.axial_coefficient,
2517            -d.zero_lift_coefficient * f(45.0),
2518            1e-14,
2519            "C_A at 135°",
2520        );
2521        assert!(axial_drag_alpha_factor(-1e-9).is_err());
2522        assert!(axial_drag_alpha_factor(PI + 1e-9).is_err());
2523    }
2524
2525    /// Leading-edge sweeps: a trapezoid's `atan(x_t/s)`; the same trapezoid as a freeform
2526    /// outline; a kinked edge's span average; an elliptical fin's closed form against a midpoint
2527    /// sum, and 0 as the chord vanishes against the span.
2528    #[test]
2529    fn leading_edge_sweep_of_every_planform() {
2530        use crate::fins::FinGeometry;
2531        let trap = FinGeometry::from_planform(&trapezoid()).unwrap();
2532        close(
2533            trap.leading_edge_sweep_rad,
2534            (0.07f64 / 0.06).atan(),
2535            1e-15,
2536            "trapezoid",
2537        );
2538        let outline = FinPlanform::Freeform {
2539            points_m: vec![[0.0, 0.0], [0.07, 0.06], [0.12, 0.06], [0.12, 0.0]],
2540            root_m: Vec::new(),
2541        };
2542        let free = FinGeometry::from_planform(&outline).unwrap();
2543        close(
2544            free.leading_edge_sweep_rad,
2545            trap.leading_edge_sweep_rad,
2546            1e-12,
2547            "as freeform",
2548        );
2549        // A kinked edge: straight out for half the span, then swept 45°.
2550        let kinked = FinPlanform::Freeform {
2551            points_m: vec![
2552                [0.0, 0.0],
2553                [0.0, 0.03],
2554                [0.03, 0.06],
2555                [0.1, 0.06],
2556                [0.1, 0.0],
2557            ],
2558            root_m: Vec::new(),
2559        };
2560        let kinked = FinGeometry::from_planform(&kinked).unwrap();
2561        close(
2562            kinked.leading_edge_sweep_rad,
2563            0.5 * PI / 4.0,
2564            1e-12,
2565            "kinked",
2566        );
2567        for (c_r, s) in [(0.1, 0.05), (0.1, 0.1), (0.2, 0.05), (0.01, 1.0)] {
2568            let e = FinGeometry::from_planform(&FinPlanform::Elliptical {
2569                root_chord_m: c_r,
2570                span_m: s,
2571            })
2572            .unwrap();
2573            let k: f64 = 0.5 * c_r / s;
2574            // η = sin t makes the integrand smooth: ∫₀^{π/2} atan(k tan t) cos t dt.
2575            let n = 200_000;
2576            let h = FRAC_PI_2 / f64::from(n);
2577            let sum: f64 = (0..n)
2578                .map(|i| {
2579                    let t = (f64::from(i) + 0.5) * h;
2580                    (k * t.tan()).atan() * t.cos() * h
2581                })
2582                .sum();
2583            close(e.leading_edge_sweep_rad, sum, 1e-8, "elliptical average");
2584        }
2585    }
2586
2587    /// The whole buildup of a simple rocket written out term by term: a 0.25 m cone on a 0.05 m
2588    /// diameter, a 1 m tube with three 4 mm square fins and one lug, at Mach 0.5, 5° and a
2589    /// Reynolds number of 5e6 per meter. Components add up to the total, and the table override
2590    /// replaces `C_D0` but keeps the angle-of-attack factor.
2591    #[test]
2592    fn buildup_by_hand() {
2593        let (r, l_nose, l_tube) = (0.025, 0.25, 1.0);
2594        let mut tube = component("tube", body_part(l_tube, r, r), None);
2595        let mut fins = with_fin("fins", 3, 0.004, FinCrossSection::Square, 0.0);
2596        fins.finish = Some(Finish::PlanedWood {});
2597        tube.children = vec![
2598            fins,
2599            component(
2600                "lug",
2601                Part::LaunchLug(LaunchLug {
2602                    length_m: 0.05,
2603                    outer_radius_m: 0.004,
2604                    thickness_m: 0.0005,
2605                    angle_rad: 0.0,
2606                    count: 1,
2607                    spacing_m: 0.0,
2608                    material: material(),
2609                }),
2610                Some(Position::Top { aft_offset_m: 0.2 }),
2611            ),
2612        ];
2613        let rocket = one_stage(
2614            vec![
2615                component("nose", nose(NoseShape::Conical {}, l_nose, r), None),
2616                tube,
2617            ],
2618            ReferenceDiameter::Maximum {},
2619        );
2620        let m = model(&rocket);
2621        let (mach, alpha, re_per_m) = (0.5, 5f64.to_radians(), 5e6);
2622        let flow = Flow::new(mach, alpha, 0.0);
2623        let conditions = DragConditions::coasting(re_per_m);
2624        let got = m.drag(&flow, &conditions).unwrap();
2625
2626        let length = l_nose + l_tube;
2627        let a_ref = PI * r * r;
2628        let re = re_per_m * length;
2629        let cf = |roughness: f64| {
2630            let rr: f64 = roughness / length;
2631            let critical = 51.0 * rr.powf(-1.039);
2632            let incompressible = if re < critical {
2633                1.0 / (1.50 * re.ln() - 5.6).powi(2)
2634            } else {
2635                0.032 * rr.powf(0.2)
2636            };
2637            incompressible * (1.0 - 0.1 * mach * mach)
2638        };
2639        let form = 1.0 + 1.0 / (2.0 * length / (2.0 * r));
2640        let body_friction = cf(20e-6) * form * (PI * r * l_nose + 2.0 * PI * r * l_tube) / a_ref;
2641        let (c_r, c_t, span) = (0.12, 0.05, 0.06);
2642        let fin_area = 0.5 * span * (c_r + c_t);
2643        let mac = 2.0 / 3.0 * (c_r * c_r + c_r * c_t + c_t * c_t) / (c_r + c_t);
2644        let fin_friction = cf(15e-6) * (1.0 + 2.0 * 0.004 / mac) * 2.0 * 3.0 * fin_area / a_ref;
2645        let stag = 0.85 * (1.0 + mach * mach / 4.0 + mach.powi(4) / 40.0);
2646        let c_base = 0.12 + 0.13 * mach * mach;
2647        let gamma_l = (0.07f64 / span).atan();
2648        let fin_pressure = (stag * gamma_l.cos().powi(2) + c_base) * 3.0 * 0.004 * span / a_ref;
2649        // Eq. 3.87 from 0.8 sin² ε at rest to eq. B.5–B.6 at Mach 1, by hand: `tan ε = r/l`.
2650        let s = (r / l_nose).atan().sin();
2651        let rest = 0.8 * s * s;
2652        let slope_at_1 = 4.0 / 2.4 * (1.0 - 0.5 * s);
2653        let b = slope_at_1 / (s - rest);
2654        let nose_pressure = (s - rest) * mach.powf(b) + rest;
2655        let base = c_base;
2656        let lug = stag * PI * 0.004 * 0.004 / a_ref;
2657        let cd0 = body_friction + fin_friction + fin_pressure + nose_pressure + base + lug;
2658
2659        close(
2660            got.friction,
2661            body_friction + fin_friction,
2662            1e-12,
2663            "friction",
2664        );
2665        close(
2666            got.pressure,
2667            fin_pressure + nose_pressure,
2668            1e-12,
2669            "pressure",
2670        );
2671        close(got.base, base, 1e-14, "base");
2672        close(got.parasitic, lug, 1e-14, "parasitic");
2673        close(got.zero_lift_coefficient, cd0, 1e-12, "C_D0");
2674        let t = 5.0 / 17.0;
2675        close(
2676            got.axial_coefficient,
2677            cd0 * (1.0 + 0.3 * t * t * (3.0 - 2.0 * t)),
2678            1e-12,
2679            "C_A",
2680        );
2681        assert_eq!(got.table, None);
2682
2683        let parts = m.buildup_components(&flow, &conditions).unwrap();
2684        let ids: Vec<&str> = parts.iter().map(|p| p.id.as_str()).collect();
2685        assert_eq!(ids, ["nose", "tube", "fins", "lug"]);
2686        let sum = |f: fn(&Drag) -> f64| parts.iter().map(|p| f(&p.drag)).sum::<f64>();
2687        close(
2688            sum(|d| d.zero_lift_coefficient),
2689            got.zero_lift_coefficient,
2690            1e-14,
2691            "C_D0 sum",
2692        );
2693        close(
2694            sum(|d| d.axial_coefficient),
2695            got.axial_coefficient,
2696            1e-14,
2697            "C_A sum",
2698        );
2699
2700        // Thrusting with an unknown motor area: the base keeps its drag.
2701        let unknown = m
2702            .drag(&flow, &DragConditions::thrusting(re_per_m, 0.0))
2703            .unwrap();
2704        assert_eq!(unknown.base, got.base);
2705
2706        // An override: the table's C_D0 (extrapolation reported), the same factor, and Mach 5
2707        // allowed for drag while the buildup refuses it.
2708        let table = DragTable::from_csv("0.1,0.6\n1.2,0.9\n", None).unwrap();
2709        let o = m.clone().with_drag_table(table);
2710        let d = o.drag(&flow, &conditions).unwrap();
2711        close(
2712            d.zero_lift_coefficient,
2713            0.6 + 0.3 * 0.4 / 1.1,
2714            1e-14,
2715            "table C_D0",
2716        );
2717        close(
2718            d.axial_coefficient / d.zero_lift_coefficient,
2719            got.axial_coefficient / cd0,
2720            1e-14,
2721            "factor",
2722        );
2723        assert_eq!(
2724            (d.friction, d.pressure, d.base, d.parasitic),
2725            (0.0, 0.0, 0.0, 0.0)
2726        );
2727        let fast = o.drag(&Flow::axial(5.0), &conditions).unwrap();
2728        assert_eq!(fast.zero_lift_coefficient, 0.9);
2729        assert!(fast.table.unwrap().extrapolated.is_some());
2730        assert!(m.drag(&Flow::axial(5.0), &conditions).is_err());
2731        assert!(m.drag(&Flow::axial(4.99), &conditions).is_ok());
2732        assert!(o.drag(&Flow::new(0.5, 4.0, 0.0), &conditions).is_err());
2733        assert!(
2734            o.buildup_components(&Flow::axial(5.0), &conditions)
2735                .is_err()
2736        );
2737    }
2738
2739    /// A boattail drags the same however its surface is split into transitions (physics review):
2740    /// one conical boattail against the same cone as two, total and base, at every speed.
2741    #[test]
2742    fn a_boattail_split_in_two_drags_as_one() {
2743        let (big, small, l) = (0.03, 0.02, 0.04);
2744        let mid = 0.5 * (big + small);
2745        let rocket = |split: bool| {
2746            let mut components = vec![
2747                component(
2748                    "nose",
2749                    nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
2750                    None,
2751                ),
2752                component("tube", body_part(0.8, big, big), None),
2753            ];
2754            if split {
2755                components.push(component("tail-a", body_part(0.5 * l, big, mid), None));
2756                components.push(component("tail-b", body_part(0.5 * l, mid, small), None));
2757            } else {
2758                components.push(component("tail", body_part(l, big, small), None));
2759            }
2760            model(&one_stage(components, ReferenceDiameter::Maximum {}))
2761        };
2762        let (one, two) = (rocket(false), rocket(true));
2763        let conditions = DragConditions::coasting(RE_PER_M);
2764        for mach in [0.3, 0.85, 0.95, 1.1, 1.5, 3.0, 4.9] {
2765            let (a, b) = (
2766                one.drag(&Flow::axial(mach), &conditions).unwrap(),
2767                two.drag(&Flow::axial(mach), &conditions).unwrap(),
2768            );
2769            close(
2770                b.zero_lift_coefficient,
2771                a.zero_lift_coefficient,
2772                1e-12,
2773                "total",
2774            );
2775            close(b.pressure, a.pressure, 1e-12, "pressure");
2776            close(b.base, a.base, 1e-12, "base");
2777        }
2778        // The first part is its own cone; the second, wholly merged, drags as the whole cone less
2779        // the first part's.
2780        let terms = two.drag_terms();
2781        let (a, b) = (
2782            terms[2].boattail.clone().unwrap(),
2783            terms[3].boattail.clone().unwrap(),
2784        );
2785        let whole = one.drag_terms()[2].boattail.clone().unwrap().own;
2786        assert!(a.merged.is_empty());
2787        assert_eq!(b.merged.len(), 1);
2788        let m = b.merged[0];
2789        assert_eq!(m.weight, 1.0);
2790        close(m.through_fore.length_m, a.own.length_m, 1e-12, "first part");
2791        close(
2792            m.through_fore.aft_diameter_m,
2793            a.own.aft_diameter_m,
2794            1e-12,
2795            "first part's end",
2796        );
2797        close(
2798            m.through_aft.length_m,
2799            whole.length_m,
2800            1e-12,
2801            "whole length",
2802        );
2803        close(
2804            m.through_aft.aft_diameter_m,
2805            whole.aft_diameter_m,
2806            1e-12,
2807            "whole end",
2808        );
2809        close(m.area_ratio, a.own_area_ratio, 1e-12, "same fore area");
2810    }
2811
2812    /// A sharp corner keeps two narrowing parts apart (physics review): a 15° boattail closed by a
2813    /// micrometer-long transition to a smaller tube drags as the same boattail and a step down,
2814    /// and as with a micrometer of tube between; and the merge weight is continuous in the turn,
2815    /// whole to 3° and none from 10°.
2816    #[test]
2817    fn a_sharp_corner_keeps_its_boattails_apart() {
2818        let (big, small, tip) = (0.03, 0.02, 0.008);
2819        let l = (big - small) / 15f64.to_radians().tan();
2820        let rocket = |closure: Option<f64>, gap: Option<f64>| {
2821            let mut components = vec![
2822                component(
2823                    "nose",
2824                    nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
2825                    None,
2826                ),
2827                component("tube", body_part(0.8, big, big), None),
2828                component("tail", body_part(l, big, small), None),
2829            ];
2830            if let Some(gap) = gap {
2831                components.push(component("gap", body_part(gap, small, small), None));
2832            }
2833            if let Some(length) = closure {
2834                components.push(component("closure", body_part(length, small, tip), None));
2835            }
2836            components.push(component("end", body_part(0.01, tip, tip), None));
2837            model(&one_stage(components, ReferenceDiameter::Maximum {}))
2838        };
2839        let conditions = DragConditions::coasting(RE_PER_M);
2840        let total = |m: &AeroModel, mach: f64| {
2841            m.drag(&Flow::axial(mach), &conditions)
2842                .unwrap()
2843                .zero_lift_coefficient
2844        };
2845        let (step, corner, apart) = (
2846            rocket(None, None),
2847            rocket(Some(1e-6), None),
2848            rocket(Some(1e-6), Some(1e-6)),
2849        );
2850        for mach in [0.3, 0.6, 0.9, 1.0, 1.5, 3.0] {
2851            let s = total(&step, mach);
2852            // The closure's own drag stands in for the step's; the straight line from Mach 0.8 to
2853            // 1 for the base drag's curve moves it by up to 0.6% of the annulus's base drag.
2854            assert!((total(&corner, mach) - s).abs() < 2e-3 * s, "Mach {mach}");
2855            assert!(
2856                (total(&apart, mach) - total(&corner, mach)).abs() < 1e-5 * s,
2857                "Mach {mach}"
2858            );
2859        }
2860        // Two cones meeting at a turn: whole below 3°, none from 10°, continuous between.
2861        let pair = |turn_deg: f64| {
2862            let (first, second) = (6f64.to_radians(), (6.0 + turn_deg).to_radians());
2863            let mid = big - 0.01 * first.tan();
2864            let end = mid - 0.01 * second.tan();
2865            let m = model(&one_stage(
2866                vec![
2867                    component(
2868                        "nose",
2869                        nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
2870                        None,
2871                    ),
2872                    component("tube", body_part(0.8, big, big), None),
2873                    component("a", body_part(0.01, big, mid), None),
2874                    component("b", body_part(0.01, mid, end), None),
2875                ],
2876                ReferenceDiameter::Maximum {},
2877            ));
2878            let merges = !m.drag_terms()[3]
2879                .boattail
2880                .clone()
2881                .unwrap()
2882                .merged
2883                .is_empty();
2884            (m, merges)
2885        };
2886        assert!(pair(2.0).1 && pair(9.0).1 && !pair(10.5).1);
2887        for edge in [3.0, 10.0] {
2888            for mach in [0.5, 1.5, 2.5] {
2889                let (a, b) = (
2890                    total(&pair(edge - 1e-7).0, mach),
2891                    total(&pair(edge + 1e-7).0, mach),
2892                );
2893                assert!(
2894                    (a - b).abs() < 1e-7 * a,
2895                    "turn {edge}° at Mach {mach}: {a} against {b}"
2896                );
2897            }
2898        }
2899    }
2900
2901    /// A lip drawn as a step up and a tube drags as the same lip drawn as a shoulder a micrometer
2902    /// long and the tube (physics review): the step's drag is in the wake too, and the base keeps
2903    /// the same share of its relief.
2904    #[test]
2905    fn a_lip_drawn_as_a_step_up_is_a_lip() {
2906        let (big, small, lip, l) = (0.03, 0.02, 0.0215, 0.04);
2907        let rocket = |shoulder: bool| {
2908            let mut components = vec![
2909                component(
2910                    "nose",
2911                    nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
2912                    None,
2913                ),
2914                component("tube", body_part(0.8, big, big), None),
2915                component("tail", body_part(l, big, small), None),
2916            ];
2917            if shoulder {
2918                components.push(component("rise", body_part(1e-6, small, lip), None));
2919            }
2920            components.push(component("lip", body_part(0.00135, lip, lip), None));
2921            model(&one_stage(components, ReferenceDiameter::Maximum {}))
2922        };
2923        let (step, shoulder) = (rocket(false), rocket(true));
2924        let conditions = DragConditions::coasting(RE_PER_M);
2925        for mach in [0.6, 0.95, 1.5, 3.0] {
2926            let total = |m: &AeroModel| {
2927                m.drag(&Flow::axial(mach), &conditions)
2928                    .unwrap()
2929                    .zero_lift_coefficient
2930            };
2931            let (a, b) = (total(&step), total(&shoulder));
2932            assert!((a - b).abs() < 1e-3 * a, "Mach {mach}: {a} against {b}");
2933        }
2934        let terms = step.drag_terms();
2935        let last = terms.iter().find(|t| t.id == "lip").unwrap();
2936        assert!(last.step.is_some() && last.in_wake_of.unwrap().step_fraction == 1.0);
2937    }
2938
2939    /// A pair of narrowing parts never drags less than nothing, and drags between its two limits
2940    /// (physics review): as two boattails, built with a
2941    /// tube between them, and as the first part plus its share of the one cone through the pair's
2942    /// ends, built as a rocket of its own. A 15° part is followed by parts turned from −12° to
2943    /// +12°, at every Mach number to 4.9; at a turn of 0° the pair is that cone.
2944    #[test]
2945    fn soft_merges_stay_between_their_limits() {
2946        let big = 0.03;
2947        let conditions = DragConditions::coasting(RE_PER_M);
2948        let rocket = |parts: &[(&str, f64, f64, f64)]| {
2949            let mut components = vec![
2950                component(
2951                    "nose",
2952                    nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
2953                    None,
2954                ),
2955                component("tube", body_part(0.8, big, big), None),
2956            ];
2957            for &(id, length, fore, aft) in parts {
2958                components.push(component(id, body_part(length, fore, aft), None));
2959            }
2960            model(&one_stage(components, ReferenceDiameter::Maximum {}))
2961        };
2962        for turn in (-24..=24).map(|t| f64::from(t) * 0.5) {
2963            let (first, second) = (15f64.to_radians(), (15.0 + turn).to_radians());
2964            let mid = big - 0.01 * first.tan();
2965            let end = mid - 0.01 * second.tan();
2966            let joined = rocket(&[("a", 0.01, big, mid), ("b", 0.01, mid, end)]);
2967            // Two boattails: a tube between them longer than the first's drop.
2968            let apart = rocket(&[
2969                ("a", 0.01, big, mid),
2970                ("gap", 0.05, mid, mid),
2971                ("b", 0.01, mid, end),
2972            ]);
2973            // The one cone through the pair's ends.
2974            let whole = rocket(&[("ab", 0.02, big, end)]);
2975            let weight: f64 = joined.drag_terms()[3]
2976                .boattail
2977                .as_ref()
2978                .unwrap()
2979                .merged
2980                .iter()
2981                .map(|m| m.weight)
2982                .sum();
2983            for step in 0..98 {
2984                let mach = f64::from(step) * 0.05;
2985                let flow = Flow::axial(mach);
2986                let parts = joined.buildup_components(&flow, &conditions).unwrap();
2987                let what = format!("turn {turn}° at Mach {mach:.2}");
2988                let got = parts[2].drag.pressure + parts[3].drag.pressure;
2989                assert!(got >= 0.0, "{what}");
2990                let separate = apart.buildup_components(&flow, &conditions).unwrap();
2991                let (own_a, own_b) = (separate[2].drag.pressure, separate[4].drag.pressure);
2992                let cone = whole.buildup_components(&flow, &conditions).unwrap()[2]
2993                    .drag
2994                    .pressure;
2995                let merged = cone;
2996                if turn == 0.0 {
2997                    assert_eq!(weight, 1.0, "{what}");
2998                    assert!((got - cone).abs() <= 1e-12 * cone.max(1e-3), "{what}");
2999                } else if weight == 0.0 {
3000                    assert!((got - own_a - own_b).abs() <= 1e-12, "{what}");
3001                }
3002                let (lo, hi) = ((own_a + own_b).min(merged), (own_a + own_b).max(merged));
3003                assert!(
3004                    got >= lo - 1e-12 && got <= hi + 1e-12,
3005                    "{what}: {got} not in [{lo}, {hi}]"
3006                );
3007            }
3008        }
3009    }
3010
3011    /// Every weight is continuous in the geometry (physics and code reviews, three rounds): a
3012    /// part narrowing or widening by `ε` drags as a tube, a part `ε` long as a step down (with or
3013    /// without a lip behind it), a step up and a shoulder `ε` apart as the two in one part, and a
3014    /// tube tapered by `ε` before the boattail as a tube, with the difference shrinking in
3015    /// proportion to `ε`, behind boattails of 2°, 5°, 9° and 14° and at every speed.
3016    #[test]
3017    fn a_part_narrowing_by_nothing_is_a_tube_and_one_of_no_length_a_step() {
3018        let (big, l) = (0.03, 0.04);
3019        let conditions = DragConditions::coasting(RE_PER_M);
3020        let total = |m: &AeroModel, mach: f64| {
3021            m.drag(&Flow::axial(mach), &conditions)
3022                .unwrap()
3023                .zero_lift_coefficient
3024        };
3025        type Case = fn(f64, f64, f64, f64) -> (f64, Vec<(f64, f64, f64)>);
3026        // Each case, from `ε`, the boattail's aft radius, a lip's top and the boattail's drop in
3027        // diameter: a taper of the tube ahead of the boattail, and the parts behind it.
3028        let cases: [(&str, Case); 8] = [
3029            ("a spacer narrowing by ε before a lip", |e, s, lip, _| {
3030                (0.0, vec![(0.001, s, s - e), (0.0013, s - e, lip)])
3031            }),
3032            ("an aft section narrowing by ε", |e, s, _, _| {
3033                (0.0, vec![(0.1, s, s - e)])
3034            }),
3035            ("an aft section widening by ε", |e, s, _, _| {
3036                (0.0, vec![(0.1, s, s + e)])
3037            }),
3038            ("a step down drawn ε long", |e, s, _, d| {
3039                let low = s - 0.05 * d;
3040                let mut parts = vec![(0.005, low, low)];
3041                if e > 0.0 {
3042                    parts.insert(0, (e, s, low));
3043                }
3044                (0.0, parts)
3045            }),
3046            ("a step down drawn ε long, then a lip", |e, s, _, d| {
3047                let low = s - 0.25 * d;
3048                let mut parts = vec![(0.003, low, low + 0.05 * d)];
3049                if e > 0.0 {
3050                    parts.insert(0, (e, s, low));
3051                }
3052                (0.0, parts)
3053            }),
3054            ("a step up and a shoulder ε apart", |e, s, _, d| {
3055                let (top, high) = (s + 0.1 * d, s + 0.3 * d);
3056                let mut parts = vec![(0.002, top, high)];
3057                if e > 0.0 {
3058                    parts.insert(0, (e, top, top));
3059                }
3060                (0.0, parts)
3061            }),
3062            (
3063                "a gap of ε before a boattail's second part",
3064                |e, s, _, _| {
3065                    let end = s - 0.005 * 8f64.to_radians().tan();
3066                    let mut parts = vec![(0.005, s, end)];
3067                    if e > 0.0 {
3068                        parts.insert(0, (e, s, s));
3069                    }
3070                    (0.0, parts)
3071                },
3072            ),
3073            ("the tube ahead tapered by ε", |e, _, _, _| (e, vec![])),
3074        ];
3075        for angle in [2.0_f64, 5.0, 9.0, 14.0] {
3076            let small = big - l * angle.to_radians().tan();
3077            let drop = 2.0 * (big - small);
3078            let lip = small + 0.5 * 0.17 * drop;
3079            let rocket = |(taper, parts): (f64, Vec<(f64, f64, f64)>)| {
3080                let mut components = vec![
3081                    component(
3082                        "nose",
3083                        nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
3084                        None,
3085                    ),
3086                    component("tube", body_part(0.8, big, big - taper), None),
3087                    component("tail", body_part(l, big - taper, small), None),
3088                ];
3089                for (i, &(length, fore, aft)) in parts.iter().enumerate() {
3090                    components.push(component(
3091                        &format!("p{i}"),
3092                        body_part(length, fore, aft),
3093                        None,
3094                    ));
3095                }
3096                model(&one_stage(components, ReferenceDiameter::Maximum {}))
3097            };
3098            for (what, case) in cases {
3099                let exact = rocket(case(0.0, small, lip, drop));
3100                for mach in [0.5, 0.95, 1.5, 3.0, 4.5] {
3101                    // A part of no length drags its own boattail drag where a step drags the base
3102                    // drag's: the two part between Mach 0.8 and 1.2, where the boattail's rise is
3103                    // a straight line (`a_sharp_corner_keeps_its_boattails_apart`); the weights
3104                    // don't.
3105                    if what.starts_with("a step down") && mach == 0.95 {
3106                        continue;
3107                    }
3108                    let base = total(&exact, mach);
3109                    let off =
3110                        |e: f64| (total(&rocket(case(e, small, lip, drop)), mach) - base).abs();
3111                    let (coarse, fine) = (off(1e-6), off(1e-8));
3112                    let at = format!("{what} behind {angle}° at Mach {mach}");
3113                    assert!(coarse < 1e-3 * base, "{at}: {coarse}");
3114                    assert!(
3115                        fine <= 0.02 * coarse + 1e-13,
3116                        "{at}: {fine} against {coarse}"
3117                    );
3118                }
3119            }
3120        }
3121        // An 8° cone behind the body tube drawn in one part or two, then a tube 0.3 of its drop
3122        // long and a 12° part that merges with it (physics review).
3123        let (aft, tip) = (big - 0.02 * 8f64.to_radians().tan(), 0.02);
3124        let mid = big - 0.01 * 8f64.to_radians().tan();
3125        let gap = 0.3 * 2.0 * (big - aft);
3126        let end = aft - 0.01 * 12f64.to_radians().tan();
3127        let cone = |parts: &[(f64, f64, f64)]| {
3128            let mut components = vec![
3129                component(
3130                    "nose",
3131                    nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
3132                    None,
3133                ),
3134                component("tube", body_part(0.8, big, big), None),
3135            ];
3136            for (i, &(length, fore, aft)) in parts.iter().enumerate() {
3137                components.push(component(
3138                    &format!("p{i}"),
3139                    body_part(length, fore, aft),
3140                    None,
3141                ));
3142            }
3143            components.push(component("end", body_part(0.01, tip, tip), None));
3144            model(&one_stage(components, ReferenceDiameter::Maximum {}))
3145        };
3146        let one = cone(&[(0.02, big, aft), (gap, aft, aft), (0.01, aft, end)]);
3147        let two = cone(&[
3148            (0.01, big, mid),
3149            (0.01, mid, aft),
3150            (gap, aft, aft),
3151            (0.01, aft, end),
3152        ]);
3153        assert!(
3154            !one.drag_terms()[4]
3155                .boattail
3156                .as_ref()
3157                .unwrap()
3158                .merged
3159                .is_empty()
3160        );
3161        for mach in [0.5, 1.5, 3.0] {
3162            let (a, b) = (total(&one, mach), total(&two, mach));
3163            assert!(
3164                (a - b).abs() < 1e-12 * a,
3165                "cone in parts at Mach {mach}: {a} against {b}"
3166            );
3167        }
3168    }
3169
3170    /// A merge shares the flow (physics and code reviews): a lip that both limits, the two parts
3171    /// as one cone and as two boattails, put wholly in the wake stays wholly in it at every turn
3172    /// between; and the base's shares of the flow add up to 1 with nothing between.
3173    #[test]
3174    fn a_partial_merge_shares_the_flow() {
3175        let big = 0.03;
3176        let (first, length) = (5f64.to_radians(), 0.01);
3177        let mid = big - length * first.tan();
3178        for turn in (0..=24).map(|t| f64::from(t) * 0.5) {
3179            let second = first + turn.to_radians();
3180            let end = mid - length * second.tan();
3181            let drop = 2.0 * (big - end);
3182            let rocket = |lip: bool| {
3183                let mut components = vec![
3184                    component(
3185                        "nose",
3186                        nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
3187                        None,
3188                    ),
3189                    component("tube", body_part(0.8, big, big), None),
3190                    component("a", body_part(length, big, mid), None),
3191                    component("b", body_part(length, mid, end), None),
3192                ];
3193                if lip {
3194                    components.push(component("lip", body_part(1e-4, end, end), None));
3195                    let top = end + 0.5 * 0.1 * 2.0 * (mid - end);
3196                    components.push(component("rise", body_part(1e-4, end, top), None));
3197                }
3198                model(&one_stage(components, ReferenceDiameter::Maximum {}))
3199            };
3200            let what = format!("turn {turn}°, drop {drop}");
3201            let bare = rocket(false);
3202            let sources = &bare.drag_terms()[3].base_behind.as_ref().unwrap().sources;
3203            let shares: f64 = sources.iter().map(|s| s.weight).sum();
3204            assert!((shares - 1.0).abs() < 1e-12, "{what}: {shares}");
3205            let with_lip = rocket(true);
3206            let wake = with_lip.drag_terms()[5].in_wake_of.unwrap();
3207            // Every tail takes the lip wholly by its rise; only the 0.1 mm tube ahead of it
3208            // fades them, by at most its length over the smallest fall, the second part's own.
3209            let least = 1.0 - 1e-4 / (2.0 * (mid - end));
3210            assert!(
3211                wake.shoulder_fraction >= least - 1e-12 && wake.shoulder_fraction <= 1.0,
3212                "{what}: {}",
3213                wake.shoulder_fraction
3214            );
3215        }
3216    }
3217
3218    /// The tails stay few (code review): a boattail drawn as 40 parts turning 7° each way, each a
3219    /// partial merge with the one before, holds at most two tails per part at once, keeps at most
3220    /// one merge per earlier part and one base source per pair of parts, and drags.
3221    #[test]
3222    fn a_zigzag_boattail_keeps_its_tails_few() {
3223        let big = 0.03;
3224        let mut components = vec![
3225            component(
3226                "nose",
3227                nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
3228                None,
3229            ),
3230            component("tube", body_part(0.8, big, big), None),
3231        ];
3232        let mut r = big;
3233        let parts = 40;
3234        for i in 0..parts {
3235            let angle = if i % 2 == 0 { 12f64 } else { 5.0 };
3236            let next = r - 0.0005 * angle.to_radians().tan();
3237            components.push(component(
3238                &format!("z{i}"),
3239                body_part(0.0005, r, next),
3240                None,
3241            ));
3242            r = next;
3243        }
3244        super::PEAK_TAILS.with(|p| p.set(0));
3245        let m = model(&one_stage(components, ReferenceDiameter::Maximum {}));
3246        // Measured: 78 at 40 parts.
3247        let peak = super::PEAK_TAILS.with(std::cell::Cell::get);
3248        assert!(peak <= 2 * parts, "{peak} tails");
3249        let terms = m.drag_terms();
3250        for t in terms.iter().skip(2) {
3251            let merges = t.boattail.as_ref().map_or(0, |b| b.merged.len());
3252            assert!(merges <= parts, "{}: {merges}", t.id);
3253        }
3254        let sources = terms
3255            .last()
3256            .unwrap()
3257            .base_behind
3258            .as_ref()
3259            .unwrap()
3260            .sources
3261            .len();
3262        // Measured: 76; at most one per pair of parts in principle.
3263        assert!(sources <= 2 * parts, "{sources}");
3264        let drag = m
3265            .drag(&Flow::axial(1.5), &DragConditions::coasting(RE_PER_M))
3266            .unwrap();
3267        assert!(drag.zero_lift_coefficient.is_finite());
3268    }
3269
3270    /// A straight cone drawn in parts merges wholly and drags as the one cone, where a part's share
3271    /// is below 0 too (physics review, rounds 3 and 4): 0.8° and 0.5° cones 300 mm long, a 7°
3272    /// boattail from 98 mm to 44 mm, and a 5° one closing to an eighth of its diameter, whole and
3273    /// in 2, 4 and 8 parts, from Mach 0.5 to 3. Held at 0, a share had put the 7° one 1.35% high
3274    /// in 4 parts at Mach 1.0, and the 5° one 5% high in 2 at Mach 1.3.
3275    #[test]
3276    fn a_straight_cone_in_parts_is_one_cone() {
3277        let conditions = DragConditions::coasting(RE_PER_M);
3278        // Fore radius, half-angle (degrees), aft radius.
3279        let cones = [
3280            (0.03, 0.8_f64, 0.03 - 0.3 * 0.8_f64.to_radians().tan()),
3281            (0.03, 0.5, 0.03 - 0.3 * 0.5_f64.to_radians().tan()),
3282            (0.049, 7.0, 0.022),
3283            (0.049, 5.0, 0.049 / 8.0),
3284        ];
3285        for (big, angle, end) in cones {
3286            let length = (big - end) / angle.to_radians().tan();
3287            let rocket = |parts: usize| {
3288                let mut components = vec![
3289                    component(
3290                        "nose",
3291                        nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
3292                        None,
3293                    ),
3294                    component("tube", body_part(0.8, big, big), None),
3295                ];
3296                let at = |k: usize| big - (big - end) * k as f64 / parts as f64;
3297                for k in 0..parts {
3298                    components.push(component(
3299                        &format!("c{k}"),
3300                        body_part(length / parts as f64, at(k), at(k + 1)),
3301                        None,
3302                    ));
3303                }
3304                model(&one_stage(components, ReferenceDiameter::Maximum {}))
3305            };
3306            let one = rocket(1);
3307            for parts in [2, 4, 8] {
3308                let split = rocket(parts);
3309                // Every later part merges wholly with the cone ahead of it.
3310                for t in split.drag_terms().iter().skip(3) {
3311                    let term = t.boattail.as_ref().unwrap();
3312                    let merged: f64 = term.merged.iter().map(|m| m.weight).sum();
3313                    assert!(
3314                        term.own_weight < 1e-12 && (merged - 1.0).abs() < 1e-12,
3315                        "{}",
3316                        t.id
3317                    );
3318                }
3319                for mach in [0.5, 0.95, 1.0, 1.2, 1.3, 1.5, 3.0] {
3320                    let (a, b) = (
3321                        one.drag(&Flow::axial(mach), &conditions).unwrap(),
3322                        split.drag(&Flow::axial(mach), &conditions).unwrap(),
3323                    );
3324                    let what = format!("{angle}° in {parts} at Mach {mach}");
3325                    close(
3326                        b.zero_lift_coefficient,
3327                        a.zero_lift_coefficient,
3328                        1e-12,
3329                        &what,
3330                    );
3331                }
3332            }
3333        }
3334    }
3335
3336    /// A step down is a boattail of no length (physics and code reviews): a motor retainer that
3337    /// rises behind a plain step down to the motor tube sits in the corner's wake, by its rise
3338    /// and the motor tube's length over the step's drop in diameter. A 98 mm airframe steps down
3339    /// to a 54 mm motor tube showing for 12 mm, then a 62 mm retainer: the retainer's step keeps
3340    /// `12/44` of its drag.
3341    #[test]
3342    fn a_retainer_behind_a_step_down_is_in_its_wake() {
3343        let (body, motor, retainer) = (0.049, 0.027, 0.031);
3344        let m = model(&one_stage(
3345            vec![
3346                component(
3347                    "nose",
3348                    nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.3, body),
3349                    None,
3350                ),
3351                component("tube", body_part(1.0, body, body), None),
3352                component("motor", body_part(0.012, motor, motor), None),
3353                component("retainer", body_part(0.02, retainer, retainer), None),
3354            ],
3355            ReferenceDiameter::Maximum {},
3356        ));
3357        let terms = m.drag_terms();
3358        let wake = terms[3].in_wake_of.unwrap();
3359        let fall = 2.0 * (body - motor);
3360        close(
3361            wake.step_fraction,
3362            1.0 - 0.012 / fall,
3363            1e-12,
3364            "step fraction",
3365        );
3366        assert_eq!(wake.shoulder_fraction, 0.0);
3367        assert!(wake.boattail.half_angle_rad > 1.57);
3368    }
3369
3370    /// A lip counts however it is drawn in parts (physics review): a tube a hair above the
3371    /// boattail's aft radius ahead of the lip changes the drag by a hair, at every speed, as
3372    /// the step up to it goes to zero.
3373    #[test]
3374    fn a_hairline_step_before_a_lip_changes_nothing() {
3375        let (big, small, lip, l) = (0.03, 0.02, 0.0215, 0.04);
3376        let rocket = |step: Option<f64>| {
3377            let mut components = vec![
3378                component(
3379                    "nose",
3380                    nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
3381                    None,
3382                ),
3383                component("tube", body_part(0.8, big, big), None),
3384                component("tail", body_part(l, big, small), None),
3385            ];
3386            if let Some(step) = step {
3387                components.push(component(
3388                    "hair",
3389                    body_part(0.001, small + step, small + step),
3390                    None,
3391                ));
3392            } else {
3393                components.push(component("hair", body_part(0.001, small, small), None));
3394            }
3395            components.push(component("lip", body_part(0.00135, small, lip), None));
3396            model(&one_stage(components, ReferenceDiameter::Maximum {}))
3397        };
3398        let conditions = DragConditions::coasting(RE_PER_M);
3399        let exact = rocket(None);
3400        for mach in [0.6, 1.5, 3.0] {
3401            let total = |m: &AeroModel| {
3402                m.drag(&Flow::axial(mach), &conditions)
3403                    .unwrap()
3404                    .zero_lift_coefficient
3405            };
3406            let base = total(&exact);
3407            for step in [1e-5, 1e-7, 2e-9] {
3408                let got = total(&rocket(Some(step)));
3409                assert!(
3410                    (got - base).abs() < 1e-3 * base,
3411                    "step {step} at Mach {mach}: {got} against {base}"
3412                );
3413            }
3414        }
3415    }
3416
3417    /// A lip right behind a boattail is in its wake (physics review): none of a shoulder's
3418    /// pressure drag while its aft end rises up to a quarter of the boattail's drop in diameter
3419    /// above the boattail's, all of it from half, a straight line between, and the same fraction
3420    /// of the base's relief; a tube between fades both over one drop in diameter. Every weight is
3421    /// continuous: a micrometer of step or tube changes the drag by a micrometer's worth.
3422    #[test]
3423    fn a_lip_in_a_boattails_wake_fades_with_its_rise() {
3424        let (big, small, l) = (0.03, 0.02, 0.04);
3425        let drop = 2.0 * (big - small);
3426        // `rise` is the lip's aft rise above the boattail's aft radius, in drops in diameter.
3427        let rocket = |rise: f64, gap: Option<f64>, step: f64| {
3428            let lip_fore = small + step;
3429            let mut components = vec![
3430                component(
3431                    "nose",
3432                    nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
3433                    None,
3434                ),
3435                component("tube", body_part(0.8, big, big), None),
3436                component("tail", body_part(l, big, small), None),
3437            ];
3438            if let Some(gap) = gap {
3439                components.push(component("gap", body_part(gap, small, small), None));
3440            }
3441            components.push(component(
3442                "lip",
3443                body_part(0.001, lip_fore, small + 0.5 * rise * drop),
3444                None,
3445            ));
3446            model(&one_stage(components, ReferenceDiameter::Maximum {}))
3447        };
3448        let conditions = DragConditions::coasting(RE_PER_M);
3449        let lip = |m: &AeroModel, mach: f64| {
3450            let parts = m
3451                .buildup_components(&Flow::axial(mach), &conditions)
3452                .unwrap();
3453            let part = parts.iter().find(|p| p.id == "lip").unwrap();
3454            (part.drag.pressure, part.drag.base)
3455        };
3456        let fraction_of = |m: &AeroModel| {
3457            let terms = m.drag_terms();
3458            let last = terms.iter().find(|t| t.id == "lip").unwrap();
3459            (
3460                last.in_wake_of.map_or(0.0, |w| w.shoulder_fraction),
3461                last.base_behind
3462                    .as_ref()
3463                    .map_or(0.0, |b| b.sources.iter().map(|s| s.weight).sum::<f64>()),
3464            )
3465        };
3466        // The lip's own length, 1 mm, is a gap between the boattail and the base.
3467        let lip_gap = 1.0 - 0.001 / drop;
3468        for mach in [0.5, 1.5, 3.0] {
3469            for (rise, fraction) in [
3470                (0.1, 1.0),
3471                (0.25, 1.0),
3472                (0.375, 0.5),
3473                (0.5, 0.0),
3474                (0.7, 0.0),
3475            ] {
3476                // A tube longer than the boattail's drop takes the lip out of the wake.
3477                let (inside, outside) = (rocket(rise, None, 0.0), rocket(rise, Some(0.05), 0.0));
3478                let free = lip(&outside, mach).0;
3479                assert!(free > 0.0 && fraction_of(&outside) == (0.0, 0.0));
3480                let what = format!("rise {rise} at Mach {mach}");
3481                let want = (1.0 - fraction) * free;
3482                assert!((lip(&inside, mach).0 - want).abs() < 1e-9 * free, "{what}");
3483                let (wake, weight) = fraction_of(&inside);
3484                assert!((wake - fraction).abs() < 1e-9, "{what}: {wake}");
3485                assert!(
3486                    (weight - fraction * lip_gap).abs() < 1e-9,
3487                    "{what}: {weight}"
3488                );
3489            }
3490            // A tube half a drop long halves both, and the lip's length fades the base further.
3491            let (wake, weight) = fraction_of(&rocket(0.1, Some(0.5 * drop), 0.0));
3492            assert!((wake - 0.5).abs() < 1e-9 && (weight - (lip_gap - 0.5)).abs() < 1e-9);
3493            // Continuous across both ends of the fade.
3494            for edge in [0.25, 0.5] {
3495                let below = lip(&rocket(edge - 1e-9, None, 0.0), mach);
3496                let above = lip(&rocket(edge + 1e-9, None, 0.0), mach);
3497                assert!((below.0 - above.0).abs() < 1e-8, "pressure at {edge}");
3498                assert!((below.1 - above.1).abs() < 1e-8, "base at {edge}");
3499            }
3500            // A micrometer of step up, step down or tube before the lip.
3501            let exact = rocket(0.1, None, 0.0);
3502            let total = |m: &AeroModel| {
3503                m.drag(&Flow::axial(mach), &conditions)
3504                    .unwrap()
3505                    .zero_lift_coefficient
3506            };
3507            for other in [
3508                rocket(0.1, None, 1e-6),
3509                rocket(0.1, None, -1e-6),
3510                rocket(0.1, Some(1e-6), 0.0),
3511            ] {
3512                let (a, b) = (total(&exact), total(&other));
3513                assert!((a - b).abs() < 1e-3 * a, "Mach {mach}: {a} against {b}");
3514            }
3515        }
3516    }
3517
3518    /// A narrowing elliptical, Haack or power-series transition ends in a blunt tip, where the
3519    /// profile's slope is infinite: the model still builds, and the boattail rule doesn't use the
3520    /// slope.
3521    #[test]
3522    fn curved_boattails_build_and_use_the_boattail_rule() {
3523        let (big, small, l) = (0.03, 0.02, 0.04);
3524        let a_ref = PI * big * big;
3525        let delta = PI * (big * big - small * small);
3526        let c_base = 0.12 + 0.13 * 0.09;
3527        for shape in [
3528            NoseShape::Elliptical {},
3529            NoseShape::Haack { parameter: 0.0 },
3530            NoseShape::PowerSeries { exponent: 0.5 },
3531            NoseShape::Conical {},
3532        ] {
3533            let mut tail = body_part(l, big, small);
3534            if let Part::Transition(t) = &mut tail {
3535                t.shape = shape;
3536            }
3537            let rocket = one_stage(
3538                vec![
3539                    component(
3540                        "nose",
3541                        nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.2, big),
3542                        None,
3543                    ),
3544                    component("tube", body_part(0.8, big, big), None),
3545                    component("tail", tail, None),
3546                ],
3547                ReferenceDiameter::Maximum {},
3548            );
3549            let m = model(&rocket);
3550            let tail = &m.bodies()[2];
3551            assert!(tail.geometry.aft_angle_rad <= 0.0, "{shape:?}");
3552            let parts = m
3553                .buildup_components(&Flow::axial(0.3), &DragConditions::coasting(RE_PER_M))
3554                .unwrap();
3555            // γ = 0.04/0.02 = 2: half the base drag on the decrease in area.
3556            close(
3557                parts[2].drag.pressure,
3558                0.5 * c_base * delta / a_ref,
3559                1e-14,
3560                "boattail",
3561            );
3562            close(
3563                parts[2].drag.base,
3564                c_base * PI * small * small / a_ref,
3565                1e-14,
3566                "base",
3567            );
3568        }
3569    }
3570
3571    /// An override table on another reference diameter is rescaled by the ratio of the areas.
3572    #[test]
3573    fn drag_table_on_another_reference_diameter_is_rescaled() {
3574        let rocket = one_stage(
3575            vec![
3576                component("nose", nose(NoseShape::Conical {}, 0.2, 0.03), None),
3577                component("tube", body_part(0.8, 0.03, 0.03), None),
3578            ],
3579            ReferenceDiameter::Maximum {},
3580        );
3581        let m = model(&rocket);
3582        let conditions = DragConditions::coasting(RE_PER_M);
3583        let flow = Flow::axial(0.3);
3584        let table = DragTable::from_csv("0,0.5\n1,0.5\n", None).unwrap();
3585        let same = m.clone().with_drag_table(table.clone());
3586        assert_eq!(
3587            same.drag(&flow, &conditions).unwrap().zero_lift_coefficient,
3588            0.5
3589        );
3590        let wider = m
3591            .clone()
3592            .with_drag_table(table.clone().with_reference_diameter_m(0.09).unwrap());
3593        close(
3594            wider
3595                .drag(&flow, &conditions)
3596                .unwrap()
3597                .zero_lift_coefficient,
3598            0.5 * 2.25,
3599            1e-14,
3600            "a 90 mm reference on a 60 mm rocket",
3601        );
3602        // A bad diameter is refused when it is given (#79), and one set on the field directly
3603        // (as a table read with serde has it) at the first lookup.
3604        for diameter_m in [0.0, -0.09, f64::INFINITY, f64::NAN] {
3605            let refused = table.clone().with_reference_diameter_m(diameter_m);
3606            assert!(
3607                matches!(&refused, Err(AeroError::Domain { what, .. })
3608                    if *what == "drag table reference diameter"),
3609                "{diameter_m}: {refused:?}"
3610            );
3611        }
3612        let mut set = table;
3613        set.reference_diameter_m = Some(0.0);
3614        let bad = m.with_drag_table(set);
3615        assert!(matches!(
3616            bad.drag(&flow, &conditions),
3617            Err(AeroError::Domain { .. })
3618        ));
3619    }
3620
3621    /// The joint angle comes from the profile at the aft end: a power-series nose `(x/L)^n` meets
3622    /// its tube at `atan(n r/L)`, a von Kármán nose and a tangent ogive smoothly; a boattail that
3623    /// closes to a point leaves no base.
3624    #[test]
3625    fn joint_angles_from_the_profile_and_a_closed_tail() {
3626        let (r, l) = (0.03, 0.24);
3627        let a_ref = PI * r * r;
3628        let nose_pressure = |shape: NoseShape| {
3629            let rocket = one_stage(
3630                vec![
3631                    component("nose", nose(shape, l, r), None),
3632                    component("tube", body_part(0.8, r, r), None),
3633                ],
3634                ReferenceDiameter::Maximum {},
3635            );
3636            model(&rocket)
3637                .buildup_components(&Flow::axial(0.3), &DragConditions::coasting(RE_PER_M))
3638                .unwrap()[0]
3639                .drag
3640                .pressure
3641        };
3642        // At rest eq. 3.86's `0.8 sin² φ`; at Mach 0.3 the x^½ nose falls toward Stoney's 0 at
3643        // Mach 0.8 by the quadratic (eq. 3.87 has no rise to fit), and the cone rises by eq. 3.87.
3644        let phi = (0.5 * r / l).atan();
3645        let rest = 0.8 * phi.sin().powi(2);
3646        close(
3647            nose_pressure(NoseShape::PowerSeries { exponent: 0.5 }),
3648            rest * (1.0 - (0.3f64 / 0.8).powi(2)),
3649            1e-12,
3650            "x^0.5 nose",
3651        );
3652        let s = (r / l).atan().sin();
3653        let rest = 0.8 * s * s;
3654        let b = 4.0 / 2.4 * (1.0 - 0.5 * s) / (s - rest);
3655        close(
3656            nose_pressure(NoseShape::Conical {}),
3657            rest + (s - rest) * 0.3f64.powf(b),
3658            1e-12,
3659            "cone",
3660        );
3661        // Smooth joints: von Kármán stays at 0 until Stoney's curve leaves 0 past Mach 0.9; the
3662        // tangent ogive rises by eq. 3.87 toward the 4:1 cone's `sin ε` at Mach 1, by 1e-6 here.
3663        assert!(nose_pressure(NoseShape::Haack { parameter: 0.0 }) < 1e-20);
3664        let s = (0.125f64).atan().sin();
3665        let b = 4.0 / 2.4 * (1.0 - 0.5 * s) / s;
3666        close(
3667            nose_pressure(NoseShape::Ogive { radius_ratio: 1.0 }),
3668            s * 0.3f64.powf(b),
3669            1e-12,
3670            "tangent ogive",
3671        );
3672
3673        // A 0.1 m cone closing a 30 mm tube to a point: γ = 0.1/0.06 < 3, no base.
3674        let rocket = one_stage(
3675            vec![
3676                component(
3677                    "nose",
3678                    nose(NoseShape::Ogive { radius_ratio: 1.0 }, l, r),
3679                    None,
3680                ),
3681                component("tube", body_part(0.8, r, r), None),
3682                component("tail", body_part(0.1, r, 0.0), None),
3683            ],
3684            ReferenceDiameter::Maximum {},
3685        );
3686        let parts = model(&rocket)
3687            .buildup_components(
3688                &Flow::axial(0.3),
3689                &DragConditions::thrusting(RE_PER_M, 1e-3),
3690            )
3691            .unwrap();
3692        let c_base = 0.12 + 0.13 * 0.09;
3693        let gamma: f64 = 0.1 / 0.06;
3694        close(
3695            parts[2].drag.pressure,
3696            0.5 * (3.0 - gamma) * c_base * a_ref / a_ref,
3697            1e-14,
3698            "tail",
3699        );
3700        assert_eq!(parts[2].drag.base, 0.0);
3701    }
3702
3703    /// Loft lesson L18 (M1.8b2): Loft's wave drag was an invented curve, never measured against
3704    /// RASAero II. `cargo xtask aero` compares hpr's `C_D0` with RocketPy's RASAero curves every
3705    /// 0.05 from Mach 0.1 to 2.0, at sea level's Reynolds number for each Mach number, and records
3706    /// the errors by band in `validation/fixtures/aero/rocketpy-drag-curves.json` (the curves
3707    /// stay in `refs/`). This recomputes hpr's value at every row from the committed designs,
3708    /// checks every verdict and band summary against the rows, checks the errors where it can
3709    /// without the curves (at Mach 0.3 against the curve value recorded there, and between two
3710    /// cases on one curve), and pins how many rows of each band are within M1.8's 10%.
3711    /// `cargo xtask aero --check` checks every error against the curves when `refs/rocketpy` is
3712    /// fetched.
3713    ///
3714    /// The lesson named this test for supersonic drag within that tolerance. It isn't, and the
3715    /// decision records on the comparison measure why. Before the boattail's supersonic wave drag
3716    /// (ADR-030), Calisto's curve, the one real RASAero II export, read 24% to 30% above hpr from
3717    /// Mach 1.2, and no plausible fin section, thickness or finish was within 10% both below
3718    /// Mach 0.8 and from 1.2 (ADR-029). With it, 8 of its 17 supersonic rows are within 10% on
3719    /// the committed inputs (ADR-009's rule), and plausible fins bring 14 to 17 of them within 10%
3720    /// (`tests::calistos_rows_by_fin_and_finish`); the other curves are hand-edited, short, or
3721    /// disagree with their own rockets' OpenRocket files.
3722    #[test]
3723    fn supersonic_cd_against_rasaero_tables() {
3724        use serde::Deserialize;
3725
3726        #[derive(Deserialize)]
3727        struct Fixture {
3728            tolerance_rel: f64,
3729            cases: Vec<Case>,
3730        }
3731        #[derive(Deserialize)]
3732        struct Case {
3733            id: String,
3734            design: String,
3735            curve: String,
3736            thrusting: bool,
3737            curve_cd0: f64,
3738            sweep: Sweep,
3739        }
3740        #[derive(Deserialize)]
3741        struct Sweep {
3742            usable_to_mach: Option<f64>,
3743            bands: Vec<Band>,
3744            rows: Vec<Row>,
3745        }
3746        #[derive(Deserialize)]
3747        struct Band {
3748            band: String,
3749            rows: usize,
3750            within_target: usize,
3751            min_error: f64,
3752            max_error: f64,
3753            rms_error: f64,
3754        }
3755        #[derive(Deserialize)]
3756        struct Row {
3757            mach: f64,
3758            band: String,
3759            hpr_cd0: f64,
3760            relative_error: f64,
3761            within_target: bool,
3762        }
3763
3764        let fixture: Fixture = serde_json::from_str(include_str!(
3765            "../../../validation/fixtures/aero/rocketpy-drag-curves.json"
3766        ))
3767        .unwrap();
3768        assert_eq!(fixture.tolerance_rel, 0.10);
3769        let air = hpr_atmos::Ussa76::standard().sample(0.0).unwrap().air;
3770        let band_of = |mach: f64| {
3771            if mach <= SUBSONIC_MACH_LIMIT {
3772                "subsonic"
3773            } else if mach < 1.2 {
3774                "transonic"
3775            } else {
3776                "supersonic"
3777            }
3778        };
3779        let mut within = Vec::new();
3780        // Each curve's values, as each case's rows imply them.
3781        let mut curves: std::collections::BTreeMap<&str, Vec<Vec<f64>>> =
3782            std::collections::BTreeMap::new();
3783        for case in &fixture.cases {
3784            let rocket = crate::testing::committed_design(&case.design);
3785            let model = AeroModel::new(&rocket.layout().unwrap()).unwrap();
3786            let motor_area: f64 = if case.thrusting {
3787                rocket.configurations[0]
3788                    .motors
3789                    .iter()
3790                    .map(|m| 0.25 * PI * m.diameter_m * m.diameter_m)
3791                    .sum()
3792            } else {
3793                0.0
3794            };
3795            let rows = &case.sweep.rows;
3796            // Every 0.05 from Mach 0.1, without a gap, to the curve's end or its usable limit.
3797            for (i, row) in rows.iter().enumerate() {
3798                assert_eq!(row.mach, f64::from(i as u32 + 2) / 20.0, "{}", case.id);
3799                assert!(
3800                    row.mach <= case.sweep.usable_to_mach.unwrap_or(2.0),
3801                    "{}@{}",
3802                    case.id,
3803                    row.mach
3804                );
3805                assert_eq!(row.band, band_of(row.mach), "{}@{}", case.id, row.mach);
3806                let reynolds_per_m =
3807                    row.mach * air.speed_of_sound_m_s / air.kinematic_viscosity_m2_s();
3808                let conditions = if case.thrusting {
3809                    DragConditions::thrusting(reynolds_per_m, motor_area)
3810                } else {
3811                    DragConditions::coasting(reynolds_per_m)
3812                };
3813                let drag = model.drag(&Flow::axial(row.mach), &conditions).unwrap();
3814                // A stale fixture: rerun `cargo xtask aero`.
3815                close(
3816                    drag.zero_lift_coefficient,
3817                    row.hpr_cd0,
3818                    1e-12,
3819                    &format!("{}@{}", case.id, row.mach),
3820                );
3821                assert_eq!(
3822                    row.within_target,
3823                    row.relative_error.abs() <= fixture.tolerance_rel,
3824                    "{}@{}",
3825                    case.id,
3826                    row.mach
3827                );
3828            }
3829            // The curves stay in `refs/`, so CI can't recompute the errors against them. What
3830            // it can check: at Mach 0.3 the error implies the curve value the Mach 0.3
3831            // comparison records, and two cases on one curve imply the same curve row by row.
3832            let implied = |r: &Row| r.hpr_cd0 / (1.0 + r.relative_error);
3833            let at_0_3 = rows.iter().find(|r| r.mach == 0.3).unwrap();
3834            assert!(
3835                (implied(at_0_3) / case.curve_cd0 - 1.0).abs() < 1e-12,
3836                "{}: the sweep's error at Mach 0.3 doesn't match the recorded curve",
3837                case.id
3838            );
3839            curves
3840                .entry(case.curve.as_str())
3841                .or_default()
3842                .push(rows.iter().map(implied).collect());
3843            let mut from = 0;
3844            for band in &case.sweep.bands {
3845                let errors: Vec<f64> = rows[from..from + band.rows]
3846                    .iter()
3847                    .map(|r| {
3848                        assert_eq!(r.band, band.band, "{}@{}", case.id, r.mach);
3849                        r.relative_error
3850                    })
3851                    .collect();
3852                from += band.rows;
3853                let count = errors.iter().filter(|e| e.abs() <= 0.10).count();
3854                assert_eq!(band.within_target, count, "{} {}", case.id, band.band);
3855                let min = errors.iter().copied().fold(f64::INFINITY, f64::min);
3856                let max = errors.iter().copied().fold(f64::NEG_INFINITY, f64::max);
3857                let rms = (errors.iter().map(|e| e * e).sum::<f64>() / errors.len() as f64).sqrt();
3858                assert_eq!((band.min_error, band.max_error), (min, max), "{}", case.id);
3859                assert!((band.rms_error - rms).abs() < 1e-15, "{}", case.id);
3860                within.push((case.id.as_str(), band.band.as_str(), count, band.rows));
3861            }
3862            assert_eq!(from, rows.len(), "{}: every row is in a band", case.id);
3863        }
3864        for (curve, cases) in &curves {
3865            for other in &cases[1..] {
3866                assert_eq!(other.len(), cases[0].len(), "{curve}");
3867                for (a, b) in cases[0].iter().zip(other) {
3868                    assert!((a / b - 1.0).abs() < 1e-12, "{curve}: {a} against {b}");
3869                }
3870            }
3871        }
3872        // Calisto's two designs share one export.
3873        assert_eq!(curves.values().filter(|c| c.len() == 2).count(), 1);
3874        // Rows within 10%, by case and band (ADR-029, ADR-030). Calisto's export on the 2018 fins:
3875        // every subsonic row, 3 of 7 transonic, and 8 of 17 supersonic, where hpr reads −14.9% to
3876        // −5.1% (−29.8% to −24.4% before the boattail's wave drag). The getting-started fins (a
3877        // variant on the same export, thick NACA 0012) now read 23% to 32% high. Juno III's
3878        // table is hand-edited from Mach 0.93 and Cavour's stop below Mach 0.93; Valetudo's is
3879        // 1.44 times its own OpenRocket export at Mach 0.3 (ADR-009).
3880        assert_eq!(
3881            within,
3882            [
3883                ("calisto-power-off", "subsonic", 15, 15),
3884                ("calisto-power-off", "transonic", 3, 7),
3885                ("calisto-power-off", "supersonic", 8, 17),
3886                ("calisto-getting-started-power-off", "subsonic", 12, 15),
3887                ("calisto-getting-started-power-off", "transonic", 0, 7),
3888                ("calisto-getting-started-power-off", "supersonic", 0, 17),
3889                ("juno-iii-power-off", "subsonic", 15, 15),
3890                ("juno-iii-power-off", "transonic", 0, 2),
3891                ("cavour-power-off", "subsonic", 6, 15),
3892                ("cavour-power-off", "transonic", 0, 1),
3893                ("cavour-power-on", "subsonic", 1, 15),
3894                ("cavour-power-on", "transonic", 0, 2),
3895                ("valetudo-power-off", "subsonic", 0, 15),
3896                ("valetudo-power-off", "transonic", 0, 7),
3897                ("valetudo-power-off", "supersonic", 0, 7),
3898                ("valetudo-power-on", "subsonic", 0, 15),
3899                ("valetudo-power-on", "transonic", 0, 7),
3900                ("valetudo-power-on", "supersonic", 0, 7),
3901            ]
3902        );
3903    }
3904}