Skip to main content

hpr_aero/
blunt_tip.rs

1//! A blunt or vertical nose tip faster than sound: modified Newtonian pressures on the cap, handed
2//! over to the second-order shock-expansion method ([`crate::shock_expansion`]) where the surface
3//! slope falls to the steepest wedge an attached shock can turn. After C. M. Jackson Jr.,
4//! W. C. Sawyer and R. S. Smith, *A Method for Determining Surface Pressures on Blunt Bodies of
5//! Revolution at Small Angles of Attack in Supersonic Flow*, NASA TN D-4865 (1968) ([J68]).
6//!
7//! **Flown faster than sound** since the milestone
8//! [M1.8e7](https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-8e7), for noses whose
9//! tip is vertical (power-series noses with `n < 1`, Haack series, elliptical noses) through
10//! [`crate::model::SupersonicBody`]. The guide's
11//! [Blunt tips](https://nrdptel.github.io/hpr-sim/physics/aero.html#blunt-tips) explains it and
12//! how it was checked.
13//!
14//! ```
15//! use hpr_aero::blunt_tip::{handover_angle_rad, newtonian_loading, pitot_pressure_ratio};
16//!
17//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
18//! // Behind a normal shock at Mach 2 the pitot pressure is 5.640 times the free stream's
19//! // (NACA Report 1135, Table II).
20//! assert!((pitot_pressure_ratio(2.0)? - 5.6404).abs() < 1e-4);
21//! // The cap hands over where its slope falls to the steepest wedge an attached shock turns
22//! // at Mach 1.5, 12.1°.
23//! assert!((handover_angle_rad(1.5)?.to_degrees() - 12.11).abs() < 0.01);
24//! // Where the cap's slope is 45° its loading is C_p,max/2, 0.829 at Mach 2.
25//! let loading = newtonian_loading(2.0, 45f64.to_radians())?;
26//! assert!((loading - 0.8286).abs() < 1e-4);
27//! # Ok(())
28//! # }
29//! ```
30//!
31//! **The cap.** Near a blunt tip the shock stands off the body and the flow behind it is subsonic,
32//! where the shock-expansion method, which marches supersonic flow from a pointed tip, can't
33//! start. The report's cap is modified Newtonian (eq. 1, p. 5):
34//!
35//! `p_s/p₀ = (p_t2/p₀ − 1) sin²δ + 1`,
36//!
37//! with `δ` the surface's slope to the wind and `p_t2` the pitot pressure behind a normal shock
38//! (the Rayleigh pitot formula, NACA Report 1135, 1953, eq. 100, p. 619):
39//!
40//! `p_t2/p₀ = [(γ + 1)M²/2]^(γ/(γ − 1)) · [(γ + 1)/(2γM² − (γ − 1))]^(1/(γ − 1))`.
41//!
42//! In coefficient form `C_p = C_p,max sin²δ`, `C_p,max = (p_t2/p₀ − 1)/(γM²/2)`.
43//!
44//! **The handover.** The shock-expansion method starts "at the point where the surface slope is
45//! the same as that required for shock attachment to a two-dimensional wedge at the free-stream
46//! Mach number", chosen "simply because it gave the best agreement with the available data in the
47//! low supersonic-speed range" (p. 5): the wedge's largest deflection `δ_max`, from the shock angle
48//! of NACA Report 1135 eq. 168 (p. 624),
49//!
50//! `sin²θ = [(γ + 1)M²/4 − 1 + √((γ + 1)((γ + 1)M⁴/16 + (γ − 1)M²/2 + 1))]/(γM²)`,
51//!
52//! turned into a deflection by eq. 138 (p. 621), `tan δ = 2 cot θ (M² sin²θ − 1)/(2 + M²(γ + 1 −
53//! 2 sin²θ))`. It is 12.1° at Mach 1.5 and 22.97° at Mach 2. hpr hands over at the lesser of
54//! `δ_max` and 24° ([`MAX_HANDOVER_RAD`]), so from Mach 2.06 up the cap reaches further aft than
55//! the report's. The cap is a cap because the method reads the tangent cone's normal-force slope
56//! at the handover, and those tables ([`crate::shock_expansion::cone_normal_force_slope`]) once
57//! stopped at TN 3527 Fig. 2's 24°. They now reach 30° ([`CONE_TABLE_CAP_RAD`]), and the
58//! handover doesn't, because the march does not carry it there yet: measured over the whole
59//! sweep in [ADR-043][adr-043], a 30° handover reads nearer TN D-4865's own sphere-cone at every
60//! row where a cap binds at all, and on the committed Arcas Robin nose above about Mach 4 it puts
61//! most of the march's elements into `η < 0`
62//! ([issue #108](https://github.com/nrdptel/hpr-sim/issues/108)), where the answer moves with the
63//! element count. No cap above 24° holds its answer to Mach 5, and the failure isn't orderly in
64//! the cap: 28° is the worst of the four measured. [`handover_angle_capped_rad`] takes the cap as
65//! a parameter so both ends are measured rather than argued.
66//!
67//! **The flow behind it: hpr's choice, not the report's.** hpr starts TN 3527's march at the
68//! handover as the method starts at a pointed vertex: with the flow on the cone tangent to the
69//! body there (Taylor–Maccoll), that cone's loading `tan δ (dC_N/dα)_tc`, and no pressure gradient
70//! (TN 3527 sketch (a), p. 6). The report starts it from the Newtonian pressure and Mach number
71//! instead (eq. 2 and p. 5). Read that way at `α → 0`, the march on the committed Arcas Robin nose
72//! reduces elements from Mach 2.96 ([issue #81](https://github.com/nrdptel/hpr-sim/issues/81),
73//! the method's open question there), so its answer changes as elements are added, and
74//! fails from Mach 3.96, where the Newtonian pressure at the handover lies below the tangent
75//! cone's; the tangent cone's start holds to Mach 5 and converges. [`crate::shock_expansion::HandoverStart`] keeps the report's start to compare,
76//! and the decision record on it, [ADR-038][adr-038], gives both readings' numbers.
77//!
78//! **The loading at `α → 0`.** On the cap, TN 3527's loading form ([`crate::shock_expansion`]),
79//! whose `C_Nα = (2π/A_ref) ∫ Λ r dx` makes `Λ` half the windward meridian's `∂C_p/∂α`, takes
80//! the wind's slope `δ + α cos φ` in `C_p = C_p,max sin²δ`: `Λ = C_p,max sin δ cos δ`
81//! ([`newtonian_loading`]). A hemisphere then carries `C_Nα = C_p,max/2`, its Newtonian drag turned
82//! into the body's axes, as it must. Behind the handover the loading is the method's. The handover
83//! itself is held where it sits on the body, as TN 3527 holds every other point; the report's
84//! equivalent bodies turn the body about the sphere's center, sliding the handover along the
85//! surface, and that term is left out. Nothing here measures what it is worth.
86//!
87//! **What it covers.** The report checked spherical caps only: a sphere-cone (a 0.175-diameter
88//! nose radius on an 11.5° cone) and a sphere on a flared body, from Mach 1.50 to 4.63 and up to
89//! 12°, calling its results "adequate engineering estimates … except where flow separation or
90//! detached secondary shock waves are present" (p. 13). A power-series, Haack or elliptical nose
91//! has no sphere at its tip; applying the slope rule to it is an extrapolation, stated. The
92//! wedge's deflection, the pitot pressure and the Newtonian pressure are exact for a perfect gas
93//! with `γ = 1.4`; the loading is only as good as Newtonian theory on the cap. A cap that shrinks
94//! to nothing doesn't reach the cone it sits on, because the march keeps its start cone's total
95//! pressure ([issue #101](https://github.com/nrdptel/hpr-sim/issues/101)), and near the join's
96//! start the cap can cover half a slender nose, far more than the report's own.
97//!
98//! [J68]: https://ntrs.nasa.gov/citations/19690000884
99//! [adr-038]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-038-blunt-and-vertical-nose-tips-faster-than-sound-by-a-newtonian-cap-the-method-started-from-the-tangent-cone-2026-09-19
100//! [adr-043]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-043-the-blunt-tips-handover-cap-what-it-is-worth-and-what-stops-it-moving-2026-09-20
101
102use crate::afterbody::GAMMA;
103use crate::error::AeroError;
104
105/// The steepest slope the cap hands over at, as hpr flies it: 24°. It was TN 3527 Fig. 2's
106/// steepest tangent cone, the steepest whose normal-force slope the method could read; since the
107/// milestone [M1.8e11](https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-8e11),
108/// which took the cone slopes to 30°, the tables reach [`CONE_TABLE_CAP_RAD`], and 24° is kept
109/// because that is as far as the march carries the handover, not as far as the tables do
110/// ([ADR-043: what the handover's cap is worth][adr-043]).
111///
112/// [adr-043]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-043-the-blunt-tips-handover-cap-what-it-is-worth-and-what-stops-it-moving-2026-09-20
113pub const MAX_HANDOVER_RAD: f64 = 24.0 * std::f64::consts::PI / 180.0;
114
115/// The steepest cap the method can be given, 30°: the steepest cone its normal-force slopes
116/// cover ([`crate::shock_expansion::cone_normal_force_slope`]), where NASA SP-3007's tables stop
117/// ("cone angles from 2.5° to 30°", Foreword, p. iii). A handover steeper than this has no
118/// tangent cone to start the march from.
119pub const CONE_TABLE_CAP_RAD: f64 = 30.0 * std::f64::consts::PI / 180.0;
120
121/// The Mach number must be finite and above 1: the cap stands behind a normal shock.
122fn check_mach(mach: f64) -> Result<(), AeroError> {
123    if mach.is_finite() && mach > 1.0 {
124        Ok(())
125    } else {
126        Err(AeroError::Domain {
127            what: "Mach number of a blunt tip's cap",
128            value: mach,
129        })
130    }
131}
132
133/// A slope to the wind within `[0, π/2]`.
134fn check_slope(slope_rad: f64) -> Result<(), AeroError> {
135    if slope_rad.is_finite() && (0.0..=std::f64::consts::FRAC_PI_2).contains(&slope_rad) {
136        Ok(())
137    } else {
138        Err(AeroError::Domain {
139            what: "surface slope on a blunt tip's cap",
140            value: slope_rad,
141        })
142    }
143}
144
145/// The pitot pressure behind a normal shock over the free stream's, `p_t2/p₀`, at Mach `mach`
146/// (the Rayleigh pitot formula, NACA Report 1135 eq. 100, p. 619; TN D-4865 eq. 1).
147///
148/// # Errors
149///
150/// [`AeroError::Domain`] for a Mach number that isn't finite and above 1, or so large (past about
151/// 1e76) that the ratio overflows.
152pub fn pitot_pressure_ratio(mach: f64) -> Result<f64, AeroError> {
153    check_mach(mach)?;
154    let ratio = pitot(mach);
155    if ratio.is_finite() {
156        Ok(ratio)
157    } else {
158        Err(AeroError::Domain {
159            what: "Mach number of a blunt tip's cap, too large for the pitot pressure",
160            value: mach,
161        })
162    }
163}
164
165fn pitot(mach: f64) -> f64 {
166    let m2 = mach * mach;
167    let g = GAMMA;
168    (0.5 * (g + 1.0) * m2).powf(g / (g - 1.0))
169        * ((g + 1.0) / (2.0 * g * m2 - (g - 1.0))).powf(1.0 / (g - 1.0))
170}
171
172/// The largest angle a two-dimensional wedge can turn the flow at Mach `mach` behind an attached
173/// shock, rad: the shock angle of NACA Report 1135 eq. 168 (p. 624) put into eq. 138 (p. 621).
174///
175/// # Errors
176///
177/// [`AeroError::Domain`] for a Mach number that isn't finite and above 1.
178pub fn wedge_detachment_angle_rad(mach: f64) -> Result<f64, AeroError> {
179    check_mach(mach)?;
180    let g = GAMMA;
181    // Both equations divided through by M² (and M⁴ under the root), so nothing overflows.
182    let i2 = 1.0 / (mach * mach);
183    let root = ((g + 1.0) * ((g + 1.0) / 16.0 + 0.5 * (g - 1.0) * i2 + i2 * i2)).sqrt();
184    let sin2 = ((0.25 * (g + 1.0) - i2 + root) / g).min(1.0);
185    let cot = ((1.0 - sin2) / sin2).sqrt();
186    let tan = 2.0 * cot * (sin2 - i2) / (2.0 * i2 + g + 1.0 - 2.0 * sin2);
187    Ok(tan.atan())
188}
189
190/// The slope at which the cap hands over to the shock-expansion method at Mach `mach`, rad: the
191/// lesser of the wedge's largest deflection ([`wedge_detachment_angle_rad`], TN D-4865 p. 5) and
192/// [`MAX_HANDOVER_RAD`], the cap hpr flies.
193///
194/// # Errors
195///
196/// As [`wedge_detachment_angle_rad`].
197pub fn handover_angle_rad(mach: f64) -> Result<f64, AeroError> {
198    handover_angle_capped_rad(mach, MAX_HANDOVER_RAD)
199}
200
201/// The handover slope at Mach `mach` under a cap of `cap_rad` rather than the flown
202/// [`MAX_HANDOVER_RAD`], rad: the lesser of the wedge's largest deflection and the cap. What
203/// [ADR-043][adr-043] sweeps; a body takes it through
204/// [`crate::shock_expansion::ShockExpansionBody::with_handover_cap_rad`].
205///
206/// # Errors
207///
208/// - As [`wedge_detachment_angle_rad`] for the Mach number.
209/// - [`AeroError::Domain`] for a cap outside `(0, `[`CONE_TABLE_CAP_RAD`]`]`, where the march has
210///   no tangent cone to start from.
211///
212/// [adr-043]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-043-the-blunt-tips-handover-cap-what-it-is-worth-and-what-stops-it-moving-2026-09-20
213pub fn handover_angle_capped_rad(mach: f64, cap_rad: f64) -> Result<f64, AeroError> {
214    if !(cap_rad.is_finite() && cap_rad > 0.0 && cap_rad <= CONE_TABLE_CAP_RAD) {
215        return Err(AeroError::Domain {
216            what: "cap on a blunt tip's handover slope",
217            value: cap_rad,
218        });
219    }
220    Ok(wedge_detachment_angle_rad(mach)?.min(cap_rad))
221}
222
223/// Modified Newtonian `C_p,max = (p_t2/p₀ − 1)/(γM²/2)` at Mach `mach`.
224///
225/// # Errors
226///
227/// As [`pitot_pressure_ratio`].
228pub fn newtonian_pressure_coefficient_max(mach: f64) -> Result<f64, AeroError> {
229    Ok((pitot_pressure_ratio(mach)? - 1.0) / (0.5 * GAMMA * mach * mach))
230}
231
232/// The cap's surface pressure over the free stream's where its slope to the wind is `slope_rad`
233/// (TN D-4865 eq. 1): `(p_t2/p₀ − 1) sin²δ + 1`.
234///
235/// # Errors
236///
237/// [`AeroError::Domain`] for a Mach number that isn't finite and above 1, or a slope outside
238/// `[0, π/2]`.
239pub fn newtonian_pressure_ratio(mach: f64, slope_rad: f64) -> Result<f64, AeroError> {
240    check_slope(slope_rad)?;
241    let sin = slope_rad.sin();
242    Ok((pitot_pressure_ratio(mach)? - 1.0) * sin * sin + 1.0)
243}
244
245/// The cap's surface Mach number where its pressure is `pressure_ratio` times the free stream's,
246/// isentropic from the pitot pressure (TN D-4865 eq. 2); zero at the stagnation point. The
247/// report's march starts from it ([`crate::shock_expansion::HandoverStart::Newtonian`]).
248///
249/// # Errors
250///
251/// [`AeroError::Domain`] for a Mach number that isn't finite and above 1, or a pressure that
252/// isn't positive and at most the pitot pressure.
253pub fn newtonian_surface_mach(mach: f64, pressure_ratio: f64) -> Result<f64, AeroError> {
254    let pitot = pitot_pressure_ratio(mach)?;
255    if !(pressure_ratio.is_finite() && pressure_ratio > 0.0 && pressure_ratio <= pitot) {
256        return Err(AeroError::Domain {
257            what: "surface pressure ratio on a blunt tip's cap",
258            value: pressure_ratio,
259        });
260    }
261    let g = GAMMA;
262    let m2 = 2.0 / (g - 1.0) * ((pressure_ratio / pitot).powf(-(g - 1.0) / g) - 1.0);
263    Ok(m2.max(0.0).sqrt())
264}
265
266/// The cap's loading at `α → 0` where its slope to the axis is `slope_rad`, `Λ = C_p,max sin δ
267/// cos δ`: half the windward `∂C_p/∂α` of `C_p = C_p,max sin²(δ + α cos φ)`, in the units of
268/// TN 3527's loading ([`crate::shock_expansion`]).
269///
270/// # Errors
271///
272/// As [`newtonian_pressure_ratio`].
273pub fn newtonian_loading(mach: f64, slope_rad: f64) -> Result<f64, AeroError> {
274    check_slope(slope_rad)?;
275    Ok(newtonian_pressure_coefficient_max(mach)? * slope_rad.sin() * slope_rad.cos())
276}
277
278/// [`newtonian_loading`] from the slope `dr/dx` itself, which may be infinite (a vertical tip):
279/// `sin δ cos δ = 1/(t + 1/t)` with `t = dr/dx`, zero at both ends. `c_p_max` is
280/// [`newtonian_pressure_coefficient_max`].
281pub(crate) fn newtonian_loading_at_slope(c_p_max: f64, slope: f64) -> f64 {
282    let t = slope.abs();
283    if t == 0.0 || t.is_infinite() {
284        0.0
285    } else {
286        c_p_max / (t + 1.0 / t)
287    }
288}
289
290/// The loading just behind a handover that holds still in the wind, `Λ = λ/(γM²)`, with
291/// `λ = 2γp/sin 2μ` at the handover's pressure `pressure_ratio` (times the free stream's) and
292/// surface Mach number `surface_mach`, and `M` the free stream's: the Prandtl–Meyer flow's
293/// `∂p/∂ν` over the free stream's `γM²/2`, halved as TN 3527's loading is. It is TN D-4865's
294/// equivalent bodies (eqs. 4a and 4b, the body turned about the sphere's center) read at `α → 0`,
295/// hpr's reading ([`crate::shock_expansion::HandoverStart::Newtonian`]).
296///
297/// # Errors
298///
299/// [`AeroError::Domain`] for a free-stream or surface Mach number that isn't finite and above 1,
300/// or a pressure that isn't finite and positive.
301pub fn handover_loading(
302    mach: f64,
303    pressure_ratio: f64,
304    surface_mach: f64,
305) -> Result<f64, AeroError> {
306    check_mach(mach)?;
307    if !(surface_mach.is_finite() && surface_mach > 1.0) {
308        return Err(AeroError::Domain {
309            what: "surface Mach number at a blunt tip's handover",
310            value: surface_mach,
311        });
312    }
313    if !(pressure_ratio.is_finite() && pressure_ratio > 0.0) {
314        return Err(AeroError::Domain {
315            what: "surface pressure ratio at a blunt tip's handover",
316            value: pressure_ratio,
317        });
318    }
319    let m2 = surface_mach * surface_mach;
320    let lambda = GAMMA * pressure_ratio * m2 / (m2 - 1.0).sqrt();
321    Ok(lambda / (GAMMA * mach * mach))
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use std::f64::consts::FRAC_PI_2;
328
329    fn close(got: f64, want: f64, tol: f64, what: &str) {
330        assert!(
331            (got - want).abs() <= tol,
332            "{what}: got {got}, want {want} ± {tol}"
333        );
334    }
335
336    /// NACA Report 1135, Table II (normal shock, γ = 1.4): `p_t2/p₁` at M = 1.5, 2, 3 and 5.
337    #[test]
338    fn the_pitot_pressure_is_rayleighs() {
339        close(pitot_pressure_ratio(1.5).unwrap(), 3.413, 5e-4, "M 1.5");
340        close(pitot_pressure_ratio(2.0).unwrap(), 5.640, 5e-4, "M 2");
341        close(pitot_pressure_ratio(3.0).unwrap(), 12.06, 5e-3, "M 3");
342        close(pitot_pressure_ratio(5.0).unwrap(), 32.65, 5e-3, "M 5");
343        // At Mach 1 the shock vanishes: the pitot pressure is the isentropic total, 1.8929.
344        close(pitot(1.0), 1.892_929, 1e-6, "M 1");
345    }
346
347    /// NACA Report 1135, chart 2: the largest wedge deflection is 12.1° at Mach 1.5, 22.97° at
348    /// Mach 2, 34.07° at Mach 3 and 41.1° at Mach 5, rising to 45.6° as Mach → ∞.
349    #[test]
350    fn the_wedge_detaches_where_naca_1135_says() {
351        let deg = |m: f64| wedge_detachment_angle_rad(m).unwrap().to_degrees();
352        close(deg(1.5), 12.11, 0.01, "M 1.5");
353        close(deg(2.0), 22.97, 0.01, "M 2");
354        close(deg(3.0), 34.07, 0.01, "M 3");
355        close(deg(5.0), 41.12, 0.02, "M 5");
356        close(deg(1e6), 45.58, 0.01, "M → ∞");
357        close(deg(1e200), 45.58, 0.01, "M → ∞, no overflow");
358        assert!(matches!(
359            pitot_pressure_ratio(1e200),
360            Err(AeroError::Domain { .. })
361        ));
362        // Near Mach 1 it falls to zero like (M² − 1)^(3/2).
363        assert!(deg(1.0 + 1e-9) < 1e-9);
364        // It rises with Mach.
365        let mut last = 0.0;
366        for i in 1..400 {
367            let d = deg(1.0 + 0.02 * f64::from(i));
368            assert!(d > last);
369            last = d;
370        }
371    }
372
373    /// Below the cap the handover is the wedge's own detachment angle, above it the cap. The
374    /// flown cap, 24°, binds from Mach 2.06; the steepest the tables carry, 30°, from Mach 2.52.
375    #[test]
376    fn the_handover_is_the_wedges_until_the_cap_binds() {
377        close(
378            handover_angle_rad(1.5).unwrap(),
379            wedge_detachment_angle_rad(1.5).unwrap(),
380            0.0,
381            "M 1.5",
382        );
383        assert_eq!(handover_angle_rad(3.0).unwrap(), MAX_HANDOVER_RAD);
384        // Where each cap starts to bind, bisected against the wedge's own angle.
385        let binds_from = |cap: f64| {
386            let (mut low, mut high) = (1.0_f64, 20.0_f64);
387            for _ in 0..200 {
388                let mid = 0.5 * (low + high);
389                if wedge_detachment_angle_rad(mid).unwrap() < cap {
390                    low = mid;
391                } else {
392                    high = mid;
393                }
394            }
395            0.5 * (low + high)
396        };
397        close(binds_from(MAX_HANDOVER_RAD), 2.0614, 5e-4, "24° binds from");
398        close(
399            binds_from(CONE_TABLE_CAP_RAD),
400            2.5192,
401            5e-4,
402            "30° binds from",
403        );
404        // Under a cap of its own the handover is the lesser of the two, and the cap can't ask for
405        // a cone the tables don't carry.
406        assert_eq!(
407            handover_angle_capped_rad(3.0, CONE_TABLE_CAP_RAD).unwrap(),
408            CONE_TABLE_CAP_RAD
409        );
410        close(
411            handover_angle_capped_rad(1.5, CONE_TABLE_CAP_RAD).unwrap(),
412            wedge_detachment_angle_rad(1.5).unwrap(),
413            0.0,
414            "M 1.5, 30° cap",
415        );
416        for bad in [0.0, -1.0, f64::NAN, CONE_TABLE_CAP_RAD * 1.000_001] {
417            assert!(
418                matches!(
419                    handover_angle_capped_rad(3.0, bad),
420                    Err(AeroError::Domain { .. })
421                ),
422                "a cap of {bad} should be refused"
423            );
424        }
425    }
426
427    #[test]
428    fn newtonian_pressures() {
429        // At the stagnation point the pitot pressure; at zero slope the free stream's.
430        let pitot = pitot_pressure_ratio(2.0).unwrap();
431        close(
432            newtonian_pressure_ratio(2.0, FRAC_PI_2).unwrap(),
433            pitot,
434            1e-12,
435            "p",
436        );
437        close(newtonian_pressure_ratio(2.0, 0.0).unwrap(), 1.0, 0.0, "p₀");
438        // C_p,max at Mach 2: (5.6404 − 1)/2.8.
439        close(
440            newtonian_pressure_coefficient_max(2.0).unwrap(),
441            (5.640_4 - 1.0) / 2.8,
442            1e-4,
443            "C_p,max",
444        );
445        assert!(matches!(
446            newtonian_pressure_ratio(2.0, 2.0),
447            Err(AeroError::Domain { .. })
448        ));
449        assert!(matches!(
450            pitot_pressure_ratio(1.0),
451            Err(AeroError::Domain { .. })
452        ));
453    }
454
455    /// A hemisphere's Newtonian loading integrates to `C_Nα = C_p,max/2` on its base: the drag
456    /// `C_p,max/2` of a Newtonian hemisphere turned into body axes (the force on a sphere passes
457    /// through its center, so it lies along the wind).
458    #[test]
459    fn a_hemisphere_carries_its_drag_turned() {
460        let mach = 3.0;
461        let c_p_max = newtonian_pressure_coefficient_max(mach).unwrap();
462        // With r = sin θ, x = 1 − cos θ (unit radius) the slope is cot θ.
463        let n = 20_000;
464        let mut sum = 0.0;
465        for i in 0..n {
466            let theta = FRAC_PI_2 * (f64::from(i) + 0.5) / f64::from(n);
467            let slope = 1.0 / theta.tan();
468            let load = newtonian_loading_at_slope(c_p_max, slope);
469            let r = theta.sin();
470            // dx = sin θ dθ.
471            sum += load * r * theta.sin() * FRAC_PI_2 / f64::from(n);
472        }
473        // C_Nα = (2π/π) ∫ Λ r dx on the unit base.
474        close(2.0 * sum, 0.5 * c_p_max, 1e-8, "C_Nα");
475        close(
476            newtonian_loading(mach, 0.3).unwrap(),
477            newtonian_loading_at_slope(c_p_max, 0.3f64.tan()),
478            1e-15,
479            "Λ",
480        );
481        assert_eq!(newtonian_loading_at_slope(c_p_max, f64::INFINITY), 0.0);
482        assert_eq!(newtonian_loading_at_slope(c_p_max, 0.0), 0.0);
483    }
484
485    /// A Newtonian cone of half-angle δ carries `C_Nα = C_p,max cos²δ` on its base, the classical
486    /// result; TN 3527's loading form gives it from `Λ = C_p,max sin δ cos δ`: `C_Nα = Λ/tan δ`.
487    #[test]
488    fn a_newtonian_cone_carries_c_p_max_cos_squared() {
489        let delta = 0.2_f64;
490        let load = newtonian_loading(2.5, delta).unwrap();
491        let c_p_max = newtonian_pressure_coefficient_max(2.5).unwrap();
492        close(
493            load / delta.tan(),
494            c_p_max * delta.cos().powi(2),
495            1e-14,
496            "cone",
497        );
498    }
499
500    #[test]
501    fn newtonian_surface_mach_numbers() {
502        let pitot = pitot_pressure_ratio(2.0).unwrap();
503        close(newtonian_surface_mach(2.0, pitot).unwrap(), 0.0, 1e-12, "M");
504        // Expanded to the free stream's pressure through the normal shock's total, the surface
505        // flow is slower than the free stream.
506        let m = newtonian_surface_mach(2.0, 1.0).unwrap();
507        assert!(m > 1.5 && m < 2.0, "{m}");
508        assert!(matches!(
509            newtonian_surface_mach(2.0, pitot * 1.01),
510            Err(AeroError::Domain { .. })
511        ));
512    }
513
514    /// Behind a handover fixed in the wind the loading is the Prandtl–Meyer flow's: a turn `dν`
515    /// lowers the pressure by `λ dν`, checked by a finite difference of the expansion itself.
516    #[test]
517    fn the_handover_loading_is_the_expansions() {
518        use crate::afterbody::{inverse_prandtl_meyer, prandtl_meyer};
519        let mach = 2.3;
520        let delta = handover_angle_rad(mach).unwrap();
521        let p = newtonian_pressure_ratio(mach, delta).unwrap();
522        let m = newtonian_surface_mach(mach, p).unwrap();
523        let total = pitot_pressure_ratio(mach).unwrap();
524        let static_at = |nu: f64| {
525            let ms = inverse_prandtl_meyer(nu);
526            total / (1.0 + 0.2 * ms * ms).powf(3.5)
527        };
528        let h = 1e-6;
529        let nu = prandtl_meyer(m);
530        // A turn smaller by ε raises the pressure by λε, C_p by 2λε/(γM²); Λ is half that.
531        let dp = (static_at(nu - h) - static_at(nu + h)) / (2.0 * h);
532        let want = dp / (GAMMA * mach * mach);
533        close(
534            handover_loading(mach, p, m).unwrap(),
535            want,
536            1e-6 * want,
537            "Λ",
538        );
539        assert!(matches!(
540            handover_loading(mach, p, 1.0),
541            Err(AeroError::Domain { .. })
542        ));
543    }
544}