Skip to main content

hpr_aero/
nose_drag.rs

1//! Pressure drag of noses, shoulders and steps at every Mach number: Niskanen's semi-empirical
2//! method (2009 §3.4.3, eq. 3.86–3.87, and appendix B), with Stoney's measured curves for the
3//! shapes that have no closed form.
4//!
5//! A nose, or a shoulder (a transition that widens toward the tail), drags on its increase in area
6//! with a coefficient `(C_D•)_p(M)` in three parts:
7//!
8//! - **At rest**, `(C_D•)_p,0 = 0.8 sin² φ` (eq. 3.86), with `φ` the joint angle at the aft end
9//!   ([`crate::drag::joint_pressure_drag_coefficient`]): the separation drag of a joint that isn't
10//!   smooth.
11//! - **From a lower bound `M_L`**, appendix B's transonic and supersonic value `C_T(M)`, which
12//!   depends on the shape and the fineness ratio `f = l/(d_aft − d_fore)` (a nose's length over
13//!   its base diameter; a shoulder's length over its rise in diameter, so a cone and a conical
14//!   shoulder of the same surface angle drag alike):
15//!
16//!   | shape | `C_T(M)` | `M_L` |
17//!   |---|---|---|
18//!   | a step (no length), a body's bare front face | the blunt cylinder, `0.85 q_stag/q` (eq. B.2) | 0.8 |
19//!   | cone | eq. B.4–B.6, a cubic between Mach 1 and 1.3 ([`cone_pressure_drag_coefficient`]) | 1 |
20//!   | ogive | the cone of the same length and diameter times `0.72 (κ − ½)² + 0.82` (eq. B.8) | 1 |
21//!   | power series, parabolic series, Haack series | Stoney's fineness-3 curves, scaled to `f` by eq. B.9 | where the curves start |
22//!   | elliptical | Hoerner's measured forebody drag below Mach 0.8, Stoney's ellipsoid scaled by eq. B.9 from Mach 1.2, a straight line between ([`ellipsoid_subsonic_pressure_drag`]; [ADR-173][adr-173], a blunt ellipsoid's measured drag) | 0 (its own curve throughout) |
23//!
24//! - **Between Mach 0 and `M_L`**, eq. 3.87: `a M^b + (C_D•)_p,0`, with `a` and `b` fitting the
25//!   value and slope of `C_T` at `M_L` ([`subsonic_pressure_drag_coefficient`]).
26//!
27//! Niskanen p. 48 treats shoulders "similar to nose cones" at all speeds and calls the result
28//! "somewhat dubious at supersonic velocities"; a step is a shoulder of zero length, fineness 0.
29//! See [Drag through Mach 1][guide] in the guide and the decision record [ADR-028][adr-028].
30//!
31//! A 5:1 von Kármán nose at Mach 1.5, the guide's worked example: Stoney's 3:1 curve gives 0.0893,
32//! scaled by eq. B.9 to 0.0407 on the base area, where a 5:1 cone drags 0.0653.
33//!
34//! ```
35//! use hpr_aero::nose_drag::{PressureDragCurve, cone_pressure_drag_coefficient};
36//! use hpr_design::NoseShape;
37//!
38//! let von_karman = PressureDragCurve::new(NoseShape::VON_KARMAN, 5.0, 0.0)?;
39//! assert!((von_karman.coefficient(1.5)? - 0.0407).abs() < 5e-5);
40//! assert!((cone_pressure_drag_coefficient(5.0, 1.5)? - 0.0653).abs() < 5e-5);
41//! # Ok::<(), hpr_aero::AeroError>(())
42//! ```
43//!
44//! [guide]: https://nrdptel.github.io/hpr-sim/physics/aero.html#drag-through-mach-1
45//! [adr-173]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0173-a-blunt-ellipsoid-s-subsonic-pressure-drag-from-hoerner.md
46//! [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
47
48use hpr_design::NoseShape;
49use serde::Serialize;
50
51use crate::drag::{
52    SUBSONIC_MACH_LIMIT, check_mach_any, joint_pressure_drag_coefficient, stagnation_ratio,
53};
54use crate::error::{AeroError, check_dimension};
55
56/// Where eq. B.4 takes over from the cubic join for cones: Mach 1.3 (Niskanen 2009 p. 107,
57/// "M ≳ 1.3").
58pub const CONE_SUPERSONIC_MACH: f64 = 1.3;
59
60/// The ratio of specific heats of air in eq. B.5, `γ = 1.4`.
61const GAMMA: f64 = 1.4;
62
63/// `ln 4`, for eq. B.9's `log₄(f + 1)`.
64const LN_4: f64 = std::f64::consts::LN_2 * 2.0;
65
66/// The slope of the blunt cylinder's coefficient `0.85 q_stag/q` in Mach (eq. B.1–B.2
67/// differentiated).
68fn stagnation_drag_slope(mach: f64) -> f64 {
69    0.85 * if mach < 1.0 {
70        0.5 * mach + 0.1 * mach * mach * mach
71    } else {
72        let i = 1.0 / mach;
73        let i3 = i * i * i;
74        1.52 * i3 - 0.664 * i3 * i * i - 0.21 * i3 * i3 * i
75    }
76}
77
78/// A cubic Hermite segment from `(x0, y0)` with slope `m0` to `(x1, y1)` with slope `m1`: the
79/// value and the slope at `x`.
80fn hermite(x0: f64, y0: f64, m0: f64, x1: f64, y1: f64, m1: f64, x: f64) -> (f64, f64) {
81    let h = x1 - x0;
82    let t = (x - x0) / h;
83    let (t2, t3) = (t * t, t * t * t);
84    let value = (2.0 * t3 - 3.0 * t2 + 1.0) * y0
85        + (t3 - 2.0 * t2 + t) * h * m0
86        + (3.0 * t2 - 2.0 * t3) * y1
87        + (t3 - t2) * h * m1;
88    let slope = ((6.0 * t2 - 6.0 * t) * y0 + (6.0 * t - 6.0 * t2) * y1) / h
89        + (3.0 * t2 - 4.0 * t + 1.0) * m0
90        + (3.0 * t2 - 2.0 * t) * m1;
91    (value, slope)
92}
93
94/// A cone's transonic and supersonic pressure drag on its base area, from Mach 1, and its slope in
95/// Mach, with `s = sin ε` the sine of its half-angle (Niskanen 2009 appendix B.2, after Hoerner
96/// pp. 16-18 to 16-20):
97///
98/// ```text
99/// C(1)  = s                                         (eq. B.6)
100/// C′(1) = 4/(γ + 1) (1 − C(1)/2)                    (eq. B.5)
101/// C(M)  = 2.1 s² + 0.5 s/√(M² − 1),   M ≥ 1.3       (eq. B.4)
102/// ```
103///
104/// and between Mach 1 and 1.3 the cubic that meets both ends' values and slopes ("polynomial
105/// interpolation with the boundary conditions from equations (B.4), (B.5) and (B.6)"; four
106/// conditions, so a cubic).
107fn cone_transonic(s: f64, mach: f64) -> (f64, f64) {
108    let supersonic = |m: f64| {
109        let root = (m * m - 1.0).sqrt();
110        (
111            2.1 * s * s + 0.5 * s / root,
112            -0.5 * s * m / (root * root * root),
113        )
114    };
115    if mach >= CONE_SUPERSONIC_MACH {
116        return supersonic(mach);
117    }
118    let slope_at_1 = 4.0 / (GAMMA + 1.0) * (1.0 - 0.5 * s);
119    let (c13, slope13) = supersonic(CONE_SUPERSONIC_MACH);
120    hermite(1.0, s, slope_at_1, CONE_SUPERSONIC_MACH, c13, slope13, mach)
121}
122
123/// Eq. 3.87's fit below `M_L`: how the pressure drag rises from its value at rest to the
124/// transonic method's value at `M_L`.
125#[derive(Debug, Clone, Copy, PartialEq)]
126enum Fit {
127    /// `a Mᵇ` added to the value at rest (eq. 3.87), held as `Δ (M/M_L)ᵇ`, which is the same with
128    /// `a = Δ/M_Lᵇ` and stays within `[0, Δ]` where `M_Lᵇ` would underflow.
129    Power {
130        /// `Δ = C_T(M_L) − (C_D•)_p,0`, the rise.
131        delta: f64,
132        /// `b`.
133        b: f64,
134    },
135    /// `Δ (M/M_L)²` added to the value at rest, where eq. 3.87 has no solution (hpr's choice).
136    Quadratic {
137        /// `Δ = C_T(M_L) − (C_D•)_p,0`.
138        delta: f64,
139    },
140}
141
142impl Fit {
143    /// Eq. 3.87's `a` and `b` from the rise `delta = C_T(M_L) − (C_D•)_p,0` and the slope
144    /// `slope = C_T′(M_L)`: `b = C_T′(M_L) M_L/Δ`, `a = Δ/M_L^b`, so that `a M^b` meets both at
145    /// `M_L`. The power meets Niskanen's conditions (non-decreasing, zero slope at rest) only for a
146    /// rise with `b > 1`; otherwise the quadratic.
147    fn new(delta: f64, slope: f64, mach_low: f64) -> Self {
148        let b = slope * mach_low / delta;
149        if delta > 0.0 && b > 1.0 {
150            Self::Power { delta, b }
151        } else {
152            Self::Quadratic { delta }
153        }
154    }
155
156    /// The fit's value and slope at `mach`, below `mach_low`, above the value at rest.
157    fn eval(self, mach: f64, mach_low: f64) -> (f64, f64) {
158        match self {
159            Self::Power { delta, b } => {
160                if mach == 0.0 {
161                    // The slope at rest is never asked for (only at and above `M_L`).
162                    (0.0, 0.0)
163                } else {
164                    let power = delta * (mach / mach_low).powf(b);
165                    (power, b * power / mach)
166                }
167            }
168            Self::Quadratic { delta } => {
169                let t = mach / mach_low;
170                (delta * t * t, 2.0 * delta * t / mach_low)
171            }
172        }
173    }
174}
175
176/// Niskanen's eq. 3.87 between Mach 0 and the transonic method's lower bound `M_L`:
177/// `(C_D•)_p = a M^b + (C_D•)_p,0`, with `a` and `b` "computed to fit the drag coefficient and
178/// its derivative at the lower bound of the transonic method" (Niskanen 2009 p. 48):
179/// `b = C_T′(M_L) M_L/Δ` and `a = Δ/M_L^b`, where `Δ = C_T(M_L) − (C_D•)_p,0`.
180///
181/// Niskanen assumes the curve "non-decreasing in the subsonic region" with zero slope at rest,
182/// which needs `Δ > 0` and a positive slope (and `b > 1` for the zero slope). Where `Δ ≤ 0` or the
183/// slope isn't positive, no `a M^b` meets both conditions, and hpr uses `(C_D•)_p,0 + Δ (M/M_L)²`
184/// instead: continuous in value, flat at rest, with a kink at `M_L` ([ADR-028][adr-028]). It
185/// arises only for small coefficients: a joint that isn't smooth on a shape whose measured curve
186/// is still near 0 at `M_L`.
187///
188/// [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
189///
190/// `c_rest` is `(C_D•)_p,0`, `c_low` and `slope_low` the value and slope at `mach_low`.
191///
192/// # Errors
193///
194/// [`AeroError::Domain`] for a Mach number outside `[0, mach_low]`, a non-positive `mach_low` or
195/// non-finite coefficients.
196pub fn subsonic_pressure_drag_coefficient(
197    c_rest: f64,
198    c_low: f64,
199    slope_low: f64,
200    mach_low: f64,
201    mach: f64,
202) -> Result<f64, AeroError> {
203    check_dimension("transonic lower bound", mach_low, false)?;
204    for (what, value) in [
205        ("pressure drag at rest", c_rest),
206        ("pressure drag at the lower bound", c_low),
207        ("pressure drag slope at the lower bound", slope_low),
208    ] {
209        if !value.is_finite() {
210            return Err(AeroError::Domain { what, value });
211        }
212    }
213    if !(0.0..=mach_low).contains(&mach) {
214        return Err(AeroError::Domain {
215            what: "Mach number below the transonic lower bound",
216            value: mach,
217        });
218    }
219    Ok(c_rest
220        + Fit::new(c_low - c_rest, slope_low, mach_low)
221            .eval(mach, mach_low)
222            .0)
223}
224
225/// Whether a nose or shoulder of `shape` takes any of Niskanen's closed-form cone (eq. B.3–B.6,
226/// times eq. B.8 for an ogive) in its transonic pressure drag ([`PressureDragCurve::new`]): a cone
227/// and an ogive wholly; a power series of exponent above ¾ and a parabolic series of parameter
228/// below ½ in part, as they blend toward the 3:1 cone. That closed form reads high against
229/// measurement from Mach 0.8 ([issue #67](https://github.com/nrdptel/hpr-sim/issues/67)), so a
230/// shape that takes it carries that issue's warning.
231#[must_use]
232pub fn takes_cone_formula(shape: NoseShape) -> bool {
233    match shape {
234        NoseShape::Conical {} | NoseShape::Ogive { .. } => true,
235        // A NaN parameter counts, so a broken number can't hide the warning.
236        NoseShape::PowerSeries { exponent } => exponent.is_nan() || exponent > 0.75,
237        NoseShape::ParabolicSeries { parameter } => parameter.is_nan() || parameter < 0.5,
238        _ => false,
239    }
240}
241
242/// The pressure drag of a cone nose of fineness ratio `f` (length over base diameter) at any
243/// Mach number, on its base area (Niskanen 2009 eq. 3.86–3.87 and B.3–B.6):
244///
245/// - below Mach 1, eq. 3.87 from `0.8 sin² ε` at rest to the transonic method at Mach 1;
246/// - `sin ε` at Mach 1 (eq. B.6), with slope `4/(γ + 1) (1 − sin ε/2)` (eq. B.5);
247/// - between Mach 1 and 1.3, the cubic that meets both ends' values and slopes;
248/// - from Mach 1.3, `2.1 sin² ε + 0.5 sin ε/√(M² − 1)` (eq. B.4),
249///
250/// with the half-angle `ε` from `tan ε = 1/(2f)` (eq. B.3). The joint angle of a cone meeting its
251/// tube is `ε`, so its value at rest is eq. 3.86's `0.8 sin² ε`. A 3:1 cone: 0.0216 at rest,
252/// 0.1644 at Mach 1, 0.1557 at Mach 1.3, 0.1042 at Mach 2.
253///
254/// Below fineness 1 the closed form passes a flat face's drag as the cone flattens, so there hpr
255/// scales between a flat face at fineness 0 and this curve at fineness 1, as eq. B.9 does, from
256/// Mach 0.8 ([`PressureDragCurve::new`]): a cone of fineness 0.5 gets 0.647 at Mach 1, not
257/// `sin ε` = 0.707.
258///
259/// # Errors
260///
261/// [`AeroError::Domain`] for a non-positive or non-finite fineness ratio, or a negative or
262/// non-finite Mach number.
263pub fn cone_pressure_drag_coefficient(fineness_ratio: f64, mach: f64) -> Result<f64, AeroError> {
264    check_dimension("cone fineness ratio", fineness_ratio, false)?;
265    PressureDragCurve::new(
266        NoseShape::Conical {},
267        fineness_ratio,
268        (0.5 / fineness_ratio).atan(),
269    )?
270    .coefficient(mach)
271}
272
273/// Eq. B.8's ratio of an ogive's pressure drag to that of the cone with the same length and base
274/// diameter, at transonic and supersonic speeds: `0.72 (κ − ½)² + 0.82` (Niskanen 2009 p. 110),
275/// with `κ = ρ_t/ρ` the tangent ogive's arc radius over the ogive's (0 for a cone, 1 for a tangent
276/// ogive). It is 1 at both ends and 0.82 at `κ = ½`, after NAVWEPS Report 1488 p. 239: the best
277/// ogive drags "consistently 18% less" than the cone at Mach 1.6 to 2.5 and fineness 2 to 3.5.
278///
279/// # Errors
280///
281/// [`AeroError::Domain`] for `κ` outside `[0, 1]`: a bulged secant ogive (`κ > 1`, arc radius
282/// below the tangent ogive's) is outside the fit.
283pub fn ogive_pressure_drag_factor(kappa: f64) -> Result<f64, AeroError> {
284    if !(0.0..=1.0).contains(&kappa) {
285        return Err(AeroError::Domain {
286            what: "ogive κ (tangent-ogive radius over arc radius)",
287            value: kappa,
288        });
289    }
290    let d = kappa - 0.5;
291    Ok(0.72 * d * d + 0.82)
292}
293
294/// Eq. B.9's fineness-ratio scaling, for shapes measured at fineness 3 (Niskanen 2009 p. 110):
295///
296/// ```text
297/// (C_D•)_p = C₀ (C₃/C₀)^log₄(f + 1)
298/// ```
299///
300/// the curve `a/(f + 1)^b` (eq. B.7) through the blunt cylinder `C₀` at fineness 0 (eq. B.2) and
301/// the measured `C₃` at fineness 3. Stoney's report "suggests that the effects of fineness ratio
302/// and Mach number may be separated" (p. 108).
303///
304/// # Errors
305///
306/// [`AeroError::Domain`] for a negative or non-finite `C₃` or fineness ratio, or a non-positive
307/// `C₀`.
308pub fn fineness_scaled_pressure_drag(
309    c3: f64,
310    c0: f64,
311    fineness_ratio: f64,
312) -> Result<f64, AeroError> {
313    check_dimension("fineness-3 pressure drag", c3, true)?;
314    check_dimension("blunt-cylinder pressure drag", c0, false)?;
315    check_dimension("fineness ratio", fineness_ratio, true)?;
316    Ok(c0 * (c3 / c0).powf((fineness_ratio + 1.0).ln() / LN_4))
317}
318
319/// The forebody pressure drag of a hemispherical head on a cylinder at low speed, on the
320/// cylinder's area: 0.01 (Hoerner, *Fluid-Dynamic Drag*, 1965, p. 3-12, Fig. 20, "evaluated from
321/// pressure distribution", from Rouse and McNown's water-tunnel heads; friction not included).
322pub const HEMISPHERE_FOREBODY_PRESSURE_DRAG: f64 = 0.01;
323
324/// The forebody pressure drag of the round head about one diameter long in the same figure:
325/// −0.05, suction on the shoulder outweighing the stagnation pressure at the tip (Hoerner 1965
326/// p. 3-12, Fig. 20). The figure prints no length; the head is drawn about one diameter long.
327pub const ROUND_HEAD_FOREBODY_PRESSURE_DRAG: f64 = -0.05;
328
329/// A hemisphere's fineness ratio `l/d`.
330const HEMISPHERE_FINENESS: f64 = 0.5;
331
332/// The round head's fineness ratio, as Hoerner's Fig. 20 draws it.
333const ROUND_HEAD_FINENESS: f64 = 1.0;
334
335/// An elliptical nose's (or shoulder's) pressure drag below Mach 0.8, on its increase in area,
336/// interpolated in fineness between the forebody pressure drags Hoerner measured at low speed
337/// (1965 p. 3-12, Fig. 20) ([ADR-173][adr-173], a blunt ellipsoid's measured drag):
338///
339/// - from the hemisphere (`f = ½`) on, the straight line through the hemisphere's 0.01 and the
340///   round head's −0.05 at `f = 1`, held at 0 once it gets there (at `f = 7/12`): a head at least
341///   that long is charged no pressure drag, as eq. 3.86 charges a tangent joint none;
342/// - blunter, hpr's interpolation (Fig. 20 measures no ellipsoid between): eq. B.9's form between
343///   the flat face at `f = 0` (the blunt cylinder's `0.85 q_stag/q`, eq. B.2, at the same Mach
344///   number) and the hemisphere's 0.01, `C₀ (0.01/C₀)^(ln(f + 1)/ln 1.5)`: a step at `f = 0`, the
345///   hemisphere at `f = ½`.
346///
347/// From the hemisphere up the value holds unchanged to Mach 0.8, and blunter heads follow the flat
348/// face's rise with Mach number; that is hpr's assumption: the measurement is at low speed, and
349/// the drag rise starts near the critical Mach number (the surface's first sonic point, about
350/// 0.65 to 0.7 for a hemisphere by the Prandtl–Glauert rule). A 0.577-calibre ellipsoid gets
351/// 0.0008, a ¼-calibre one 0.074 at Mach 0.
352///
353/// [adr-173]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0173-a-blunt-ellipsoid-s-subsonic-pressure-drag-from-hoerner.md
354///
355/// # Errors
356///
357/// [`AeroError::Domain`] for a non-positive or non-finite fineness ratio (a zero-length nose is a
358/// flat face, [`PressureDragCurve::step`]), or a Mach number outside `[0, 0.8)`.
359pub fn ellipsoid_subsonic_pressure_drag(fineness_ratio: f64, mach: f64) -> Result<f64, AeroError> {
360    check_dimension("ellipsoid fineness ratio", fineness_ratio, false)?;
361    if !(0.0..SUBSONIC_MACH_LIMIT).contains(&mach) {
362        return Err(AeroError::Domain {
363            what: "Mach number below 0.8 for an ellipsoid's measured pressure drag",
364            value: mach,
365        });
366    }
367    Ok(ellipsoid_low_speed(fineness_ratio, mach).0)
368}
369
370/// [`ellipsoid_subsonic_pressure_drag`]'s value and slope in Mach, for a positive fineness ratio
371/// and a Mach number from 0 to 0.8 (the join above Mach 0.8 starts from its value at 0.8).
372fn ellipsoid_low_speed(fineness_ratio: f64, mach: f64) -> (f64, f64) {
373    if fineness_ratio >= HEMISPHERE_FINENESS {
374        let along =
375            (fineness_ratio - HEMISPHERE_FINENESS) / (ROUND_HEAD_FINENESS - HEMISPHERE_FINENESS);
376        let line = HEMISPHERE_FOREBODY_PRESSURE_DRAG
377            + along * (ROUND_HEAD_FOREBODY_PRESSURE_DRAG - HEMISPHERE_FOREBODY_PRESSURE_DRAG);
378        return (line.max(0.0), 0.0);
379    }
380    let (c0, c0_slope) = Reference::Blunt.value_and_slope(mach);
381    let exponent = (fineness_ratio + 1.0).ln() / (1.0 + HEMISPHERE_FINENESS).ln();
382    let c = c0 * (HEMISPHERE_FOREBODY_PRESSURE_DRAG / c0).powf(exponent);
383    (c, c * (1.0 - exponent) * c0_slope / c0)
384}
385
386/// A nose shape Stoney measured at fineness 3 (NASA TR R-100, 1961, Figure 12, printed p. 16),
387/// whose pressure-drag curve hpr carries as digitized points.
388///
389/// Niskanen 2009 p. 108 names these nine; hpr's shapes between them are interpolated in their
390/// parameter ([`PressureDragCurve::new`]).
391#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
392#[non_exhaustive]
393pub enum StoneyNose {
394    /// Power series `x^¼`.
395    PowerQuarter,
396    /// Power series `x^½`.
397    PowerHalf,
398    /// Power series `x^¾`.
399    PowerThreeQuarters,
400    /// Half (`K′ = ½`) parabola.
401    ParabolaHalf,
402    /// Three-quarter (`K′ = ¾`) parabola.
403    ParabolaThreeQuarters,
404    /// Full (`K′ = 1`) parabola.
405    Parabola,
406    /// Ellipsoid (hpr's elliptical nose).
407    Ellipsoid,
408    /// L-V Haack (`C = ⅓`).
409    LvHaack,
410    /// Von Kármán (LD-Haack, `C = 0`).
411    VonKarman,
412}
413
414impl StoneyNose {
415    /// Every curve.
416    pub const ALL: &'static [Self] = &[
417        Self::PowerQuarter,
418        Self::PowerHalf,
419        Self::PowerThreeQuarters,
420        Self::ParabolaHalf,
421        Self::ParabolaThreeQuarters,
422        Self::Parabola,
423        Self::Ellipsoid,
424        Self::LvHaack,
425        Self::VonKarman,
426    ];
427
428    /// The digitized points `(M, C_D,N)`, in increasing Mach number: the nose's pressure drag on
429    /// its base area at fineness 3.
430    pub fn points(self) -> &'static [(f64, f64)] {
431        match self {
432            Self::PowerQuarter => stoney::POWER_QUARTER,
433            Self::PowerHalf => stoney::POWER_HALF,
434            Self::PowerThreeQuarters => stoney::POWER_THREE_QUARTERS,
435            Self::ParabolaHalf => stoney::PARABOLA_HALF,
436            Self::ParabolaThreeQuarters => stoney::PARABOLA_THREE_QUARTERS,
437            Self::Parabola => stoney::PARABOLA,
438            Self::Ellipsoid => stoney::ELLIPSOID,
439            Self::LvHaack => stoney::LV_HAACK,
440            Self::VonKarman => stoney::VON_KARMAN,
441        }
442    }
443
444    /// Where the points were read: Figure 12's panel and the curve.
445    pub fn source(self) -> &'static str {
446        match self {
447            Self::PowerQuarter => stoney::POWER_QUARTER_SOURCE,
448            Self::PowerHalf => stoney::POWER_HALF_SOURCE,
449            Self::PowerThreeQuarters => stoney::POWER_THREE_QUARTERS_SOURCE,
450            Self::ParabolaHalf => stoney::PARABOLA_HALF_SOURCE,
451            Self::ParabolaThreeQuarters => stoney::PARABOLA_THREE_QUARTERS_SOURCE,
452            Self::Parabola => stoney::PARABOLA_SOURCE,
453            Self::Ellipsoid => stoney::ELLIPSOID_SOURCE,
454            Self::LvHaack => stoney::LV_HAACK_SOURCE,
455            Self::VonKarman => stoney::VON_KARMAN_SOURCE,
456        }
457    }
458
459    /// The first Mach number of the curve.
460    pub fn first_mach(self) -> f64 {
461        self.points()[0].0
462    }
463
464    /// The pressure drag at fineness 3 and `mach`, on the base area, and its slope in Mach:
465    /// linear between the points, the right-hand slope at a point, and the last value held past
466    /// the last point (slope 0). A curve that starts after Mach 0.8 (the x^¼ and the ellipsoid,
467    /// from 1.2) is joined by a straight line to 0 at Mach 0.8, where every smooth 3:1 nose of
468    /// panel (a) reads 0 (ADR-028); nothing is asked below Mach 0.8. An elliptical nose doesn't use
469    /// this line: it joins its scaled curve at Mach 1.2 from its measured value at Mach 0.8
470    /// (ADR-173).
471    fn value_and_slope(self, mach: f64) -> (f64, f64) {
472        let points = self.points();
473        let (first, last) = (points[0], points[points.len() - 1]);
474        if mach < first.0 {
475            if first.0 > SUBSONIC_MACH_LIMIT && mach >= SUBSONIC_MACH_LIMIT {
476                let slope = first.1 / (first.0 - SUBSONIC_MACH_LIMIT);
477                return (slope * (mach - SUBSONIC_MACH_LIMIT), slope);
478            }
479            return (
480                if first.0 > SUBSONIC_MACH_LIMIT {
481                    0.0
482                } else {
483                    first.1
484                },
485                0.0,
486            );
487        }
488        if mach >= last.0 {
489            return (last.1, 0.0);
490        }
491        // The segment [i, i + 1] that holds `mach`, with `mach` at or past point `i`: the first
492        // point is at or below `mach` (checked above, and every caller checks the Mach number is
493        // finite), so the partition point is at least 1.
494        let i = points.partition_point(|p| p.0 <= mach) - 1;
495        let ((m0, c0), (m1, c1)) = (points[i], points[i + 1]);
496        let slope = (c1 - c0) / (m1 - m0);
497        (c0 + slope * (mach - m0), slope)
498    }
499}
500
501/// A reference curve that eq. B.9's scaling and the interpolation between shapes run between.
502#[derive(Debug, Clone, Copy, PartialEq)]
503enum Reference {
504    /// The blunt cylinder (fineness 0, and a power series of exponent 0): eq. B.2.
505    Blunt,
506    /// A cone of `fineness_ratio` times `factor`, its whole curve: eq. 3.87 from `rest` below
507    /// Mach 1, eq. B.4–B.6 above.
508    Cone {
509        /// The fineness ratio.
510        fineness_ratio: f64,
511        /// Eq. B.8's ogive factor, 1 for a cone.
512        factor: f64,
513        /// The value at rest.
514        rest: f64,
515    },
516    /// Stoney's measured curve at fineness 3.
517    Stoney(StoneyNose),
518}
519
520impl Reference {
521    /// The 3:1 cone, an end of the power and parabolic series' interpolations, at rest
522    /// `0.8 sin² ε` with `sin ε = 1/√37` (`tan ε = 1/6`).
523    const CONE_3: Self = Self::Cone {
524        fineness_ratio: 3.0,
525        factor: 1.0,
526        rest: 0.8 / 37.0,
527    };
528
529    fn value_and_slope(self, mach: f64) -> (f64, f64) {
530        match self {
531            Self::Blunt => (0.85 * stagnation_ratio(mach), stagnation_drag_slope(mach)),
532            Self::Cone {
533                fineness_ratio,
534                factor,
535                rest,
536            } => PressureDragCurve::from_transonic(
537                rest,
538                Transonic::cone(fineness_ratio, factor),
539                1.0,
540            )
541            .value_and_slope(mach),
542            Self::Stoney(nose) => nose.value_and_slope(mach),
543        }
544    }
545}
546
547/// A transonic and supersonic method, from its lower bound `M_L` up.
548#[derive(Debug, Clone, Copy, PartialEq)]
549enum Transonic {
550    /// The blunt cylinder, `0.85 q_stag/q` (eq. B.2).
551    Blunt,
552    /// A cone of half-angle `ε`, times an ogive's factor (eq. B.4–B.6, B.8).
553    Cone {
554        /// `sin ε`.
555        sin_half_angle: f64,
556        /// Eq. B.8's factor, 1 for a cone.
557        factor: f64,
558    },
559    /// Reference curves, interpolated in the shape's parameter and scaled from their fineness
560    /// `f_ref` to the shape's `f` by eq. B.9.
561    Scaled {
562        /// The curve at the lower parameter.
563        lower: Reference,
564        /// The curve at the upper parameter.
565        upper: Reference,
566        /// The weight of `upper`, in `[0, 1]`.
567        weight: f64,
568        /// `ln(f + 1)/ln(f_ref + 1)`: `log₄(f + 1)` for Stoney's fineness 3.
569        exponent: f64,
570    },
571    /// An ellipsoid at every Mach number: Hoerner's measured value below Mach 0.8
572    /// ([`ellipsoid_subsonic_pressure_drag`]), Stoney's curve scaled by eq. B.9 from its first
573    /// point, Mach 1.2, and a straight line between (ADR-173).
574    Ellipsoid {
575        /// The fineness ratio.
576        fineness_ratio: f64,
577        /// Eq. 3.86's value at rest from the joint angle, the least below Mach 0.8.
578        rest: f64,
579    },
580}
581
582impl Transonic {
583    /// A cone of fineness ratio `f`, `tan ε = 1/(2f)` (eq. B.3), times `factor`.
584    fn cone(fineness_ratio: f64, factor: f64) -> Self {
585        let tan = 0.5 / fineness_ratio;
586        Self::Cone {
587            sin_half_angle: tan / (1.0 + tan * tan).sqrt(),
588            factor,
589        }
590    }
591
592    fn value_and_slope(self, mach: f64) -> (f64, f64) {
593        match self {
594            Self::Blunt => Reference::Blunt.value_and_slope(mach),
595            Self::Cone {
596                sin_half_angle,
597                factor,
598            } => {
599                let (c, slope) = cone_transonic(sin_half_angle, mach);
600                (factor * c, factor * slope)
601            }
602            Self::Scaled {
603                lower,
604                upper,
605                weight,
606                exponent,
607            } => {
608                let (lo, lo_slope) = lower.value_and_slope(mach);
609                let (hi, hi_slope) = upper.value_and_slope(mach);
610                let c3 = lo + weight * (hi - lo);
611                let c3_slope = lo_slope + weight * (hi_slope - lo_slope);
612                let (c0, c0_slope) = Reference::Blunt.value_and_slope(mach);
613                if c3 <= 0.0 {
614                    return (0.0, 0.0);
615                }
616                let c = c0 * (c3 / c0).powf(exponent);
617                (
618                    c,
619                    c * ((1.0 - exponent) * c0_slope / c0 + exponent * c3_slope / c3),
620                )
621            }
622            Self::Ellipsoid {
623                fineness_ratio,
624                rest,
625            } => {
626                let low_speed = |mach: f64| {
627                    let (c, slope) = ellipsoid_low_speed(fineness_ratio, mach);
628                    if rest > c { (rest, 0.0) } else { (c, slope) }
629                };
630                if mach < SUBSONIC_MACH_LIMIT {
631                    return low_speed(mach);
632                }
633                let stoney = Reference::Stoney(StoneyNose::Ellipsoid);
634                let measured = Self::Scaled {
635                    lower: stoney,
636                    upper: stoney,
637                    weight: 0.0,
638                    exponent: (fineness_ratio + 1.0).ln() / LN_4,
639                };
640                let first = StoneyNose::Ellipsoid.first_mach();
641                if mach >= first {
642                    return measured.value_and_slope(mach);
643                }
644                // Neither source measures between Mach 0.8 and Stoney's first point: a straight
645                // line, after eq. B.9's scaling rather than before, so the drag doesn't rise from
646                // Mach 0.8 with an infinite slope (ADR-173).
647                let low = low_speed(SUBSONIC_MACH_LIMIT).0;
648                let slope =
649                    (measured.value_and_slope(first).0 - low) / (first - SUBSONIC_MACH_LIMIT);
650                (low + slope * (mach - SUBSONIC_MACH_LIMIT), slope)
651            }
652        }
653    }
654}
655
656/// A nose's, shoulder's or step's pressure-drag coefficient against Mach number, on its increase
657/// in area: the value at rest, eq. 3.87's fit, and appendix B's transonic method from `M_L`
658/// (the module docs). It serializes what it was built from, not its internals.
659#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
660pub struct PressureDragCurve {
661    /// The nose or shoulder shape; `None` for a step or a bare front face.
662    shape: Option<NoseShape>,
663    /// The fineness ratio `l/(d_aft − d_fore)`; 0 for a step.
664    fineness_ratio: f64,
665    /// The value at rest: `(C_D•)_p,0`, eq. 3.86's, or an ellipsoid's measured value where that
666    /// is more.
667    rest: f64,
668    /// `M_L`, where the transonic method starts.
669    mach_low: f64,
670    /// Eq. 3.87's fit below `M_L`.
671    #[serde(skip)]
672    fit: Fit,
673    /// The transonic method.
674    #[serde(skip)]
675    transonic: Transonic,
676}
677
678impl PressureDragCurve {
679    /// The curve from its value at rest, its transonic method and `M_L`. With `M_L` 0 the method
680    /// covers every Mach number, and the value at rest is its own.
681    fn from_transonic(rest: f64, transonic: Transonic, mach_low: f64) -> Self {
682        let (c_low, slope_low) = transonic.value_and_slope(mach_low);
683        let rest = if mach_low == 0.0 { c_low } else { rest };
684        Self {
685            shape: None,
686            fineness_ratio: 0.0,
687            rest,
688            mach_low,
689            fit: Fit::new(c_low - rest, slope_low, mach_low),
690            transonic,
691        }
692    }
693
694    /// A step up in radius, or a body's bare front face: a flat face, the blunt cylinder's
695    /// `0.85 q_stag/q` at every Mach number (eq. B.1–B.2), 0.85 at rest. Eq. 3.86 "does not take
696    /// into account the effect of extremely blunt nose cones (length less than half of the
697    /// diameter)" (Niskanen 2009 p. 47), and a step has no length.
698    pub fn step() -> Self {
699        Self::from_transonic(0.0, Transonic::Blunt, 0.0)
700    }
701
702    /// The curve of a nose or shoulder of `shape`, fineness ratio `f = l/(d_aft − d_fore)` and
703    /// joint angle `joint_angle_rad` at its aft end (eq. 3.86's `φ`).
704    ///
705    /// - A cone takes eq. B.4–B.6, and its value at rest from `φ` (for a cone nose, `φ = ε`).
706    /// - An ogive takes the cone of the same fineness times eq. B.8's factor, `κ` the reciprocal of
707    ///   its radius ratio.
708    /// - An ellipsoid takes Hoerner's measured forebody pressure drag below Mach 0.8
709    ///   ([`ellipsoid_subsonic_pressure_drag`]), or eq. 3.86's value at rest where that is more,
710    ///   Stoney's ellipsoid scaled by eq. B.9 from its first point, Mach 1.2, and a straight line
711    ///   between ([ADR-173][adr-173], a blunt ellipsoid's measured drag).
712    /// - The other shapes interpolate Stoney's fineness-3 curves linearly in their parameter, then
713    ///   scale by eq. B.9 (Niskanen p. 108: "If data for a particular parameter value is missing,
714    ///   interpolate"). A power series `xⁿ` runs through the blunt cylinder (`n = 0`), `x^¼`, `x^½`,
715    ///   `x^¾` and the 3:1 cone (`n = 1`); a parabolic series through the cone (`K′ = 0`) and the
716    ///   `½`, `¾` and full parabolas; a Haack series between von Kármán (`C = 0`) and L-V Haack
717    ///   (`C = ⅓`). `M_L` is the first Mach number both ends' curves have.
718    ///
719    /// Cones and ogives below fineness 1 scale by eq. B.9's form between a flat face at fineness 0
720    /// and their closed form at fineness 1, from Mach 0.8, because the closed form passes a flat
721    /// face's drag as the cone flattens. A zero fineness ratio is a step:
722    /// [`PressureDragCurve::step`] whatever the shape.
723    ///
724    /// # Errors
725    ///
726    /// [`AeroError::Domain`] for a negative or non-finite fineness ratio, a joint angle outside
727    /// `[0, π/2]`, or a power-series exponent, parabolic parameter or Haack parameter outside
728    /// `[0, 1]`, `[0, 1]` or `[0, ⅓]`; [`AeroError::Unsupported`] for an ogive whose radius ratio
729    /// isn't a finite number of at least 1 (a bulged secant ogive has one below 1) or a Haack series
730    /// above `C = ⅓`, where no data reaches, and for a shape this model doesn't know.
731    ///
732    /// [adr-173]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0173-a-blunt-ellipsoid-s-subsonic-pressure-drag-from-hoerner.md
733    pub fn new(
734        shape: NoseShape,
735        fineness_ratio: f64,
736        joint_angle_rad: f64,
737    ) -> Result<Self, AeroError> {
738        let mut curve = Self::build(shape, fineness_ratio, joint_angle_rad)?;
739        if fineness_ratio > 0.0 {
740            curve.shape = Some(shape);
741            curve.fineness_ratio = fineness_ratio;
742        }
743        Ok(curve)
744    }
745
746    /// [`PressureDragCurve::new`]'s curve, before it records its inputs.
747    fn build(
748        shape: NoseShape,
749        fineness_ratio: f64,
750        joint_angle_rad: f64,
751    ) -> Result<Self, AeroError> {
752        check_dimension("fineness ratio", fineness_ratio, true)?;
753        let rest = joint_pressure_drag_coefficient(joint_angle_rad)?;
754        if fineness_ratio == 0.0 {
755            return Ok(Self::step());
756        }
757        let interpolated = |lower: Reference, upper: Reference, weight: f64| {
758            let transonic = Transonic::Scaled {
759                lower,
760                upper,
761                weight,
762                exponent: (fineness_ratio + 1.0).ln() / LN_4,
763            };
764            Ok(Self::from_transonic(rest, transonic, SUBSONIC_MACH_LIMIT))
765        };
766        // Cones and ogives: the closed form from fineness 1; below it, at every Mach number,
767        // eq. B.9's form between the flat face and the whole curve at fineness 1, since eq. B.4
768        // passes the flat face as the cone flattens (ADR-028).
769        let cone = |factor: f64| {
770            if fineness_ratio >= 1.0 {
771                Ok(Self::from_transonic(
772                    rest,
773                    Transonic::cone(fineness_ratio, factor),
774                    1.0,
775                ))
776            } else {
777                let transonic = Transonic::Scaled {
778                    lower: Reference::Blunt,
779                    upper: Reference::Cone {
780                        fineness_ratio: 1.0,
781                        factor,
782                        rest,
783                    },
784                    weight: 1.0,
785                    exponent: (fineness_ratio + 1.0).ln() / std::f64::consts::LN_2,
786                };
787                Ok(Self::from_transonic(rest, transonic, 0.0))
788            }
789        };
790        use Reference::{Blunt, Stoney};
791        let cone_3 = Reference::CONE_3;
792        match shape {
793            NoseShape::Conical {} => cone(1.0),
794            NoseShape::Ogive { radius_ratio } => {
795                if !(radius_ratio.is_finite() && radius_ratio >= 1.0) {
796                    return Err(AeroError::Unsupported(format!(
797                        "an ogive of radius ratio {radius_ratio}, not at least 1 (below 1 is a \
798                         bulged secant ogive): Niskanen's \
799                         eq. B.8 runs only from the cone to the tangent ogive"
800                    )));
801                }
802                cone(ogive_pressure_drag_factor(1.0 / radius_ratio)?)
803            }
804            NoseShape::Elliptical {} => Ok(Self::from_transonic(
805                rest,
806                Transonic::Ellipsoid {
807                    fineness_ratio,
808                    rest,
809                },
810                0.0,
811            )),
812            NoseShape::PowerSeries { exponent: n } => {
813                let (lower, upper, low, high) = if n < 0.25 {
814                    (Blunt, Stoney(StoneyNose::PowerQuarter), 0.0, 0.25)
815                } else if n < 0.5 {
816                    (
817                        Stoney(StoneyNose::PowerQuarter),
818                        Stoney(StoneyNose::PowerHalf),
819                        0.25,
820                        0.5,
821                    )
822                } else if n < 0.75 {
823                    (
824                        Stoney(StoneyNose::PowerHalf),
825                        Stoney(StoneyNose::PowerThreeQuarters),
826                        0.5,
827                        0.75,
828                    )
829                } else {
830                    (Stoney(StoneyNose::PowerThreeQuarters), cone_3, 0.75, 1.0)
831                };
832                check_parameter("power series exponent", n, 0.0, 1.0)?;
833                interpolated(lower, upper, (n - low) / (high - low))
834            }
835            NoseShape::ParabolicSeries { parameter: k } => {
836                let (lower, upper, low, high) = if k < 0.5 {
837                    (cone_3, Stoney(StoneyNose::ParabolaHalf), 0.0, 0.5)
838                } else if k < 0.75 {
839                    (
840                        Stoney(StoneyNose::ParabolaHalf),
841                        Stoney(StoneyNose::ParabolaThreeQuarters),
842                        0.5,
843                        0.75,
844                    )
845                } else {
846                    (
847                        Stoney(StoneyNose::ParabolaThreeQuarters),
848                        Stoney(StoneyNose::Parabola),
849                        0.75,
850                        1.0,
851                    )
852                };
853                check_parameter("parabolic series parameter", k, 0.0, 1.0)?;
854                interpolated(lower, upper, (k - low) / (high - low))
855            }
856            NoseShape::Haack { parameter: c } => {
857                if c > 1.0 / 3.0 {
858                    return Err(AeroError::Unsupported(format!(
859                        "a Haack series nose of C = {c}, above the L-V Haack's 1/3: Stoney's data \
860                         stops there (Niskanen 2009 p. 103)"
861                    )));
862                }
863                check_parameter("Haack series parameter", c, 0.0, 1.0 / 3.0)?;
864                interpolated(
865                    Stoney(StoneyNose::VonKarman),
866                    Stoney(StoneyNose::LvHaack),
867                    3.0 * c,
868                )
869            }
870            // A new shape needs a pressure-drag decision.
871            _ => Err(AeroError::Unsupported(
872                "this nose shape (no transonic pressure-drag method)".to_owned(),
873            )),
874        }
875    }
876
877    /// The value at rest: eq. 3.86's `0.8 sin² φ`, or for an ellipsoid, Hoerner's measured value
878    /// where it is more ([ADR-173][adr-173], a blunt ellipsoid's measured drag).
879    ///
880    /// [adr-173]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0173-a-blunt-ellipsoid-s-subsonic-pressure-drag-from-hoerner.md
881    pub fn rest_coefficient(&self) -> f64 {
882        self.rest
883    }
884
885    /// `M_L`, where appendix B's transonic method takes over from eq. 3.87; 0 for an ellipsoid,
886    /// whose own curve covers every Mach number.
887    pub fn transonic_lower_bound(&self) -> f64 {
888        self.mach_low
889    }
890
891    /// The coefficient at `mach`, on the increase in area.
892    ///
893    /// # Errors
894    ///
895    /// [`AeroError::Domain`] for a negative or non-finite Mach number.
896    pub fn coefficient(&self, mach: f64) -> Result<f64, AeroError> {
897        check_mach_any(mach)?;
898        Ok(self.value_and_slope(mach).0)
899    }
900
901    /// The value and slope at a checked Mach number.
902    fn value_and_slope(&self, mach: f64) -> (f64, f64) {
903        if mach < self.mach_low {
904            let (value, slope) = self.fit.eval(mach, self.mach_low);
905            (self.rest + value, slope)
906        } else {
907            self.transonic.value_and_slope(mach)
908        }
909    }
910}
911
912/// Checks a shape parameter against `[low, high]`.
913fn check_parameter(what: &'static str, value: f64, low: f64, high: f64) -> Result<(), AeroError> {
914    if (low..=high).contains(&value) {
915        Ok(())
916    } else {
917        Err(AeroError::Domain { what, value })
918    }
919}
920
921/// Stoney's curves, read from NASA TR R-100 (1961), Figure 12 ("Pressure drag of noses of
922/// fineness ratio 3"), printed p. 16 (PDF p. 20): `(M, C_D,N)`, the nose's pressure drag on its
923/// base area, at the faired line's center (600-dpi render, each panel's grid calibrated locally;
924/// 2026-09-18, M1.8b1). Panel (a), Stoney's flight models, for the seven shapes it has; panel (b),
925/// the wind tunnel of his ref. 30, for the x^¼ and the ellipsoid, which begin at Mach 1.2 there.
926/// A curve ends at its last visible point, and [`super::StoneyNose`] holds its end value past it.
927/// Configuration numbers are Stoney's (Fig. 9's key, PDF p. 19).
928#[rustfmt::skip]
929mod stoney {
930    /// Stoney 1961, Fig. 12(a), flight models: x^3/4, configuration 61, the faired line from Mach 0.80 to its end at 1.967; leaving zero at Mach 0.870, peak 0.1185 at 1.057; read to +-0.0015 (at most +-0.0048).
931    pub(super) const POWER_THREE_QUARTERS: &[(f64, f64)] = &[(0.8, 0.0), (0.85, 0.0), (0.87, 0.0), (0.9, 0.0131), (0.95, 0.0375), (1.0, 0.0848), (1.05, 0.1185), (1.057, 0.1185), (1.1, 0.1095), (1.15, 0.1089), (1.2, 0.109), (1.25, 0.1032), (1.3, 0.1001), (1.4, 0.097), (1.5, 0.0932), (1.6, 0.09), (1.8, 0.0847), (1.967, 0.0789)];
932    pub(super) const POWER_THREE_QUARTERS_SOURCE: &str = "Stoney 1961, Fig. 12(a), flight models: x^3/4, configuration 61, the faired line from Mach 0.80 to its end at 1.967; leaving zero at Mach 0.870, peak 0.1185 at 1.057; read to +-0.0015 (at most +-0.0048)";
933    /// Stoney 1961, Fig. 12(a), flight models: x^1/2, configuration 63, the faired line from Mach 0.80 to its end at 1.941; leaving zero at Mach 0.934; read to +-0.0014 (at most +-0.0023).
934    pub(super) const POWER_HALF: &[(f64, f64)] = &[(0.8, 0.0), (0.85, 0.0), (0.9, 0.0), (0.934, 0.0), (0.95, 0.0069), (1.0, 0.046), (1.05, 0.0597), (1.1, 0.0573), (1.15, 0.0675), (1.2, 0.08), (1.25, 0.0853), (1.3, 0.0851), (1.4, 0.0847), (1.5, 0.0856), (1.6, 0.086), (1.8, 0.0885), (1.941, 0.0898)];
935    pub(super) const POWER_HALF_SOURCE: &str = "Stoney 1961, Fig. 12(a), flight models: x^1/2, configuration 63, the faired line from Mach 0.80 to its end at 1.941; leaving zero at Mach 0.934; read to +-0.0014 (at most +-0.0023)";
936    /// Stoney 1961, Fig. 12(a), flight models: parabola, configuration 59, the faired line from Mach 0.80 to its end at 1.975; leaving zero at Mach 0.952, peak 0.1162 at 1.165; read to +-0.0016 (at most +-0.0046).
937    pub(super) const PARABOLA: &[(f64, f64)] = &[(0.8, 0.0), (0.85, 0.0), (0.9, 0.0), (0.95, 0.0), (0.952, 0.0), (1.0, 0.037), (1.05, 0.0898), (1.1, 0.1073), (1.15, 0.1149), (1.165, 0.1162), (1.2, 0.1161), (1.25, 0.1145), (1.3, 0.1134), (1.4, 0.1103), (1.5, 0.1078), (1.6, 0.1067), (1.8, 0.1062), (1.975, 0.1073)];
938    pub(super) const PARABOLA_SOURCE: &str = "Stoney 1961, Fig. 12(a), flight models: parabola, configuration 59, the faired line from Mach 0.80 to its end at 1.975; leaving zero at Mach 0.952, peak 0.1162 at 1.165; read to +-0.0016 (at most +-0.0046)";
939    /// Stoney 1961, Fig. 12(a), flight models: 3/4 parabola, configuration 62, the faired line from Mach 0.80 to its end at 1.968; leaving zero at Mach 0.902, peak 0.1078 at 1.136; read to +-0.0016 (at most +-0.0035).
940    pub(super) const PARABOLA_THREE_QUARTERS: &[(f64, f64)] = &[(0.8, 0.0), (0.85, 0.0), (0.9, 0.0), (0.902, 0.0), (0.95, 0.0182), (1.0, 0.069), (1.05, 0.0889), (1.1, 0.1049), (1.136, 0.1078), (1.15, 0.1072), (1.2, 0.1036), (1.25, 0.0997), (1.3, 0.0932), (1.4, 0.0861), (1.5, 0.082), (1.6, 0.0817), (1.8, 0.0797), (1.968, 0.0814)];
941    pub(super) const PARABOLA_THREE_QUARTERS_SOURCE: &str = "Stoney 1961, Fig. 12(a), flight models: 3/4 parabola, configuration 62, the faired line from Mach 0.80 to its end at 1.968; leaving zero at Mach 0.902, peak 0.1078 at 1.136; read to +-0.0016 (at most +-0.0035)";
942    /// Stoney 1961, Fig. 12(a), flight models: 1/2 parabola, configuration 57, the faired line from Mach 0.80 to its end at 1.968; leaving zero at Mach 0.817, peak 0.1247 at 1.081; read to +-0.0016 (at most +-0.003).
943    pub(super) const PARABOLA_HALF: &[(f64, f64)] = &[(0.8, 0.0), (0.817, 0.0), (0.85, 0.0057), (0.9, 0.0155), (0.95, 0.0403), (1.0, 0.094), (1.05, 0.1231), (1.081, 0.1247), (1.1, 0.1235), (1.15, 0.1149), (1.2, 0.1139), (1.25, 0.1068), (1.3, 0.1009), (1.4, 0.0936), (1.5, 0.0877), (1.6, 0.0854), (1.8, 0.0854), (1.968, 0.0864)];
944    pub(super) const PARABOLA_HALF_SOURCE: &str = "Stoney 1961, Fig. 12(a), flight models: 1/2 parabola, configuration 57, the faired line from Mach 0.80 to its end at 1.968; leaving zero at Mach 0.817, peak 0.1247 at 1.081; read to +-0.0016 (at most +-0.003)";
945    /// Stoney 1961, Fig. 12(a), flight models: Von Karman, configuration 58, the faired line from Mach 0.80 to its end at 1.994; leaving zero at Mach 0.915, peak 0.0898 at 1.553; read to +-0.0014 (at most +-0.0022).
946    pub(super) const VON_KARMAN: &[(f64, f64)] = &[(0.8, 0.0), (0.85, 0.0), (0.9, 0.0), (0.915, 0.0), (0.95, 0.0077), (1.0, 0.0253), (1.05, 0.0581), (1.1, 0.0694), (1.15, 0.0734), (1.2, 0.0758), (1.25, 0.0779), (1.3, 0.0825), (1.4, 0.0879), (1.5, 0.0893), (1.553, 0.0898), (1.6, 0.0898), (1.8, 0.0869), (1.994, 0.0794)];
947    pub(super) const VON_KARMAN_SOURCE: &str = "Stoney 1961, Fig. 12(a), flight models: Von Karman, configuration 58, the faired line from Mach 0.80 to its end at 1.994; leaving zero at Mach 0.915, peak 0.0898 at 1.553; read to +-0.0014 (at most +-0.0022)";
948    /// Stoney 1961, Fig. 12(a), flight models: L-V Haack, configuration 60, the faired line from Mach 0.80 to its end at 1.977; leaving zero at Mach 0.915, peak 0.1172 at 1.617; read to +-0.0014 (at most +-0.0022).
949    pub(super) const LV_HAACK: &[(f64, f64)] = &[(0.8, 0.0), (0.85, 0.0), (0.9, 0.0), (0.915, 0.0), (0.95, 0.0077), (1.0, 0.0253), (1.05, 0.065), (1.1, 0.0847), (1.15, 0.095), (1.2, 0.1002), (1.25, 0.1028), (1.3, 0.107), (1.4, 0.1135), (1.5, 0.1156), (1.6, 0.117), (1.617, 0.1172), (1.8, 0.1155), (1.977, 0.1115)];
950    pub(super) const LV_HAACK_SOURCE: &str = "Stoney 1961, Fig. 12(a), flight models: L-V Haack, configuration 60, the faired line from Mach 0.80 to its end at 1.977; leaving zero at Mach 0.915, peak 0.1172 at 1.617; read to +-0.0014 (at most +-0.0022)";
951    /// Stoney 1961, Fig. 12(b), wind tunnel (Stoney's ref. 30): x^1/4, the faired line from Mach 1.20 to its end at 3.587; read to +-0.0014 (at most +-0.003).
952    pub(super) const POWER_QUARTER: &[(f64, f64)] = &[(1.2, 0.141), (1.25, 0.148), (1.3, 0.1558), (1.4, 0.1689), (1.5, 0.1809), (1.6, 0.1894), (1.8, 0.2051), (2.0, 0.2165), (2.4, 0.2331), (2.8, 0.2441), (3.2, 0.248), (3.587, 0.2491)];
953    pub(super) const POWER_QUARTER_SOURCE: &str = "Stoney 1961, Fig. 12(b), wind tunnel (Stoney's ref. 30): x^1/4, the faired line from Mach 1.20 to its end at 3.587; read to +-0.0014 (at most +-0.003)";
954    /// Stoney 1961, Fig. 12(b), wind tunnel (Stoney's ref. 30): ellipsoid, the faired line from Mach 1.20 to its end at 3.587; read to +-0.0014 (at most +-0.004).
955    pub(super) const ELLIPSOID: &[(f64, f64)] = &[(1.2, 0.111), (1.25, 0.1298), (1.3, 0.14), (1.4, 0.1478), (1.5, 0.1509), (1.6, 0.1523), (1.8, 0.1552), (2.0, 0.1576), (2.4, 0.1601), (2.8, 0.1601), (3.2, 0.16), (3.587, 0.158)];
956    pub(super) const ELLIPSOID_SOURCE: &str = "Stoney 1961, Fig. 12(b), wind tunnel (Stoney's ref. 30): ellipsoid, the faired line from Mach 1.20 to its end at 3.587; read to +-0.0014 (at most +-0.004)";
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962
963    fn close(got: f64, want: f64, rel: f64, what: &str) {
964        let err = ((got - want) / want).abs();
965        assert!(
966            err <= rel,
967            "{what}: got {got}, want {want}, rel err {err:e}"
968        );
969    }
970
971    /// The shapes that take the cone's closed form, at the ends of the series' blends: a power
972    /// series of exponent 1 and a parabolic series of parameter 0 are the cone itself, and a hair
973    /// inside each blend still draws on it.
974    #[test]
975    fn the_cone_formula_reaches_into_the_series_blends() {
976        let at = |shape: NoseShape| PressureDragCurve::new(shape, 3.0, 0.0).unwrap();
977        let cone = at(NoseShape::Conical {}).coefficient(1.1).unwrap();
978        for shape in [
979            NoseShape::PowerSeries { exponent: 1.0 },
980            NoseShape::ParabolicSeries { parameter: 0.0 },
981        ] {
982            assert!(takes_cone_formula(shape));
983            close(
984                at(shape).coefficient(1.1).unwrap(),
985                cone,
986                1e-12,
987                "a cone by another name",
988            );
989        }
990        let above = |x: f64| f64::from_bits(x.to_bits() + 1);
991        let below = |x: f64| f64::from_bits(x.to_bits() - 1);
992        for (shape, takes) in [
993            (NoseShape::PowerSeries { exponent: 0.75 }, false),
994            (
995                NoseShape::PowerSeries {
996                    exponent: above(0.75),
997                },
998                true,
999            ),
1000            (NoseShape::ParabolicSeries { parameter: 0.5 }, false),
1001            (
1002                NoseShape::ParabolicSeries {
1003                    parameter: below(0.5),
1004                },
1005                true,
1006            ),
1007            (NoseShape::PowerSeries { exponent: f64::NAN }, true),
1008            (NoseShape::Conical {}, true),
1009            (NoseShape::TANGENT_OGIVE, true),
1010            (NoseShape::VON_KARMAN, false),
1011            (
1012                NoseShape::Haack {
1013                    parameter: 1.0 / 3.0,
1014                },
1015                false,
1016            ),
1017            (NoseShape::Elliptical {}, false),
1018        ] {
1019            assert_eq!(takes_cone_formula(shape), takes, "{shape:?}");
1020        }
1021    }
1022
1023    /// A 3:1 cone by hand from eq. 3.86–3.87 and B.3–B.6, and the cubic joining Mach 1 to 1.3
1024    /// smoothly (values and slopes agree on both sides of each join).
1025    #[test]
1026    fn a_cone_follows_appendix_b() {
1027        let s = 1.0 / 37f64.sqrt();
1028        let rest = 0.8 * s * s;
1029        let slope_at_1 = 4.0 / 2.4 * (1.0 - 0.5 * s);
1030        let b4 = |m: f64| 2.1 * s * s + 0.5 * s / (m * m - 1.0).sqrt();
1031        let cone = |m: f64| cone_pressure_drag_coefficient(3.0, m).unwrap();
1032        close(cone(0.0), rest, 1e-15, "at rest, 0.8 sin² ε");
1033        close(rest, 0.021_621_6, 1e-5, "0.0216");
1034        close(cone(1.0), s, 1e-15, "eq. B.6 at Mach 1");
1035        close(cone(1.3), b4(1.3), 1e-15, "eq. B.4 at 1.3");
1036        for m in [1.5, 2.0, 3.0, 4.99] {
1037            close(cone(m), b4(m), 1e-15, "eq. B.4");
1038        }
1039        close(cone(2.0), 0.104_215, 1e-5, "0.1042 at Mach 2");
1040        // Eq. 3.87 below Mach 1: b = C′(1)/(C(1) − C₀), a = C(1) − C₀.
1041        let b = slope_at_1 / (s - rest);
1042        close(b, 10.714, 1e-4, "b");
1043        for m in [0.3, 0.6, 0.9, 0.99] {
1044            close(
1045                cone(m),
1046                rest + (s - rest) * f64::powf(m, b),
1047                1e-13,
1048                "eq. 3.87",
1049            );
1050        }
1051        // Smooth at both joins.
1052        let curve =
1053            PressureDragCurve::new(NoseShape::Conical {}, 3.0, (1.0 / 6f64).atan()).unwrap();
1054        let h = 1e-7;
1055        for (m, want) in [
1056            (1.0, slope_at_1),
1057            (1.3, -0.5 * s * 1.3 / (0.69f64).powf(1.5)),
1058        ] {
1059            let c = |m: f64| curve.coefficient(m).unwrap();
1060            close((c(m) - c(m - h)) / h, want, 1e-5, "slope below a join");
1061            close((c(m + h) - c(m)) / h, want, 1e-5, "slope above a join");
1062            let below = curve.coefficient(m - 1e-12).unwrap();
1063            let above = curve.coefficient(m).unwrap();
1064            assert!((below - above).abs() < 1e-11, "value at Mach {m}");
1065        }
1066        assert_eq!(curve.transonic_lower_bound(), 1.0);
1067        close(curve.rest_coefficient(), rest, 1e-15, "rest");
1068    }
1069
1070    /// Eq. B.8: 1 for a cone and a tangent ogive, 0.82 at `κ = ½`; outside `[0, 1]` refused, and a
1071    /// bulged ogive refused where the model is built.
1072    #[test]
1073    fn an_ogive_is_the_cone_times_eq_b8() {
1074        assert_eq!(ogive_pressure_drag_factor(0.0).unwrap(), 1.0);
1075        assert_eq!(ogive_pressure_drag_factor(1.0).unwrap(), 1.0);
1076        close(
1077            ogive_pressure_drag_factor(0.5).unwrap(),
1078            0.82,
1079            1e-15,
1080            "κ = ½",
1081        );
1082        for bad in [-0.1, 1.01, f64::NAN] {
1083            assert!(ogive_pressure_drag_factor(bad).is_err(), "{bad}");
1084        }
1085        let cone = PressureDragCurve::new(NoseShape::Conical {}, 4.0, 0.0).unwrap();
1086        let tangent = PressureDragCurve::new(NoseShape::TANGENT_OGIVE, 4.0, 0.0).unwrap();
1087        let secant =
1088            PressureDragCurve::new(NoseShape::Ogive { radius_ratio: 2.0 }, 4.0, 0.0).unwrap();
1089        for m in [1.0, 1.2, 2.0, 4.0] {
1090            close(
1091                tangent.coefficient(m).unwrap(),
1092                cone.coefficient(m).unwrap(),
1093                1e-15,
1094                "tangent",
1095            );
1096            close(
1097                secant.coefficient(m).unwrap(),
1098                0.82 * cone.coefficient(m).unwrap(),
1099                1e-14,
1100                "κ = ½",
1101            );
1102        }
1103        // With a smooth joint, nothing at rest.
1104        assert_eq!(tangent.coefficient(0.0).unwrap(), 0.0);
1105        let bulged = PressureDragCurve::new(NoseShape::Ogive { radius_ratio: 0.8 }, 4.0, 0.1);
1106        assert!(
1107            matches!(bulged, Err(AeroError::Unsupported(_))),
1108            "{bulged:?}"
1109        );
1110    }
1111
1112    /// Eq. B.9 passes through the blunt cylinder at fineness 0 and the measured value at 3.
1113    #[test]
1114    fn eq_b9_runs_from_the_blunt_cylinder_to_fineness_3() {
1115        let (c3, c0) = (0.08, 1.4);
1116        close(
1117            fineness_scaled_pressure_drag(c3, c0, 3.0).unwrap(),
1118            c3,
1119            1e-15,
1120            "f = 3",
1121        );
1122        close(
1123            fineness_scaled_pressure_drag(c3, c0, 0.0).unwrap(),
1124            c0,
1125            1e-15,
1126            "f = 0",
1127        );
1128        let mut previous = c0;
1129        for f in [0.5, 1.0, 2.0, 3.0, 5.0, 10.0] {
1130            let c = fineness_scaled_pressure_drag(c3, c0, f).unwrap();
1131            assert!(c < previous, "falls with fineness: {c} at {f}");
1132            previous = c;
1133        }
1134        // a/(f + 1)^b through both (eq. B.7).
1135        let b = (c0 / c3).ln() / 4f64.ln();
1136        close(
1137            fineness_scaled_pressure_drag(c3, c0, 5.0).unwrap(),
1138            c0 / 6f64.powf(b),
1139            1e-14,
1140            "eq. B.7",
1141        );
1142        assert_eq!(fineness_scaled_pressure_drag(0.0, c0, 2.0).unwrap(), 0.0);
1143        for (c3, c0, f) in [(-0.01, 1.0, 3.0), (0.1, 0.0, 3.0), (0.1, 1.0, -1.0)] {
1144            assert!(fineness_scaled_pressure_drag(c3, c0, f).is_err());
1145        }
1146    }
1147
1148    /// Eq. 3.87 meets the transonic value and slope at `M_L`; where it can't (no rise, or a
1149    /// falling curve), the quadratic meets the value.
1150    #[test]
1151    fn eq_3_87_meets_the_lower_bound() {
1152        let (rest, low, slope, m_l) = (0.02, 0.16, 1.5, 1.0);
1153        let fit = |m: f64| subsonic_pressure_drag_coefficient(rest, low, slope, m_l, m).unwrap();
1154        assert_eq!(fit(0.0), rest);
1155        close(fit(m_l), low, 1e-15, "value at M_L");
1156        close(
1157            (fit(m_l) - fit(m_l - 1e-7)) / 1e-7,
1158            slope,
1159            1e-5,
1160            "slope at M_L",
1161        );
1162        // No rise: the quadratic, from the value at rest to the value at M_L.
1163        let no_rise =
1164            |m: f64| subsonic_pressure_drag_coefficient(0.01, 0.005, 0.3, 0.8, m).unwrap();
1165        close(no_rise(0.4), 0.01 - 0.005 * 0.25, 1e-15, "quadratic");
1166        close(no_rise(0.8), 0.005, 1e-15, "quadratic at M_L");
1167        // Falling: the same.
1168        let falling = |m: f64| subsonic_pressure_drag_coefficient(0.0, 0.1, -0.2, 0.8, m).unwrap();
1169        close(falling(0.4), 0.025, 1e-15, "falling");
1170        for bad in [-0.1, 0.81, f64::NAN] {
1171            assert!(subsonic_pressure_drag_coefficient(rest, low, slope, 0.8, bad).is_err());
1172        }
1173        assert!(subsonic_pressure_drag_coefficient(rest, f64::NAN, slope, 0.8, 0.5).is_err());
1174        assert!(subsonic_pressure_drag_coefficient(rest, low, slope, 0.0, 0.0).is_err());
1175    }
1176
1177    /// A step: the flat face, the blunt cylinder's `0.85 q_stag/q` at every Mach number (eq.
1178    /// B.1–B.2), 0.85 at rest, with its published jump at Mach 1.
1179    #[test]
1180    fn a_step_rises_to_the_blunt_cylinder() {
1181        let step = PressureDragCurve::step();
1182        let blunt = |m: f64| stagnation_drag_coefficient_for_test(m);
1183        assert_eq!(step.coefficient(0.0).unwrap(), 0.85);
1184        assert_eq!(step.rest_coefficient(), 0.85);
1185        close(
1186            step.coefficient(0.8).unwrap(),
1187            0.994_704,
1188            1e-12,
1189            "0.85 × 1.17024",
1190        );
1191        close(
1192            step.coefficient(0.3).unwrap(),
1193            0.85 * (1.0 + 0.0225 + 0.0081 / 40.0),
1194            1e-15,
1195            "0.3",
1196        );
1197        for m in [0.1, 0.5, 0.9, 0.999, 1.0, 2.0, 4.9] {
1198            close(
1199                step.coefficient(m).unwrap(),
1200                blunt(m),
1201                1e-15,
1202                "blunt cylinder",
1203            );
1204        }
1205        let mut previous = 0.85;
1206        for m in [0.1, 0.3, 0.5, 0.7, 0.8] {
1207            let c = step.coefficient(m).unwrap();
1208            assert!(c > previous, "rises: {c} at Mach {m}");
1209            previous = c;
1210        }
1211        // A zero-length shoulder of any shape is the step.
1212        for shape in [
1213            NoseShape::Conical {},
1214            NoseShape::VON_KARMAN,
1215            NoseShape::Elliptical {},
1216        ] {
1217            assert_eq!(
1218                PressureDragCurve::new(shape, 0.0, std::f64::consts::FRAC_PI_2).unwrap(),
1219                step
1220            );
1221        }
1222    }
1223
1224    fn stagnation_drag_coefficient_for_test(mach: f64) -> f64 {
1225        crate::drag::stagnation_drag_coefficient(mach).unwrap()
1226    }
1227
1228    /// Loft lesson L15 through Mach 1: a cone or ogive shoulder tends to the step as it shortens,
1229    /// and its blend below fineness 1 meets the closed form at 1.
1230    #[test]
1231    fn short_cones_tend_to_the_step_and_meet_the_closed_form_at_fineness_1() {
1232        let step = PressureDragCurve::step();
1233        for shape in [NoseShape::Conical {}, NoseShape::TANGENT_OGIVE] {
1234            let at = |f: f64| {
1235                let joint = (0.5 / f).atan();
1236                PressureDragCurve::new(shape, f, joint).unwrap()
1237            };
1238            for m in [0.0, 0.3, 0.79, 0.8, 0.95, 1.0, 1.2, 2.0, 4.9] {
1239                let near_zero = at(1e-9).coefficient(m).unwrap();
1240                close(near_zero, step.coefficient(m).unwrap(), 1e-7, "f → 0");
1241                let below = at(1.0 - 1e-10).coefficient(m).unwrap();
1242                let at_1 = at(1.0).coefficient(m).unwrap();
1243                close(below, at_1, 1e-8, "f → 1 from below");
1244            }
1245        }
1246    }
1247
1248    /// Refusals: a Haack series past L-V Haack, shape parameters and fineness out of range, and
1249    /// a Mach number that isn't one.
1250    #[test]
1251    fn out_of_range_shapes_are_refused() {
1252        let haack = PressureDragCurve::new(NoseShape::Haack { parameter: 0.5 }, 3.0, 0.0);
1253        assert!(matches!(haack, Err(AeroError::Unsupported(_))), "{haack:?}");
1254        for (shape, f, joint) in [
1255            (NoseShape::Conical {}, -1.0, 0.1),
1256            (NoseShape::Conical {}, f64::NAN, 0.1),
1257            (NoseShape::Conical {}, 3.0, 2.0),
1258            (NoseShape::PowerSeries { exponent: 1.5 }, 3.0, 0.1),
1259            (NoseShape::ParabolicSeries { parameter: -0.1 }, 3.0, 0.1),
1260            (NoseShape::Haack { parameter: -0.1 }, 3.0, 0.0),
1261        ] {
1262            assert!(
1263                PressureDragCurve::new(shape, f, joint).is_err(),
1264                "{shape:?} {f} {joint}"
1265            );
1266        }
1267        let cone = PressureDragCurve::new(NoseShape::Conical {}, 3.0, 0.1).unwrap();
1268        for bad in [-0.1, f64::NAN, f64::INFINITY] {
1269            assert!(cone.coefficient(bad).is_err());
1270        }
1271        assert!(cone_pressure_drag_coefficient(0.0, 1.0).is_err());
1272    }
1273
1274    /// Stoney's curves as carried: increasing Mach, non-negative, panel (a)'s from Mach 0.8 and
1275    /// panel (b)'s from 1.2; each shape at fineness 3 is its curve from `M_L` (eq. B.9 is the
1276    /// identity there), its end value held past its last point; shapes between measured ones
1277    /// interpolate, and the series' ends are the 3:1 cone and the blunt cylinder.
1278    #[test]
1279    fn stoney_curves_are_read_as_published() {
1280        for nose in StoneyNose::ALL {
1281            let points = nose.points();
1282            assert!(points.windows(2).all(|w| w[0].0 < w[1].0), "{nose:?}");
1283            assert!(points.iter().all(|p| p.1 >= 0.0 && p.1 < 0.3), "{nose:?}");
1284            let first = match nose {
1285                StoneyNose::PowerQuarter | StoneyNose::Ellipsoid => 1.2,
1286                _ => 0.8,
1287            };
1288            assert_eq!(nose.first_mach(), first, "{nose:?}");
1289            assert!(
1290                nose.source().starts_with("Stoney 1961, Fig. 12"),
1291                "{nose:?}"
1292            );
1293        }
1294        let at_3 = |shape: NoseShape| PressureDragCurve::new(shape, 3.0, 0.0).unwrap();
1295        let vk = at_3(NoseShape::VON_KARMAN);
1296        for &(m, c) in StoneyNose::VonKarman.points() {
1297            close(
1298                vk.coefficient(m).unwrap() + 1e-300,
1299                c + 1e-300,
1300                1e-12,
1301                "von Kármán",
1302            );
1303        }
1304        let (last_m, last_c) = *StoneyNose::VonKarman.points().last().unwrap();
1305        assert!(last_m < 2.0);
1306        close(
1307            vk.coefficient(4.0).unwrap(),
1308            last_c,
1309            1e-12,
1310            "held past the end",
1311        );
1312        close(
1313            vk.coefficient(1.5).unwrap(),
1314            0.0893,
1315            1e-12,
1316            "0.0893 at Mach 1.5",
1317        );
1318        let lv = at_3(NoseShape::LV_HAACK);
1319        close(
1320            lv.coefficient(1.5).unwrap(),
1321            0.1156,
1322            1e-12,
1323            "L-V Haack at 1.5",
1324        );
1325        let between = at_3(NoseShape::Haack {
1326            parameter: 1.0 / 6.0,
1327        });
1328        close(
1329            between.coefficient(1.5).unwrap(),
1330            0.5 * (0.0893 + 0.1156),
1331            1e-12,
1332            "C = 1/6",
1333        );
1334        // The series' ends: a power series of exponent 1 and a parabolic series of 0 are the 3:1
1335        // cone from Mach 0.8; exponent 0 would be the blunt cylinder.
1336        let cone = PressureDragCurve::new(NoseShape::Conical {}, 3.0, (1.0 / 6f64).atan()).unwrap();
1337        for shape in [
1338            NoseShape::PowerSeries { exponent: 1.0 },
1339            NoseShape::ParabolicSeries { parameter: 0.0 },
1340        ] {
1341            for m in [0.8, 0.9, 1.0, 1.2, 2.0, 4.0] {
1342                close(
1343                    at_3(shape).coefficient(m).unwrap(),
1344                    cone.coefficient(m).unwrap(),
1345                    1e-12,
1346                    "the 3:1 cone",
1347                );
1348            }
1349        }
1350        let blunt_ish = PressureDragCurve::new(NoseShape::PowerSeries { exponent: 0.05 }, 3.0, 0.0)
1351            .unwrap()
1352            .coefficient(2.0)
1353            .unwrap();
1354        let x_quarter = at_3(NoseShape::PowerSeries { exponent: 0.25 })
1355            .coefficient(2.0)
1356            .unwrap();
1357        close(x_quarter, 0.2165, 1e-12, "x^¼ at Mach 2");
1358        close(
1359            blunt_ish,
1360            0.8 * stagnation_drag_coefficient_for_test(2.0) + 0.2 * 0.2165,
1361            1e-12,
1362            "a fifth of the way from the blunt cylinder",
1363        );
1364        // The x^¼ and the ellipsoid start at Mach 1.2: a straight line joins them to 0 at Mach
1365        // 0.8, where the other smooth 3:1 noses read 0, so every Stoney shape starts at 0.8.
1366        // The line's slope holds from Mach 0.8 itself, where eq. 3.87 is fitted (physics re-check).
1367        let (at_08, slope) = StoneyNose::PowerQuarter.value_and_slope(0.8);
1368        assert_eq!(at_08, 0.0);
1369        close(slope, 0.141 / 0.4, 1e-12, "the line's slope at Mach 0.8");
1370        let ellipse = at_3(NoseShape::Elliptical {});
1371        // The ellipsoid's own curve covers every Mach number (ADR-173).
1372        assert_eq!(ellipse.transonic_lower_bound(), 0.0);
1373        assert_eq!(vk.transonic_lower_bound(), 0.8);
1374        assert_eq!(ellipse.coefficient(0.6).unwrap(), 0.0);
1375        assert_eq!(ellipse.coefficient(0.8).unwrap(), 0.0);
1376        close(
1377            ellipse.coefficient(1.0).unwrap(),
1378            0.5 * 0.111,
1379            1e-12,
1380            "halfway up the line",
1381        );
1382        close(
1383            ellipse.coefficient(1.2).unwrap(),
1384            0.111,
1385            1e-12,
1386            "its first point",
1387        );
1388    }
1389
1390    /// ADR-173: an ellipsoid below Mach 0.8 is Hoerner's measured forebody pressure drag (1965
1391    /// p. 3-12, Fig. 20): the hemisphere's 0.01, the round head's −0.05 at one diameter, the line
1392    /// between held at 0, and eq. B.9's form from the flat face to the hemisphere. *Base drag
1393    /// hack*'s 0.577-calibre nose gets 0.0008, not the 0.064 of OpenRocket at Mach 0.3.
1394    #[test]
1395    fn a_blunt_ellipsoid_takes_hoerners_measured_forebody_drag() {
1396        assert_eq!(HEMISPHERE_FOREBODY_PRESSURE_DRAG, 0.01);
1397        assert_eq!(ROUND_HEAD_FOREBODY_PRESSURE_DRAG, -0.05);
1398        let at = |f: f64, m: f64| {
1399            PressureDragCurve::new(NoseShape::Elliptical {}, f, 0.0)
1400                .unwrap()
1401                .coefficient(m)
1402                .unwrap()
1403        };
1404        for m in [0.0, 0.1, 0.3, 0.5, 0.79] {
1405            // The hemisphere is the measurement, at every Mach number below 0.8.
1406            close(at(0.5, m), 0.01, 1e-12, "hemisphere");
1407            close(
1408                ellipsoid_subsonic_pressure_drag(0.5, m).unwrap(),
1409                0.01,
1410                1e-12,
1411                "hemisphere, by the function",
1412            );
1413            // Between the hemisphere and the round head, on the line; past 7/12, 0.
1414            close(at(0.577, m), 0.01 - 0.12 * 0.077, 1e-9, "0.577 calibres");
1415            assert_eq!(at(7.0 / 12.0 + 1e-9, m), 0.0);
1416            assert_eq!(at(1.0, m), 0.0, "the round head's −0.05 is held at 0");
1417            assert_eq!(ellipsoid_subsonic_pressure_drag(1.0, m).unwrap(), 0.0);
1418            assert_eq!(at(3.0, m), 0.0);
1419            // Blunter than a hemisphere: eq. B.9's form from the flat face at the same Mach number.
1420            let c0 = stagnation_drag_coefficient_for_test(m);
1421            let e = 1.25f64.ln() / 1.5f64.ln();
1422            close(at(0.25, m), c0 * (0.01 / c0).powf(e), 1e-12, "¼ calibre");
1423        }
1424        close(at(0.25, 0.0), 0.0737, 2e-3, "¼ calibre at rest");
1425        // By hand at Mach 0.5: q_stag/q = 1 + 0.25/4 + 0.0625/40, 0.85 of it 0.90445; to the
1426        // power ln 1.25/ln 1.5 = 0.55034 between it and 0.01.
1427        close(at(0.25, 0.5), 0.075807, 2e-5, "¼ calibre at Mach 0.5");
1428        // A joint that isn't smooth keeps eq. 3.86's 0.8 sin² φ where that is more: 0.0699 at
1429        // φ = 0.3 against nothing measured at fineness 1, and against 0.0737 measured at ¼.
1430        let joint = |f: f64, m: f64| {
1431            PressureDragCurve::new(NoseShape::Elliptical {}, f, 0.3)
1432                .unwrap()
1433                .coefficient(m)
1434                .unwrap()
1435        };
1436        let rest_03 = 0.8 * 0.3f64.sin().powi(2);
1437        for m in [0.0, 0.5, 0.79] {
1438            close(
1439                joint(1.0, m),
1440                rest_03,
1441                1e-12,
1442                "eq. 3.86 over nothing measured",
1443            );
1444            close(
1445                joint(0.25, m),
1446                at(0.25, m),
1447                1e-12,
1448                "the measurement over eq. 3.86",
1449            );
1450        }
1451        let rest = PressureDragCurve::new(NoseShape::Elliptical {}, 0.25, 0.0).unwrap();
1452        close(
1453            rest.rest_coefficient(),
1454            at(0.25, 0.0),
1455            1e-15,
1456            "the value at rest",
1457        );
1458        // Towards a flat face, the step's drag; at the hemisphere, the line's, from both sides.
1459        let step = PressureDragCurve::step();
1460        for m in [0.0, 0.4, 0.79] {
1461            let flat = step.coefficient(m).unwrap();
1462            close(at(1e-9, m), flat, 1e-6, "towards a flat face");
1463            close(
1464                at(0.5 - 1e-9, m),
1465                at(0.5 + 1e-9, m),
1466                1e-6,
1467                "at the hemisphere",
1468            );
1469        }
1470        // The function refuses what it doesn't cover.
1471        for (f, m) in [
1472            (0.0, 0.3),
1473            (-0.1, 0.3),
1474            (0.5, 0.8),
1475            (0.5, -0.1),
1476            (f64::NAN, 0.3),
1477        ] {
1478            assert!(
1479                ellipsoid_subsonic_pressure_drag(f, m).is_err(),
1480                "{f} at {m}"
1481            );
1482        }
1483    }
1484
1485    /// ADR-173: from Mach 0.8 an ellipsoid rises in a straight line to Stoney's curve scaled by
1486    /// eq. B.9 at its first point, Mach 1.2, so its slope is finite at Mach 0.8 (it was infinite,
1487    /// `(M − 0.8)^log₄(f + 1)`, below fineness 3). At fineness 3 the line is the one before.
1488    #[test]
1489    fn an_ellipsoid_rises_from_mach_08_in_a_straight_line() {
1490        for f in [0.1, 0.25, 0.5, 0.577, 1.0, 2.0, 3.0, 6.0] {
1491            let curve = PressureDragCurve::new(NoseShape::Elliptical {}, f, 0.0).unwrap();
1492            let c = |m: f64| curve.coefficient(m).unwrap();
1493            let (low, high) = (c(0.8), c(1.2));
1494            let scaled =
1495                fineness_scaled_pressure_drag(0.111, stagnation_drag_coefficient_for_test(1.2), f)
1496                    .unwrap();
1497            close(high, scaled, 1e-9, "Stoney's first point, scaled");
1498            let near = |got: f64, want: f64, what: &str| {
1499                assert!(
1500                    (got - want).abs() <= 1e-9,
1501                    "{what} at fineness {f}: {got} {want}"
1502                );
1503            };
1504            near(c(0.8 - 1e-12), low, "continuous at Mach 0.8");
1505            near(c(1.2 - 1e-12), high, "continuous at Mach 1.2");
1506            for t in [0.25, 0.5, 0.75] {
1507                near(c(0.8 + 0.4 * t), low + t * (high - low), "the line");
1508            }
1509            let (_, slope) = curve.value_and_slope(0.8);
1510            close(
1511                slope,
1512                (high - low) / 0.4,
1513                1e-9,
1514                "a finite slope at Mach 0.8",
1515            );
1516        }
1517        let three = PressureDragCurve::new(NoseShape::Elliptical {}, 3.0, 0.0).unwrap();
1518        close(
1519            three.coefficient(1.0).unwrap(),
1520            0.5 * 0.111,
1521            1e-12,
1522            "fineness 3 halfway",
1523        );
1524    }
1525
1526    /// The guide's worked example (`docs/physics/aero.md`, *Drag through Mach 1*): at Mach 1.5 a
1527    /// 5:1 von Kármán nose drags 0.0407 on its base area, eq. B.9 from Stoney's 3:1 value 0.0893
1528    /// and the blunt cylinder's 1.3074 with the exponent `log₄ 6`; a 5:1 cone 0.0653 (eq. B.4).
1529    #[test]
1530    fn the_guides_worked_example() {
1531        let blunt = PressureDragCurve::step().coefficient(1.5).unwrap();
1532        close(blunt, 1.3074, 1e-4, "the blunt cylinder at Mach 1.5");
1533        let exponent = 6f64.ln() / 4f64.ln();
1534        close(exponent, 1.2925, 1e-4, "log₄ 6");
1535        let by_hand = blunt * (0.0893 / blunt).powf(exponent);
1536        let vk = PressureDragCurve::new(NoseShape::VON_KARMAN, 5.0, 0.0).unwrap();
1537        close(
1538            vk.coefficient(1.5).unwrap(),
1539            by_hand,
1540            1e-12,
1541            "5:1 von Kármán",
1542        );
1543        close(by_hand, 0.0407, 1e-3, "0.0407");
1544        let cone = cone_pressure_drag_coefficient(5.0, 1.5).unwrap();
1545        close(cone, 0.0653, 1e-3, "5:1 cone");
1546    }
1547
1548    /// Niskanen's closed-form 3:1 cone (eq. 3.87 below Mach 1, eq. B.4–B.6 and the cubic above)
1549    /// against Stoney's measured 3:1 cone, configuration 56 of Figure 12(a) (read with the other
1550    /// curves, to ±0.0014): high through the whole rise, +87% at Mach 0.8 and +105% at 0.85, +49%
1551    /// at Mach 1 and +48% at 1.1, near the cubic's peak; +15% at 1.5 and +4% at the curve's end,
1552    /// 1.94. Documented in `docs/physics/aero.md`; the ogives inherit it.
1553    #[test]
1554    fn niskanens_cone_against_stoneys_measured_cone() {
1555        let stoney = [
1556            (0.8, 0.0186, 0.865),
1557            (0.85, 0.0228, 1.046),
1558            (0.9, 0.0366, 0.852),
1559            (0.95, 0.0673, 0.546),
1560            (1.0, 0.1102, 0.492),
1561            (1.1, 0.1580, 0.483),
1562            (1.2, 0.1378, 0.453),
1563            (1.5, 0.1136, 0.147),
1564            (1.8, 0.1044, 0.070),
1565            (1.937, 0.1022, 0.040),
1566        ];
1567        for (m, measured, error) in stoney {
1568            let hpr = cone_pressure_drag_coefficient(3.0, m).unwrap();
1569            let got = hpr / measured - 1.0;
1570            assert!(
1571                (got - error).abs() < 0.001,
1572                "Mach {m}: {got:+.4}, recorded {error:+.3}"
1573            );
1574        }
1575    }
1576
1577    /// Code review: eq. 3.87's `a = Δ/M_Lᵇ` overflowed where `Δ` is small and `b` huge, and gave
1578    /// NaN below `M_L`. An x^0.868229375 nose at 3:1 with its own joint angle has `Δ` near 0; the
1579    /// curve now stays finite and between the value at rest and `C_T(M_L)` below `M_L`.
1580    #[test]
1581    fn eq_3_87_stays_finite_when_the_rise_is_tiny() {
1582        for n in [0.868229375, 0.8675, 0.869, 0.8695] {
1583            let joint = (n / 6.0f64).atan();
1584            let curve =
1585                PressureDragCurve::new(NoseShape::PowerSeries { exponent: n }, 3.0, joint).unwrap();
1586            let rest = curve.rest_coefficient();
1587            let at_l = curve.coefficient(curve.transonic_lower_bound()).unwrap();
1588            for m in [0.0, 0.1, 0.3, 0.5, 0.79, 0.8, 1.0, 1.1, 1.19, 1.5] {
1589                let c = curve.coefficient(m).unwrap();
1590                assert!(c.is_finite(), "n = {n}, Mach {m}: {c}");
1591                if m < curve.transonic_lower_bound() {
1592                    assert!(c >= rest.min(at_l) - 1e-15 && c <= rest.max(at_l) + 1e-15);
1593                }
1594            }
1595        }
1596        let tiny = subsonic_pressure_drag_coefficient(0.01, 0.01 + 1e-12, 1.0, 0.8, 0.5).unwrap();
1597        close(tiny, 0.01, 1e-9, "a rise of 1e-12");
1598    }
1599
1600    /// Below fineness 1 a cone scales between a flat face and its fineness-1 closed form: at
1601    /// fineness 0.5 and Mach 1, 0.647 where `sin ε` would give 0.707.
1602    #[test]
1603    fn a_stubby_cone_takes_the_blend() {
1604        let c = cone_pressure_drag_coefficient(0.5, 1.0).unwrap();
1605        close(c, 0.647, 1e-3, "fineness 0.5 at Mach 1");
1606        // At rest the blend sits above eq. 3.86's 0.8 sin² ε = 0.400 (physics re-check).
1607        close(
1608            cone_pressure_drag_coefficient(0.5, 0.0).unwrap(),
1609            0.547,
1610            1e-3,
1611            "at rest",
1612        );
1613        assert!(
1614            c < std::f64::consts::FRAC_1_SQRT_2 - 0.05,
1615            "below sin ε = sin 45°"
1616        );
1617    }
1618
1619    /// Physics review: a near-flat power series (x^0.05) has almost nothing at rest by eq. 3.86,
1620    /// which leaves bluntness out (Niskanen p. 47), and nearly the flat face's drag at Mach 0.8.
1621    /// No `a Mᵇ` with `b > 1` joins them, so the curve rises as `Δ (M/M_L)²`, flat at rest.
1622    #[test]
1623    fn a_near_flat_nose_rises_from_rest_without_a_jump() {
1624        let n = 0.05;
1625        let curve = PressureDragCurve::new(
1626            NoseShape::PowerSeries { exponent: n },
1627            3.0,
1628            (n / 6.0).atan(),
1629        )
1630        .unwrap();
1631        let rest = curve.rest_coefficient();
1632        let at_08 = curve.coefficient(0.8).unwrap();
1633        for m in [0.01, 0.1, 0.3, 0.6] {
1634            let want = rest + (at_08 - rest) * (m / 0.8) * (m / 0.8);
1635            close(curve.coefficient(m).unwrap(), want, 1e-12, "quadratic");
1636        }
1637        assert!(curve.coefficient(0.01).unwrap() < 1e-3);
1638        close(
1639            at_08,
1640            0.7958,
1641            1e-3,
1642            "0.80 at Mach 0.8, near the flat face's 0.9947",
1643        );
1644    }
1645
1646    proptest::proptest! {
1647        /// Every shape at any fineness and joint angle gives a finite, non-negative coefficient
1648        /// from rest to Mach 5, and nothing jumps at `M_L`.
1649        #[test]
1650        fn every_shape_is_finite_and_non_negative(
1651            which in 0usize..6,
1652            parameter in 0.0f64..=1.0,
1653            fineness in 0.0f64..12.0,
1654            joint in 0.0f64..=std::f64::consts::FRAC_PI_2,
1655            mach in 0.0f64..5.0,
1656        ) {
1657            let shape = match which {
1658                0 => NoseShape::Conical {},
1659                1 => NoseShape::Ogive { radius_ratio: 1.0 + 10.0 * parameter },
1660                2 => NoseShape::Elliptical {},
1661                3 => NoseShape::PowerSeries { exponent: 0.05 + 0.95 * parameter },
1662                4 => NoseShape::ParabolicSeries { parameter },
1663                _ => NoseShape::Haack { parameter: parameter / 3.0 },
1664            };
1665            let curve = PressureDragCurve::new(shape, fineness, joint).unwrap();
1666            let c = curve.coefficient(mach).unwrap();
1667            proptest::prop_assert!(c.is_finite() && c >= 0.0, "{c}");
1668            let m_l = curve.transonic_lower_bound();
1669            // An ellipsoid's own curve joins at Mach 0.8 and 1.2 instead (ADR-173).
1670            let joins: &[f64] = if which == 2 { &[0.8, 1.2] } else { &[m_l] };
1671            for &join in joins {
1672                let below = curve.coefficient(join * (1.0 - 1e-12)).unwrap();
1673                let at = curve.coefficient(join).unwrap();
1674                proptest::prop_assert!((below - at).abs() <= 1e-9 * (1.0 + at), "{below} {at}");
1675            }
1676        }
1677
1678        /// Physics review: the drag is continuous in the shape's parameter, across the measured
1679        /// shapes where the interpolation changes its ends (x^¼, x^½, x^¾; the ½ and ¾
1680        /// parabolas), at every Mach number.
1681        #[test]
1682        fn continuous_in_the_shape_parameter(
1683            which in 0usize..3,
1684            knot in 0usize..3,
1685            fineness in 0.5f64..8.0,
1686            mach in 0.0f64..5.0,
1687        ) {
1688            let shape = |p: f64| match which {
1689                0 => NoseShape::PowerSeries { exponent: p },
1690                1 => NoseShape::ParabolicSeries { parameter: p },
1691                _ => NoseShape::Haack { parameter: p / 3.0 },
1692            };
1693            let p = [0.25, 0.5, 0.75][knot];
1694            let at = |p: f64| {
1695                PressureDragCurve::new(shape(p), fineness, 0.0)
1696                    .unwrap()
1697                    .coefficient(mach)
1698                    .unwrap()
1699            };
1700            // Eq. B.9 raises the fineness-3 value to `log₄(f + 1)`, below 1 under fineness 3, so
1701            // the drag is continuous but steep where that value is near 0: the gap across the knot
1702            // shrinks with the step, and is small at a step of 1e-12 (a jump, like the 0.05 the
1703            // review found at n = ½, would not shrink).
1704            let gap = |step: f64| (at(p - step) - at(p + step)).abs();
1705            let (wide, narrow) = (gap(1e-6), gap(1e-12));
1706            // From fineness 1 the exponent is at least ½, and the gap at 1e-12 at most about 1e-6.
1707            let bound = if fineness >= 1.0 { 1e-4 } else { 0.01 };
1708            proptest::prop_assert!(
1709                narrow <= wide + 1e-12 && narrow <= bound,
1710                "{shape:?} at Mach {mach}: gaps {wide} and {narrow}",
1711                shape = shape(p)
1712            );
1713        }
1714    }
1715}