Skip to main content

hpr_aero/
shock_expansion.rs

1//! The normal force of a pointed body of revolution faster than sound, by Syvertson and Dennis's
2//! second-order shock-expansion method (NACA TN 3527, 1956, also NACA Report 1328).
3//!
4//! **Flown faster than sound** for a pointed nose and the cylinders behind it since the milestone
5//! [M1.8e2](https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-8e2), the body's
6//! supersonic normal force in flight, and for boattails and cylinders behind those since
7//! [M1.8e4](https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-8e4), and behind a
8//! blunt or vertical nose tip's Newtonian cap ([`crate::blunt_tip`]) since
9//! [M1.8e7](https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-8e7), through [`crate::model::SupersonicBody`]. The guide's
10//! [Bodies faster than sound](https://nrdptel.github.io/hpr-sim/physics/aero.html#bodies-faster-than-sound)
11//! explains the method and how it was checked.
12//!
13//! ```
14//! use hpr_aero::shock_expansion::{BodySegment, DEFAULT_ELEMENTS_PER_CURVE, ShockExpansionBody};
15//! use hpr_design::{NoseShape, Profile};
16//!
17//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
18//! // A cone five calibres long on a cylinder of four, 1 m across, at Mach 4.24.
19//! let body = ShockExpansionBody::new(
20//!     &[
21//!         BodySegment::Profile {
22//!             profile: Profile::nose(NoseShape::Conical {}, 5.0, 0.5)?,
23//!         },
24//!         BodySegment::Cylinder {
25//!             length_m: 4.0,
26//!             radius_m: 0.5,
27//!         },
28//!     ],
29//!     DEFAULT_ELEMENTS_PER_CURVE,
30//! )?;
31//! let slope = body.slope(4.24, std::f64::consts::PI / 4.0)?;
32//! // Slender-body theory: 2 per radian. TN 3527's own value: 2.91; its wind tunnel: 2.84.
33//! assert!((slope.slope_per_rad - 2.922).abs() < 5e-4);
34//! # Ok(())
35//! # }
36//! ```
37//!
38//! Slender-body theory gives a body's nose `C_Nα = 2` and its cylinder nothing, at every Mach
39//! number ([B67] p. 18). Faster than sound the cylinder behind a nose carries lift too: the flow
40//! that expands around the shoulder recovers toward free-stream pressure along the cylinder, and
41//! at an angle of attack it recovers unevenly around it. The method computes that loading, at
42//! `α → 0`, for a pointed body whose flow is supersonic everywhere.
43//!
44//! **The tangent body.** The profile is replaced by straight elements tangent to it (TN 3527
45//! sketch (a), p. 6): the first tangent at the vertex, so the flow there is exactly a cone's
46//! (Taylor–Maccoll), the rest meeting at corners. Around each corner the flow turns by
47//! Prandtl–Meyer; along each element the surface pressure relaxes exponentially from its value
48//! behind the corner toward the pressure on a cone tangent to the body there (eq. 8):
49//!
50//! `p = p_c − (p_c − p₂) e^(−η)`, `η = (∂p/∂s)₂ (x − x₂) / ((p_c − p₂) cos δ₂)` (eq. 9),
51//!
52//! where the gradient just behind a corner comes from the one ahead of it (eq. 4, straight
53//! elements):
54//!
55//! `(∂p/∂s)₂ = (B₂/r)(Ω₁/Ω₂ · sin δ₁ − sin δ₂) + (B₂Ω₁)/(B₁Ω₂) · (∂p/∂s)₁`,
56//!
57//! with `B = γpM²/(2(M² − 1))` (eq. 6), `Ω` the one-dimensional area ratio `A/A*` (eq. 7), and at
58//! the element's end `(∂p/∂s)₃ = (p_c − p₃)/(p_c − p₂) · (∂p/∂s)₂` (eq. 10).
59//!
60//! **The loading.** Near `α = 0` the lifting pressures follow the same law (eq. 19):
61//!
62//! `Λ = (1 − e^(−η)) tan δ · (dC_N/dα)_tc + (λ₂/λ₁) e^(−η) Λ₁`, `λ = 2γp / sin 2μ` (eq. 5),
63//!
64//! where `(dC_N/dα)_tc` is the tangent cone's slope (Fig. 2, read by hand into
65//! [`cone_normal_force_slope`]) and `Λ₁` the loading just ahead of the corner; on the vertex
66//! cone `Λ = tan δ_v · (dC_N/dα)_tcv`. The slope and moment follow by integration over the body
67//! (eqs. 14 and 21):
68//!
69//! `C_Nα = (2π/A_ref) ∫ Λ r dx`, `x_cp = ∫ Λ r x dx / ∫ Λ r dx` (from the vertex).
70//!
71//! A cylinder element's tangent cone is the free stream (`p_c = p₀`, `tan δ = 0`), so its
72//! loading decays to zero. A boattail element has no tangent cone; footnote 8 (p. 12) takes
73//! `p_c = p₀` and `(dC_N/dα)_tc = 2`, which the report found reasonable "for bodies having
74//! moderate amounts of boattail". That is unvalidated here.
75//!
76//! **Limits.** The report states the method for `M/f_n` (Mach number over nose fineness) from
77//! 0.4 to 2, within ±0.2 per radian and ±0.2 calibers of its measurements (Summary, p. 1). A
78//! pointed tip's cone shock must be attached; a blunt or vertical tip (an infinite slope, or a
79//! [`BodySegment::SphericalCap`]) takes TN D-4865's Newtonian cap and starts the march at its
80//! handover ([`crate::blunt_tip`], [`HandoverStart`]). Fig. 2 spans Mach 3 to 10; below Mach 3 its
81//! Mach 3 curve is held, and above 10 its Mach 10 curve, both assumptions. Viscous crossflow is
82//! not part of it: the method is the slope at `α → 0`.
83//!
84//! [B67]: https://ntrs.nasa.gov/citations/19660030728
85
86use hpr_core::quadrature::{Tolerance, integrate};
87use hpr_design::{NoseShape, Profile};
88use serde::{Deserialize, Serialize};
89use std::f64::consts::PI;
90
91use crate::afterbody::{GAMMA, MAX_TURNING_RAD, inverse_prandtl_meyer, prandtl_meyer};
92use crate::error::{AeroError, check_dimension};
93
94/// `(γ − 1)/2`.
95const G1: f64 = 0.5 * (GAMMA - 1.0);
96
97/// The number of straight elements a curved segment's tangent body gets by default: TN 3527's
98/// own, tangent at `x/l = 0, 0.1, …, 1.0` ("in all applications of the present method to curved
99/// bodies", footnote 9, p. 15). Four times as many move the report's ogive-cylinders by under
100/// 0.01 per radian and 0.01 calibers (test `curved_elements_converge`).
101pub const DEFAULT_ELEMENTS_PER_CURVE: usize = 10;
102
103/// The most elements a curved segment may take, far past where the result stops changing.
104pub const MAX_ELEMENTS_PER_CURVE: usize = 1000;
105
106/// One piece of a body of revolution, listed from the nose aft.
107#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
108#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
109#[non_exhaustive]
110pub enum BodySegment {
111    /// A nose cone (the first segment, pointed or with a vertical tip) or a transition.
112    Profile {
113        /// The profile.
114        profile: Profile,
115    },
116    /// A cylinder.
117    Cylinder {
118        /// Length, m.
119        length_m: f64,
120        /// Radius, m.
121        radius_m: f64,
122    },
123    /// A sphere's cap from its pole, the first segment of a sphere-cone: the first `length_m` of
124    /// a sphere of `radius_m`, up to a hemisphere. Its tip is blunt, so the body flies
125    /// TN D-4865's Newtonian cap ahead of the method ([`crate::blunt_tip`]).
126    SphericalCap {
127        /// The sphere's radius, m.
128        radius_m: f64,
129        /// Length along the axis from the pole, m, in `(0, radius_m]`.
130        length_m: f64,
131    },
132}
133
134impl BodySegment {
135    fn length_m(&self) -> f64 {
136        match self {
137            Self::Profile { profile } => profile.length_m(),
138            Self::Cylinder { length_m, .. } | Self::SphericalCap { length_m, .. } => *length_m,
139        }
140    }
141
142    /// Radius and slope `dr/dx` at `x_m` aft of the segment's forward end; the slope is infinite
143    /// at a blunt tip.
144    fn radius_and_slope(&self, x_m: f64) -> (f64, f64) {
145        match self {
146            Self::Profile { profile } => profile.radius_and_slope(x_m),
147            Self::Cylinder { radius_m, .. } => (*radius_m, 0.0),
148            Self::SphericalCap { radius_m, length_m } => {
149                let x = x_m.clamp(0.0, *length_m);
150                let r = (x * (2.0 * radius_m - x)).max(0.0).sqrt();
151                if r == 0.0 {
152                    (0.0, f64::INFINITY)
153                } else {
154                    (r, (radius_m - x) / r)
155                }
156            }
157        }
158    }
159
160    fn fore_radius_m(&self) -> f64 {
161        self.radius_and_slope(0.0).0
162    }
163
164    fn aft_radius_m(&self) -> f64 {
165        self.radius_and_slope(self.length_m()).0
166    }
167
168    /// Whether the profile is straight, so one element covers it.
169    fn is_straight(&self) -> bool {
170        match self {
171            Self::Profile { profile } => matches!(profile.shape(), NoseShape::Conical {}),
172            Self::Cylinder { .. } => true,
173            Self::SphericalCap { .. } => false,
174        }
175    }
176}
177
178/// A straight element of the tangent body: where it starts (its corner with the element ahead,
179/// the vertex, or a blunt tip's handover) and its angle to the axis.
180#[derive(Debug, Clone, Copy, PartialEq)]
181struct Element {
182    /// Whether the element is tangent to one of the nose's segments ([`nose_segments`]): the first
183    /// one, and behind a spherical cap the curved segments that carry the nose on past it.
184    on_nose: bool,
185    corner_x_m: f64,
186    corner_radius_m: f64,
187    angle_rad: f64,
188}
189
190/// A body of revolution laid out for the second-order shock-expansion method: its segments and
191/// the straight elements of its tangent body. A pointed nose's elements are laid out once; a blunt
192/// tip's start at a handover that moves with the Mach number ([`crate::blunt_tip`]), so they are
193/// laid out at each.
194#[derive(Debug, Clone, PartialEq)]
195pub struct ShockExpansionBody {
196    /// Each segment with the station of its forward end, m aft of the vertex.
197    segments: Vec<(f64, BodySegment)>,
198    length_m: f64,
199    elements_per_curve: usize,
200    /// The tangent body's elements from the vertex; empty for a blunt tip.
201    elements: Vec<Element>,
202    /// Whether the tip is blunt or vertical (an infinite slope at the vertex).
203    blunt: bool,
204    /// Where the march behind a blunt tip's cap starts from.
205    handover_start: HandoverStart,
206    /// The cap on the handover slope, rad ([`crate::blunt_tip::MAX_HANDOVER_RAD`] as flown).
207    handover_cap_rad: f64,
208}
209
210/// Where the method's march starts behind a blunt tip's Newtonian cap
211/// ([`crate::blunt_tip`]; the decision record on it, [ADR-038][adr-038]).
212///
213/// [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
214#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "snake_case")]
216#[non_exhaustive]
217pub enum HandoverStart {
218    /// As the method starts at a pointed vertex: the flow on the cone tangent to the body at the
219    /// handover, that cone's loading and no pressure gradient (TN 3527 sketch (a), p. 6). hpr's
220    /// choice, and what a flight takes.
221    #[default]
222    TangentCone,
223    /// TN D-4865's own: the Newtonian pressure and Mach number there (eqs. 1 and 2), the total
224    /// pressure behind the normal shock and no gradient, with the loading of a handover fixed in
225    /// the wind ([`crate::blunt_tip::handover_loading`]). Kept to compare: on the Arcas Robin's
226    /// nose the march then fails from Mach 3.96.
227    Newtonian,
228}
229
230/// A blunt tip's Newtonian cap at one Mach number: where it hands over and its `C_p,max`.
231#[derive(Debug, Clone, Copy, PartialEq)]
232struct Cap {
233    end_x_m: f64,
234    c_p_max: f64,
235}
236
237/// The flow over the body at one Mach number: a blunt tip's cap, then each element's flow.
238#[derive(Debug, Clone, PartialEq)]
239struct March {
240    cap: Option<Cap>,
241    flows: Vec<ElementFlow>,
242    /// The total pressure the march expands from, over the free stream's static pressure: what
243    /// turns a surface pressure back into a surface Mach number ([`mach_from_pressure`]).
244    total: f64,
245}
246
247/// The flow on one element of the tangent body ([`ShockExpansionBody::element_flows`]): its state
248/// just behind the element's corner, the tangent cone it relaxes toward, and how fast it does so.
249/// At an axial distance `x` aft of its corner the pressure is `p_c − (p_c − p₂) e^(−η)` and the
250/// loading `(1 − e^(−η)) Λ_c + e^(−η) Λ₂`, with `η = `[`Self::decay_per_m`]` · x` (TN 3527 eqs. 8,
251/// 9 and 19). Pressures are over the free stream's, `p₀`.
252#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
253#[non_exhaustive]
254pub struct ElementFlowReport {
255    /// Where the element starts, m aft of the vertex.
256    pub corner_x_m: f64,
257    /// Its angle to the axis, rad; negative on a boattail.
258    pub angle_rad: f64,
259    /// `p₂/p₀`, the pressure just behind its corner.
260    pub pressure_ratio: f64,
261    /// `Λ₂`, the loading just behind its corner, per radian of angle of attack.
262    pub loading_per_rad: f64,
263    /// `p_c/p₀` on its tangent cone (the free stream's for a cylinder, and footnote 8's for a
264    /// boattail).
265    pub tangent_cone_pressure_ratio: f64,
266    /// `Λ_c = tan δ (dC_N/dα)_tc`, the loading it relaxes toward, per radian of angle of attack.
267    pub tangent_cone_loading_per_rad: f64,
268    /// `dη/dx`, per m of axial distance aft of the corner, not of distance along the surface;
269    /// zero where the pressure already sits at its tangent cone's, or where the element is
270    /// reduced ([issue #81: the gradient a reduced element carries
271    /// on](https://github.com/nrdptel/hpr-sim/issues/81)).
272    pub decay_per_m: f64,
273    /// The radius at its corner, m: the `r` of eq. 19's `∫ Λ r dx`.
274    pub corner_radius_m: f64,
275}
276
277/// One segment's share of the body's normal-force slope at `α → 0`
278/// ([`ShockExpansionBody::segment_slopes`]).
279///
280/// A share can be negative or zero (a boattail's, TN 3527 footnote 8, p. 12), so `moment_slope_m /
281/// slope_per_rad` need not lie within its segment and is unbounded where a share crosses zero:
282/// carry the moment, not a station.
283#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
284#[non_exhaustive]
285pub struct SegmentSlope {
286    /// The segment's `C_Nα`, per radian, on the reference area given.
287    pub slope_per_rad: f64,
288    /// That slope's moment about the vertex, `C_Nα · x̄`, m per radian (x̄ aft of the vertex).
289    pub moment_slope_m: f64,
290}
291
292/// The surface flow the march delivers to a body's aft end
293/// ([`ShockExpansionBody::aft_flow`]): what a corner behind that body (the juncture of a flare,
294/// say) turns.
295#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
296#[non_exhaustive]
297pub struct AftFlow {
298    /// The Mach number on the surface at the aft end, from the pressure the last element's decay
299    /// has reached there (TN 3527 eq. 8) expanded back through the march's total pressure.
300    pub surface_mach: f64,
301    /// The surface's angle to the axis there, rad; zero on a cylinder, negative on a boattail.
302    pub angle_rad: f64,
303    /// `p₁/p₀`, the surface pressure there over the free stream's.
304    pub pressure_ratio: f64,
305    /// `(∂p/∂s)₁`, the pressure gradient the last element carries to there, in units of the free
306    /// stream's pressure per m of **axial** distance, not of distance along the surface
307    /// (TN 3527 eq. 10). Positive where the pressure is still climbing.
308    pub gradient_p0_per_m: f64,
309    /// `Λ₁`, the loading the last element carries to there, per radian of angle of attack
310    /// (TN 3527 eq. 19): what a corner behind the body carries on through `λ₂/λ₁`.
311    pub loading_per_rad: f64,
312    /// The free-stream Mach number the march was run at, so that a corner behind this flow can be
313    /// read without being told it again ([`flare_reduction_turns_rad`]).
314    pub free_stream_mach: f64,
315    /// The body's radius there, m: eq. 4's `r` at a corner behind it. This is the **profile's**
316    /// radius at the aft end, so where the tangent body's last corner is not at the aft end (a
317    /// body that ends in a curve, or one whose last tangency point was merged as nearly parallel),
318    /// it sits a little off the element's own corner radius, as [`Self::angle_rad`] does. On a
319    /// body that ends in a cylinder or a cone, which is every body a flare joins in a flight
320    /// today, the two are the same.
321    pub radius_m: f64,
322}
323
324/// The body's normal-force slope at `α → 0` and where it acts.
325#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
326#[non_exhaustive]
327pub struct ShockExpansionSlope {
328    /// `C_Nα`, per radian, on the reference area given.
329    pub slope_per_rad: f64,
330    /// The center of pressure, m aft of the vertex. Written before the move to US spelling as
331    /// `centre_of_pressure_m`, which is still read and never written.
332    #[serde(alias = "centre_of_pressure_m")]
333    pub center_of_pressure_m: f64,
334}
335
336impl ShockExpansionBody {
337    /// Lays out a body from its segments, nose first: a curved segment gets
338    /// `elements_per_curve` equal steps in `x` (tangent at both ends and between), a straight
339    /// one a single element. Behind a blunt tip the steps are counted from the handover aft, laid
340    /// out at each Mach number ([`crate::blunt_tip`]); the segments its cap covers get none.
341    ///
342    /// # Errors
343    ///
344    /// - [`AeroError::Unsupported`] if the first segment doesn't close to a point at its front
345    ///   (pointed, or blunt with a vertical tip), a spherical cap isn't the first segment, the
346    ///   radius steps between segments (by more than a millionth of it), the radius falls to zero
347    ///   anywhere but the tip, or, for a pointed nose, the tangent lines of consecutive elements
348    ///   don't meet in order along the body (a profile the tangent body can't follow).
349    /// - [`AeroError::Domain`] for no segments, elements per curve outside
350    ///   `1..=`[`MAX_ELEMENTS_PER_CURVE`], a negative or non-finite cylinder dimension, or a
351    ///   spherical cap whose radius isn't finite and positive or whose length isn't in
352    ///   `(0, radius]`.
353    pub fn new(segments: &[BodySegment], elements_per_curve: usize) -> Result<Self, AeroError> {
354        let Some(first) = segments.first() else {
355            return Err(AeroError::Domain {
356                what: "number of body segments",
357                value: 0.0,
358            });
359        };
360        if !(1..=MAX_ELEMENTS_PER_CURVE).contains(&elements_per_curve) {
361            return Err(AeroError::Domain {
362                what: "elements per curved segment",
363                value: elements_per_curve as f64,
364            });
365        }
366        for (index, segment) in segments.iter().enumerate() {
367            match segment {
368                BodySegment::Cylinder { length_m, radius_m } => {
369                    check_dimension("cylinder length", *length_m, true)?;
370                    check_dimension("cylinder radius", *radius_m, false)?;
371                }
372                BodySegment::SphericalCap { radius_m, length_m } => {
373                    check_dimension("spherical cap radius", *radius_m, false)?;
374                    if !(*length_m > 0.0 && *length_m <= *radius_m) {
375                        return Err(AeroError::Domain {
376                            what: "spherical cap length",
377                            value: *length_m,
378                        });
379                    }
380                    if index > 0 {
381                        return Err(AeroError::Unsupported(
382                            "a spherical cap can only be the body's first segment".to_owned(),
383                        ));
384                    }
385                }
386                BodySegment::Profile { .. } => {}
387            }
388        }
389        let tip_slope = first.radius_and_slope(0.0).1;
390        // A NaN slope compares as nothing, so it is refused too.
391        if matches!(first, BodySegment::Cylinder { .. })
392            || first.fore_radius_m() != 0.0
393            || tip_slope.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater)
394        {
395            return Err(AeroError::Unsupported(
396                "the second-order shock-expansion method needs a nose that closes to a point at \
397                 its front, pointed or blunt"
398                    .to_owned(),
399            ));
400        }
401        let blunt = tip_slope.is_infinite();
402        let mut laid = Vec::with_capacity(segments.len());
403        let mut station = 0.0;
404        let mut previous_aft: Option<f64> = None;
405        for segment in segments {
406            if let Some(aft) = previous_aft {
407                let fore = segment.fore_radius_m();
408                if (fore - aft).abs() > 1e-6 * aft.max(fore) {
409                    return Err(AeroError::Unsupported(format!(
410                        "the second-order shock-expansion method needs a continuous profile; the \
411                         radius steps from {aft} m to {fore} m at {station} m"
412                    )));
413                }
414            }
415            laid.push((station, *segment));
416            station += segment.length_m();
417            previous_aft = Some(segment.aft_radius_m());
418        }
419        let length_m = station;
420        let elements = if blunt {
421            Vec::new()
422        } else {
423            lay_out(&laid, length_m, elements_per_curve, 0.0)?
424        };
425        Ok(Self {
426            segments: laid,
427            length_m,
428            elements_per_curve,
429            elements,
430            blunt,
431            handover_start: HandoverStart::default(),
432            handover_cap_rad: crate::blunt_tip::MAX_HANDOVER_RAD,
433        })
434    }
435
436    /// This body with its march behind a blunt tip's cap starting from `start`; a pointed body
437    /// is unchanged.
438    #[must_use]
439    pub fn with_handover_start(mut self, start: HandoverStart) -> Self {
440        self.handover_start = start;
441        self
442    }
443
444    /// This body with its blunt tip handing over no steeper than `cap_rad` instead of the flown
445    /// [`crate::blunt_tip::MAX_HANDOVER_RAD`]; a pointed body is unchanged. A steeper cap follows
446    /// TN D-4865's own rule to a higher Mach number and starts the march from a steeper cone;
447    /// what each is worth is measured in [ADR-043][adr-043].
448    ///
449    /// The cap is checked when the handover is taken ([`Self::handover_m`]), which refuses one
450    /// outside `(0, `[`crate::blunt_tip::CONE_TABLE_CAP_RAD`]`]`. A pointed body has no handover,
451    /// so it ignores the cap and never reports a bad one.
452    ///
453    /// ```
454    /// use hpr_aero::blunt_tip::CONE_TABLE_CAP_RAD;
455    /// use hpr_aero::shock_expansion::{BodySegment, DEFAULT_ELEMENTS_PER_CURVE, ShockExpansionBody};
456    /// use hpr_design::{NoseShape, Profile};
457    ///
458    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
459    /// let nose = BodySegment::Profile {
460    ///     profile: Profile::nose(NoseShape::PowerSeries { exponent: 0.5 }, 0.5, 0.05)?,
461    /// };
462    /// let body = ShockExpansionBody::new(&[nose], DEFAULT_ELEMENTS_PER_CURVE)?;
463    /// // At Mach 3 the wedge detaches past either cap, so each hands over at its own slope, and
464    /// // the steeper one leaves the shorter cap. This nose is r = R√(x/L), whose slope is
465    /// // R/(2√(xL)), so the handover sits at x = (R/(2 tan δ))²/L: 6.31 mm of nose at 24°,
466    /// // 3.75 mm at 30°.
467    /// let station = |degrees: f64| (0.05 / (2.0 * degrees.to_radians().tan())).powi(2) / 0.5;
468    /// let flown = body.handover_m(3.0)?.expect("a vertical tip hands over");
469    /// let steeper = body
470    ///     .clone()
471    ///     .with_handover_cap_rad(CONE_TABLE_CAP_RAD)
472    ///     .handover_m(3.0)?
473    ///     .expect("a vertical tip hands over");
474    /// assert!((flown - station(24.0)).abs() < 1e-9 && (flown - 0.006_31).abs() < 5e-6);
475    /// assert!((steeper - station(30.0)).abs() < 1e-9 && (steeper - 0.003_75).abs() < 5e-6);
476    /// # Ok(())
477    /// # }
478    /// ```
479    ///
480    /// [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
481    #[must_use]
482    pub fn with_handover_cap_rad(mut self, cap_rad: f64) -> Self {
483        self.handover_cap_rad = cap_rad;
484        self
485    }
486
487    /// The body's length, m.
488    pub fn length_m(&self) -> f64 {
489        self.length_m
490    }
491
492    /// The tip's half-angle, rad: `π/2` for a blunt or vertical tip.
493    pub fn vertex_angle_rad(&self) -> f64 {
494        // `new` refuses a body without segments.
495        self.segments[0].1.radius_and_slope(0.0).1.atan()
496    }
497
498    /// Whether the tip is blunt or vertical, so the body flies TN D-4865's Newtonian cap ahead of
499    /// the method ([`crate::blunt_tip`]).
500    pub fn has_blunt_tip(&self) -> bool {
501        self.blunt
502    }
503
504    /// Where a blunt tip's cap hands over to the method at Mach `mach`, m aft of the vertex:
505    /// where the body's slope first falls to [`crate::blunt_tip::handover_angle_rad`], or to the
506    /// body's own cap ([`Self::with_handover_cap_rad`]). `None` for a pointed tip.
507    ///
508    /// # Errors
509    ///
510    /// - [`AeroError::Domain`] for a Mach number that isn't finite and above 1, or a handover cap
511    ///   outside `(0, `[`crate::blunt_tip::CONE_TABLE_CAP_RAD`]`]`.
512    /// - [`AeroError::Unsupported`] if the body is steeper than the handover's slope all the way
513    ///   to its end.
514    pub fn handover_m(&self, mach: f64) -> Result<Option<f64>, AeroError> {
515        check_mach(mach)?;
516        if !self.blunt {
517            return Ok(None);
518        }
519        let angle = crate::blunt_tip::handover_angle_capped_rad(mach, self.handover_cap_rad)?;
520        let target = angle.tan();
521        // The cap ends where the nose's slope first falls to the handover's, which needn't be in
522        // the first segment: behind a spherical cap the nose can carry on through another curved
523        // segment ([`nose_segments`]). Take the first of the nose's segments that is shallower
524        // than the handover at its aft end, and bisect inside it: the slope falls from infinite
525        // at the tip, so the segments ahead of that one are steeper all through. The search stops
526        // where the nose does: a cap that reached a cylinder would hand over at no angle at all,
527        // with none of the total pressure the tip took out of the flow.
528        for (start, segment) in &self.segments[..nose_segments(&self.segments)] {
529            let length = segment.length_m();
530            if segment.radius_and_slope(length).1 > target {
531                continue;
532            }
533            // Bisect to the last bit of an `f64` for the first station where the slope is at most
534            // the handover's.
535            let (mut low, mut high) = (0.0_f64, length);
536            for _ in 0..HANDOVER_BISECTIONS {
537                let mid = 0.5 * (low + high);
538                if mid <= low || mid >= high {
539                    break;
540                }
541                if segment.radius_and_slope(mid).1 > target {
542                    low = mid;
543                } else {
544                    high = mid;
545                }
546            }
547            return Ok(Some(start + high));
548        }
549        Err(AeroError::Unsupported(format!(
550            "the nose is steeper than the blunt tip's handover slope, {}°, all the way to its \
551             end at Mach {mach}",
552            angle.to_degrees()
553        )))
554    }
555
556    fn radius_and_slope_m(&self, x_m: f64) -> (f64, f64) {
557        let index = self
558            .segments
559            .partition_point(|(start, _)| *start <= x_m)
560            .saturating_sub(1);
561        let (start, segment) = &self.segments[index];
562        segment.radius_and_slope(x_m - start)
563    }
564
565    fn radius_m(&self, x_m: f64) -> f64 {
566        self.radius_and_slope_m(x_m).0
567    }
568
569    /// `C_Nα` (per radian, on `reference_area_m2`) and the center of pressure at Mach `mach`, by
570    /// TN 3527's multi-step method.
571    ///
572    /// # Errors
573    ///
574    /// - [`AeroError::Domain`] for a Mach number that isn't above 1, or a reference area that
575    ///   isn't positive.
576    /// - [`AeroError::Unsupported`] where the method doesn't hold: a tip cone whose shock
577    ///   detaches, a tangent cone steeper than the cone tables' 30°, a corner the flow can't turn
578    ///   supersonically, a tip cone whose surface flow is subsonic, a cylinder's or a boattail's
579    ///   element whose pressure moves away from the one it relaxes toward (the free stream's and
580    ///   footnote 8's; neither is a tangent cone of that element's own flow, so the reduction of
581    ///   [`flare_reduction_turns_rad`] is not read there), or a lift that doesn't sum to a
582    ///   positive force; and for a blunt tip, whose elements are laid out at each Mach number, a
583    ///   nose steeper than the handover's slope all the way to its end, or a tangent body whose
584    ///   elements don't meet in order behind the handover (as [`Self::new`] says for a pointed
585    ///   one).
586    ///
587    /// The report states the method for Mach number over nose fineness from 0.4 to 2 (Summary,
588    /// p. 1); `slope` doesn't enforce that range, and its own Mach 6.28 rows are at 2.09.
589    pub fn slope(
590        &self,
591        mach: f64,
592        reference_area_m2: f64,
593    ) -> Result<ShockExpansionSlope, AeroError> {
594        let windows = self.windows(mach, reference_area_m2)?;
595        let (force, moment) = total_lift(&windows)?;
596        Ok(ShockExpansionSlope {
597            slope_per_rad: 2.0 * PI * force / reference_area_m2,
598            center_of_pressure_m: moment / force,
599        })
600    }
601
602    /// Each segment's share of [`Self::slope`], in the order of the segments: its `C_Nα` (per
603    /// radian, on `reference_area_m2`) and that slope's moment about the vertex. The shares sum
604    /// to the whole body's slope and moment up to rounding: every segment's start is a break of
605    /// the integral, so each piece of it lies inside one segment.
606    ///
607    /// # Errors
608    ///
609    /// As [`Self::slope`].
610    pub fn segment_slopes(
611        &self,
612        mach: f64,
613        reference_area_m2: f64,
614    ) -> Result<Vec<SegmentSlope>, AeroError> {
615        let windows = self.windows(mach, reference_area_m2)?;
616        total_lift(&windows)?;
617        let per_unit = 2.0 * PI / reference_area_m2;
618        let mut shares = vec![SegmentSlope::default(); self.segments.len()];
619        for (start_m, [force, moment]) in windows {
620            let index = self
621                .segments
622                .partition_point(|(start, _)| *start <= start_m)
623                .saturating_sub(1);
624            shares[index].slope_per_rad += per_unit * force;
625            shares[index].moment_slope_m += per_unit * moment;
626        }
627        Ok(shares)
628    }
629
630    /// The flow the method computes on each element of the tangent body at Mach `mach`, in order
631    /// from the vertex or a blunt tip's handover: what a hand calculation of eq. 19,
632    /// `C_Nα = (2π/A_ref) ∫ Λ r dx`, needs. Behind a blunt tip the list starts at the handover
633    /// ([`Self::handover_m`]), and the lift of the Newtonian cap ahead of it is not in the list.
634    ///
635    /// # Errors
636    ///
637    /// As [`Self::slope`], less the check that the lift sums to a positive force.
638    pub fn element_flows(&self, mach: f64) -> Result<Vec<ElementFlowReport>, AeroError> {
639        Ok(self
640            .flows(mach)?
641            .flows
642            .iter()
643            .map(|flow| ElementFlowReport {
644                corner_x_m: flow.corner_x_m,
645                angle_rad: flow.angle_rad,
646                pressure_ratio: flow.pressure,
647                loading_per_rad: flow.load,
648                tangent_cone_pressure_ratio: flow.cone_pressure,
649                tangent_cone_loading_per_rad: flow.angle_rad.tan() * flow.cone_slope,
650                decay_per_m: flow.decay_rate(),
651                corner_radius_m: flow.corner_radius_m,
652            })
653            .collect())
654    }
655
656    /// The surface flow the march delivers to the body's aft end at Mach `mach`: everything a
657    /// corner behind the body needs: the surface Mach number and angle there, the pressure, the
658    /// gradient the last element carries to it, the radius, and the free stream's Mach number.
659    ///
660    /// The angle is the **last element's**, so on a body that ends in a curve it is that element's
661    /// chord rather than the tangent at the very end, and it moves a little with
662    /// `elements_per_curve`. On a body that ends in a cylinder or a cone, which is every body a
663    /// flare joins in a flight today, the two are the same.
664    ///
665    /// This is the flow a corner *behind* the body turns. The march is downstream-only: TN 3527
666    /// eq. 3 fixes each element from the one ahead of it and nothing behind, so a flare added at
667    /// the aft end cannot change it, and the limit on that flare's corner
668    /// ([`flare_corner_limit_rad`]) can be read from this body before the flare is drawn.
669    ///
670    /// # Errors
671    ///
672    /// As [`Self::slope`], less the check that the lift sums to a positive force, and
673    /// [`AeroError::Unsupported`] where the surface flow at the aft end is not supersonic.
674    pub fn aft_flow(&self, mach: f64) -> Result<AftFlow, AeroError> {
675        check_mach(mach)?;
676        let march = self.flows(mach)?;
677        // `flows` starts with the vertex's or the handover's element, so it is never empty.
678        let last = march.flows[march.flows.len() - 1];
679        let (pressure, loading) = last.at(self.length_m);
680        Ok(AftFlow {
681            surface_mach: mach_from_pressure(march.total, pressure)?,
682            angle_rad: last.angle_rad,
683            pressure_ratio: pressure,
684            gradient_p0_per_m: last.gradient_at(pressure),
685            loading_per_rad: loading,
686            free_stream_mach: mach,
687            // `new` refuses a body with no segments, so there is always a last one.
688            radius_m: self.segments[self.segments.len() - 1].1.aft_radius_m(),
689        })
690    }
691
692    /// How many of the body's elements the march reduces to the generalized method at Mach
693    /// `mach`: those where the gradient behind the corner points away from the tangent cone's
694    /// pressure (`η < 0`, TN 3527 p. 13), which carry no gradient on (see
695    /// [issue #81](https://github.com/nrdptel/hpr-sim/issues/81)). Zero means the result doesn't
696    /// depend on that reading.
697    ///
698    /// Which turns a corner reduces is a property of its own state, and
699    /// [`flare_reduction_turns_rad`] solves for the two that bound them.
700    ///
701    /// # Errors
702    ///
703    /// - [`AeroError::Domain`] for a Mach number that isn't finite and above 1.
704    /// - [`AeroError::Unsupported`] where the march fails, as for [`Self::slope`]: a detached tip
705    ///   shock, a tangent cone past the cone tables' 30°, a corner the flow can't turn, subsonic surface
706    ///   flow, or a cylinder's or boattail's element that would be reduced. An `Ok` count doesn't
707    ///   promise that [`Self::slope`] succeeds: it also needs a positive total lift.
708    pub fn reduced_elements(&self, mach: f64) -> Result<usize, AeroError> {
709        check_mach(mach)?;
710        Ok(self
711            .flows(mach)?
712            .flows
713            .iter()
714            .filter(|f| f.is_reduced())
715            .count())
716    }
717
718    /// How many times the marched surface pressure crosses its own tangent cone's at Mach `mach`:
719    /// the number of elements whose gap `p_c − p₂` has the opposite sign to the last element that
720    /// had one, counting only pairs within one segment of the body. A pair that straddles a
721    /// segment's start does not count: there `p_c` itself steps (from a cone's pressure to the
722    /// free stream's where a nose meets a cylinder, to footnote 8's where a boattail begins, or
723    /// to a steeper cone's where a flare does), so the gap changes sign without ever passing
724    /// through zero, and there is no pole. Within a segment the profile is continuous, so `p_c`
725    /// is too, and a sign change means the gap really closed.
726    ///
727    /// **A crossing is what marks an answer that moves with the element count.** Along an element
728    /// the method
729    /// relaxes the pressure and the loading toward the tangent cone's as `e^(−η)` with
730    /// `η = k (x − x₂)` (eqs. 8, 9 and 19), where the rate per unit length is
731    /// `k = (∂p/∂s)₂ / ((p_c − p₂) cos δ₂)`. Where the pressure crosses its tangent cone's the gap
732    /// passes through zero while the gradient does not, so `k` has a pole. The pressure itself
733    /// rides through it (`k (p_c − p) cos δ₂` is just the gradient, which stays finite), but the
734    /// loading borrows the pressure's `k` (eq. 19) while its own gap `Λ_c − Λ` does not close
735    /// with it, so the loading is driven onto the tangent cone's arbitrarily fast. A march applies
736    /// `k` from the corner over a whole element, so how much of that lands depends on where the
737    /// crossing falls between corners, and the answer follows the element count instead of
738    /// settling.
739    ///
740    /// **It is a flag, not a verdict, at either end.** A count of zero does not promise an answer
741    /// settled: whether a crossing is seen depends on the mesh, and the count is not even
742    /// monotone in it: readings that cross at 40 and 160 elements per curve can show none at 10.
743    /// Nor does a count above zero promise the answer never settles: one reading of hpr's own
744    /// sweep crosses at every mesh and still holds to 0.003 per radian from 60 elements on. What
745    /// is measured is that over 10, 40 and 160 elements the crossings, and only the crossings,
746    /// mark the readings that move
747    /// ([ADR-044](https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md)). Nothing in a
748    /// flight calls this: it is a tool for studying a body, not a guard.
749    ///
750    /// The fineness-3 ogive TN 3527 prints values for never crosses at the Mach numbers hpr can
751    /// check it at; there `η < 0` comes from the gradient changing sign with the gap all one way,
752    /// which is bounded and settles. So this count, not [`Self::reduced_elements`], is the one to
753    /// read when an answer moves with the element count; see
754    /// [issue #108](https://github.com/nrdptel/hpr-sim/issues/108).
755    ///
756    /// An element whose gap is exactly zero is skipped rather than given a sign: behind a blunt
757    /// tip the march starts on its own tangent cone under the default
758    /// [`HandoverStart::TangentCone`], and that element has no side to be on. Under
759    /// [`HandoverStart::Newtonian`] it does, because its pressure and its tangent cone's come
760    /// from different models, and the count then includes that mismatch.
761    ///
762    /// # Errors
763    ///
764    /// As [`Self::reduced_elements`].
765    pub fn tangent_cone_crossings(&self, mach: f64) -> Result<usize, AeroError> {
766        check_mach(mach)?;
767        // Where `p_c` may step: the start of every segment after the first.
768        let starts: Vec<f64> = self
769            .segments
770            .iter()
771            .skip(1)
772            .map(|(start, _)| *start)
773            .collect();
774        let within_one_segment = |from: f64, to: f64| {
775            !starts
776                .iter()
777                .any(|start| *start > from && *start <= to + 1e-12 * self.length_m)
778        };
779        let mut crossings = 0;
780        // The last element that had a gap: where it starts, and which side of its cone it is on.
781        let mut last: Option<(f64, bool)> = None;
782        for flow in &self.flows(mach)?.flows {
783            let gap = flow.cone_pressure - flow.pressure;
784            if gap == 0.0 {
785                continue;
786            }
787            if let Some((at_m, was_positive)) = last
788                && was_positive != (gap > 0.0)
789                && within_one_segment(at_m, flow.corner_x_m)
790            {
791                crossings += 1;
792            }
793            last = Some((flow.corner_x_m, gap > 0.0));
794        }
795        Ok(crossings)
796    }
797
798    /// The integrals of the lift per unit length and of its moment about the vertex (both over
799    /// `2π`), one per piece between consecutive corners, segment starts, a blunt tip's handover
800    /// and the body's end, keyed by the piece's forward end.
801    fn windows(
802        &self,
803        mach: f64,
804        reference_area_m2: f64,
805    ) -> Result<Vec<(f64, [f64; 2])>, AeroError> {
806        check_mach(mach)?;
807        check_dimension("reference area", reference_area_m2, false)?;
808        let March { cap, flows, .. } = self.flows(mach)?;
809        let cap_end_m = cap.map_or(0.0, |c| c.end_x_m);
810        let loading = |x: f64| {
811            if let Some(cap) = cap.filter(|c| x < c.end_x_m) {
812                return crate::blunt_tip::newtonian_loading_at_slope(
813                    cap.c_p_max,
814                    self.radius_and_slope_m(x).1,
815                );
816            }
817            let index = flows
818                .partition_point(|f| f.corner_x_m <= x)
819                .saturating_sub(1);
820            flows[index].at(x).1
821        };
822        let mut breaks: Vec<f64> = flows
823            .iter()
824            .map(|f| f.corner_x_m)
825            .chain(self.segments.iter().map(|(start, _)| *start))
826            .chain([0.0, cap_end_m, self.length_m])
827            .filter(|x| *x >= 0.0 && *x <= self.length_m)
828            .collect();
829        breaks.sort_by(f64::total_cmp);
830        breaks.dedup();
831        let scale = self.length_m * self.radius_m(self.length_m).max(1e-12);
832        let tolerance = Tolerance {
833            relative: 1e-11,
834            absolute: 1e-13 * scale,
835            max_intervals: 4000,
836        };
837        breaks
838            .windows(2)
839            .map(|pair| {
840                let integral = integrate(
841                    |x| {
842                        let lr = loading(x) * self.radius_m(x);
843                        [lr, lr * x]
844                    },
845                    pair[0],
846                    pair[1],
847                    tolerance,
848                )?;
849                Ok((pair[0], integral.value))
850            })
851            .collect()
852    }
853
854    /// Marches the flow over the tangent body's elements at Mach `mach`: from the vertex's cone
855    /// for a pointed tip, or from a blunt tip's handover, behind its Newtonian cap.
856    fn flows(&self, mach: f64) -> Result<March, AeroError> {
857        let (cap, elements, first, total) = if self.blunt {
858            let (cap, elements, first, total) = self.handover_flow(mach)?;
859            (cap, std::borrow::Cow::Owned(elements), first, total)
860        } else {
861            // `new` lays out a pointed body's elements, the vertex's first.
862            let vertex = self.elements[0];
863            let cone = cone_flow(mach, vertex.angle_rad)?;
864            if cone.surface_mach <= 1.0 {
865                return Err(AeroError::Unsupported(format!(
866                    "the flow on the tip's cone is subsonic (Mach {}) at Mach {mach}",
867                    cone.surface_mach
868                )));
869            }
870            let total = cone.surface_pressure_ratio * total_over_static(cone.surface_mach);
871            let vertex_slope = cone_normal_force_slope(mach, vertex.angle_rad)?;
872            let first = ElementFlow {
873                corner_x_m: 0.0,
874                corner_radius_m: 0.0,
875                angle_rad: vertex.angle_rad,
876                pressure: cone.surface_pressure_ratio,
877                gradient: 0.0,
878                load: vertex.angle_rad.tan() * vertex_slope,
879                cone_pressure: cone.surface_pressure_ratio,
880                cone_slope: vertex_slope,
881            };
882            (
883                None,
884                std::borrow::Cow::Borrowed(&self.elements[..]),
885                first,
886                total,
887            )
888        };
889        let mut flows = vec![first];
890        for element in &elements[1..] {
891            let before = flows[flows.len() - 1];
892            let (p1, load1) = before.at(element.corner_x_m);
893            let gradient1 = before.gradient_at(p1);
894            let m1 = mach_from_pressure(total, p1)?;
895            let nu2 = prandtl_meyer(m1) + (before.angle_rad - element.angle_rad);
896            if !(nu2 > 0.0 && nu2 < MAX_TURNING_RAD) {
897                return Err(AeroError::Unsupported(format!(
898                    "the flow at Mach {m1} can't turn through {} rad supersonically at {} m",
899                    before.angle_rad - element.angle_rad,
900                    element.corner_x_m
901                )));
902            }
903            let m2 = inverse_prandtl_meyer(nu2);
904            let p2 = total / total_over_static(m2);
905            let (b1, b2) = (b_factor(p1, m1), b_factor(p2, m2));
906            let (o1, o2) = (area_ratio(m1), area_ratio(m2));
907            let gradient2 = b2 / element.corner_radius_m
908                * (o1 / o2 * before.angle_rad.sin() - element.angle_rad.sin())
909                + b2 * o1 / (b1 * o2) * gradient1;
910            let load2 = lambda(p2, m2) / lambda(p1, m1) * load1;
911            let (cone_pressure, cone_slope) = if element.angle_rad > CONE_ANGLE_FLOOR_RAD {
912                (
913                    cone_flow(mach, element.angle_rad)?.surface_pressure_ratio,
914                    cone_normal_force_slope(mach, element.angle_rad)?,
915                )
916            } else {
917                // A cylinder's tangent cone is the free stream; a boattail's is footnote 8's.
918                (1.0, 2.0)
919            };
920            let flow = ElementFlow {
921                corner_x_m: element.corner_x_m,
922                corner_radius_m: element.corner_radius_m,
923                angle_rad: element.angle_rad,
924                pressure: p2,
925                gradient: gradient2,
926                load: load2,
927                cone_pressure,
928                cone_slope,
929            };
930            // Where the element has no tangent cone of its own (a cylinder's is the free
931            // stream, a boattail's is footnote 8's), there is nothing for the reduction to
932            // relax toward, and a reduced element would carry its corner's loading over any
933            // length. Nothing measures what that is worth, so hpr refuses those (issue #123).
934            if flow.is_reduced() && element.angle_rad <= CONE_ANGLE_FLOOR_RAD {
935                return Err(AeroError::Unsupported(format!(
936                    "behind the corner at {} m, where the element has no tangent cone of its \
937                     own, the pressure moves away from the one it relaxes toward",
938                    element.corner_x_m
939                )));
940            }
941            flows.push(flow);
942        }
943        Ok(March { cap, flows, total })
944    }
945
946    /// A blunt tip at Mach `mach` ([`crate::blunt_tip`]): its Newtonian cap and handover
947    /// (TN D-4865), the elements from the handover aft, the flow just behind the handover (as
948    /// [`HandoverStart`] says) and the total pressure the march expands from.
949    fn handover_flow(
950        &self,
951        mach: f64,
952    ) -> Result<(Option<Cap>, Vec<Element>, ElementFlow, f64), AeroError> {
953        // A blunt body always has a handover where the method holds.
954        let Some(end_x_m) = self.handover_m(mach)? else {
955            return Err(AeroError::Unsupported(
956                "a blunt tip without a handover".to_owned(),
957            ));
958        };
959        let elements = lay_out(
960            &self.segments,
961            self.length_m,
962            self.elements_per_curve,
963            end_x_m,
964        )?;
965        // `lay_out` always returns the handover's element first.
966        let handover = elements[0];
967        let cone = cone_flow(mach, handover.angle_rad)?;
968        let cone_slope = cone_normal_force_slope(mach, handover.angle_rad)?;
969        let (pressure, load, total) = match self.handover_start {
970            HandoverStart::TangentCone => {
971                if cone.surface_mach <= 1.0 {
972                    return Err(AeroError::Unsupported(format!(
973                        "the flow on the cone tangent at the blunt tip's handover is subsonic \
974                         (Mach {}) at Mach {mach}",
975                        cone.surface_mach
976                    )));
977                }
978                (
979                    cone.surface_pressure_ratio,
980                    handover.angle_rad.tan() * cone_slope,
981                    cone.surface_pressure_ratio * total_over_static(cone.surface_mach),
982                )
983            }
984            HandoverStart::Newtonian => {
985                use crate::blunt_tip::{
986                    handover_loading, newtonian_pressure_ratio, newtonian_surface_mach,
987                    pitot_pressure_ratio,
988                };
989                let pressure = newtonian_pressure_ratio(mach, handover.angle_rad)?;
990                let surface_mach = newtonian_surface_mach(mach, pressure)?;
991                if surface_mach <= 1.0 {
992                    return Err(AeroError::Unsupported(format!(
993                        "the flow at the blunt tip's handover is subsonic (Mach {surface_mach}) \
994                         at Mach {mach}"
995                    )));
996                }
997                (
998                    pressure,
999                    handover_loading(mach, pressure, surface_mach)?,
1000                    pitot_pressure_ratio(mach)?,
1001                )
1002            }
1003        };
1004        let first = ElementFlow {
1005            corner_x_m: end_x_m,
1006            corner_radius_m: handover.corner_radius_m,
1007            angle_rad: handover.angle_rad,
1008            pressure,
1009            gradient: 0.0,
1010            load,
1011            cone_pressure: cone.surface_pressure_ratio,
1012            cone_slope,
1013        };
1014        let cap = Cap {
1015            end_x_m,
1016            c_p_max: crate::blunt_tip::newtonian_pressure_coefficient_max(mach)?,
1017        };
1018        Ok((Some(cap), elements, first, total))
1019    }
1020}
1021
1022/// How many leading segments are the nose.
1023///
1024/// Normally one: a nose is a single [`Profile`](hpr_design::Profile), and everything behind it is
1025/// the afterbody. A [`BodySegment::SphericalCap`] is the exception: it is a *piece* of a nose,
1026/// never a whole one, so behind a cap the nose carries on through the curved segments that
1027/// follow it, and stops at the first that is straight or doesn't widen. TN D-4865's own model 2
1028/// needs that: its nose is
1029/// a 0.257-diameter sphere blended into a 2.75° cone by a 0.429 arc, and the sphere is still at
1030/// 38.3° where the arc takes over, steeper than the handover's 24° cap at any Mach number
1031/// (M1.8e18, ADR-048).
1032///
1033/// It says where a blunt tip's cap may hand the flow over ([`ShockExpansionBody::handover_m`]),
1034/// and which elements the rule on a reduced element treats as the nose's. Deliberately narrow: a
1035/// body that isn't led by a cap reads exactly as it did before, so no committed number moved.
1036///
1037/// [adr-048]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-048-what-a-marched-flare-is-worth-measured-against-tn-d-4865s-model-2-2026-09-20
1038fn nose_segments(segments: &[(f64, BodySegment)]) -> usize {
1039    if !matches!(
1040        segments.first(),
1041        Some((_, BodySegment::SphericalCap { .. }))
1042    ) {
1043        return 1;
1044    }
1045    segments
1046        .iter()
1047        .take_while(|(_, segment)| {
1048            // A curved segment that narrows is a boattail, not the nose: letting a cap reach one
1049            // would hand the flow over on a falling surface, at a slope the handover angle meets
1050            // from the wrong side.
1051            !segment.is_straight()
1052                && segment.radius_and_slope(segment.length_m()).0 > segment.radius_and_slope(0.0).0
1053        })
1054        .count()
1055        .max(1)
1056}
1057
1058/// The tangent body's elements over `segments` (each with its fore station, the body `length_m`
1059/// long): tangent at `elements_per_curve` equal steps along a curved segment (a straight one
1060/// takes one element), counted from `start_m` (the vertex, or a blunt tip's handover, whose cap
1061/// may cover whole segments). The first element starts at `start_m`.
1062///
1063/// # Errors
1064///
1065/// [`AeroError::Unsupported`] where the tangent lines of consecutive elements don't meet in order
1066/// along the body, meet at no radius, or are parallel but apart.
1067fn lay_out(
1068    segments: &[(f64, BodySegment)],
1069    length_m: f64,
1070    elements_per_curve: usize,
1071    start_m: f64,
1072) -> Result<Vec<Element>, AeroError> {
1073    let nose_segments = nose_segments(segments);
1074    // The tangency points: (x, r, slope, on the nose).
1075    let mut points = Vec::new();
1076    for (index, (start, segment)) in segments.iter().enumerate() {
1077        // A blunt tip's cap can cover whole segments: those carry no elements, and the one the
1078        // handover falls in starts there.
1079        if start + segment.length_m() <= start_m {
1080            continue;
1081        }
1082        let from = (start_m - start).max(0.0);
1083        let span = segment.length_m() - from;
1084        let steps = if segment.is_straight() {
1085            1
1086        } else {
1087            elements_per_curve
1088        };
1089        let count = if segment.is_straight() { 1 } else { steps + 1 };
1090        for i in 0..count {
1091            let local = from + span * i as f64 / steps as f64;
1092            let (r, slope) = segment.radius_and_slope(local);
1093            points.push((start + local, r, slope, index < nose_segments));
1094        }
1095    }
1096
1097    let Some(&(x0, r0, t0, _)) = points.first() else {
1098        return Err(AeroError::Unsupported(format!(
1099            "a blunt tip's cap reaches the body's end at {start_m} m"
1100        )));
1101    };
1102    let mut elements = vec![Element {
1103        on_nose: true,
1104        corner_x_m: x0,
1105        corner_radius_m: r0,
1106        angle_rad: t0.atan(),
1107    }];
1108    let (mut xp, mut rp, mut tp) = (x0, r0, t0);
1109    for &(x, r, t, on_nose) in &points[1..] {
1110        // A point on the previous element's line adds nothing. Nor does one whose tangent turns
1111        // by under `NEARLY_PARALLEL_RAD`: its corner with the previous tangent would be lost in the
1112        // profile's rounding (a blunt tip's handover close to the nose's end packs its elements
1113        // into a few nanometers), and so small a turn changes nothing the method computes.
1114        let on_line = r - (rp + tp * (x - xp));
1115        if (t.atan() - tp.atan()).abs() <= NEARLY_PARALLEL_RAD
1116            && on_line.abs() <= 1e-9 * r.max(1e-12)
1117        {
1118            continue;
1119        }
1120        if (t - tp).abs() <= 1e-12 * (1.0 + tp.abs()) {
1121            return Err(AeroError::Unsupported(format!(
1122                "the tangent body's elements at {xp} m and {x} m are parallel but apart"
1123            )));
1124        }
1125        let corner_x = (r - rp + tp * xp - t * x) / (tp - t);
1126        let corner_r = rp + tp * (corner_x - xp);
1127        if corner_r.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) {
1128            return Err(AeroError::Unsupported(format!(
1129                "the tangent body's corner at {corner_x} m has no radius: the method needs the \
1130                 body open everywhere but its tip"
1131            )));
1132        }
1133        // `elements` starts with the first element's.
1134        let last = elements[elements.len() - 1].corner_x_m;
1135        if !(corner_x.is_finite() && corner_x >= last && corner_x <= x + 1e-12 * length_m) {
1136            return Err(AeroError::Unsupported(format!(
1137                "the tangent body's corner at {corner_x} m falls outside [{last}, {x}] m: the \
1138                 profile turns too quickly for its elements"
1139            )));
1140        }
1141        elements.push(Element {
1142            on_nose,
1143            corner_x_m: corner_x,
1144            corner_radius_m: corner_r,
1145            angle_rad: t.atan(),
1146        });
1147        (xp, rp, tp) = (x, r, t);
1148    }
1149    Ok(elements)
1150}
1151
1152/// The method's Mach number: finite and above 1.
1153fn check_mach(mach: f64) -> Result<(), AeroError> {
1154    if mach.is_finite() && mach > 1.0 {
1155        Ok(())
1156    } else {
1157        Err(AeroError::Domain {
1158            what: "Mach number of the second-order shock-expansion method",
1159            value: mach,
1160        })
1161    }
1162}
1163
1164/// Below this angle an element is a cylinder: its tangent cone is the free stream.
1165const CONE_ANGLE_FLOOR_RAD: f64 = 1e-9;
1166
1167/// A tangency point turning the tangent body by less than this, rad, on the previous element's
1168/// line, is merged into that element. Corners of so small a turn are ill-conditioned: two tangents
1169/// a distance `h` apart on a curve of curvature `κ` meet at `h/2` from a numerator of order `κh²`,
1170/// while the profile's radius carries rounding of order `ε r`; at this turn the corner's error is
1171/// under 1e-4 of `h` for `rκ` up to 1 (a sphere's is at most 1). Pointed noses' ten elements turn by
1172/// degrees and never merge. Which points merge behind a blunt tip's cap changes with the handover,
1173/// so a body's slope steps by about 1e-6 per radian as they do.
1174const NEARLY_PARALLEL_RAD: f64 = 1e-6;
1175
1176/// Enough halvings to find a blunt tip's handover to the last bit of an `f64`; the loop stops
1177/// sooner, when no `f64` lies between the ends.
1178const HANDOVER_BISECTIONS: usize = 1100;
1179
1180/// The flow along one element: its state just behind its corner, and its tangent cone.
1181#[derive(Debug, Clone, Copy, PartialEq)]
1182struct ElementFlow {
1183    corner_x_m: f64,
1184    /// The radius at the corner, m.
1185    corner_radius_m: f64,
1186    angle_rad: f64,
1187    /// `p₂/p₀` just behind the corner.
1188    pressure: f64,
1189    /// `(∂p/∂s)₂`, `p₀` per m.
1190    gradient: f64,
1191    /// `Λ` just behind the corner.
1192    load: f64,
1193    /// `p_c/p₀` on the tangent cone.
1194    cone_pressure: f64,
1195    /// `(dC_N/dα)` of the tangent cone, per rad.
1196    cone_slope: f64,
1197}
1198
1199impl ElementFlow {
1200    /// `dη/dx` from eq. 9: zero where the pressure already sits at its tangent cone's.
1201    fn eta_rate(&self) -> f64 {
1202        let gap = self.cone_pressure - self.pressure;
1203        if gap == 0.0 || self.gradient == 0.0 {
1204            0.0
1205        } else {
1206            self.gradient / (gap * self.angle_rad.cos())
1207        }
1208    }
1209
1210    /// Whether the element is reduced to the generalized method (see [`Self::decay_rate`]).
1211    fn is_reduced(&self) -> bool {
1212        self.eta_rate() < 0.0
1213    }
1214
1215    /// The rate `η` grows at along the element. The exponential form holds only where the
1216    /// gradient behind the corner has the sign of `p_c − p₂`, `η ≥ 0` (TN 3527 p. 13), which the
1217    /// report states as a condition of the method without saying how it continued where the
1218    /// condition fails. hpr's reading, not the report's rule: there it takes `η = 0`, where "all
1219    /// equations reduce to those given by the generalized shock-expansion method" (p. 13), so the
1220    /// pressure and loading stay at their values behind the corner and no gradient is passed to
1221    /// the next corner (the generalized method's constant pressure along an element, p. 5).
1222    /// Eq. 10 read literally would pass the gradient on; that diverges as elements are added.
1223    /// This happens on sharp noses at high Mach number, and it departs from the report's values
1224    /// on its fineness-3 ogive at Mach 5.05 (issue #81).
1225    fn decay_rate(&self) -> f64 {
1226        self.eta_rate().max(0.0)
1227    }
1228
1229    /// `p/p₀` and `Λ` at `x_m` (eqs. 8, 9 and 19).
1230    fn at(&self, x_m: f64) -> (f64, f64) {
1231        let decay = (-self.decay_rate() * (x_m - self.corner_x_m)).exp();
1232        let pressure = self.cone_pressure - (self.cone_pressure - self.pressure) * decay;
1233        let load = (1.0 - decay) * self.angle_rad.tan() * self.cone_slope + decay * self.load;
1234        (pressure, load)
1235    }
1236
1237    /// `∂p/∂s` where the pressure is `pressure` (eq. 10); zero on an element of the generalized
1238    /// method (see [`Self::decay_rate`]).
1239    fn gradient_at(&self, pressure: f64) -> f64 {
1240        let gap = self.cone_pressure - self.pressure;
1241        if gap == 0.0 || self.is_reduced() {
1242            0.0
1243        } else {
1244            (self.cone_pressure - pressure) / gap * self.gradient
1245        }
1246    }
1247}
1248
1249/// `B = γpM²/(2(M² − 1))` (eq. 6), `p` in units of `p₀`.
1250fn b_factor(pressure: f64, mach: f64) -> f64 {
1251    GAMMA * pressure * mach * mach / (2.0 * (mach * mach - 1.0))
1252}
1253
1254/// `λ = 2γp / sin 2μ` (eq. 5), with `sin 2μ = 2√(M² − 1)/M²`.
1255fn lambda(pressure: f64, mach: f64) -> f64 {
1256    let m2 = mach * mach;
1257    2.0 * GAMMA * pressure / (2.0 * (m2 - 1.0).sqrt() / m2)
1258}
1259
1260/// `Ω = A/A* = (1/M)[(1 + (γ − 1)M²/2)/((γ + 1)/2)]^((γ + 1)/(2(γ − 1)))` (eq. 7).
1261fn area_ratio(mach: f64) -> f64 {
1262    ((1.0 + G1 * mach * mach) / (0.5 * (GAMMA + 1.0))).powf(0.5 * (GAMMA + 1.0) / (GAMMA - 1.0))
1263        / mach
1264}
1265
1266/// `p_t/p = (1 + (γ − 1)M²/2)^(γ/(γ − 1))`.
1267fn total_over_static(mach: f64) -> f64 {
1268    (1.0 + G1 * mach * mach).powf(GAMMA / (GAMMA - 1.0))
1269}
1270
1271/// The Mach number where isentropic flow of total pressure `total` has static pressure
1272/// `pressure` (both in units of `p₀`).
1273fn mach_from_pressure(total: f64, pressure: f64) -> Result<f64, AeroError> {
1274    let m2 = ((total / pressure).powf((GAMMA - 1.0) / GAMMA) - 1.0) / G1;
1275    if !(m2.is_finite() && m2 > 1.0) {
1276        return Err(AeroError::Unsupported(format!(
1277            "the surface flow isn't supersonic (Mach² {m2}) where the method needs it"
1278        )));
1279    }
1280    Ok(m2.sqrt())
1281}
1282
1283/// The steepest turn the method reads at a flare's corner, rad, where the surface flow reaching
1284/// that corner is `surface_mach` ([`ShockExpansionBody::aft_flow`]): the largest deflection
1285/// behind an attached plane oblique shock.
1286///
1287/// This is a bound on the **turn**, measured from the surface just ahead of the corner. The cone
1288/// tables' [`crate::blunt_tip::CONE_TABLE_CAP_RAD`] bounds the flare's **surface angle** instead,
1289/// since that is what an element's tangent cone is looked up by, and a caller that draws a flare
1290/// to this turn must cap the angle it draws separately.
1291///
1292/// **The attachment test.** A flare's shock springs from a circular corner, not from a point, so
1293/// where it forms the flow is two-dimensional: the body's radius is the scale over which the
1294/// axisymmetric relief acts, and at the corner itself there is none of it yet. The test is
1295/// therefore NACA Report 1135's largest deflection behind an attached plane oblique shock
1296/// ([`crate::blunt_tip::wedge_detachment_angle_rad`], eq. 168 into eq. 138), read at the flow
1297/// reaching the corner rather than at the free stream. TN D-4865 p. 5 uses the same test to hand
1298/// a blunt tip's cap over to this method ([`crate::blunt_tip::handover_angle_rad`]). A cone's
1299/// shock holds to steeper angles than a wedge's and a conical flare on a cylinder sits between
1300/// the two ([ADR-045][adr-045]), so this is the conservative
1301/// side of the boundary: it stops reading some flares whose shock is in fact still attached, and
1302/// never marches one whose shock is not.
1303///
1304/// **Where it is read matters.** The march is downstream-only, so `surface_mach` is the flow the
1305/// body ahead delivers to the corner, not the free stream: on a flare behind an ogive nose and a
1306/// tube it comes out a little below the free stream, on one behind a cone and a tube a little
1307/// above ([ADR-047][adr-047]).
1308///
1309/// # Errors
1310///
1311/// As [`crate::blunt_tip::wedge_detachment_angle_rad`] for the Mach number.
1312///
1313/// [adr-045]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-045-where-a-flares-march-stops-is-the-corners-isentropic-turn-not-the-shock-detaching-2026-09-20
1314/// [adr-047]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-047-a-flare-flies-the-method-where-its-corners-shock-is-attached-and-is-read-drawn-out-where-it-is-not-2026-09-20
1315pub fn flare_corner_limit_rad(surface_mach: f64) -> Result<f64, AeroError> {
1316    crate::blunt_tip::wedge_detachment_angle_rad(surface_mach)
1317}
1318
1319/// The two turns that bound where the second-order method's exponential form does not hold at a
1320/// corner behind a body ([`flare_reduction_turns_rad`]).
1321#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1322#[non_exhaustive]
1323pub struct ReductionTurns {
1324    /// The turn whose pressure just behind the corner lands exactly on its tangent cone's,
1325    /// `p₂ = p_c`. `η` has a pole here, because eq. 9 divides by that gap.
1326    pub crossing_rad: f64,
1327    /// What the crossing's solution left behind: `p₂ − p_c` there, in units of the free stream's
1328    /// pressure. **Read it before trusting the turn.** How small it can be made is the tangent
1329    /// cone's accuracy, not the solver's: below [`SLENDER_CONE_RAD`] the cone flow is
1330    /// slender-cone theory's closed form and this closes to the last bits of an `f64`, while
1331    /// above it the cone flow is an integration and what is left is that integration's own.
1332    pub crossing_residual_p0: f64,
1333    /// The turn whose own compression exactly cancels the pressure gradient the body ahead
1334    /// delivers to the corner, `(∂p/∂s)₂ = 0` (TN 3527 eq. 4). `η` is zero here, so the method is
1335    /// already the generalized one.
1336    pub balance_rad: f64,
1337    /// What the balance's solution left behind: `(∂p/∂s)₂` there, `p₀` per m of axial distance.
1338    pub balance_residual_p0_per_m: f64,
1339    /// `Λ₂ − Λ_c` at [`Self::crossing_rad`], per radian of angle of attack: **the whole size of
1340    /// the step the crossing leaves**, before it is integrated over the element that holds it.
1341    ///
1342    /// At the crossing `η` has a pole, and the two sides of it take the two constants eq. 19
1343    /// relaxes between: the side the method still owns sheds the corner's loading onto its
1344    /// tangent cone's at once (`Λ_c = tan δ₂ (dC_N/dα)_tc`), and the reduced side holds the
1345    /// corner's (`Λ₂ = (λ₂/λ₁) Λ₁`). Both are constant along a conical flare, so eq. 19's
1346    /// `C_Nα = (2π/A_ref) ∫ Λ r dx` integrates a constant and the step in the body's slope is
1347    ///
1348    /// `ΔC_Nα = (2π/A_ref) (Λ₂ − Λ_c) · ½(r_fore + r_aft) · L`
1349    ///
1350    /// for a flare of length `L` between those radii. It is exact, not a sample, and the one
1351    /// number a reader needs to work out what the crossing costs on their own body
1352    /// ([ADR-050][adr-050-turns]).
1353    ///
1354    /// [adr-050-turns]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-050-a-reduced-element-is-read-by-the-generalized-method-wherever-it-has-a-tangent-cone-of-its-own-2026-09-20
1355    pub crossing_loading_gap_per_rad: f64,
1356}
1357
1358/// Where the second-order shock-expansion method's exponential form fails at a corner behind
1359/// `aft`, the flow a body delivers to its aft end ([`ShockExpansionBody::aft_flow`]): the two
1360/// turns between which the march reduces the element behind that corner to the generalized
1361/// method.
1362///
1363/// **An element is reduced exactly when its turn lies strictly between the two**, in whichever
1364/// order they come. Eq. 9's rate is `η/(x − x₂) = (∂p/∂s)₂ / ((p_c − p₂) cos δ₂)`, and TN 3527
1365/// p. 13 keeps the exponential form only where `η ≥ 0`, so a reduced element is one whose
1366/// gradient behind the corner and whose gap to its tangent cone have opposite signs. Each of
1367/// those two is a continuous function of the turn, and (on every corner state measured for
1368/// [ADR-050][adr-050], an observation rather than a proof) each has a single zero, so the signs
1369/// disagree on exactly the open interval between them and nowhere else.
1370///
1371/// Both zeros are properties of the corner's own state. With `δ₁` the angle ahead, `r` the radius
1372/// at the corner, `B = γpM²/(2(M² − 1))` (eq. 6) and `Ω = A/A*` (eq. 7), all read from `aft`:
1373///
1374/// - the balance solves `sin(δ₁ + θ) = (Ω₁/Ω₂(θ)) (sin δ₁ + r (∂p/∂s)₁ / B₁)`, which is eq. 4 set
1375///   to zero and rearranged. `Ω₁/Ω₂` is `1 + O(θ)`, so iterating on it contracts;
1376/// - the crossing solves `p₂(θ) = p_c(δ₁ + θ)`, the isentropic turn's pressure against its
1377///   tangent cone's ([`cone_flow`]), by false position from the turn that would bring `p₂` back
1378///   to the free stream's pressure, `θ ≈ (1/p₁ − 1)√(M₁² − 1)/(γM₁²)`.
1379///
1380/// Neither is a search over the march's own refusal, which is a sign test on two pressures within
1381/// a thousandth of each other and so carries about nine significant digits
1382/// ([issue #117](https://github.com/nrdptel/hpr-sim/issues/117)). What is left is the accuracy of
1383/// `aft` and of the tangent cone, and each solution reports what it left behind, in
1384/// [`ReductionTurns::crossing_residual_p0`] and
1385/// [`ReductionTurns::balance_residual_p0_per_m`], because neither is promised to be zero.
1386/// Differentiating the crossing's equation, a change `Δp₁` in the pressure the body delivers
1387/// moves the crossing by about `Δp₁ √(M₁² − 1) / (γ p₁ M₁²)`.
1388///
1389/// # Errors
1390///
1391/// - [`AeroError::Unsupported`] where the corner's state can't carry a turn (a surface that
1392///   isn't supersonic, no radius, a free-stream Mach number at or below 1), or where either root
1393///   lies outside the turns a widening corner can make: between zero surface angle and the
1394///   shallower of the isentropic turn's end and the cone tables' 30°.
1395///
1396/// [adr-050]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-050-a-reduced-element-is-read-by-the-generalized-method-wherever-it-has-a-tangent-cone-of-its-own-2026-09-20
1397pub fn flare_reduction_turns_rad(aft: &AftFlow) -> Result<ReductionTurns, AeroError> {
1398    let (m1, p1, d1) = (aft.surface_mach, aft.pressure_ratio, aft.angle_rad);
1399    let mach = aft.free_stream_mach;
1400    if !(m1 > 1.0
1401        && mach > 1.0
1402        && p1 > 0.0
1403        && aft.radius_m > 0.0
1404        && aft.gradient_p0_per_m.is_finite()
1405        && d1.abs() < 0.5 * PI)
1406    {
1407        return Err(AeroError::Unsupported(format!(
1408            "a corner behind a surface at Mach {m1} in a Mach {mach} stream, {p1} of the free \
1409             stream's pressure and {} m of radius, turns nothing",
1410            aft.radius_m
1411        )));
1412    }
1413    let nu1 = prandtl_meyer(m1);
1414    let total = p1 * total_over_static(m1);
1415    // The surface Mach number and pressure just behind a corner turning the flow by `turn`,
1416    // compressing where that is positive. A turn of nothing is the state itself: round-tripping
1417    // it through the isentropic relations would leave a bit of noise where the answer is exact.
1418    let behind = |turn: f64| -> Option<(f64, f64)> {
1419        if turn == 0.0 {
1420            return Some((m1, p1));
1421        }
1422        let nu2 = nu1 - turn;
1423        (nu2 > 0.0 && nu2 < MAX_TURNING_RAD).then(|| {
1424            let m2 = inverse_prandtl_meyer(nu2);
1425            (m2, total / total_over_static(m2))
1426        })
1427    };
1428    // The turns a widening corner can make: from a surface lying along the axis up to, but not
1429    // including, the shallower of the isentropic turn running out and the cone tables' cap.
1430    let (lowest, highest) = (
1431        -d1,
1432        (crate::blunt_tip::CONE_TABLE_CAP_RAD - d1)
1433            .min(nu1)
1434            .next_down(),
1435    );
1436    if lowest >= highest || !highest.is_finite() {
1437        return Err(AeroError::Unsupported(format!(
1438            "a corner behind a surface at Mach {m1} lying {}° to the axis has no widening turn \
1439             the method holds",
1440            d1.to_degrees()
1441        )));
1442    }
1443    let outside = |what: &str| {
1444        AeroError::Unsupported(format!(
1445            "the {what} of a corner behind a surface at Mach {m1} in a Mach {mach} stream lies \
1446             outside the turns a widening corner can make"
1447        ))
1448    };
1449
1450    // The balance: eq. 4 set to zero. `k` is the corner's own state, and the only turn left in
1451    // the equation is through the `Ω₂` that turn reaches.
1452    let (o1, b1) = (area_ratio(m1), b_factor(p1, m1));
1453    let k = d1.sin() + aft.radius_m * aft.gradient_p0_per_m / b1;
1454    if !(-1.0..=1.0).contains(&k) {
1455        return Err(outside("balance"));
1456    }
1457    let mut balance = k.asin() - d1;
1458    for _ in 0..REDUCTION_ITERATIONS {
1459        // A fixed point that wants to sit outside the widening turns has no root among them:
1460        // say so rather than iterate against an end and hand that back as one.
1461        if !(lowest..=highest).contains(&balance) {
1462            return Err(outside("balance"));
1463        }
1464        let (m2, _) = behind(balance).ok_or_else(|| outside("balance"))?;
1465        let next = (o1 / area_ratio(m2) * k).asin() - d1;
1466        if !next.is_finite() {
1467            return Err(outside("balance"));
1468        }
1469        let step = next - balance;
1470        balance = next;
1471        if step.abs() <= f64::EPSILON * (1.0 + balance.abs()) {
1472            break;
1473        }
1474    }
1475    if !(lowest..=highest).contains(&balance) {
1476        return Err(outside("balance"));
1477    }
1478    let (m2, p2) = behind(balance).ok_or_else(|| outside("balance"))?;
1479    let (o2, b2) = (area_ratio(m2), b_factor(p2, m2));
1480    let balance_left = b2 / aft.radius_m * (o1 / o2 * d1.sin() - (d1 + balance).sin())
1481        + b2 * o1 / (b1 * o2) * aft.gradient_p0_per_m;
1482
1483    // The crossing: the isentropic turn's pressure against its tangent cone's. Swept over the
1484    // widening turns first, because the gap is not promised to have one zero: a blunt shoulder
1485    // at high Mach has three, and a bracket taken on the ends alone would hide two of them and
1486    // return whichever root the solver happened to walk to. Where the sweep finds exactly one
1487    // sign change, false position inside that bracket lands on the root and stays there.
1488    let gap = |turn: f64| -> Option<f64> {
1489        let (_, p2) = behind(turn)?;
1490        let angle = d1 + turn;
1491        if !(0.0..crate::blunt_tip::CONE_TABLE_CAP_RAD).contains(&angle) {
1492            return None;
1493        }
1494        let cone = if angle <= CONE_ANGLE_FLOOR_RAD {
1495            1.0
1496        } else {
1497            cone_flow(mach, angle).ok()?.surface_pressure_ratio
1498        };
1499        Some(p2 - cone)
1500    };
1501    let start = gap(lowest).ok_or_else(|| outside("crossing"))?;
1502    // A body that has handed the free stream's own pressure to the corner meets its tangent
1503    // cone's at a turn of nothing, which is the first end rather than a station.
1504    let mut bracket = (start == 0.0).then_some(((lowest, start), (lowest, start)));
1505    let mut crossings = usize::from(start == 0.0);
1506    let mut before = (lowest, start);
1507    for station in 1..=REDUCTION_STATIONS {
1508        // The last station is the end itself: stepping to it can round a hair past it, and a
1509        // hair past is where the isentropic turn has run out.
1510        let turn = if station == REDUCTION_STATIONS {
1511            highest
1512        } else {
1513            lowest + (highest - lowest) * station as f64 / REDUCTION_STATIONS as f64
1514        };
1515        let here = (turn, gap(turn).ok_or_else(|| outside("crossing"))?);
1516        if here.1 == 0.0 || before.1.signum() != here.1.signum() {
1517            crossings += 1;
1518            bracket = Some((before, here));
1519        }
1520        before = here;
1521    }
1522    if crossings != 1 {
1523        return Err(AeroError::Unsupported(format!(
1524            "the pressure behind a corner behind a surface at Mach {m1} in a Mach {mach} stream \
1525             meets its tangent cone's {crossings} times over the turns a widening corner can \
1526             make, so the element it reduces is not one band of turns"
1527        )));
1528    }
1529    // `crossings == 1` put a bracket there.
1530    let ((mut low, mut at_low), (mut high, mut at_high)) =
1531        bracket.ok_or_else(|| outside("crossing"))?;
1532    let mut crossing = if at_low == 0.0 {
1533        low
1534    } else if at_high == 0.0 {
1535        high
1536    } else {
1537        // Start from the linearized guess where it falls inside the bracket, the midpoint where
1538        // it does not.
1539        let beta1 = (m1 * m1 - 1.0).sqrt();
1540        let guess = (1.0 / p1 - 1.0) * beta1 / (GAMMA * m1 * m1);
1541        if guess > low && guess < high {
1542            guess
1543        } else {
1544            0.5 * (low + high)
1545        }
1546    };
1547    let mut here = gap(crossing).ok_or_else(|| outside("crossing"))?;
1548    for _ in 0..REDUCTION_ITERATIONS {
1549        if here == 0.0 {
1550            break;
1551        }
1552        if here.signum() == at_low.signum() {
1553            (low, at_low) = (crossing, here);
1554            at_high *= 0.5;
1555        } else {
1556            (high, at_high) = (crossing, here);
1557            at_low *= 0.5;
1558        }
1559        let next = (low * at_high - high * at_low) / (at_high - at_low);
1560        let next = if next.is_finite() && next > low && next < high {
1561            next
1562        } else {
1563            0.5 * (low + high)
1564        };
1565        let moved = next - crossing;
1566        crossing = next;
1567        here = gap(crossing).ok_or_else(|| outside("crossing"))?;
1568        if moved.abs() <= f64::EPSILON * (1.0 + crossing.abs()) {
1569            break;
1570        }
1571    }
1572    // The two constants eq. 19 relaxes between, at the crossing: what the reduced side holds and
1573    // what the side the method owns sheds onto at once. Their difference is the step.
1574    let angle = d1 + crossing;
1575    let (m2, p2) = behind(crossing).ok_or_else(|| outside("crossing"))?;
1576    let held = lambda(p2, m2) / lambda(p1, m1) * aft.loading_per_rad;
1577    let cone_loading = if angle <= CONE_ANGLE_FLOOR_RAD {
1578        // A cylinder's tangent cone is the free stream, which carries no loading.
1579        0.0
1580    } else {
1581        angle.tan() * cone_normal_force_slope(mach, angle)?
1582    };
1583    Ok(ReductionTurns {
1584        crossing_rad: crossing,
1585        crossing_residual_p0: here,
1586        balance_rad: balance,
1587        balance_residual_p0_per_m: balance_left,
1588        crossing_loading_gap_per_rad: held - cone_loading,
1589    })
1590}
1591
1592/// How many stations [`flare_reduction_turns_rad`] sweeps the widening turns at before bracketing
1593/// the crossing. A pair of extra roots closer together than a hundredth of that range would not
1594/// be seen, and the gap would be reported as one band when it is three.
1595const REDUCTION_STATIONS: usize = 100;
1596
1597/// Enough steps for either solution of [`flare_reduction_turns_rad`] to stop moving; both take
1598/// well under twenty, and the loops break when they do.
1599const REDUCTION_ITERATIONS: usize = 64;
1600
1601/// The flow over a cone at zero angle of attack.
1602#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1603#[non_exhaustive]
1604pub struct ConeFlow {
1605    /// The conical shock's angle to the axis, rad.
1606    pub shock_angle_rad: f64,
1607    /// The Mach number on the cone's surface.
1608    pub surface_mach: f64,
1609    /// The surface pressure over the free stream's, `p_c/p₀`.
1610    pub surface_pressure_ratio: f64,
1611}
1612
1613/// The flow over a cone of half-angle `half_angle_rad` at Mach `mach` and zero angle of attack:
1614/// the Taylor–Maccoll equation (NACA Report 1135, 1953, eq. 177, p. 628) integrated from the
1615/// shock to the surface, the shock angle found so the surface falls on the cone. The weak,
1616/// attached solution.
1617///
1618/// In `V′ = V/V_max` with `V_θ = dV_r/dθ`:
1619/// `V_r″ = [V_θ² V_r − ((γ − 1)/2)(1 − V_r² − V_θ²)(2V_r + V_θ cot θ)] /
1620/// [((γ − 1)/2)(1 − V_r² − V_θ²) − V_θ²]`, started behind the oblique shock (eqs. 148 to 153,
1621/// p. 623) and stopped where `V_θ = 0`.
1622///
1623/// Below [`SLENDER_CONE_RAD`] (0.029°) the start behind so weak a shock is too near the
1624/// equation's singular line to integrate, and the flow is linearized slender-cone theory's:
1625/// `C_p = δ²(2 ln(2/(βδ)) − 1)`, `β = √(M² − 1)`, the shock on the Mach angle and the surface
1626/// Mach number isentropic from the free stream. From there to twice that angle the two are
1627/// blended linearly, so the flow is continuous in the half-angle. At a millidegree scale these
1628/// pressures differ from the free stream's by under 1e-5. Near Mach 1 (1.01) the integration can
1629/// still fail just above that angle; it then returns an error.
1630///
1631/// # Errors
1632///
1633/// - [`AeroError::Domain`] for a Mach number that isn't above 1 or a half-angle outside
1634///   `[0, π/2)`.
1635/// - [`AeroError::Unsupported`] where the shock detaches (the half-angle exceeds the steepest
1636///   cone an attached shock allows at this Mach number), or should the integration fail to
1637///   reach the cone.
1638pub fn cone_flow(mach: f64, half_angle_rad: f64) -> Result<ConeFlow, AeroError> {
1639    if !(mach.is_finite() && mach > 1.0) {
1640        return Err(AeroError::Domain {
1641            what: "Mach number of a cone's flow",
1642            value: mach,
1643        });
1644    }
1645    if !(half_angle_rad.is_finite() && (0.0..0.5 * PI).contains(&half_angle_rad)) {
1646        return Err(AeroError::Domain {
1647            what: "cone half-angle",
1648            value: half_angle_rad,
1649        });
1650    }
1651    if half_angle_rad <= SLENDER_CONE_RAD {
1652        return Ok(slender_cone_flow(mach, half_angle_rad));
1653    }
1654    let exact = taylor_maccoll_cone_flow(mach, half_angle_rad)?;
1655    if half_angle_rad >= 2.0 * SLENDER_CONE_RAD {
1656        return Ok(exact);
1657    }
1658    let slender = slender_cone_flow(mach, half_angle_rad);
1659    let w = half_angle_rad / SLENDER_CONE_RAD - 1.0;
1660    let blend = |a: f64, b: f64| (1.0 - w) * a + w * b;
1661    Ok(ConeFlow {
1662        shock_angle_rad: blend(slender.shock_angle_rad, exact.shock_angle_rad),
1663        surface_mach: blend(slender.surface_mach, exact.surface_mach),
1664        surface_pressure_ratio: blend(slender.surface_pressure_ratio, exact.surface_pressure_ratio),
1665    })
1666}
1667
1668/// Below this half-angle, 5e-4 rad (0.029°), [`cone_flow`] takes slender-cone theory; up to twice
1669/// it, a blend.
1670pub const SLENDER_CONE_RAD: f64 = 5e-4;
1671
1672/// Linearized slender-cone theory's flow (see [`cone_flow`]).
1673fn slender_cone_flow(mach: f64, half_angle_rad: f64) -> ConeFlow {
1674    let mach_angle = (1.0 / mach).asin();
1675    if half_angle_rad <= 0.0 {
1676        return ConeFlow {
1677            shock_angle_rad: mach_angle,
1678            surface_mach: mach,
1679            surface_pressure_ratio: 1.0,
1680        };
1681    }
1682    let beta = (mach * mach - 1.0).sqrt();
1683    let delta = half_angle_rad;
1684    let cp = delta * delta * (2.0 * (2.0 / (beta * delta)).ln() - 1.0);
1685    let pressure = 1.0 + 0.5 * GAMMA * mach * mach * cp;
1686    // Isentropic from the free stream: the shock's loss is of higher order still.
1687    let m2 = ((total_over_static(mach) / pressure).powf((GAMMA - 1.0) / GAMMA) - 1.0) / G1;
1688    ConeFlow {
1689        shock_angle_rad: mach_angle,
1690        surface_mach: m2.sqrt(),
1691        surface_pressure_ratio: pressure,
1692    }
1693}
1694
1695/// The Taylor–Maccoll solution of [`cone_flow`], above [`SLENDER_CONE_RAD`].
1696fn taylor_maccoll_cone_flow(mach: f64, half_angle_rad: f64) -> Result<ConeFlow, AeroError> {
1697    let mach_angle = (1.0 / mach).asin();
1698    let not_converged = || {
1699        AeroError::Unsupported(format!(
1700            "the flow over a cone of half-angle {}° at Mach {mach} didn't converge",
1701            half_angle_rad.to_degrees()
1702        ))
1703    };
1704    let cone_at = |shock: f64| -> Result<f64, AeroError> {
1705        let angle = cone_behind_shock(mach, shock).0;
1706        if angle.is_finite() {
1707            Ok(angle)
1708        } else {
1709            Err(not_converged())
1710        }
1711    };
1712    // Bracket the shock angle: the cone angle grows with it from zero at the Mach angle up to the
1713    // detachment limit, then falls.
1714    let step = 0.5_f64.to_radians();
1715    let mut before = mach_angle;
1716    let mut lo = mach_angle;
1717    let mut lo_angle = 0.0;
1718    let mut hi = mach_angle;
1719    let mut hi_angle;
1720    loop {
1721        hi += step;
1722        if hi >= 0.5 * PI {
1723            return Err(detached(mach, half_angle_rad));
1724        }
1725        hi_angle = cone_at(hi)?;
1726        if hi_angle >= half_angle_rad {
1727            break;
1728        }
1729        if hi_angle < lo_angle {
1730            // Past the steepest cone: find it between the last two steps (golden section), in
1731            // case it reaches the half-angle between them.
1732            let golden = 0.5 * (5.0_f64.sqrt() - 1.0);
1733            let (mut a, mut b) = (before, hi);
1734            for _ in 0..80 {
1735                let c = b - golden * (b - a);
1736                let d = a + golden * (b - a);
1737                if cone_at(c)? > cone_at(d)? {
1738                    b = d;
1739                } else {
1740                    a = c;
1741                }
1742            }
1743            let peak = 0.5 * (a + b);
1744            let peak_angle = cone_at(peak)?;
1745            if peak_angle < half_angle_rad {
1746                return Err(detached(mach, half_angle_rad));
1747            }
1748            // The cone angle rises from the bracket's low end to the peak: from `lo` when the
1749            // peak lies past it, from `before` when it lies between the two (the falling side
1750            // past the peak is the strong shock's).
1751            if peak <= lo {
1752                (lo, lo_angle) = (before, cone_at(before)?);
1753            }
1754            (hi, hi_angle) = (peak, peak_angle);
1755            break;
1756        }
1757        before = lo;
1758        (lo, lo_angle) = (hi, hi_angle);
1759    }
1760    // Regula falsi (Illinois) on the cone angle against the shock angle.
1761    let (mut f_lo, mut f_hi) = (lo_angle - half_angle_rad, hi_angle - half_angle_rad);
1762    let mut side = 0;
1763    let mut shock = hi;
1764    for _ in 0..100 {
1765        let next = if f_hi != f_lo {
1766            (lo * f_hi - hi * f_lo) / (f_hi - f_lo)
1767        } else {
1768            0.5 * (lo + hi)
1769        };
1770        let next = if next > lo && next < hi {
1771            next
1772        } else {
1773            0.5 * (lo + hi)
1774        };
1775        let settled = (next - shock).abs() <= 4.0 * f64::EPSILON * next;
1776        shock = next;
1777        if settled {
1778            break;
1779        }
1780        let f = cone_at(shock)? - half_angle_rad;
1781        if f == 0.0 {
1782            break;
1783        }
1784        if f > 0.0 {
1785            hi = shock;
1786            f_hi = f;
1787            if side == 1 {
1788                f_lo *= 0.5;
1789            }
1790            side = 1;
1791        } else {
1792            lo = shock;
1793            f_lo = f;
1794            if side == -1 {
1795                f_hi *= 0.5;
1796            }
1797            side = -1;
1798        }
1799    }
1800    let (angle, surface_speed) = cone_behind_shock(mach, shock);
1801    // The shock angle found must put the surface on the cone: near the slender limit to the
1802    // integration's own accuracy there (2e-4 of the angle at 0.029°), far inside the 45% and more
1803    // of a run that never reached the surface.
1804    let miss = (angle - half_angle_rad).abs();
1805    if !(miss.is_finite() && miss <= 1e-3 * half_angle_rad) {
1806        return Err(not_converged());
1807    }
1808    let surface_mach =
1809        (surface_speed * surface_speed / (G1 * (1.0 - surface_speed * surface_speed))).sqrt();
1810    let normal = mach * shock.sin();
1811    let total_ratio = normal_shock_total_pressure_ratio(normal);
1812    let flow = ConeFlow {
1813        shock_angle_rad: shock,
1814        surface_mach,
1815        surface_pressure_ratio: total_over_static(mach) * total_ratio
1816            / total_over_static(surface_mach),
1817    };
1818    if !(flow.surface_mach.is_finite() && flow.surface_pressure_ratio.is_finite()) {
1819        return Err(not_converged());
1820    }
1821    Ok(flow)
1822}
1823
1824fn detached(mach: f64, half_angle_rad: f64) -> AeroError {
1825    AeroError::Unsupported(format!(
1826        "a cone of half-angle {}° at Mach {mach} has a detached shock",
1827        half_angle_rad.to_degrees()
1828    ))
1829}
1830
1831/// `p_t2/p_t1` across a normal shock at normal Mach number `normal` (NACA Report 1135, eq. 99).
1832fn normal_shock_total_pressure_ratio(normal: f64) -> f64 {
1833    let m2 = normal * normal;
1834    ((GAMMA + 1.0) * m2 / ((GAMMA - 1.0) * m2 + 2.0)).powf(GAMMA / (GAMMA - 1.0))
1835        * ((GAMMA + 1.0) / (2.0 * GAMMA * m2 - (GAMMA - 1.0))).powf(1.0 / (GAMMA - 1.0))
1836}
1837
1838/// The largest Taylor–Maccoll integration step in `θ`, rad.
1839const TM_STEP_RAD: f64 = 1e-3;
1840
1841/// The most Taylor–Maccoll steps one shock angle may take before the integration gives up.
1842const TM_MAX_STEPS: usize = 1_000_000;
1843
1844/// The Taylor–Maccoll step at `state`: at most [`TM_STEP_RAD`] and half the angle left, and small
1845/// enough that the equation's denominator `D = a′² − V_θ²` (where the flow normal to the rays is
1846/// sonic) changes by at most 2% of itself. Behind a weak shock (a slender cone) the flow starts
1847/// nearly sonic normal to the shock, `D` starts near zero and the solution turns sharply; a fixed
1848/// step there gives nonsense. The step is a continuous function of the state, not an error
1849/// estimate's accept-or-reject, so a last-bit difference between platforms moves the answer by
1850/// last bits too.
1851fn tm_step(theta: f64, [vr, vt]: [f64; 2]) -> f64 {
1852    let denominator = G1 * (1.0 - vr * vr - vt * vt) - vt * vt;
1853    let acceleration = taylor_maccoll(theta, [vr, vt])[1];
1854    // dD/dθ, with dV_r/dθ = V_θ.
1855    let rate = (2.0 * G1 * vr * vt + 2.0 * (1.0 + G1) * vt * acceleration).abs();
1856    let limit = if rate > 0.0 {
1857        0.02 * denominator.abs() / rate
1858    } else {
1859        TM_STEP_RAD
1860    };
1861    TM_STEP_RAD.min(0.5 * theta).min(limit)
1862}
1863
1864/// For a conical shock at `shock_rad`, the cone angle where the flow behind it meets the surface
1865/// and the speed `V/V_max` there; NaN if the integration doesn't reach the surface.
1866fn cone_behind_shock(mach: f64, shock_rad: f64) -> (f64, f64) {
1867    let normal = mach * shock_rad.sin();
1868    if normal <= 1.0 {
1869        return (0.0, speed_ratio(mach));
1870    }
1871    let n2 = normal * normal;
1872    let normal_after = ((1.0 + G1 * n2) / (GAMMA * n2 - G1)).sqrt();
1873    // The flow deflection behind an oblique shock (NACA Report 1135, eq. 138).
1874    let deflection = (2.0 / shock_rad.tan() * (n2 - 1.0)
1875        / (mach * mach * (GAMMA + (2.0 * shock_rad).cos()) + 2.0))
1876        .atan();
1877    let mach_after = normal_after / (shock_rad - deflection).sin();
1878    let speed = speed_ratio(mach_after);
1879    let mut state = [
1880        speed * (shock_rad - deflection).cos(),
1881        -speed * (shock_rad - deflection).sin(),
1882    ];
1883    let mut theta = shock_rad;
1884    for _ in 0..TM_MAX_STEPS {
1885        let h = tm_step(theta, state);
1886        let next = rk4(theta, state, -h);
1887        if next[1] >= 0.0 {
1888            // The surface lies within this step: refine the step length by regula falsi.
1889            let (mut a, mut b) = (0.0, h);
1890            let (mut fa, mut fb) = (state[1], next[1]);
1891            let mut s = h;
1892            for _ in 0..60 {
1893                let trial = if fb != fa {
1894                    a - fa * (b - a) / (fb - fa)
1895                } else {
1896                    0.5 * (a + b)
1897                };
1898                let trial = if trial > a && trial < b {
1899                    trial
1900                } else {
1901                    0.5 * (a + b)
1902                };
1903                if (trial - s).abs() <= 4.0 * f64::EPSILON * theta {
1904                    s = trial;
1905                    break;
1906                }
1907                s = trial;
1908                let f = rk4(theta, state, -s)[1];
1909                if f == 0.0 {
1910                    break;
1911                }
1912                if f > 0.0 {
1913                    (b, fb) = (s, f);
1914                } else {
1915                    (a, fa) = (s, f);
1916                }
1917            }
1918            let surface = rk4(theta, state, -s);
1919            return (theta - s, surface[0]);
1920        }
1921        state = next;
1922        theta -= h;
1923        if theta <= 1e-9 {
1924            return (0.0, state[0]);
1925        }
1926    }
1927    (f64::NAN, f64::NAN)
1928}
1929
1930/// `V/V_max = (2/((γ − 1)M²) + 1)^(−1/2)`.
1931fn speed_ratio(mach: f64) -> f64 {
1932    (2.0 / ((GAMMA - 1.0) * mach * mach) + 1.0).powf(-0.5)
1933}
1934
1935/// The Taylor–Maccoll right-hand side: `d(V_r, V_θ)/dθ`.
1936fn taylor_maccoll(theta: f64, [vr, vt]: [f64; 2]) -> [f64; 2] {
1937    let b = G1 * (1.0 - vr * vr - vt * vt);
1938    [
1939        vt,
1940        (vt * vt * vr - b * (2.0 * vr + vt / theta.tan())) / (b - vt * vt),
1941    ]
1942}
1943
1944/// One classical Runge–Kutta step of the Taylor–Maccoll equation.
1945fn rk4(theta: f64, y: [f64; 2], h: f64) -> [f64; 2] {
1946    let add = |y: [f64; 2], k: [f64; 2], s: f64| [y[0] + s * k[0], y[1] + s * k[1]];
1947    let k1 = taylor_maccoll(theta, y);
1948    let k2 = taylor_maccoll(theta + 0.5 * h, add(y, k1, 0.5 * h));
1949    let k3 = taylor_maccoll(theta + 0.5 * h, add(y, k2, 0.5 * h));
1950    let k4 = taylor_maccoll(theta + h, add(y, k3, h));
1951    [
1952        y[0] + h / 6.0 * (k1[0] + 2.0 * k2[0] + 2.0 * k3[0] + k4[0]),
1953        y[1] + h / 6.0 * (k1[1] + 2.0 * k2[1] + 2.0 * k3[1] + k4[1]),
1954    ]
1955}
1956
1957/// The semivertex angles of [`CONE_SLOPES`], degrees.
1958const CONE_ANGLES_DEG: [f64; 22] = [
1959    0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0,
1960    22.0, 24.0, 25.0, 27.5, 30.0,
1961];
1962
1963/// The Mach numbers of [`CONE_SLOPES`]' rows.
1964const CONE_MACHS: [f64; 6] = [3.0, 4.0, 5.0, 6.0, 8.0, 10.0];
1965
1966/// `dC_N/dα` at `α = 0` for cones, per radian on the base area, from two sources.
1967///
1968/// To 24°, TN 3527 Fig. 2 (p. 40, from its ref. 14), read by hand at [`CONE_ANGLES_DEG`] for each
1969/// of [`CONE_MACHS`] from a 400-dpi render against the chart's 0.2° by 0.002 grid, to about
1970/// ±0.001 (±0.0025 below 3°, where the Mach 8 and 10 curves merge; read as crossing, so the
1971/// values stay ordered in Mach). Interpolated as [`cone_normal_force_slope`] does, it gives all
1972/// 12 of Table I's cone-alone values (4.1° to 9.5°, Mach 3 to 6.28) to their printed two
1973/// decimals.
1974///
1975/// Past 24°, where the chart stops, the last three columns are J. L. Sims, *Tables for Supersonic
1976/// Flow Around Right Circular Cones at Small Angle of Attack*, NASA SP-3007 (1964), Table 2
1977/// (printed p. 20), at his own 25°, 27.5° and 30°, from the same theory the chart plots (Stone's,
1978/// which Sims says gives expressions "identical to those found by Kopal", p. 7; Fig. 2 plots
1979/// Kopal's tables), tabulated rather than drawn, on the same base area and for `γ = 1.4`. His
1980/// Mach rows include all six of [`CONE_MACHS`] exactly, so nothing is interpolated between
1981/// sources. Where the two overlap they agree to about the chart's own reading error: at 22.5°,
1982/// the steepest angle both cover, this reading of Fig. 2 and Sims's value differ by 0.0005 to
1983/// 0.0021 per radian, the largest at Mach 6 (1.7013 read against his 1.6992), twice the ±0.001
1984/// the chart is read to, so the hand reading is the looser of the two there
1985/// (`sims_and_fig_2_agree_where_they_overlap`). The chart's columns are kept below 24° rather
1986/// than replaced by Sims's so that nothing already validated moves; M1.8e12 revisits that.
1987const CONE_SLOPES: [[f64; 22]; 6] = [
1988    // Mach 3
1989    [
1990        2.000, 1.976, 1.953, 1.931, 1.911, 1.892, 1.874, 1.858, 1.843, 1.831, 1.820, 1.810, 1.799,
1991        1.776, 1.750, 1.721, 1.687, 1.648, 1.605, 1.5798551, 1.5174588, 1.4497109,
1992    ],
1993    // Mach 4
1994    [
1995        2.000, 1.963, 1.935, 1.912, 1.893, 1.877, 1.865, 1.856, 1.849, 1.844, 1.838, 1.831, 1.823,
1996        1.805, 1.782, 1.753, 1.718, 1.678, 1.634, 1.6096523, 1.5454397, 1.4756774,
1997    ],
1998    // Mach 5
1999    [
2000        2.000, 1.958, 1.927, 1.904, 1.885, 1.873, 1.865, 1.863, 1.863, 1.862, 1.859, 1.853, 1.847,
2001        1.828, 1.805, 1.775, 1.740, 1.699, 1.652, 1.6272149, 1.5613461, 1.4900257,
2002    ],
2003    // Mach 6
2004    [
2005        2.000, 1.950, 1.917, 1.890, 1.878, 1.874, 1.874, 1.876, 1.879, 1.880, 1.877, 1.872, 1.865,
2006        1.847, 1.822, 1.790, 1.754, 1.713, 1.666, 1.6381839, 1.5710540, 1.4986224,
2007    ],
2008    // Mach 8
2009    [
2010        2.000, 1.926, 1.891, 1.883, 1.884, 1.890, 1.899, 1.904, 1.907, 1.908, 1.905, 1.899, 1.891,
2011        1.870, 1.843, 1.811, 1.771, 1.727, 1.678, 1.6503536, 1.5816157, 1.5078364,
2012    ],
2013    // Mach 10
2014    [
2015        2.000, 1.904, 1.885, 1.887, 1.897, 1.908, 1.916, 1.921, 1.924, 1.924, 1.921, 1.916, 1.907,
2016        1.884, 1.855, 1.819, 1.779, 1.734, 1.684, 1.6564935, 1.5868584, 1.5123524,
2017    ],
2018];
2019
2020/// Whether a tangent cone of `half_angle_rad` is steeper than the cone tables reach, 30°
2021/// (NASA SP-3007 Table 2), so that [`cone_normal_force_slope`] refuses it at every Mach number
2022/// ([issue #121](https://github.com/nrdptel/hpr-sim/issues/121)). A millionth of a degree over
2023/// admits 30° itself through the degree conversion's rounding.
2024pub(crate) fn past_cone_tables(half_angle_rad: f64) -> bool {
2025    half_angle_rad.to_degrees() > CONE_ANGLES_DEG[CONE_ANGLES_DEG.len() - 1] + 1e-6
2026}
2027
2028/// A cone's normal-force slope at `α → 0`, per radian on its base area, interpolated linearly
2029/// between the table's angles and Mach numbers; below Mach 3 its Mach 3 row, above 10 its Mach 10
2030/// row. The slopes are TN 3527's Fig. 2 to 24°, and NASA SP-3007's tables of the same theory from
2031/// there to 30° ([ADR-042: cone slopes past Fig. 2's edge][adr-042]).
2032///
2033/// [adr-042]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-042-cone-slopes-from-24-to-30-come-from-simss-tables-where-tn-3527s-chart-stops-2026-09-20
2034///
2035/// # Errors
2036///
2037/// - [`AeroError::Domain`] for a negative or non-finite half-angle, or a Mach number that isn't
2038///   finite and above 1.
2039/// - [`AeroError::Unsupported`] for a half-angle past the tables' 30°.
2040pub fn cone_normal_force_slope(mach: f64, half_angle_rad: f64) -> Result<f64, AeroError> {
2041    let degrees = half_angle_rad.to_degrees();
2042    if !(degrees.is_finite() && degrees >= 0.0) {
2043        return Err(AeroError::Domain {
2044            what: "tangent-cone half-angle",
2045            value: half_angle_rad,
2046        });
2047    }
2048    let last = CONE_ANGLES_DEG[CONE_ANGLES_DEG.len() - 1];
2049    if past_cone_tables(half_angle_rad) {
2050        return Err(AeroError::Unsupported(format!(
2051            "a tangent cone of {degrees}° is past the cone tables' {last}° (NASA SP-3007 Table 2)"
2052        )));
2053    }
2054    let degrees = degrees.min(last);
2055    if !(mach.is_finite() && mach > 1.0) {
2056        return Err(AeroError::Domain {
2057            what: "Mach number of a cone's normal-force slope",
2058            value: mach,
2059        });
2060    }
2061    let row = |i: usize| linear(&CONE_ANGLES_DEG, &CONE_SLOPES[i], degrees);
2062    let m = mach.clamp(CONE_MACHS[0], CONE_MACHS[CONE_MACHS.len() - 1]);
2063    let j = CONE_MACHS
2064        .partition_point(|&c| c <= m)
2065        .clamp(1, CONE_MACHS.len() - 1);
2066    let (m0, m1) = (CONE_MACHS[j - 1], CONE_MACHS[j]);
2067    let w = (m - m0) / (m1 - m0);
2068    Ok((1.0 - w) * row(j - 1) + w * row(j))
2069}
2070
2071/// Linear interpolation in `xs` (increasing), `x` within `[xs[0], xs[n − 1]]`.
2072fn linear(xs: &[f64], ys: &[f64], x: f64) -> f64 {
2073    let i = xs.partition_point(|&c| c <= x).clamp(1, xs.len() - 1);
2074    let w = (x - xs[i - 1]) / (xs[i] - xs[i - 1]);
2075    (1.0 - w) * ys[i - 1] + w * ys[i]
2076}
2077
2078/// The body's lift and its moment, summed in order over `windows`, where they place a center of
2079/// pressure.
2080fn total_lift(windows: &[(f64, [f64; 2])]) -> Result<(f64, f64), AeroError> {
2081    let (mut force, mut moment) = (0.0, 0.0);
2082    for (_, [f, m]) in windows {
2083        force += f;
2084        moment += m;
2085    }
2086    if !(force.is_finite() && moment.is_finite() && force > 0.0) {
2087        return Err(AeroError::Unsupported(format!(
2088            "the body's lift sums to {force}, which places no center of pressure"
2089        )));
2090    }
2091    Ok((force, moment))
2092}
2093
2094#[cfg(test)]
2095mod tests {
2096    use super::*;
2097    use serde_json::Value;
2098
2099    /// A body of unit diameter: a cone or tangent ogive of `fineness` calibers and a cylinder of
2100    /// `afterbody` calibers, as TN 3527 tested them.
2101    fn body(ogive: bool, fineness: f64, afterbody: f64, steps: usize) -> ShockExpansionBody {
2102        let shape = if ogive {
2103            NoseShape::TANGENT_OGIVE
2104        } else {
2105            NoseShape::Conical {}
2106        };
2107        let mut segments = vec![BodySegment::Profile {
2108            profile: Profile::nose(shape, fineness, 0.5).unwrap(),
2109        }];
2110        if afterbody > 0.0 {
2111            segments.push(BodySegment::Cylinder {
2112                length_m: afterbody,
2113                radius_m: 0.5,
2114            });
2115        }
2116        ShockExpansionBody::new(&segments, steps).unwrap()
2117    }
2118
2119    #[test]
2120    #[allow(
2121        clippy::approx_constant,
2122        reason = "6.28 is one of TN 3527's test Mach numbers, not 2π"
2123    )]
2124    fn a_cone_alone_carries_its_fig_2_slope_at_two_thirds_its_length() {
2125        // On a cone the loading is uniform, `Λ = tan δ (dC_N/dα)_tc`, so eq. 14 returns Fig. 2's
2126        // slope and the CP sits at the centroid of `r`, two thirds of the length.
2127        for (fineness, mach) in [(3.0, 3.0), (5.0, 4.24), (7.0, 6.28)] {
2128            let cone = body(false, fineness, 0.0, 1);
2129            let s = cone.slope(mach, 0.25 * PI).unwrap();
2130            let expected = cone_normal_force_slope(mach, (0.5 / fineness).atan()).unwrap();
2131            assert!(
2132                (s.slope_per_rad - expected).abs() < 1e-12,
2133                "{fineness} {mach}"
2134            );
2135            assert!((s.center_of_pressure_m - 2.0 / 3.0 * fineness).abs() < 1e-12);
2136        }
2137    }
2138
2139    #[test]
2140    fn a_cylinder_adds_lift_that_grows_with_its_length() {
2141        let mut last = 0.0;
2142        for afterbody in [0.0, 2.0, 4.0, 6.0, 10.0] {
2143            let s = body(false, 5.0, afterbody, 1)
2144                .slope(4.24, 0.25 * PI)
2145                .unwrap();
2146            assert!(s.slope_per_rad > last, "{afterbody}: {}", s.slope_per_rad);
2147            last = s.slope_per_rad;
2148        }
2149    }
2150
2151    #[test]
2152    fn a_boattail_takes_lift_off_by_footnote_8() {
2153        // Footnote 8's tangent cone for a boattail element: the free stream's pressure and a
2154        // slope of 2, so the loading relaxes toward a negative one.
2155        let body_with = |tail: bool| {
2156            let mut segments = vec![
2157                BodySegment::Profile {
2158                    profile: Profile::nose(NoseShape::TANGENT_OGIVE, 4.0, 0.5).unwrap(),
2159                },
2160                BodySegment::Cylinder {
2161                    length_m: 8.0,
2162                    radius_m: 0.5,
2163                },
2164            ];
2165            if tail {
2166                segments.push(BodySegment::Profile {
2167                    profile: Profile::transition(NoseShape::Conical {}, 1.0, 0.5, 0.35, false)
2168                        .unwrap(),
2169                });
2170            }
2171            ShockExpansionBody::new(&segments, DEFAULT_ELEMENTS_PER_CURVE).unwrap()
2172        };
2173        for mach in [2.0, 3.0, 4.63] {
2174            let (bare, tailed) = (
2175                body_with(false).slope(mach, 0.25 * PI).unwrap(),
2176                body_with(true).slope(mach, 0.25 * PI).unwrap(),
2177            );
2178            assert!(tailed.slope_per_rad < bare.slope_per_rad, "Mach {mach}");
2179            assert!(
2180                tailed.center_of_pressure_m < bare.center_of_pressure_m,
2181                "Mach {mach}"
2182            );
2183        }
2184    }
2185
2186    #[test]
2187    #[allow(
2188        clippy::approx_constant,
2189        reason = "6.28 is one of TN 3527's test Mach numbers, not 2π"
2190    )]
2191    fn segment_shares_sum_to_the_body_and_follow_its_segments() {
2192        // A tangent ogive of 4 calibers, a cylinder of 8 and a conical boattail of 1: the nose
2193        // and the cylinder carry lift, and footnote 8's boattail takes some off. The nose's share
2194        // is the nose alone's slope, and the cylinder's the difference the cylinder makes, which a
2195        // piece given to the wrong segment would break.
2196        let segments = [
2197            BodySegment::Profile {
2198                profile: Profile::nose(NoseShape::TANGENT_OGIVE, 4.0, 0.5).unwrap(),
2199            },
2200            BodySegment::Cylinder {
2201                length_m: 8.0,
2202                radius_m: 0.5,
2203            },
2204            BodySegment::Profile {
2205                profile: Profile::transition(NoseShape::Conical {}, 1.0, 0.5, 0.35, false).unwrap(),
2206            },
2207        ];
2208        let body_of = |count: usize| {
2209            ShockExpansionBody::new(&segments[..count], DEFAULT_ELEMENTS_PER_CURVE).unwrap()
2210        };
2211        let (nose, forebody, body) = (body_of(1), body_of(2), body_of(3));
2212        let area = 0.25 * PI;
2213        for mach in [2.0, 3.0, 4.63, 6.28] {
2214            let whole = body.slope(mach, area).unwrap();
2215            let nose_alone = nose.slope(mach, area).unwrap().slope_per_rad;
2216            let with_cylinder = forebody.slope(mach, area).unwrap().slope_per_rad;
2217            let shares = body.segment_slopes(mach, area).unwrap();
2218            assert_eq!(shares.len(), 3);
2219            let slope: f64 = shares.iter().map(|s| s.slope_per_rad).sum();
2220            let moment: f64 = shares.iter().map(|s| s.moment_slope_m).sum();
2221            let cp = moment / slope;
2222            assert!(
2223                (slope - whole.slope_per_rad).abs() <= 1e-12 * whole.slope_per_rad,
2224                "Mach {mach}: {slope} against {}",
2225                whole.slope_per_rad
2226            );
2227            assert!(
2228                (cp - whole.center_of_pressure_m).abs() <= 1e-12 * whole.center_of_pressure_m,
2229                "Mach {mach}: {cp} against {}",
2230                whole.center_of_pressure_m
2231            );
2232            assert!(
2233                (shares[0].slope_per_rad - nose_alone).abs() <= 1e-13 * nose_alone,
2234                "Mach {mach}: nose {} against {nose_alone}",
2235                shares[0].slope_per_rad
2236            );
2237            let cylinder = with_cylinder - nose_alone;
2238            assert!(
2239                (shares[1].slope_per_rad - cylinder).abs() <= 1e-12 * with_cylinder,
2240                "Mach {mach}: cylinder {} against {cylinder}",
2241                shares[1].slope_per_rad
2242            );
2243            // The nose's and the cylinder's loadings are positive, so their stations lie within
2244            // them; the boattail's share is negative here, but its station isn't bounded.
2245            let station = |s: &SegmentSlope| s.moment_slope_m / s.slope_per_rad;
2246            assert!(shares[0].slope_per_rad > 0.0 && shares[1].slope_per_rad > 0.0);
2247            assert!(shares[2].slope_per_rad < 0.0, "Mach {mach}");
2248            assert!((0.0..=4.0).contains(&station(&shares[0])), "Mach {mach}");
2249            assert!((4.0..=12.0).contains(&station(&shares[1])), "Mach {mach}");
2250        }
2251    }
2252
2253    #[test]
2254    fn slender_cones_approach_linear_theory() {
2255        // As the cone thins, Taylor–Maccoll tends to linearized slender-cone theory,
2256        // `C_p = δ²(2 ln(2/(βδ)) − 1)`, `β = √(M² − 1)`, whose error is of higher order in δ:
2257        // within 3% of `C_p` at 0.5° and 1°.
2258        for mach in [1.5, 2.0, 3.0, 5.0] {
2259            for degrees in [0.5, 1.0] {
2260                let delta = f64::to_radians(degrees);
2261                let beta = f64::sqrt(mach * mach - 1.0);
2262                let linear = delta * delta * (2.0 * (2.0 / (beta * delta)).ln() - 1.0);
2263                let flow = cone_flow(mach, delta).unwrap();
2264                let cp = (flow.surface_pressure_ratio - 1.0) / (0.5 * GAMMA * mach * mach);
2265                assert!(
2266                    (cp / linear - 1.0).abs() < 0.03,
2267                    "Mach {mach}, {degrees}°: {cp} {linear}"
2268                );
2269            }
2270        }
2271    }
2272
2273    #[test]
2274    #[allow(
2275        clippy::approx_constant,
2276        reason = "6.28 is one of TN 3527's test Mach numbers, not 2π"
2277    )]
2278    fn curved_elements_converge() {
2279        // DEFAULT_ELEMENTS_PER_CURVE's claim, on TN 3527's tangent ogives with long cylinders,
2280        // across its Mach numbers: fineness 3 at Mach 5.05 and 6.28 runs through elements of the
2281        // generalized method near the tip.
2282        for (fineness, mach) in [
2283            (3.0, 3.0),
2284            (3.0, 5.05),
2285            (3.0, 6.28),
2286            (5.0, 4.24),
2287            (7.0, 3.0),
2288        ] {
2289            let coarse = body(true, fineness, 10.0, DEFAULT_ELEMENTS_PER_CURVE);
2290            let fine = body(true, fineness, 10.0, 4 * DEFAULT_ELEMENTS_PER_CURVE);
2291            let (a, b) = (
2292                coarse.slope(mach, 0.25 * PI).unwrap(),
2293                fine.slope(mach, 0.25 * PI).unwrap(),
2294            );
2295            assert!(
2296                (a.slope_per_rad - b.slope_per_rad).abs() < 0.01,
2297                "{fineness} {mach}"
2298            );
2299            assert!((a.center_of_pressure_m - b.center_of_pressure_m).abs() < 0.01);
2300        }
2301    }
2302
2303    #[test]
2304    fn reduced_elements_are_counted_where_issue_81_bites() {
2305        // The fineness-3 ogive at Mach 5.05, where hpr departs from TN 3527 (#81), reduces two of
2306        // its nose's elements near the tip; a cone's nose is one element, the tip's, and is never
2307        // reduced; the same ogive at Mach 3 has none either.
2308        let ogive = body(true, 3.0, 10.0, DEFAULT_ELEMENTS_PER_CURVE);
2309        assert_eq!(ogive.reduced_elements(5.05).unwrap(), 2);
2310        assert_eq!(ogive.reduced_elements(3.0).unwrap(), 0);
2311        assert_eq!(
2312            body(false, 3.0, 10.0, DEFAULT_ELEMENTS_PER_CURVE)
2313                .reduced_elements(5.05)
2314                .unwrap(),
2315            0
2316        );
2317        assert!(matches!(
2318            ogive.reduced_elements(1.0),
2319            Err(AeroError::Domain { .. })
2320        ));
2321    }
2322
2323    #[test]
2324    fn cone_flow_agrees_with_naca_1135_charts() {
2325        // NACA Report 1135's cone charts, read by tracing the 300-dpi scan: Chart 5 (shock angle,
2326        // p. 660), Chart 6 (surface pressure coefficient, p. 662) and Chart 7 (surface Mach
2327        // number, p. 664), each to about ±0.15°, ±0.002 and ±0.007. The charts are drawn for
2328        // γ = 1.405, hpr uses 1.4, so the bounds are twice the reading uncertainty.
2329        for (mach, cone_deg, shock_deg, pressure_coefficient, surface_mach) in [
2330            (2.0, 10.0, 31.25, 0.1035, 1.838),
2331            (3.0, 20.0, 29.62, 0.283, 2.280),
2332            (1.5, 10.0, 42.68, 0.123, 1.378),
2333        ] {
2334            let flow = cone_flow(mach, f64::to_radians(cone_deg)).unwrap();
2335            let what = format!("Mach {mach}, {cone_deg}°");
2336            assert!(
2337                (flow.shock_angle_rad.to_degrees() - shock_deg).abs() < 0.3,
2338                "{what}"
2339            );
2340            let cp = (flow.surface_pressure_ratio - 1.0) / (0.5 * GAMMA * mach * mach);
2341            assert!((cp - pressure_coefficient).abs() < 0.004, "{what}: {cp}");
2342            assert!((flow.surface_mach - surface_mach).abs() < 0.015, "{what}");
2343        }
2344    }
2345
2346    #[test]
2347    fn cone_flow_rises_smoothly_with_the_cone_angle() {
2348        // Slender cones start almost sonic normal to the shock, where the Taylor–Maccoll equation
2349        // is nearly singular; a fixed step once gave pressures that jumped and NaN there.
2350        // Below SLENDER_CONE_RAD slender-cone theory takes over, blended up to twice it.
2351        // Nearer Mach 1 the start is nearer still to the singular line, and just above
2352        // SLENDER_CONE_RAD the integration may refuse (at Mach 1.01, 0.029°): an error, not a
2353        // wrong answer.
2354        assert!(cone_flow(1.01, 1e-8).unwrap().surface_pressure_ratio - 1.0 < 1e-12);
2355        for mach in [1.2, 1.5, 1.97, 2.0, 3.0, 5.0, 7.0, 10.0] {
2356            let mut last = 1.0;
2357            let mut angles: Vec<f64> = (0..=54)
2358                .map(|i| 1e-6 * 10f64.powf(0.05 * f64::from(i)))
2359                .collect();
2360            angles.extend((1..=100).map(|i| f64::to_radians(0.05 * f64::from(i))));
2361            for angle in angles {
2362                let p = cone_flow(mach, angle).unwrap().surface_pressure_ratio;
2363                assert!(p.is_finite() && p > last, "Mach {mach}, {angle} rad: {p}");
2364                last = p;
2365            }
2366        }
2367    }
2368
2369    #[test]
2370    fn the_slope_is_smooth_in_mach() {
2371        // Every tangent ogive's elements near the shoulder are cones of a degree or two.
2372        for fineness in [3.0, 5.0, 7.0] {
2373            let ogive = body(true, fineness, 4.0, DEFAULT_ELEMENTS_PER_CURVE);
2374            let mut last: Option<f64> = None;
2375            for i in 0..=300 {
2376                let mach = 3.0 + 0.01 * f64::from(i);
2377                let s = ogive.slope(mach, 0.25 * PI).unwrap().slope_per_rad;
2378                if let Some(last) = last {
2379                    assert!((s - last).abs() < 0.01, "fineness {fineness}, Mach {mach}");
2380                }
2381                last = Some(s);
2382                // A last-bit change in the Mach number moves the slope by last bits only.
2383                let nudged = ogive.slope(mach * (1.0 + f64::EPSILON), 0.25 * PI).unwrap();
2384                assert!(
2385                    (nudged.slope_per_rad - s).abs() < 1e-12,
2386                    "fineness {fineness}, Mach {mach}"
2387                );
2388            }
2389        }
2390    }
2391
2392    #[test]
2393    fn cone_flow_keeps_the_weak_shock_up_to_detachment() {
2394        // The weak shock's angle rises with the cone's up to the steepest attached cone; the
2395        // strong shock's falls. Just under it the solver once returned the strong one.
2396        for mach in [1.4, 3.0, 7.4] {
2397            // The steepest attached cone, to 1e-7°.
2398            let (mut ok, mut bad) = (10.0_f64, 60.0_f64);
2399            while bad - ok > 1e-7 {
2400                let mid = 0.5 * (ok + bad);
2401                if cone_flow(mach, mid.to_radians()).is_ok() {
2402                    ok = mid;
2403                } else {
2404                    bad = mid;
2405                }
2406            }
2407            let mut last = 0.0;
2408            for below in [0.5, 0.1, 0.01, 0.003, 0.002, 0.001, 0.0003, 0.0001] {
2409                let flow = cone_flow(mach, (ok - below).to_radians()).unwrap();
2410                assert!(flow.shock_angle_rad > last, "Mach {mach}, {below}° under");
2411                last = flow.shock_angle_rad;
2412            }
2413        }
2414        // A weak-branch value just under the steepest cone at Mach 1.4 (the physics review's
2415        // independent solver: 69.180°, where the strong branch is 69.527°).
2416        let flow = cone_flow(1.4, 27.494_845_f64.to_radians()).unwrap();
2417        assert!((flow.shock_angle_rad.to_degrees() - 69.180).abs() < 0.01);
2418    }
2419
2420    #[test]
2421    fn cone_flow_tends_to_the_free_stream() {
2422        let flow = cone_flow(2.5, 0.0).unwrap();
2423        assert_eq!(flow.surface_pressure_ratio, 1.0);
2424        assert_eq!(flow.surface_mach, 2.5);
2425        let thin = cone_flow(2.5, 0.2_f64.to_radians()).unwrap();
2426        assert!((thin.shock_angle_rad - (1.0 / 2.5_f64).asin()).abs() < 1e-3);
2427        assert!((thin.surface_pressure_ratio - 1.0).abs() < 1e-3);
2428        // Steeper cones compress the flow more.
2429        let (a, b) = (
2430            cone_flow(2.5, 10f64.to_radians()).unwrap(),
2431            cone_flow(2.5, 20f64.to_radians()).unwrap(),
2432        );
2433        assert!(
2434            b.surface_pressure_ratio > a.surface_pressure_ratio && b.surface_mach < a.surface_mach
2435        );
2436    }
2437
2438    /// The two sources of [`CONE_SLOPES`] agree where they overlap. TN 3527's Fig. 2 is read by
2439    /// hand to about ±0.001 per radian and stops at 24°; Sims's tables (NASA SP-3007 Table 2,
2440    /// printed p. 20) are printed to eight digits and start their 2.5° grid well below that. At
2441    /// 22.5°, the steepest angle both cover, the chart's reading and Sims's value differ by no
2442    /// more than the chart's own error, which is the check that the two are the same theory and
2443    /// that the columns line up.
2444    #[test]
2445    fn sims_and_fig_2_agree_where_they_overlap() {
2446        // Sims's 22.5° column at each of `CONE_MACHS`, the rows the table holds.
2447        let sims = [
2448            1.6362061, 1.6674853, 1.6867491, 1.6991506, 1.7132534, 1.7205191,
2449        ];
2450        for (index, mach) in CONE_MACHS.iter().enumerate() {
2451            let read = cone_normal_force_slope(*mach, 22.5f64.to_radians()).unwrap();
2452            // 0.0022: the measured worst, at Mach 6, twice the ±0.001 the chart is read to.
2453            assert!(
2454                (read - sims[index]).abs() <= 2.2e-3,
2455                "Mach {mach}: the chart reads {read}, Sims has {}",
2456                sims[index]
2457            );
2458        }
2459        // And the join at 24° is smooth to the same order: the chart's last value against Sims's
2460        // first, a degree apart, differ by less than the chart's error times that gap's slope.
2461        for mach in CONE_MACHS {
2462            let (at_24, at_25) = (
2463                cone_normal_force_slope(mach, 24f64.to_radians()).unwrap(),
2464                cone_normal_force_slope(mach, 25f64.to_radians()).unwrap(),
2465            );
2466            let step = (at_24 - at_25) / 1.0;
2467            // Over 22° to 24° the chart falls about 0.022 per degree; the first Sims step should
2468            // be of that order, not a jump.
2469            assert!(
2470                (0.015..=0.035).contains(&step),
2471                "Mach {mach}: {at_24} to {at_25} across the sources' join"
2472            );
2473        }
2474    }
2475
2476    /// [`ShockExpansionBody::aft_flow`] is the flow the march has reached at the body's aft end:
2477    /// the last element's pressure decayed to that station (TN 3527 eq. 8), read back as a Mach
2478    /// number through the total pressure the march expands from, and that element's angle.
2479    ///
2480    /// The hand calculation is exact here: a conical nose's vertex fixes the total pressure, and
2481    /// the flow along the cylinder behind it is an isentropic expansion from it.
2482    #[test]
2483    fn the_aft_flow_is_what_the_march_has_reached_at_the_end() {
2484        let segments = [
2485            BodySegment::Profile {
2486                profile: Profile::nose(NoseShape::Conical {}, 0.25, 0.027).unwrap(),
2487            },
2488            BodySegment::Cylinder {
2489                length_m: 0.7,
2490                radius_m: 0.027,
2491            },
2492        ];
2493        let body = ShockExpansionBody::new(&segments, DEFAULT_ELEMENTS_PER_CURVE).unwrap();
2494        let half_angle_rad = (0.027_f64 / 0.25).atan();
2495        for mach in [1.3, 2.0, 3.0, 5.0] {
2496            let aft = body.aft_flow(mach).unwrap();
2497            let flows = body.element_flows(mach).unwrap();
2498            let last = *flows.last().unwrap();
2499            assert_eq!(aft.angle_rad, last.angle_rad);
2500            // The total pressure the march expands from: the vertex cone's own state.
2501            let cone = cone_flow(mach, half_angle_rad).unwrap();
2502            let total = cone.surface_pressure_ratio * total_over_static(cone.surface_mach);
2503            // The pressure the last element's decay has reached at the body's aft end, 0.95 m.
2504            let decay = (-last.decay_per_m * (0.95 - last.corner_x_m)).exp();
2505            let pressure = last.tangent_cone_pressure_ratio
2506                - (last.tangent_cone_pressure_ratio - last.pressure_ratio) * decay;
2507            let want = mach_from_pressure(total, pressure).unwrap();
2508            assert!(
2509                (aft.surface_mach - want).abs() < 1e-12 * want,
2510                "Mach {mach}: the aft flow is Mach {}, by hand {want}",
2511                aft.surface_mach
2512            );
2513            // A cylinder behind a cone has expanded past the free stream by the aft end.
2514            assert!(aft.surface_mach > mach, "Mach {mach}: {}", aft.surface_mach);
2515            // The rest of the corner's state: the same pressure, the gradient the last element
2516            // carries to the aft end, the free stream it was read in, and the radius **there**,
2517            // which on this body is the tube's and not the vertex's.
2518            assert_eq!(aft.pressure_ratio, pressure);
2519            assert_eq!(aft.free_stream_mach, mach);
2520            assert_eq!(aft.radius_m, 0.027);
2521            let want_gradient = if last.decay_per_m == 0.0 {
2522                0.0
2523            } else {
2524                (last.tangent_cone_pressure_ratio - pressure)
2525                    * last.decay_per_m
2526                    * (last.angle_rad.cos())
2527            };
2528            assert!(
2529                (aft.gradient_p0_per_m - want_gradient).abs() <= 1e-12 * want_gradient.abs(),
2530                "Mach {mach}: the gradient is {}, by hand {want_gradient}",
2531                aft.gradient_p0_per_m
2532            );
2533            // Below the free stream and still climbing toward it, which is what puts a near-flat
2534            // flare's corner in the region `flare_reduction_turns_rad` solves for.
2535            assert!(aft.pressure_ratio < 1.0 && aft.gradient_p0_per_m > 0.0);
2536        }
2537        // A flare at the aft end cannot change it: the march is downstream-only, so every
2538        // element ahead of the flare's corner carries the same flow with it and without it.
2539        let mut flared = segments.to_vec();
2540        flared.push(BodySegment::Profile {
2541            profile: Profile::transition(NoseShape::Conical {}, 0.3, 0.027, 0.08, false).unwrap(),
2542        });
2543        let with_flare = ShockExpansionBody::new(&flared, DEFAULT_ELEMENTS_PER_CURVE).unwrap();
2544        for mach in [2.0, 3.0, 5.0] {
2545            let bare = body.element_flows(mach).unwrap();
2546            let both = with_flare.element_flows(mach).unwrap();
2547            assert!(both.len() > bare.len());
2548            assert_eq!(&both[..bare.len()], &bare[..]);
2549            // Its own aft flow is the flare's, not the cylinder's.
2550            let aft = with_flare.aft_flow(mach).unwrap();
2551            assert_eq!(aft.angle_rad, both[bare.len()].angle_rad);
2552            assert!((aft.angle_rad - (0.053_f64 / 0.3).atan()).abs() < 1e-12);
2553        }
2554    }
2555
2556    /// The turn a flare's corner is read at is the largest deflection an attached plane oblique
2557    /// shock can turn the flow through, at the flow reaching the corner rather than at the free
2558    /// stream (M1.8e17, ADR-047 in `docs/DECISIONS.md`). The cone tables' 30° bounds the flare's
2559    /// surface angle instead, and the model applies it there.
2560    #[test]
2561    fn a_flares_corner_turns_no_more_than_an_attached_shock_can() {
2562        // Below the cap the limit is the largest deflection an attached plane shock can turn the
2563        // flow through, which is the maximum of NACA 1135 eq. 138's θ over the shock angle β.
2564        // Swept here rather than read from eq. 168, so the closed form is checked, not restated.
2565        let swept = |mach: f64| {
2566            let (g, m2) = (GAMMA, mach * mach);
2567            let start = (1.0_f64 / mach).asin();
2568            (0..=2_000_000)
2569                .map(|i| start + (PI / 2.0 - start) * i as f64 / 2_000_000.0)
2570                .map(|beta| {
2571                    let s2 = beta.sin() * beta.sin();
2572                    (2.0 / beta.tan() * (m2 * s2 - 1.0) / (m2 * (g + (2.0 * beta).cos()) + 2.0))
2573                        .atan()
2574                })
2575                .fold(f64::NEG_INFINITY, f64::max)
2576        };
2577        for mach in [1.2, 1.5, 2.0, 2.5, 3.0, 5.0] {
2578            let limit = flare_corner_limit_rad(mach).unwrap();
2579            assert!(
2580                (limit - swept(mach)).abs() < 1e-6,
2581                "Mach {mach}: the corner is read to {}°, the swept maximum is {}°",
2582                limit.to_degrees(),
2583                swept(mach).to_degrees()
2584            );
2585        }
2586        // Where the cone tables' 30° takes over from the shock, bisected: a flare on a cylinder
2587        // is drawn no steeper than that above it, whatever the flow could turn through.
2588        let (mut low, mut high) = (2.0_f64, 3.0_f64);
2589        loop {
2590            let middle = 0.5 * (low + high);
2591            if middle <= low || middle >= high {
2592                break;
2593            }
2594            if flare_corner_limit_rad(middle).unwrap() < crate::blunt_tip::CONE_TABLE_CAP_RAD {
2595                low = middle;
2596            } else {
2597                high = middle;
2598            }
2599        }
2600        assert!(
2601            (high - 2.519_203_426_042).abs() < 5e-12,
2602            "the tables bind from Mach {high}"
2603        );
2604        assert!(flare_corner_limit_rad(1.0).is_err());
2605    }
2606
2607    #[test]
2608    fn refuses_what_the_method_does_not_cover() {
2609        let area = 0.25 * PI;
2610        // A blunt tip steeper than the handover's slope all the way to its end: a power-series
2611        // nose one radius long (45° at its base).
2612        let stubby = BodySegment::Profile {
2613            profile: Profile::nose(NoseShape::PowerSeries { exponent: 0.5 }, 0.5, 0.5).unwrap(),
2614        };
2615        let stubby = ShockExpansionBody::new(&[stubby], 10).unwrap();
2616        assert!(stubby.has_blunt_tip());
2617        for mach in [1.5, 3.0, 5.0] {
2618            assert!(matches!(
2619                stubby.slope(mach, area),
2620                Err(AeroError::Unsupported(_))
2621            ));
2622        }
2623        // A spherical cap anywhere but first, or longer than a hemisphere.
2624        let cap = |length_m| BodySegment::SphericalCap {
2625            radius_m: 0.5,
2626            length_m,
2627        };
2628        let cylinder = BodySegment::Cylinder {
2629            length_m: 1.0,
2630            radius_m: 0.5,
2631        };
2632        assert!(matches!(
2633            ShockExpansionBody::new(&[cap(0.5), cylinder, cap(0.5)], 10),
2634            Err(AeroError::Unsupported(_))
2635        ));
2636        for length in [0.0, 0.6, f64::NAN] {
2637            assert!(matches!(
2638                ShockExpansionBody::new(&[cap(length), cylinder], 10),
2639                Err(AeroError::Domain { .. })
2640            ));
2641        }
2642        // A body that doesn't start with a nose.
2643        assert!(matches!(
2644            ShockExpansionBody::new(&[cylinder], 10),
2645            Err(AeroError::Unsupported(_))
2646        ));
2647        // A step in radius.
2648        let nose = BodySegment::Profile {
2649            profile: Profile::nose(NoseShape::Conical {}, 3.0, 0.5).unwrap(),
2650        };
2651        let wider = BodySegment::Cylinder {
2652            length_m: 1.0,
2653            radius_m: 0.6,
2654        };
2655        assert!(matches!(
2656            ShockExpansionBody::new(&[nose, wider], 10),
2657            Err(AeroError::Unsupported(_))
2658        ));
2659        // Subsonic, and a detached shock (a 40° cone at Mach 1.5).
2660        let cone = body(false, 3.0, 2.0, 1);
2661        assert!(matches!(
2662            cone.slope(0.9, area),
2663            Err(AeroError::Domain { .. })
2664        ));
2665        assert!(matches!(
2666            cone_flow(1.5, 40f64.to_radians()),
2667            Err(AeroError::Unsupported(_))
2668        ));
2669        // A fineness-1 cone is 26.57°, past TN 3527's chart and inside Sims's tables, so it flies
2670        // since M1.8e11; a fineness-0.8 cone is 32.0°, past 30°, and does not.
2671        assert!(body(false, 1.0, 2.0, 1).slope(3.0, area).is_ok());
2672        assert!(matches!(
2673            body(false, 0.8, 2.0, 1).slope(3.0, area),
2674            Err(AeroError::Unsupported(_))
2675        ));
2676        assert!(cone_normal_force_slope(3.0, 30f64.to_radians()).is_ok());
2677        assert!(matches!(
2678            cone_normal_force_slope(3.0, 30.001f64.to_radians()),
2679            Err(AeroError::Unsupported(_))
2680        ));
2681        for (mach, angle) in [(3.0, -0.1), (3.0, f64::NAN), (0.5, 0.1)] {
2682            assert!(matches!(
2683                cone_normal_force_slope(mach, angle),
2684                Err(AeroError::Domain { .. })
2685            ));
2686        }
2687        // Elements per curve outside 1 to 1000.
2688        let ogive = BodySegment::Profile {
2689            profile: Profile::nose(NoseShape::TANGENT_OGIVE, 3.0, 0.5).unwrap(),
2690        };
2691        for count in [0, MAX_ELEMENTS_PER_CURVE + 1, usize::MAX] {
2692            assert!(matches!(
2693                ShockExpansionBody::new(&[ogive], count),
2694                Err(AeroError::Domain { .. })
2695            ));
2696        }
2697        // A body that closes to a point and opens again.
2698        let closing = BodySegment::Profile {
2699            profile: Profile::transition(NoseShape::Conical {}, 3.0, 0.5, 0.0, false).unwrap(),
2700        };
2701        assert!(matches!(
2702            ShockExpansionBody::new(&[nose, closing, nose], 10),
2703            Err(AeroError::Unsupported(_))
2704        ));
2705    }
2706
2707    /// M1.8e1 done-when: `cargo xtask aero` writes `validation/fixtures/aero/shock-expansion.json`;
2708    /// this test recomputes every hpr value from the committed tables (TN 3527's Tables I and
2709    /// II, `tn3527-bodies.json`) and the Arcas Robin's geometry, so the recorded errors can't go
2710    /// stale, and pins the set of rows outside the targets set before measuring: 0.05 per radian
2711    /// and 0.1 calibers of the report's second-order values, and its stated ±0.2 per radian and
2712    /// ±0.2 calibers of its measurements. The targets are not met; ADR-033 and
2713    /// `docs/physics/aero.md` record every miss.
2714    #[test]
2715    fn against_tn3527_and_the_arcas_robin() {
2716        let fixture: Value = serde_json::from_str(include_str!(
2717            "../../../validation/fixtures/aero/shock-expansion.json"
2718        ))
2719        .unwrap();
2720        let tables: Value = serde_json::from_str(include_str!(
2721            "../../../validation/fixtures/aero/tn3527-bodies.json"
2722        ))
2723        .unwrap();
2724        let targets = &fixture["targets"];
2725        let limits = [
2726            ("second_order", "c_n_alpha", 0.05),
2727            ("second_order", "cp_calibers", 0.1),
2728            ("experiment", "c_n_alpha", 0.2),
2729            ("experiment", "cp_calibers", 0.2),
2730        ];
2731        for (reference, quantity, limit) in limits {
2732            assert_eq!(targets[format!("{reference}_{quantity}")], limit);
2733        }
2734        assert_eq!(fixture["elements_per_curve"], DEFAULT_ELEMENTS_PER_CURVE);
2735        let rows = fixture["tn3527"].as_array().unwrap();
2736        let references = tables["rows"].as_array().unwrap();
2737        assert_eq!(rows.len(), 144);
2738        assert_eq!(references.len(), 144);
2739        let close = |a: f64, b: f64, what: &str| {
2740            assert!(
2741                (a - b).abs() <= 1e-12 * b.abs().max(1.0),
2742                "{what}: {a} against {b}"
2743            );
2744        };
2745        let mut misses = Vec::new();
2746        for (row, reference) in rows.iter().zip(references) {
2747            let nose = reference["nose"].as_str().unwrap();
2748            let fineness = reference["fineness"].as_f64().unwrap();
2749            let mach = reference["mach"].as_f64().unwrap();
2750            let afterbody = reference["afterbody_calibers"].as_f64().unwrap();
2751            let what = format!("{nose}-{fineness}-{afterbody}@{mach}");
2752            assert_eq!(row["nose"], nose, "{what}");
2753            let hpr = body(
2754                nose == "ogive",
2755                fineness,
2756                afterbody,
2757                DEFAULT_ELEMENTS_PER_CURVE,
2758            )
2759            .slope(mach, 0.25 * PI);
2760            let hpr = match (hpr, row["refused"].as_str()) {
2761                (Ok(hpr), None) => hpr,
2762                (Err(e), Some(refused)) => {
2763                    assert_eq!(e.to_string(), refused, "{what}");
2764                    misses.push(format!("{what} refused"));
2765                    continue;
2766                }
2767                (hpr, refused) => panic!("{what}: {hpr:?} where the fixture has {refused:?}"),
2768            };
2769            close(
2770                row["hpr"]["c_n_alpha"].as_f64().unwrap(),
2771                hpr.slope_per_rad,
2772                &what,
2773            );
2774            close(
2775                row["hpr"]["cp_calibers"].as_f64().unwrap(),
2776                hpr.center_of_pressure_m,
2777                &what,
2778            );
2779            for (reference_name, quantity, limit) in limits {
2780                let (value, source) = match quantity {
2781                    "c_n_alpha" => (hpr.slope_per_rad, &reference["c_n_alpha"]),
2782                    _ => (hpr.center_of_pressure_m, &reference["cp"]),
2783                };
2784                let compared = &row[reference_name][quantity];
2785                let Some(r) = source[reference_name].as_f64() else {
2786                    assert!(compared.is_null(), "{what}");
2787                    continue;
2788                };
2789                close(compared["reference"].as_f64().unwrap(), r, &what);
2790                close(compared["error"].as_f64().unwrap(), value - r, &what);
2791                let within = (value - r).abs() <= limit + 1e-12;
2792                assert_eq!(compared["within"], within, "{what}");
2793                if !within {
2794                    misses.push(format!("{what} {reference_name} {quantity}"));
2795                }
2796            }
2797        }
2798        // Every miss, in the tables' order (docs/physics/aero.md, ADR-033). Against the report's
2799        // own values: inside the method's limit, hpr and a separate implementation of the same
2800        // equations agree within 0.001 per radian where the printed values depart (the
2801        // fineness-7 cone on long cylinders high, the ogives low); at the limit, the fineness-3
2802        // ogive at Mach 5.05 and 6.28, hpr's reduction departs from the report (issue #81).
2803        // Against its measurements: the same fineness-7 cones, three rows where the report is
2804        // itself 0.20 to 0.22 off, the fineness-3 ogive at Mach 5.05 (#81), and one at -0.206.
2805        let pinned = [
2806            "cone-7-6@3 experiment cp_calibers",
2807            "cone-7-8@3 second_order c_n_alpha",
2808            "cone-7-10@3 second_order c_n_alpha",
2809            "cone-7-6@4.24 second_order c_n_alpha",
2810            "cone-7-8@4.24 second_order c_n_alpha",
2811            "cone-7-8@4.24 second_order cp_calibers",
2812            "cone-7-10@4.24 second_order c_n_alpha",
2813            "cone-7-10@4.24 second_order cp_calibers",
2814            "cone-7-10@4.24 experiment c_n_alpha",
2815            "cone-7-10@4.24 experiment cp_calibers",
2816            "cone-7-4@5.05 second_order c_n_alpha",
2817            "cone-7-6@5.05 second_order c_n_alpha",
2818            "cone-7-6@5.05 second_order cp_calibers",
2819            "cone-7-8@5.05 second_order c_n_alpha",
2820            "cone-7-8@5.05 second_order cp_calibers",
2821            "cone-7-10@5.05 second_order c_n_alpha",
2822            "cone-7-10@5.05 second_order cp_calibers",
2823            "cone-7-10@5.05 experiment cp_calibers",
2824            "cone-7-4@6.28 second_order c_n_alpha",
2825            "cone-7-6@6.28 second_order c_n_alpha",
2826            "cone-7-6@6.28 second_order cp_calibers",
2827            "cone-7-8@6.28 second_order c_n_alpha",
2828            "cone-7-8@6.28 second_order cp_calibers",
2829            "cone-7-10@6.28 second_order c_n_alpha",
2830            "cone-7-10@6.28 second_order cp_calibers",
2831            "cone-7-10@6.28 experiment c_n_alpha",
2832            "cone-7-10@6.28 experiment cp_calibers",
2833            "cone-5-10@6.28 second_order c_n_alpha",
2834            "cone-5-10@6.28 experiment cp_calibers",
2835            "cone-3-10@6.28 experiment cp_calibers",
2836            "ogive-7-4@3 second_order cp_calibers",
2837            "ogive-7-8@3 second_order c_n_alpha",
2838            "ogive-7-2@4.24 second_order c_n_alpha",
2839            "ogive-7-2@5.05 second_order c_n_alpha",
2840            "ogive-7-4@5.05 second_order c_n_alpha",
2841            "ogive-7-4@5.05 experiment cp_calibers",
2842            "ogive-7-6@5.05 second_order c_n_alpha",
2843            "ogive-7-8@5.05 second_order c_n_alpha",
2844            "ogive-7-2@6.28 second_order c_n_alpha",
2845            "ogive-7-10@6.28 second_order cp_calibers",
2846            "ogive-5-2@3 second_order c_n_alpha",
2847            "ogive-5-4@3 second_order c_n_alpha",
2848            "ogive-5-4@3 second_order cp_calibers",
2849            "ogive-5-6@3 second_order c_n_alpha",
2850            "ogive-5-6@3 second_order cp_calibers",
2851            "ogive-5-8@3 second_order c_n_alpha",
2852            "ogive-5-8@3 second_order cp_calibers",
2853            "ogive-5-10@3 second_order c_n_alpha",
2854            "ogive-5-10@3 second_order cp_calibers",
2855            "ogive-5-4@4.24 second_order c_n_alpha",
2856            "ogive-5-6@4.24 second_order c_n_alpha",
2857            "ogive-5-10@5.05 experiment cp_calibers",
2858            "ogive-5-2@6.28 second_order c_n_alpha",
2859            "ogive-5-4@6.28 second_order c_n_alpha",
2860            "ogive-3-2@4.24 second_order c_n_alpha",
2861            "ogive-3-4@4.24 second_order c_n_alpha",
2862            "ogive-3-6@4.24 second_order c_n_alpha",
2863            "ogive-3-8@4.24 second_order c_n_alpha",
2864            "ogive-3-10@4.24 second_order c_n_alpha",
2865            "ogive-3-2@5.05 second_order c_n_alpha",
2866            "ogive-3-2@5.05 second_order cp_calibers",
2867            "ogive-3-4@5.05 second_order c_n_alpha",
2868            "ogive-3-4@5.05 second_order cp_calibers",
2869            "ogive-3-4@5.05 experiment cp_calibers",
2870            "ogive-3-6@5.05 second_order c_n_alpha",
2871            "ogive-3-6@5.05 second_order cp_calibers",
2872            "ogive-3-6@5.05 experiment cp_calibers",
2873            "ogive-3-8@5.05 second_order c_n_alpha",
2874            "ogive-3-8@5.05 second_order cp_calibers",
2875            "ogive-3-10@5.05 second_order c_n_alpha",
2876            "ogive-3-10@5.05 second_order cp_calibers",
2877            "ogive-3-10@5.05 experiment c_n_alpha",
2878            "ogive-3-10@5.05 experiment cp_calibers",
2879            "ogive-3-2@6.28 second_order c_n_alpha",
2880            "ogive-3-4@6.28 second_order c_n_alpha",
2881        ];
2882        assert_eq!(misses, pinned);
2883
2884        // The Arcas Robin: hpr's values rebuilt from the recorded nose and the report's
2885        // dimensions; the measured slopes are M1.8a's fits of the committed points.
2886        let tunnel: Value = serde_json::from_str(include_str!(
2887            "../../../validation/fixtures/aero/arcas-robin-wind-tunnel.json"
2888        ))
2889        .unwrap();
2890        let arcas = &fixture["arcas_robin"];
2891        let ratio = arcas["nose"]["radius_ratio"].as_f64().unwrap();
2892        let inch = 0.0254;
2893        let (radius, nose_length) = (1.125 * inch, 9.375 * inch);
2894        let area = PI * radius * radius;
2895        let mut rows = 0;
2896        for configuration in arcas["configurations"].as_array().unwrap() {
2897            let id = configuration["id"].as_str().unwrap();
2898            let end = tunnel["geometry"]["cylinder_ends_in"][id].as_f64().unwrap();
2899            assert_eq!(configuration["cylinder_ends_in"], end);
2900            let mut segments = vec![
2901                BodySegment::Profile {
2902                    profile: Profile::nose(
2903                        NoseShape::Ogive {
2904                            radius_ratio: ratio,
2905                        },
2906                        nose_length,
2907                        radius,
2908                    )
2909                    .unwrap(),
2910                },
2911                BodySegment::Cylinder {
2912                    length_m: end * inch - nose_length,
2913                    radius_m: radius,
2914                },
2915            ];
2916            let bare = ShockExpansionBody::new(&segments, DEFAULT_ELEMENTS_PER_CURVE).unwrap();
2917            segments.push(BodySegment::Profile {
2918                profile: Profile::transition(
2919                    NoseShape::Conical {},
2920                    1.757 * inch,
2921                    radius,
2922                    0.5 * 1.308 * inch,
2923                    false,
2924                )
2925                .unwrap(),
2926            });
2927            let tailed = ShockExpansionBody::new(&segments, DEFAULT_ELEMENTS_PER_CURVE).unwrap();
2928            let curves = tunnel["configurations"]
2929                .as_array()
2930                .unwrap()
2931                .iter()
2932                .find(|c| c["id"] == id)
2933                .unwrap()["cn_alpha_fins_off"]
2934                .as_array()
2935                .unwrap();
2936            for row in configuration["rows"].as_array().unwrap() {
2937                let mach = row["mach"].as_f64().unwrap();
2938                let what = format!("{id} at Mach {mach}");
2939                let curve = curves.iter().find(|c| c["mach"] == mach).unwrap();
2940                let points = curve["alpha_deg_c_n"].as_array().unwrap();
2941                let alphas: Vec<f64> = points
2942                    .iter()
2943                    .map(|p| p[0].as_f64().unwrap().to_radians())
2944                    .collect();
2945                let c_n: Vec<f64> = points.iter().map(|p| p[1].as_f64().unwrap()).collect();
2946                let n = alphas.len() as f64;
2947                let (ma, mc) = (alphas.iter().sum::<f64>() / n, c_n.iter().sum::<f64>() / n);
2948                let sxy: f64 = alphas
2949                    .iter()
2950                    .zip(&c_n)
2951                    .map(|(a, c)| (a - ma) * (c - mc))
2952                    .sum();
2953                let sxx: f64 = alphas.iter().map(|a| (a - ma) * (a - ma)).sum();
2954                let measured = sxy / sxx;
2955                close(row["measured_c_n_alpha"].as_f64().unwrap(), measured, &what);
2956                for (key, body) in [("nose_and_cylinder", &bare), ("with_boattail", &tailed)] {
2957                    let s = body.slope(mach, area).unwrap();
2958                    let entry = &row[key];
2959                    close(entry["c_n_alpha"].as_f64().unwrap(), s.slope_per_rad, &what);
2960                    close(
2961                        entry["cp_calibers"].as_f64().unwrap(),
2962                        s.center_of_pressure_m / (2.0 * radius),
2963                        &what,
2964                    );
2965                    close(
2966                        entry["c_n_alpha_error"].as_f64().unwrap(),
2967                        s.slope_per_rad / measured - 1.0,
2968                        &what,
2969                    );
2970                }
2971                rows += 1;
2972            }
2973        }
2974        // Mach 1.5 to 4.63 on the short model, 1.8 to 4.63 on the long.
2975        assert_eq!(rows, 11);
2976    }
2977
2978    /// A hemisphere on a cylinder, reference its cross-section.
2979    fn hemisphere_cylinder() -> ShockExpansionBody {
2980        ShockExpansionBody::new(
2981            &[
2982                BodySegment::SphericalCap {
2983                    radius_m: 0.5,
2984                    length_m: 0.5,
2985                },
2986                BodySegment::Cylinder {
2987                    length_m: 4.0,
2988                    radius_m: 0.5,
2989                },
2990            ],
2991            DEFAULT_ELEMENTS_PER_CURVE,
2992        )
2993        .unwrap()
2994    }
2995
2996    /// The handover sits where a sphere's slope is the handover's, `x = R(1 − sin δ)`, found to
2997    /// the last few bits of an `f64`. The tip's half-angle is 90°.
2998    #[test]
2999    fn a_blunt_tip_hands_over_where_its_slope_falls_to_the_wedges() {
3000        let sphere = hemisphere_cylinder();
3001        assert!(sphere.has_blunt_tip());
3002        assert_eq!(sphere.vertex_angle_rad(), 0.5 * PI);
3003        for mach in [1.3, 1.5, 2.0, 3.0, 5.0] {
3004            let delta = crate::blunt_tip::handover_angle_rad(mach).unwrap();
3005            let want = 0.5 * (1.0 - delta.sin());
3006            let got = sphere.handover_m(mach).unwrap().unwrap();
3007            assert!(
3008                (got - want).abs() <= 1e-14,
3009                "Mach {mach}: {got} against {want}"
3010            );
3011        }
3012        // A pointed nose has none.
3013        let pointed = body(false, 3.0, 2.0, DEFAULT_ELEMENTS_PER_CURVE);
3014        assert!(!pointed.has_blunt_tip());
3015        assert_eq!(pointed.handover_m(2.0).unwrap(), None);
3016        assert!(matches!(
3017            sphere.handover_m(1.0),
3018            Err(AeroError::Domain { .. })
3019        ));
3020    }
3021
3022    /// The cap's Newtonian loading integrates to its closed form: on a sphere of radius `R`, with
3023    /// `θ` from the pole, `C_Nα = 2 C_p,max ∫ cos θ sin³θ dθ = C_p,max sin⁴θ_h / 2` on `πR²` to
3024    /// the handover's `θ_h = 90° − δ_h`.
3025    #[test]
3026    fn the_cap_carries_its_newtonian_loading() {
3027        let body = hemisphere_cylinder();
3028        let area = 0.25 * PI;
3029        for mach in [1.5, 3.0] {
3030            let windows = body.windows(mach, area).unwrap();
3031            // The first window runs from the pole to the handover.
3032            let (start, [force, _]) = windows[0];
3033            assert_eq!(start, 0.0);
3034            let cap = 2.0 * PI * force / area;
3035            let theta = 0.5 * PI - crate::blunt_tip::handover_angle_rad(mach).unwrap();
3036            let c_p_max = crate::blunt_tip::newtonian_pressure_coefficient_max(mach).unwrap();
3037            let want = 0.5 * c_p_max * theta.sin().powi(4);
3038            assert!(
3039                (cap - want).abs() <= 1e-9 * want,
3040                "Mach {mach}: {cap} against {want}"
3041            );
3042            // The rest of the body carries lift too, and the whole places a center of pressure
3043            // on the body.
3044            let slope = body.slope(mach, area).unwrap();
3045            assert!(slope.slope_per_rad > cap);
3046            assert!(slope.center_of_pressure_m > 0.0 && slope.center_of_pressure_m < 4.5);
3047        }
3048    }
3049
3050    /// [`crate::blunt_tip::CONE_TABLE_CAP_RAD`] is the steepest cone the tables carry, so a cap at
3051    /// it reads a slope and a hair over it does not. If [`CONE_ANGLES_DEG`] ever grows or shrinks,
3052    /// this is what says the constant has to follow.
3053    #[test]
3054    fn the_handovers_ceiling_is_the_cone_tables_last_angle() {
3055        let ceiling = crate::blunt_tip::CONE_TABLE_CAP_RAD;
3056        let last = CONE_ANGLES_DEG[CONE_ANGLES_DEG.len() - 1];
3057        // Equal to the last bit the two conversions allow: 30° → rad → 30° lands a bit low.
3058        assert!(
3059            (ceiling.to_degrees() - last).abs() <= 4.0 * f64::EPSILON * last,
3060            "the handover's ceiling is {}°, the tables' last angle {last}°",
3061            ceiling.to_degrees()
3062        );
3063        assert!(cone_normal_force_slope(3.0, ceiling).is_ok());
3064        assert!(matches!(
3065            cone_normal_force_slope(3.0, ceiling * (1.0 + 1e-6)),
3066            Err(AeroError::Unsupported(_))
3067        ));
3068    }
3069
3070    /// What stops a blunt tip's handover moving to the cone tables' 30° (M1.8e12, ADR-043).
3071    /// Under the flown cap the committed Arcas Robin nose's march holds its answer to 0.01 per
3072    /// radian from 10 elements to 160 at every Mach, reducing at most 2 of 160 elements to the
3073    /// generalized method. Under the tables' cap the same nose reduces 109 of 160 at Mach 4.63
3074    /// and 145 at Mach 5, and the answer moves with the element count: 0.21 per radian at Mach
3075    /// 4.63, 7% of it. That is the method, not the arithmetic: both readings are unchanged when
3076    /// the Mach number is nudged by eight of its last bits.
3077    #[test]
3078    fn a_steeper_handover_moves_the_march_out_of_its_range() {
3079        use crate::blunt_tip::{CONE_TABLE_CAP_RAD, MAX_HANDOVER_RAD};
3080        let radius = 1.125 * 0.0254;
3081        let area = PI * radius * radius;
3082        let body = |steps: usize, cap_rad: f64| {
3083            ShockExpansionBody::new(
3084                &[
3085                    BodySegment::Profile {
3086                        profile: Profile::nose(
3087                            NoseShape::PowerSeries { exponent: 0.6369 },
3088                            9.375 * 0.0254,
3089                            radius,
3090                        )
3091                        .unwrap(),
3092                    },
3093                    BodySegment::Cylinder {
3094                        length_m: (39.14 - 9.375) * 0.0254,
3095                        radius_m: radius,
3096                    },
3097                ],
3098                steps,
3099            )
3100            .unwrap()
3101            .with_handover_cap_rad(cap_rad)
3102        };
3103        let counts = [
3104            DEFAULT_ELEMENTS_PER_CURVE,
3105            4 * DEFAULT_ELEMENTS_PER_CURVE,
3106            16 * DEFAULT_ELEMENTS_PER_CURVE,
3107        ];
3108        let read = |cap_rad: f64, mach: f64| {
3109            let slopes: Vec<f64> = counts
3110                .iter()
3111                .map(|&n| body(n, cap_rad).slope(mach, area).unwrap().slope_per_rad)
3112                .collect();
3113            let high = slopes.iter().copied().fold(f64::MIN, f64::max);
3114            let low = slopes.iter().copied().fold(f64::MAX, f64::min);
3115            let reduced = body(counts[2], cap_rad).reduced_elements(mach).unwrap();
3116            (slopes[0], high - low, reduced)
3117        };
3118        // The cap hpr flies: the element count is worth a thousandth of the answer, all the way
3119        // to Mach 5, and the march barely leaves the second-order method.
3120        for mach in [1.5, 2.3, 2.96, 3.96, 4.63, 5.0] {
3121            let (_, spread, reduced) = read(MAX_HANDOVER_RAD, mach);
3122            assert!(
3123                spread < 0.01 && reduced <= 2,
3124                "the flown cap at Mach {mach}: the count is worth {spread:.4} per radian, \
3125                 {reduced} of {} elements reduced",
3126                counts[2]
3127            );
3128        }
3129        // The cone tables' cap costs nothing below Mach 4. That is not where it is blocked.
3130        for mach in [1.5, 2.3, 2.96, 3.96] {
3131            let (_, spread, reduced) = read(CONE_TABLE_CAP_RAD, mach);
3132            assert!(
3133                spread < 0.02 && reduced <= 2,
3134                "the tables' cap at Mach {mach}: the count is worth {spread:.4} per radian, \
3135                 {reduced} of {} elements reduced",
3136                counts[2]
3137            );
3138        }
3139        // Above it the march reduces most of the nose and the answer follows the element count.
3140        for (mach, least_reduced, least_spread) in [(4.63, 100, 0.2), (5.0, 140, 0.04)] {
3141            let (coarse, spread, reduced) = read(CONE_TABLE_CAP_RAD, mach);
3142            let (flown, _, _) = read(MAX_HANDOVER_RAD, mach);
3143            assert!(
3144                reduced >= least_reduced && spread >= least_spread,
3145                "the tables' cap at Mach {mach}: {reduced} of {} elements reduced, the count \
3146                 worth {spread:.4} per radian",
3147                counts[2]
3148            );
3149            assert!(
3150                coarse - flown > 0.01,
3151                "the tables' cap at Mach {mach} should read above the flown cap's \
3152                 ({coarse:.4} against {flown:.4})"
3153            );
3154        }
3155        // The arithmetic isn't what moves. Which elements reduce is a decision on the sign of
3156        // `η`, and none of them is close enough to zero to turn on rounding: nudge the Mach
3157        // number by eight of its last bits and the same elements reduce, for an answer that
3158        // follows to a part in a billion.
3159        for cap in [MAX_HANDOVER_RAD, CONE_TABLE_CAP_RAD] {
3160            for mach in [4.63_f64, 5.0] {
3161                let nudged = mach * (1.0 + 8.0 * f64::EPSILON);
3162                let (slope, _, reduced) = read(cap, mach);
3163                let (nudged_slope, _, nudged_reduced) = read(cap, nudged);
3164                assert_eq!(
3165                    (reduced, (nudged_slope / slope - 1.0).abs() < 1e-9),
3166                    (nudged_reduced, true),
3167                    "{}° at Mach {mach} against {nudged}: {slope} against {nudged_slope}",
3168                    cap.to_degrees()
3169                );
3170            }
3171        }
3172    }
3173
3174    /// What a reduced element costs, and what a crossing costs (M1.8e13, ADR-044). On TN 3527's
3175    /// own fineness-3 ogive the march reduces 27 of 160 elements at Mach 5.05 and 50 at Mach
3176    /// 6.28, and the answer still settles to 0.002 per radian from 10 elements to 160:
3177    /// there `η < 0` is the gradient changing sign, with the surface pressure below its tangent
3178    /// cone's the whole way. The report's bodies never cross, so the report never had to say
3179    /// what a crossing does, which is why hpr's reading of `η < 0`
3180    /// ([issue #81](https://github.com/nrdptel/hpr-sim/issues/81)) is not what
3181    /// [issue #108](https://github.com/nrdptel/hpr-sim/issues/108) turns on.
3182    #[test]
3183    #[allow(
3184        clippy::approx_constant,
3185        reason = "6.28 is one of TN 3527's test Mach numbers, not 2π"
3186    )]
3187    fn a_reduced_element_settles_where_tn3527s_own_bodies_never_cross() {
3188        let body = |steps: usize| {
3189            ShockExpansionBody::new(
3190                &[BodySegment::Profile {
3191                    profile: Profile::nose(NoseShape::TANGENT_OGIVE, 3.0, 0.5).unwrap(),
3192                }],
3193                steps,
3194            )
3195            .unwrap()
3196        };
3197        let counts = [DEFAULT_ELEMENTS_PER_CURVE, 16 * DEFAULT_ELEMENTS_PER_CURVE];
3198        for (mach, coarse_want, fine_want) in [(5.05, 2, 27), (6.28, 3, 50)] {
3199            let read = |n: usize| {
3200                let b = body(n);
3201                (
3202                    b.slope(mach, 0.25 * PI).unwrap().slope_per_rad,
3203                    b.reduced_elements(mach).unwrap(),
3204                    b.tangent_cone_crossings(mach).unwrap(),
3205                )
3206            };
3207            let (coarse, coarse_reduced, _) = read(counts[0]);
3208            let (fine, fine_reduced, _) = read(counts[1]);
3209            // Exact, because the guide and the ADR quote these counts.
3210            assert_eq!(
3211                (coarse_reduced, fine_reduced),
3212                (coarse_want, fine_want),
3213                "the ogive at Mach {mach} should reduce {coarse_want} of {} elements and \
3214                 {fine_want} of {}",
3215                counts[0],
3216                counts[1]
3217            );
3218            for n in counts {
3219                assert_eq!(
3220                    read(n).2,
3221                    0,
3222                    "the ogive at Mach {mach} on {n} elements should never cross its tangent cone"
3223                );
3224            }
3225            assert!(
3226                (fine - coarse).abs() < 2e-3,
3227                "the ogive at Mach {mach} should settle: {coarse} on {} elements against {fine} \
3228                 on {}",
3229                counts[0],
3230                counts[1]
3231            );
3232        }
3233    }
3234
3235    /// Why a crossing costs what it does (M1.8e13, ADR-044). Along an element the method relaxes
3236    /// the pressure and the loading toward the tangent cone's as `e^(−η)`, `η = k (x − x₂)` with
3237    /// `k = (∂p/∂s)₂/((p_c − p₂) cos δ₂)`, so where the pressure crosses its tangent cone's the gap
3238    /// closes while the gradient carries on and `k` has a pole. The pressure rides through it;
3239    /// the loading does not, because eq. 19 borrows the pressure's `k` while its own gap stays
3240    /// open. Here, at the case [issue #108](https://github.com/nrdptel/hpr-sim/issues/108)
3241    /// reports (the committed nose under the cone tables' cap at Mach 4.63), the march crosses
3242    /// twice. Between the crossings nearly every element is reduced, so the loading never relaxes
3243    /// and stands about a quarter above its tangent cone's by the second one. The element there is
3244    /// back inside the method, and how much of that gap it sheds in one step is the mesh's to
3245    /// choose: 98% of it on the 40-element march against 12% on the 160-element one. The cap hpr
3246    /// flies never crosses at all.
3247    #[test]
3248    fn a_crossing_is_a_pole_in_the_rate_the_march_relaxes_at() {
3249        use crate::blunt_tip::{CONE_TABLE_CAP_RAD, MAX_HANDOVER_RAD};
3250        let mach = 4.63;
3251        let radius = 1.125 * 0.0254;
3252        let body = |steps: usize, cap_rad: f64| {
3253            ShockExpansionBody::new(
3254                &[
3255                    BodySegment::Profile {
3256                        profile: Profile::nose(
3257                            NoseShape::PowerSeries { exponent: 0.6369 },
3258                            9.375 * 0.0254,
3259                            radius,
3260                        )
3261                        .unwrap(),
3262                    },
3263                    BodySegment::Cylinder {
3264                        length_m: (39.14 - 9.375) * 0.0254,
3265                        radius_m: radius,
3266                    },
3267                ],
3268                steps,
3269            )
3270            .unwrap()
3271            .with_handover_cap_rad(cap_rad)
3272        };
3273        // The elements a crossing falls between, as `tangent_cone_crossings` counts them: the gap
3274        // changes sign between two elements that have one and share a kind of tangent cone.
3275        let crossings = |steps: usize, cap_rad: f64| {
3276            let flows = body(steps, cap_rad).element_flows(mach).unwrap();
3277            let gap = |e: &ElementFlowReport| e.tangent_cone_pressure_ratio - e.pressure_ratio;
3278            let mut last: Option<(usize, f64)> = None;
3279            let mut at = Vec::new();
3280            for (index, flow) in flows.iter().enumerate() {
3281                let this = gap(flow);
3282                if this == 0.0 {
3283                    continue;
3284                }
3285                if let Some((previous, was)) = last
3286                    && (this > 0.0) != (was > 0.0)
3287                    && (flows[previous].angle_rad > CONE_ANGLE_FLOOR_RAD)
3288                        == (flow.angle_rad > CONE_ANGLE_FLOOR_RAD)
3289                {
3290                    at.push(index);
3291                }
3292                last = Some((index, this));
3293            }
3294            (flows, at)
3295        };
3296        // What the mesh decides: the share of the loading's gap the element at the second
3297        // crossing sheds in its own length, `1 − e^(−η)`.
3298        let mut shares = Vec::new();
3299        for steps in [
3300            4 * DEFAULT_ELEMENTS_PER_CURVE,
3301            16 * DEFAULT_ELEMENTS_PER_CURVE,
3302        ] {
3303            let (flows, at) = crossings(steps, CONE_TABLE_CAP_RAD);
3304            assert_eq!(
3305                (
3306                    at.len(),
3307                    body(steps, CONE_TABLE_CAP_RAD)
3308                        .tangent_cone_crossings(mach)
3309                        .unwrap()
3310                ),
3311                (2, 2),
3312                "the tables' cap on {steps} elements should cross its tangent cone twice, and \
3313                 the shipped count should agree with the rule spelled out here"
3314            );
3315            // The sign test behind the count is not a coin flip: the gap either side of a
3316            // crossing is far larger than the 1e-12 a march reproduces to across platforms.
3317            for &index in &at {
3318                let margin =
3319                    (flows[index].tangent_cone_pressure_ratio - flows[index].pressure_ratio).abs()
3320                        / flows[index].pressure_ratio;
3321                assert!(
3322                    margin > 1e-8,
3323                    "the crossing at element {index} on {steps} elements leaves a gap of \
3324                     {margin:.2e} of the pressure, too near the noise to decide a sign on"
3325                );
3326            }
3327            assert!(
3328                at[1] + 1 < flows.len(),
3329                "a crossing needs an element after it"
3330            );
3331            let second = &flows[at[1]];
3332            let length_m = flows[at[1] + 1].corner_x_m - second.corner_x_m;
3333            let loading_gap = (second.tangent_cone_loading_per_rad - second.loading_per_rad)
3334                / second.loading_per_rad;
3335            // The step is taken by an element the method still owns, across a gap the reduced
3336            // stretch behind it left wide open. This is why a rule for `η < 0` alone would not
3337            // settle the answer, and why it cannot be judged apart from the crossing either.
3338            assert!(
3339                second.decay_per_m > 0.0 && loading_gap < -0.15,
3340                "on {steps} elements the second crossing's element should be inside the method \
3341                 (rate {}) with its loading {:.1}% from its tangent cone's",
3342                second.decay_per_m,
3343                100.0 * loading_gap
3344            );
3345            shares.push(1.0 - (-second.decay_per_m * length_m).exp());
3346        }
3347        assert!(
3348            shares[0] > 0.9 && shares[1] < 0.2,
3349            "how much of the gap one step sheds should be the mesh's answer, not the model's: \
3350             {:.0}% on {} elements against {:.0}% on {}",
3351            100.0 * shares[0],
3352            4 * DEFAULT_ELEMENTS_PER_CURVE,
3353            100.0 * shares[1],
3354            16 * DEFAULT_ELEMENTS_PER_CURVE
3355        );
3356        // The cap hpr flies marches the same nose at the same Mach numbers without crossing.
3357        for steps in [
3358            DEFAULT_ELEMENTS_PER_CURVE,
3359            4 * DEFAULT_ELEMENTS_PER_CURVE,
3360            16 * DEFAULT_ELEMENTS_PER_CURVE,
3361        ] {
3362            for mach in [4.63, 5.0] {
3363                assert_eq!(
3364                    body(steps, MAX_HANDOVER_RAD)
3365                        .tangent_cone_crossings(mach)
3366                        .unwrap(),
3367                    0,
3368                    "the flown cap at Mach {mach} on {steps} elements should not cross"
3369                );
3370            }
3371        }
3372    }
3373
3374    /// A crossing is a flag, not a verdict (M1.8e13, ADR-044). It says the answer moved over the
3375    /// meshes the cap sweep holds (10, 40 and 160 elements per curve), not that no mesh settles
3376    /// it. Under a 28° cap at Mach 5 the committed nose crosses at every mesh, and its 0.69 per
3377    /// radian spread over the sweep's three is all in the coarse end: from 60 elements on it holds
3378    /// to 0.005, tighter than the worst reading in the sweep that never crosses. Under the cone
3379    /// tables' cap at Mach 4.63 it is the other kind, still moving by 0.2 per radian from 60
3380    /// elements to 640. The guide says both.
3381    #[test]
3382    fn a_crossing_says_the_answer_moved_not_that_it_never_settles() {
3383        use crate::blunt_tip::CONE_TABLE_CAP_RAD;
3384        let radius = 1.125 * 0.0254;
3385        let area = PI * radius * radius;
3386        let read = |steps: usize, cap_rad: f64, mach: f64| {
3387            let body = ShockExpansionBody::new(
3388                &[
3389                    BodySegment::Profile {
3390                        profile: Profile::nose(
3391                            NoseShape::PowerSeries { exponent: 0.6369 },
3392                            9.375 * 0.0254,
3393                            radius,
3394                        )
3395                        .unwrap(),
3396                    },
3397                    BodySegment::Cylinder {
3398                        length_m: (39.14 - 9.375) * 0.0254,
3399                        radius_m: radius,
3400                    },
3401                ],
3402                steps,
3403            )
3404            .unwrap()
3405            .with_handover_cap_rad(cap_rad);
3406            (
3407                body.slope(mach, area).unwrap().slope_per_rad,
3408                body.tangent_cone_crossings(mach).unwrap(),
3409            )
3410        };
3411        // Past the coarse end: six, sixteen and sixty-four times the flown element count.
3412        let fine = [
3413            6 * DEFAULT_ELEMENTS_PER_CURVE,
3414            16 * DEFAULT_ELEMENTS_PER_CURVE,
3415            64 * DEFAULT_ELEMENTS_PER_CURVE,
3416        ];
3417        let spread = |cap_rad: f64, mach: f64| {
3418            let slopes: Vec<(f64, usize)> = fine.iter().map(|&n| read(n, cap_rad, mach)).collect();
3419            assert!(
3420                slopes.iter().all(|&(_, crossings)| crossings > 0),
3421                "this case should cross at every mesh: {slopes:?}"
3422            );
3423            let high = slopes.iter().map(|&(s, _)| s).fold(f64::MIN, f64::max);
3424            let low = slopes.iter().map(|&(s, _)| s).fold(f64::MAX, f64::min);
3425            high - low
3426        };
3427        // Crossing, and settled once the mesh is fine enough.
3428        let settles = spread(28_f64.to_radians(), 5.0);
3429        assert!(
3430            settles < 0.005,
3431            "28° at Mach 5 crosses but settles: it moves {settles:.4} per radian over {fine:?}"
3432        );
3433        // Crossing, and still moving there.
3434        let wanders = spread(CONE_TABLE_CAP_RAD, 4.63);
3435        assert!(
3436            wanders > 0.2,
3437            "the tables' cap at Mach 4.63 crosses and keeps moving: {wanders:.4} per radian over \
3438             {fine:?}"
3439        );
3440    }
3441
3442    /// The Arcas Robin's committed nose (a power series, `n` = 0.6369, 9.375 in long on a
3443    /// 2.25-in body) and the short model's cylinder: four times the default elements move its
3444    /// slope by under 0.01 per radian and its center of pressure by under 0.01 calibers, through
3445    /// Mach 5. Sixteen times the default move a five-calibre elliptical or von Kármán nose's by
3446    /// under 0.02: the tangent body settles more slowly on a nose whose slope changes fastest
3447    /// just behind the cap.
3448    #[test]
3449    fn a_vertical_tip_converges_as_elements_are_added() {
3450        let radius = 1.125 * 0.0254;
3451        let area = PI * radius * radius;
3452        let body = |steps| {
3453            ShockExpansionBody::new(
3454                &[
3455                    BodySegment::Profile {
3456                        profile: Profile::nose(
3457                            NoseShape::PowerSeries { exponent: 0.6369 },
3458                            9.375 * 0.0254,
3459                            radius,
3460                        )
3461                        .unwrap(),
3462                    },
3463                    BodySegment::Cylinder {
3464                        length_m: (39.14 - 9.375) * 0.0254,
3465                        radius_m: radius,
3466                    },
3467                ],
3468                steps,
3469            )
3470            .unwrap()
3471        };
3472        let (coarse, fine) = (body(DEFAULT_ELEMENTS_PER_CURVE), body(40));
3473        for mach in [1.5, 2.3, 2.96, 3.96, 4.63, 5.0] {
3474            let a = coarse.slope(mach, area).unwrap();
3475            let b = fine.slope(mach, area).unwrap();
3476            assert!(
3477                (a.slope_per_rad - b.slope_per_rad).abs() < 0.01,
3478                "Mach {mach}: {a:?} against {b:?}"
3479            );
3480            assert!(
3481                (a.center_of_pressure_m - b.center_of_pressure_m).abs() < 0.01 * 2.0 * radius,
3482                "Mach {mach}: {a:?} against {b:?}"
3483            );
3484        }
3485        // Five-calibre elliptical and von Kármán noses on a cylinder, the default elements
3486        // against sixteen times as many.
3487        for shape in [NoseShape::Elliptical {}, NoseShape::VON_KARMAN] {
3488            let body = |steps| {
3489                ShockExpansionBody::new(
3490                    &[
3491                        BodySegment::Profile {
3492                            profile: Profile::nose(shape, 5.0, 0.5).unwrap(),
3493                        },
3494                        BodySegment::Cylinder {
3495                            length_m: 5.0,
3496                            radius_m: 0.5,
3497                        },
3498                    ],
3499                    steps,
3500                )
3501                .unwrap()
3502            };
3503            let (coarse, fine) = (body(DEFAULT_ELEMENTS_PER_CURVE), body(160));
3504            for mach in [1.5, 3.0, 5.0] {
3505                let a = coarse.slope(mach, 0.25 * PI).unwrap();
3506                let b = fine.slope(mach, 0.25 * PI).unwrap();
3507                assert!(
3508                    (a.slope_per_rad - b.slope_per_rad).abs() < 0.02
3509                        && (a.center_of_pressure_m - b.center_of_pressure_m).abs() < 0.02,
3510                    "{shape:?} at Mach {mach}: {a:?} against {b:?}"
3511                );
3512            }
3513        }
3514    }
3515
3516    /// TN D-4865's own start behind the cap, kept to compare (ADR-038): on the Arcas Robin's
3517    /// committed nose its march fails from Mach 3.96, where the tangent cone's start holds; on a
3518    /// pointed body the choice changes nothing. Its JSON form is snake case.
3519    #[test]
3520    fn the_reports_own_start_is_kept_to_compare() {
3521        let radius = 1.125 * 0.0254;
3522        let area = PI * radius * radius;
3523        let nose = BodySegment::Profile {
3524            profile: Profile::nose(
3525                NoseShape::PowerSeries { exponent: 0.6369 },
3526                9.375 * 0.0254,
3527                radius,
3528            )
3529            .unwrap(),
3530        };
3531        let cylinder = BodySegment::Cylinder {
3532            length_m: 0.75,
3533            radius_m: radius,
3534        };
3535        let body = ShockExpansionBody::new(&[nose, cylinder], DEFAULT_ELEMENTS_PER_CURVE).unwrap();
3536        let reports = body.clone().with_handover_start(HandoverStart::Newtonian);
3537        assert!(body.slope(3.96, area).is_ok());
3538        assert!(matches!(
3539            reports.slope(3.96, area),
3540            Err(AeroError::Unsupported(_))
3541        ));
3542        let (a, b) = (
3543            body.slope(2.3, area).unwrap(),
3544            reports.slope(2.3, area).unwrap(),
3545        );
3546        assert!((a.slope_per_rad - b.slope_per_rad).abs() > 0.1);
3547        let pointed = body_of_cone();
3548        assert_eq!(
3549            pointed.slope(3.0, area).unwrap(),
3550            pointed
3551                .clone()
3552                .with_handover_start(HandoverStart::Newtonian)
3553                .slope(3.0, area)
3554                .unwrap()
3555        );
3556        assert_eq!(
3557            serde_json::to_string(&HandoverStart::TangentCone).unwrap(),
3558            "\"tangent_cone\""
3559        );
3560        let back: HandoverStart = serde_json::from_str("\"newtonian\"").unwrap();
3561        assert_eq!(back, HandoverStart::Newtonian);
3562    }
3563
3564    fn body_of_cone() -> ShockExpansionBody {
3565        body(false, 3.0, 2.0, DEFAULT_ELEMENTS_PER_CURVE)
3566    }
3567
3568    /// A cap that shrinks to nothing doesn't reach the cone it sits on: the march starts from the
3569    /// tangent cone at the handover, and carries that cone's total pressure the whole way, however
3570    /// small the cap. A power-series nose of `n` = 0.99, four calibres long, is a 7.1° cone but for
3571    /// a tip 1e-55 calibres across, yet at Mach 4 its cylinder carries 1.21 per radian where the
3572    /// cone's carries 1.37, because the march runs on the 24° cone's total pressure (107 free
3573    /// streams against the 7.1° cone's 151). The noses themselves agree to 0.01. Pinned so the
3574    /// limit is visible and any fix shows here ([issue #101](https://github.com/nrdptel/hpr-sim/issues/101)).
3575    #[test]
3576    fn a_vanishing_cap_does_not_reach_the_cone_it_sits_on() {
3577        let area = 0.25 * PI;
3578        let body = |exponent| {
3579            ShockExpansionBody::new(
3580                &[
3581                    BodySegment::Profile {
3582                        profile: Profile::nose(NoseShape::PowerSeries { exponent }, 4.0, 0.5)
3583                            .unwrap(),
3584                    },
3585                    BodySegment::Cylinder {
3586                        length_m: 6.0,
3587                        radius_m: 0.5,
3588                    },
3589                ],
3590                DEFAULT_ELEMENTS_PER_CURVE,
3591            )
3592            .unwrap()
3593        };
3594        let blunt = body(0.99);
3595        let cone = body(1.0);
3596        assert!(blunt.has_blunt_tip() && !cone.has_blunt_tip());
3597        let (blunt, cone) = (
3598            blunt.segment_slopes(4.0, area).unwrap(),
3599            cone.segment_slopes(4.0, area).unwrap(),
3600        );
3601        let near = |got: f64, want: f64, tol: f64, what: &str| {
3602            assert!(
3603                (got - want).abs() <= tol,
3604                "{what}: {got} against {want} ± {tol}"
3605            );
3606        };
3607        near(
3608            blunt[0].slope_per_rad,
3609            cone[0].slope_per_rad,
3610            0.01,
3611            "the noses",
3612        );
3613        near(
3614            blunt[1].slope_per_rad,
3615            1.211,
3616            5e-3,
3617            "the cylinder behind the vanishing cap",
3618        );
3619        near(cone[1].slope_per_rad, 1.374, 5e-3, "the cone's cylinder");
3620    }
3621
3622    /// A blunt nose can take more than one segment, and the cap hands over wherever its slope
3623    /// falls to the handover's, but never past the nose (M1.8e18).
3624    ///
3625    /// TN D-4865's model 2 is a 0.257-diameter sphere blended into a 2.75° cone by a 0.429 arc,
3626    /// and the sphere is still at 38.3° where the arc takes over, steeper than the handover's 24°
3627    /// cap at any Mach number. The handover is on the arc. A cylinder behind a nose is not the
3628    /// nose, so a cap that reaches one is refused instead of handing over at no angle at all,
3629    /// with none of the total pressure the tip took out of the flow.
3630    #[test]
3631    fn a_blunt_nose_hands_over_on_a_later_segment_but_never_past_the_nose() {
3632        // Model 2's nose, in base diameters, as `xtask/src/aero_flare.rs` builds it.
3633        let (sphere_end_x, sphere_end_r) = (0.097_751_728_849_191_7, 0.201_715_116_279_069_8);
3634        let (nose_end_x, nose_end_r) = (0.342_995_992_350_487_5, 0.293_505_957_802_043_8);
3635        let arc = Profile::transition(
3636            NoseShape::Ogive {
3637                radius_ratio: 1.148_551_684_394_344_9,
3638            },
3639            nose_end_x - sphere_end_x,
3640            sphere_end_r,
3641            nose_end_r,
3642            false,
3643        )
3644        .unwrap();
3645        let cone = Profile::transition(
3646            NoseShape::Conical {},
3647            0.757_619_879_544_843_6,
3648            nose_end_r,
3649            0.329_897_050_227_035_4,
3650            false,
3651        )
3652        .unwrap();
3653        let segments = [
3654            BodySegment::SphericalCap {
3655                radius_m: 0.257,
3656                length_m: sphere_end_x,
3657            },
3658            BodySegment::Profile { profile: arc },
3659            BodySegment::Profile { profile: cone },
3660        ];
3661        let body = ShockExpansionBody::new(&segments, DEFAULT_ELEMENTS_PER_CURVE).unwrap();
3662        for mach in [1.6, 2.0, 3.0, 4.63] {
3663            let handover = body.handover_m(mach).unwrap().expect("a blunt tip");
3664            assert!(
3665                handover > sphere_end_x && handover < nose_end_x,
3666                "Mach {mach}: the handover is at {handover}, not on the blend arc"
3667            );
3668            let angle = crate::blunt_tip::handover_angle_rad(mach)
3669                .unwrap()
3670                .min(crate::blunt_tip::MAX_HANDOVER_RAD);
3671            let slope = arc.radius_and_slope(handover - sphere_end_x).1;
3672            assert!(
3673                (slope.atan() - angle).abs() < 1e-12,
3674                "Mach {mach}: the handover is at {}°, not the handover's {}°",
3675                slope.atan().to_degrees(),
3676                angle.to_degrees()
3677            );
3678            // The cap is still the sphere plus part of the arc, so the march starts behind it.
3679            body.slope(mach, 0.25 * PI).expect("model 2's nose marches");
3680        }
3681        // A curved segment that narrows is a boattail, so a cap may not reach one either: the
3682        // handover would land on its fore end at a slope of zero, with none of the total pressure
3683        // the tip took out of the flow.
3684        let boattail = [
3685            BodySegment::SphericalCap {
3686                radius_m: 0.5,
3687                length_m: 0.1,
3688            },
3689            BodySegment::Profile {
3690                profile: Profile::transition(NoseShape::TANGENT_OGIVE, 0.4, 0.3, 0.2, false)
3691                    .unwrap(),
3692            },
3693        ];
3694        let boattail = ShockExpansionBody::new(&boattail, DEFAULT_ELEMENTS_PER_CURVE).unwrap();
3695        for mach in [1.5, 2.0, 3.0] {
3696            let err = boattail
3697                .handover_m(mach)
3698                .expect_err("a cap that reaches a boattail")
3699                .to_string();
3700            assert!(err.contains("all the way to its end"), "Mach {mach}: {err}");
3701        }
3702        // A pointed nose is one segment however many curved shapes follow it, so a curved
3703        // widening transition behind one is still the afterbody and a reduced element there is
3704        // still refused: only a spherical cap, which is a piece of a nose rather than a whole
3705        // one, carries the nose past its own segment.
3706        let pointed = [
3707            BodySegment::Profile {
3708                profile: Profile::nose(NoseShape::TANGENT_OGIVE, 1.0, 0.25).unwrap(),
3709            },
3710            BodySegment::Profile {
3711                profile: Profile::transition(
3712                    NoseShape::Ogive { radius_ratio: 2.0 },
3713                    0.4,
3714                    0.25,
3715                    0.35,
3716                    false,
3717                )
3718                .unwrap(),
3719            },
3720        ];
3721        let pointed = ShockExpansionBody::new(&pointed, DEFAULT_ELEMENTS_PER_CURVE).unwrap();
3722        assert_eq!(
3723            super::nose_segments(&pointed.segments),
3724            1,
3725            "a pointed nose is one segment"
3726        );
3727        // A cylinder behind a nose is not the nose: the cap may not reach it.
3728        let steep = [
3729            BodySegment::Profile {
3730                profile: Profile::nose(NoseShape::PowerSeries { exponent: 0.6369 }, 4.17, 0.5)
3731                    .unwrap(),
3732            },
3733            BodySegment::Cylinder {
3734                length_m: 4.0,
3735                radius_m: 0.5,
3736            },
3737        ];
3738        let steep = ShockExpansionBody::new(&steep, DEFAULT_ELEMENTS_PER_CURVE).unwrap();
3739        let err = steep
3740            .handover_m(1.2)
3741            .expect_err("a cap that reaches the cylinder")
3742            .to_string();
3743        assert!(err.contains("all the way to its end"), "{err}");
3744    }
3745
3746    /// Just above the Mach number where a blunt tip's handover first falls on its nose (its
3747    /// slope at the nose's end), the method holds, and just below it doesn't, at every offset
3748    /// from 1e-15 to 1e-3: the nose's elements, packed into nanometers there, merge rather than
3749    /// meet at corners lost in rounding. TN D-4865's sphere-cone and two power-series noses.
3750    #[test]
3751    fn a_blunt_tip_holds_from_where_its_handover_first_falls_on_the_nose() {
3752        let (radius, half_angle) = (0.175_f64, 11.5_f64.to_radians());
3753        let tangent_r = radius * half_angle.cos();
3754        let sphere_cone = [
3755            BodySegment::SphericalCap {
3756                radius_m: radius,
3757                length_m: radius * (1.0 - half_angle.sin()),
3758            },
3759            BodySegment::Profile {
3760                profile: Profile::transition(
3761                    NoseShape::Conical {},
3762                    (0.5 - tangent_r) / half_angle.tan(),
3763                    tangent_r,
3764                    0.5,
3765                    false,
3766                )
3767                .unwrap(),
3768            },
3769        ];
3770        let power = |exponent: f64, length_m: f64| {
3771            [
3772                BodySegment::Profile {
3773                    profile: Profile::nose(NoseShape::PowerSeries { exponent }, length_m, 0.5)
3774                        .unwrap(),
3775                },
3776                BodySegment::Cylinder {
3777                    length_m: 4.0,
3778                    radius_m: 0.5,
3779                },
3780            ]
3781        };
3782        for segments in [sphere_cone, power(0.6369, 4.17), power(0.5, 2.28)] {
3783            let body = ShockExpansionBody::new(&segments, DEFAULT_ELEMENTS_PER_CURVE).unwrap();
3784            let first = segments[0];
3785            let end_angle = first.radius_and_slope(first.length_m()).1.atan();
3786            let (mut low, mut high) = (1.0 + 1e-9, 3.0);
3787            for _ in 0..200 {
3788                let mid = 0.5 * (low + high);
3789                if crate::blunt_tip::handover_angle_rad(mid).unwrap() < end_angle {
3790                    low = mid;
3791                } else {
3792                    high = mid;
3793                }
3794            }
3795            for k in 0..=12 {
3796                for step in [1.0, 2.0, 5.0] {
3797                    let d = step * 10f64.powi(-15 + k);
3798                    assert!(
3799                        body.slope(high + d, 0.25 * PI).is_ok(),
3800                        "{segments:?}: fails {d} above Mach {high}"
3801                    );
3802                    assert!(
3803                        body.slope(high - d, 0.25 * PI).is_err(),
3804                        "{segments:?}: holds {d} below Mach {high}"
3805                    );
3806                }
3807            }
3808        }
3809    }
3810    /// A pointed 2.75° cone, five calibres of tube, and a conical flare of `flare_deg`. The two
3811    /// angles are TN D-4865 model 2's; the layout is **not** (that model is blunt-nosed and has no
3812    /// tube), and the edge below depends on the tube, which sets the flow reaching the flare. The
3813    /// method is inviscid, so only angles and ratios of lengths to radii matter.
3814    fn flared_body(flare_deg: f64) -> Vec<BodySegment> {
3815        let radius_m = 0.1;
3816        let flare_length_m = 0.3;
3817        vec![
3818            BodySegment::Profile {
3819                profile: Profile::nose(
3820                    NoseShape::Conical {},
3821                    radius_m / 2.75_f64.to_radians().tan(),
3822                    radius_m,
3823                )
3824                .unwrap(),
3825            },
3826            BodySegment::Cylinder {
3827                length_m: 1.0,
3828                radius_m,
3829            },
3830            BodySegment::Profile {
3831                profile: Profile::transition(
3832                    NoseShape::Conical {},
3833                    flare_length_m,
3834                    radius_m,
3835                    radius_m + flare_length_m * flare_deg.to_radians().tan(),
3836                    false,
3837                )
3838                .unwrap(),
3839            },
3840        ]
3841    }
3842
3843    /// Whether the method marches that body at `mach`.
3844    fn flare_marches(mach: f64, flare_deg: f64) -> Result<(), AeroError> {
3845        ShockExpansionBody::new(&flared_body(flare_deg), DEFAULT_ELEMENTS_PER_CURVE)?
3846            .slope(mach, PI * 0.01)
3847            .map(|_| ())
3848    }
3849
3850    /// The steepest flare the method marches at `mach`, bisected to f64 resolution: the last angle
3851    /// that returns a slope, with the first that doesn't a bit above it.
3852    ///
3853    /// The marchable set is not an interval: a band of very shallow flares, under a degree on
3854    /// this body, is refused because the pressure behind the corner moves away from its tangent
3855    /// cone's. So the bracket's lower end is asserted to march rather than assumed.
3856    fn steepest_flare_deg(mach: f64) -> f64 {
3857        let (mut lo, mut hi) = (1.0_f64, 45.0_f64);
3858        assert!(
3859            flare_marches(mach, lo).is_ok(),
3860            "Mach {mach}: 1° already fails, so the shallow band this brackets above has moved"
3861        );
3862        assert!(flare_marches(mach, hi).is_err(), "Mach {mach}: 45° marches");
3863        loop {
3864            let mid = 0.5 * (lo + hi);
3865            if mid <= lo || mid >= hi {
3866                return lo;
3867            }
3868            if flare_marches(mach, mid).is_ok() {
3869                lo = mid;
3870            } else {
3871                hi = mid;
3872            }
3873        }
3874    }
3875
3876    /// The body the tests' flared rocket puts ahead of its flare: an ogive nose 0.25 m long on a
3877    /// 27 mm radius and a 0.7 m tube, which is what delivers the flow to the flare's corner.
3878    fn ahead_of_the_flare() -> Vec<BodySegment> {
3879        vec![
3880            BodySegment::Profile {
3881                profile: Profile::nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.25, 0.027)
3882                    .unwrap(),
3883            },
3884            BodySegment::Cylinder {
3885                length_m: 0.7,
3886                radius_m: 0.027,
3887            },
3888        ]
3889    }
3890
3891    /// That body with a conical flare 0.3 m long of `deg` on the back of it.
3892    fn with_a_flare(deg: f64) -> Vec<BodySegment> {
3893        let mut segments = ahead_of_the_flare();
3894        segments.push(BodySegment::Profile {
3895            profile: Profile::transition(
3896                NoseShape::Conical {},
3897                0.3,
3898                0.027,
3899                0.027 + 0.3 * deg.to_radians().tan(),
3900                false,
3901            )
3902            .unwrap(),
3903        });
3904        segments
3905    }
3906
3907    /// Which turns the march reduces to the generalized method is a property of the corner's own
3908    /// state, and [`flare_reduction_turns_rad`] solves for the two that bound them: the crossing,
3909    /// where the pressure behind the corner lands on its tangent cone's, and the balance, where
3910    /// the corner's own compression cancels the gradient the body ahead delivers. An element is
3911    /// reduced strictly between them, in whichever order they come. On this body the crossing is
3912    /// the shallower from about Mach 1.5 up and the deeper below it.
3913    ///
3914    /// The three angles [issue #117](https://github.com/nrdptel/hpr-sim/issues/117) reported,
3915    /// each found by bisecting the model's own refusal, come back out of those two equations: the
3916    /// band's edges are the crossing at Mach 4.70 and the balance at Mach 5, and the shallowest
3917    /// angle that lifted the join's start is the crossing at Mach 2.20.
3918    ///
3919    /// **What is left is the tangent cone's own accuracy, not the search's.** Below
3920    /// [`SLENDER_CONE_RAD`] the tangent cone is slender-cone theory's closed form and the
3921    /// crossing solves to the last bits of an `f64`; above it the cone flow is a Taylor–Maccoll
3922    /// integration, and the residual is that integration's, about 1e-10 of the free stream's
3923    /// pressure. Dividing by the gap's slope in the turn, that is about 2e-10°, which is the
3924    /// spread the three platforms of CI showed on the band's lower edge.
3925    #[test]
3926    fn the_turns_a_reduced_element_lies_between_come_from_the_corners_own_state() {
3927        let ahead = ShockExpansionBody::new(&ahead_of_the_flare(), DEFAULT_ELEMENTS_PER_CURVE)
3928            .expect("the body ahead of the flare");
3929        let turns = |mach: f64| flare_reduction_turns_rad(&ahead.aft_flow(mach).unwrap()).unwrap();
3930        // The three angles issue #117 quoted, from the corner's state instead of a bisection.
3931        // The two past Mach 4 are held to 2e-9°, ten times the 2e-10° the tangent cone's own
3932        // integration leaves in them (see the residuals at the end): pinning them tighter would
3933        // be a statement about one machine.
3934        //
3935        // These three are **regression pins on the solver's own answer**, not measurements of the
3936        // march: they catch a change in either equation or in the cone flow beneath them. What
3937        // checks the answer against something else is the `±1e-6` relative probe below, which
3938        // brackets each edge against the march's own reduction to about 4e-8°.
3939        assert!(
3940            (turns(4.7).crossing_rad.to_degrees() - 0.038_161_270_2).abs() < 2e-9,
3941            "the band's lower edge is {}°",
3942            turns(4.7).crossing_rad.to_degrees()
3943        );
3944        assert!(
3945            (turns(5.0).balance_rad.to_degrees() - 0.058_820_517_4).abs() < 2e-9,
3946            "the band's upper edge is {}°",
3947            turns(5.0).balance_rad.to_degrees()
3948        );
3949        // This one's tangent cone is slender-cone theory's closed form, so it carries its digits.
3950        assert!(
3951            (turns(2.2).crossing_rad.to_degrees() - 0.000_901_824_655).abs() < 5e-12,
3952            "the join's shallowest step is at {}°",
3953            turns(2.2).crossing_rad.to_degrees()
3954        );
3955        // A turn strictly between the two is reduced, and one outside is not. The march's own
3956        // reading of it: a reduced element carries no decay (`η = 0`).
3957        let reduced = |mach: f64, deg: f64| {
3958            let body = ShockExpansionBody::new(&with_a_flare(deg), DEFAULT_ELEMENTS_PER_CURVE)
3959                .expect("a flared body");
3960            let flows = body.element_flows(mach).expect("a march over it");
3961            flows[flows.len() - 1].decay_per_m == 0.0
3962        };
3963        for mach in [2.0, 2.2, 3.0, 4.0, 4.3, 4.65, 4.7, 5.0] {
3964            let both = turns(mach);
3965            let (low, high) = (
3966                both.crossing_rad.min(both.balance_rad).to_degrees(),
3967                both.crossing_rad.max(both.balance_rad).to_degrees(),
3968            );
3969            // From Mach 1.5 up on this body the crossing is the shallower of the two; which one
3970            // is, though, is not part of the rule, and it swaps below that (see the end).
3971            assert!(low == both.crossing_rad.to_degrees(), "Mach {mach}");
3972            for (deg, want) in [
3973                (low * (1.0 - 1e-6), false),
3974                (low * (1.0 + 1e-6), true),
3975                (0.5 * (low + high), true),
3976                (high * (1.0 - 1e-6), true),
3977                (high * (1.0 + 1e-6), false),
3978            ] {
3979                assert_eq!(
3980                    reduced(mach, deg),
3981                    want,
3982                    "Mach {mach}: {deg}° should{} be reduced, between {low}° and {high}°",
3983                    if want { "" } else { " not" }
3984                );
3985            }
3986        }
3987        // The residual each solution left: `p₂ − p_c` at the crossing and `(∂p/∂s)₂` at the
3988        // balance. The balance is trigonometry and closes to the last bits of an `f64`; so does
3989        // the crossing while its tangent cone is slender-cone theory's closed form (Mach 3's turn
3990        // is 0.0066°, under `SLENDER_CONE_RAD`'s 0.029°). Above that angle the cone flow is an
3991        // integration and the residual is its own, four orders larger, which is the whole point
3992        // of reporting it.
3993        assert!(turns(3.0).crossing_rad < SLENDER_CONE_RAD);
3994        assert!(turns(4.7).crossing_rad > SLENDER_CONE_RAD);
3995        for mach in [2.0, 2.2, 3.0] {
3996            assert!(
3997                turns(mach).crossing_residual_p0.abs() < 2e-14,
3998                "Mach {mach} leaves {} of the pressure at the crossing",
3999                turns(mach).crossing_residual_p0
4000            );
4001        }
4002        for mach in [4.3, 4.65, 4.7, 5.0] {
4003            let left = turns(mach).crossing_residual_p0.abs();
4004            assert!(
4005                left < 1e-9,
4006                "Mach {mach} leaves {left} of the pressure at the crossing"
4007            );
4008        }
4009        for mach in [2.0, 3.0, 4.7, 5.0] {
4010            assert!(
4011                turns(mach).balance_residual_p0_per_m.abs() < 2e-14,
4012                "Mach {mach} leaves {} of the gradient at the balance",
4013                turns(mach).balance_residual_p0_per_m
4014            );
4015        }
4016        // Which of the two is the shallower is not part of the rule, and on this body it swaps
4017        // between Mach 1.46 and Mach 1.51. Both turns are under `NEARLY_PARALLEL_RAD` there, so a
4018        // flare of either angle is merged into the tube ahead of it and there is no corner to
4019        // reduce: nothing switches down there because nothing is drawn.
4020        for mach in [1.2, 1.46] {
4021            let both = turns(mach);
4022            assert!(
4023                both.balance_rad < both.crossing_rad,
4024                "at Mach {mach} the balance is {} rad and the crossing {} rad",
4025                both.balance_rad,
4026                both.crossing_rad
4027            );
4028            assert!(
4029                both.crossing_rad < NEARLY_PARALLEL_RAD,
4030                "at Mach {mach} a flare of {} rad would still be drawn",
4031                both.crossing_rad
4032            );
4033        }
4034        assert!(turns(1.51).crossing_rad < turns(1.51).balance_rad);
4035    }
4036
4037    /// A body long enough to hand the free stream's own pressure to a corner behind it still has
4038    /// both turns, and the crossing is a turn of nothing rather than a refusal.
4039    ///
4040    /// The crossing solves `p₂(θ) = p_c(δ₁ + θ)`, and where the body ahead has relaxed all the
4041    /// way back to the free stream both sides already agree at `θ = 0`. Round-tripping that turn
4042    /// through the isentropic relations instead of returning the state itself would leave a bit
4043    /// of noise there, and the sign of that bit would decide whether the answer came back at all
4044    /// (a different set of Mach rows on each platform). So `behind` short-circuits a turn of
4045    /// nothing, and the root is bracketed over the turns a widening corner can make rather than
4046    /// chased from a guess.
4047    #[test]
4048    fn a_corner_behind_a_relaxed_body_still_has_both_turns() {
4049        let mut relaxed = 0;
4050        for tube_m in [0.7_f64, 3.0, 6.0] {
4051            let segments = [
4052                BodySegment::Profile {
4053                    profile: Profile::nose(NoseShape::Ogive { radius_ratio: 1.0 }, 0.25, 0.027)
4054                        .unwrap(),
4055                },
4056                BodySegment::Cylinder {
4057                    length_m: tube_m,
4058                    radius_m: 0.027,
4059                },
4060            ];
4061            let body = ShockExpansionBody::new(&segments, DEFAULT_ELEMENTS_PER_CURVE).unwrap();
4062            let mut mach = 1.2;
4063            while mach < 5.0 {
4064                let aft = body.aft_flow(mach).expect("a march to the aft end");
4065                let turns = flare_reduction_turns_rad(&aft)
4066                    .unwrap_or_else(|e| panic!("a {tube_m} m tube at Mach {mach}: {e}"));
4067                assert!(
4068                    turns.crossing_rad.is_finite() && turns.balance_rad.is_finite(),
4069                    "a {tube_m} m tube at Mach {mach}"
4070                );
4071                if aft.pressure_ratio == 1.0 {
4072                    relaxed += 1;
4073                    assert_eq!(turns.crossing_rad, 0.0);
4074                    assert_eq!(turns.crossing_residual_p0, 0.0);
4075                }
4076                mach += 0.1;
4077            }
4078        }
4079        assert!(
4080            relaxed > 20,
4081            "only {relaxed} rows reached the free stream's pressure"
4082        );
4083    }
4084
4085    /// A body of a nose, a tube and a conical flare, for measuring what the crossing costs.
4086    fn nosed_tube_and_flare(
4087        cone_deg: Option<f64>,
4088        radius_m: f64,
4089        tube_m: f64,
4090        flare_m: f64,
4091        flare_deg: f64,
4092    ) -> Vec<BodySegment> {
4093        let (shape, nose_m) = match cone_deg {
4094            Some(deg) => (NoseShape::Conical {}, radius_m / deg.to_radians().tan()),
4095            None => (NoseShape::Ogive { radius_ratio: 1.0 }, 0.25),
4096        };
4097        let mut segments = vec![
4098            BodySegment::Profile {
4099                profile: Profile::nose(shape, nose_m, radius_m).unwrap(),
4100            },
4101            BodySegment::Cylinder {
4102                length_m: tube_m,
4103                radius_m,
4104            },
4105        ];
4106        if flare_m > 0.0 {
4107            segments.push(BodySegment::Profile {
4108                profile: Profile::transition(
4109                    NoseShape::Conical {},
4110                    flare_m,
4111                    radius_m,
4112                    radius_m + flare_m * flare_deg.to_radians().tan(),
4113                    false,
4114                )
4115                .unwrap(),
4116            });
4117        }
4118        segments
4119    }
4120
4121    /// What the crossing costs is the loading's gap at the pole times the element that holds it,
4122    /// so it is not bounded by the tests' rocket, and it is crossed in the Mach number as well as
4123    /// in the flare's angle.
4124    ///
4125    /// [`the_turns_a_reduced_element_lies_between_come_from_the_corners_own_state`] shows where the
4126    /// march reduces an element; where a drawn flare's angle sweeps past the crossing the loading
4127    /// steps, because `η` has a pole there. The whole rocket of
4128    /// `a_near_flat_flare_reads_through_and_leaves_only_the_corners_crossing` puts that step at
4129    /// +0.129% at worst, but that is one body, one flare length and one place to measure. On the
4130    /// body alone it is an order larger, it **grows with the flare's length**, and shortening the
4131    /// tube ahead of the corner moves the whole region from thousandths of a degree to degrees,
4132    /// where real flares live. None of those four is a bound, but there is one, and it is exact:
4133    /// both branches are constant along a conical flare, so the step is
4134    /// [`ReductionTurns::crossing_loading_gap_per_rad`] times `2π ∫ r dx / A_ref`, which this
4135    /// checks against the measured step at four flare lengths.
4136    ///
4137    /// The same pole is crossed in Mach at a fixed angle, and the table's rows are 0.05 Mach
4138    /// apart, so a flight reads it as a step between two adjacent rows. Before
4139    /// [M1.8e19](https://github.com/nrdptel/hpr-sim/blob/main/docs/ROADMAP.md) the reduced rows
4140    /// were refused, so the table stopped above them and the join covered the pole; it is now
4141    /// inside the table. That trade is the milestone's, and this test is its size.
4142    #[test]
4143    fn what_the_crossing_costs_is_the_loading_gap_times_the_element_that_holds_it() {
4144        // Either side of a body's own crossing, on the body alone: the fraction its `C_Nα` moves
4145        // and how far its center of pressure moves, in calibres of the tube ahead of the flare.
4146        let across = |cone_deg: Option<f64>,
4147                      radius_m: f64,
4148                      tube_m: f64,
4149                      flare_m: f64,
4150                      mach: f64|
4151         -> (f64, f64) {
4152            let ahead = ShockExpansionBody::new(
4153                &nosed_tube_and_flare(cone_deg, radius_m, tube_m, 0.0, 0.0),
4154                DEFAULT_ELEMENTS_PER_CURVE,
4155            )
4156            .expect("the body ahead");
4157            let turns = flare_reduction_turns_rad(&ahead.aft_flow(mach).expect("its aft flow"))
4158                .expect("its crossing");
4159            let deg = turns.crossing_rad.to_degrees();
4160            let area = PI * radius_m * radius_m;
4161            let read = |flare_deg: f64| {
4162                ShockExpansionBody::new(
4163                    &nosed_tube_and_flare(cone_deg, radius_m, tube_m, flare_m, flare_deg),
4164                    DEFAULT_ELEMENTS_PER_CURVE,
4165                )
4166                .expect("a flared body")
4167                .slope(mach, area)
4168                .expect("its slope")
4169            };
4170            let (below, above) = (read(deg * (1.0 - 1e-6)), read(deg * (1.0 + 1e-6)));
4171            (
4172                above.slope_per_rad / below.slope_per_rad - 1.0,
4173                (above.center_of_pressure_m - below.center_of_pressure_m) / (2.0 * radius_m),
4174            )
4175        };
4176        // The tests' rocket's body, then the same body with a flare seven times as long, then
4177        // with the tube cut from 0.7 m to 0.1 m, then a 10° cone in front instead of an ogive.
4178        for (label, cone, tube, flare, mach, force, calibres) in [
4179            (
4180                "as the rocket has it",
4181                None,
4182                0.7,
4183                0.3,
4184                5.0,
4185                0.004_045,
4186                0.064,
4187            ),
4188            ("a 2 m flare", None, 0.7, 2.0, 5.0, 0.026_058, 0.761),
4189            ("a 0.1 m tube", None, 0.1, 0.3, 5.0, 0.043_396, 0.186),
4190            (
4191                "a 10° cone, 0.3 m tube",
4192                Some(10.0),
4193                0.3,
4194                0.3,
4195                5.0,
4196                0.038_235,
4197                0.251,
4198            ),
4199        ] {
4200            let (moved, shifted) = across(cone, 0.027, tube, flare, mach);
4201            assert!(
4202                (moved - force).abs() < 0.03 * force
4203                    && (shifted - calibres).abs() < 0.03 * calibres,
4204                "{label}: the crossing moves the body's slope {moved:+.6} and its center of \
4205                 pressure {shifted:+.4} calibres"
4206            );
4207        }
4208        // And it is not a sample either: the step is the loading's gap at the pole times the
4209        // element that holds it, and both branches are constant along a conical flare, so eq.
4210        // 19's integral is a constant's. Against the measured step at four flare lengths:
4211        let ahead = ShockExpansionBody::new(
4212            &nosed_tube_and_flare(None, 0.027, 0.7, 0.0, 0.0),
4213            DEFAULT_ELEMENTS_PER_CURVE,
4214        )
4215        .expect("the body ahead");
4216        let turns = flare_reduction_turns_rad(&ahead.aft_flow(5.0).expect("its aft flow"))
4217            .expect("its turns");
4218        assert!(
4219            (turns.crossing_loading_gap_per_rad - 6.116_195e-4).abs() < 1e-9,
4220            "the loading gap at the crossing is {}",
4221            turns.crossing_loading_gap_per_rad
4222        );
4223        let area = PI * 0.027 * 0.027;
4224        for flare_m in [0.3_f64, 1.0, 2.0, 5.0] {
4225            let read = |flare_deg: f64| {
4226                ShockExpansionBody::new(
4227                    &nosed_tube_and_flare(None, 0.027, 0.7, flare_m, flare_deg),
4228                    DEFAULT_ELEMENTS_PER_CURVE,
4229                )
4230                .expect("a flared body")
4231                .slope(5.0, area)
4232                .expect("its slope")
4233                .slope_per_rad
4234            };
4235            let deg = turns.crossing_rad.to_degrees();
4236            let measured = read(deg * (1.0 + 1e-7)) - read(deg * (1.0 - 1e-7));
4237            let aft_radius_m = 0.027 + flare_m * turns.crossing_rad.tan();
4238            let closed = 2.0 * PI / area
4239                * turns.crossing_loading_gap_per_rad
4240                * 0.5
4241                * (0.027 + aft_radius_m)
4242                * flare_m;
4243            assert!(
4244                (measured / closed - 1.0).abs() < 1e-5,
4245                "a {flare_m} m flare steps {measured:+.8}, against the closed form's {closed:+.8}"
4246            );
4247        }
4248
4249        // And in the Mach number, on the table's own 0.05 grid. A 1° flare on the short-tubed
4250        // body: the rows below Mach 2.95 are the reduced ones, and the first row above them is
4251        // where the reading steps.
4252        let body = ShockExpansionBody::new(
4253            &nosed_tube_and_flare(None, 0.027, 0.1, 0.3, 1.0),
4254            DEFAULT_ELEMENTS_PER_CURVE,
4255        )
4256        .expect("the short-tubed flared body");
4257        let area = PI * 0.027 * 0.027;
4258        let row = |step: usize| {
4259            // The table's own grid: `SUPERSONIC_STEPS_PER_MACH` rows to the Mach number.
4260            let mach = step as f64 / 20.0;
4261            let slope = body.slope(mach, area).expect("a slope at every row");
4262            let flows = body.element_flows(mach).expect("a march at every row");
4263            (
4264                slope.slope_per_rad,
4265                slope.center_of_pressure_m,
4266                flows[flows.len() - 1].decay_per_m == 0.0,
4267            )
4268        };
4269        for step in 56..=58 {
4270            assert!(row(step).2, "Mach {} should be reduced", step as f64 / 20.0);
4271        }
4272        for step in 59..=62 {
4273            assert!(!row(step).2, "Mach {} should not be", step as f64 / 20.0);
4274        }
4275        let (before, after) = (row(58), row(59));
4276        let moved = after.0 / before.0 - 1.0;
4277        let shifted = (after.1 - before.1) / 0.054;
4278        assert!(
4279            (moved + 0.027_72).abs() < 5e-5 && (shifted + 0.137_5).abs() < 5e-4,
4280            "Mach 2.90 to 2.95 moves the slope {moved:+.5} and the center of pressure \
4281             {shifted:+.4} calibres"
4282        );
4283        // Its neighbours move by a fifth of that or less, so it is the pole and not the trend.
4284        for pair in [(56, 57), (57, 58), (59, 60), (60, 61)] {
4285            let step = row(pair.1).0 / row(pair.0).0 - 1.0;
4286            assert!(
4287                step.abs() < 0.2 * moved.abs(),
4288                "Mach {} to {} moves the slope {step:+.5}",
4289                pair.0 as f64 / 20.0,
4290                pair.1 as f64 / 20.0
4291            );
4292        }
4293    }
4294
4295    /// The gap between the pressure behind a corner and its tangent cone's is not promised to
4296    /// have one zero, and where it has three the element it reduces is two bands rather than one.
4297    ///
4298    /// On a 25° cone with 0.02 m of tube behind it at Mach 7, `p₂ − p_c` vanishes at about 0.91°,
4299    /// 7.3° and 24°, so a 0.1 m flare is reduced from 0.91° to 3.88° **and again** from 7.3° to
4300    /// 24°. [`flare_reduction_turns_rad`] sweeps the widening turns before it brackets, so it
4301    /// reports that rather than returning whichever root it walked to.
4302    #[test]
4303    fn a_corner_whose_gap_has_three_zeros_is_refused_rather_than_guessed_at() {
4304        let ahead = ShockExpansionBody::new(
4305            &nosed_tube_and_flare(Some(25.0), 0.027, 0.02, 0.0, 0.0),
4306            DEFAULT_ELEMENTS_PER_CURVE,
4307        )
4308        .expect("the blunt-shouldered body");
4309        let aft = ahead.aft_flow(7.0).expect("its aft flow");
4310        let Err(AeroError::Unsupported(why)) = flare_reduction_turns_rad(&aft) else {
4311            panic!("a corner whose gap has three zeros should not report one band");
4312        };
4313        assert!(
4314            why.contains("meets its tangent cone's 3 times"),
4315            "it reported: {why}"
4316        );
4317        // The march's own flag either side of the second band, which is what makes it real.
4318        let reduced = |deg: f64| {
4319            ShockExpansionBody::new(
4320                &nosed_tube_and_flare(Some(25.0), 0.027, 0.02, 0.1, deg),
4321                DEFAULT_ELEMENTS_PER_CURVE,
4322            )
4323            .expect("a flared body")
4324            .element_flows(7.0)
4325            .map(|flows| flows[flows.len() - 1].decay_per_m == 0.0)
4326        };
4327        for (deg, want) in [
4328            (0.5, false),
4329            (3.0, true),
4330            (6.0, false),
4331            (10.0, true),
4332            (22.0, true),
4333            (25.0, false),
4334        ] {
4335            assert_eq!(
4336                reduced(deg),
4337                Ok(want),
4338                "a {deg}° flare on that body at Mach 7"
4339            );
4340        }
4341    }
4342
4343    /// The method's flare limit is the corner's **isentropic** turn running out (the flow reaching
4344    /// the flare turned to Mach 1), and not the shock detaching, which is a different angle on
4345    /// either side of it (M1.8e14, ADR-045 in `docs/DECISIONS.md`).
4346    ///
4347    /// Second-order shock-expansion fixes the pressure just behind a corner from the Prandtl and
4348    /// Meyer turn there (TN 3527 pp. 7-8, the first of eq. 3's three conditions; `ν` itself is
4349    /// NACA 1135 eq. 171c), so the march stops where `ν` reaches zero. The wedge's largest
4350    /// deflection ([`crate::blunt_tip::wedge_detachment_angle_rad`], NACA 1135) is a conservative
4351    /// stand-in for the flare's own boundary, not the boundary itself: a cone's shock holds to
4352    /// steeper angles, so only the side where the march stops **below** the wedge's angle proves
4353    /// anything. On this body it stops 0.18° short of it at Mach 1.5 and marches 3.5° past it at
4354    /// Mach 2, and which side is the tube's doing, so a march that returns a number is not on its
4355    /// own evidence the flare's shock is attached.
4356    #[test]
4357    fn a_flare_marches_to_the_isentropic_turn_not_to_detachment() {
4358        let edge_1_5 = steepest_flare_deg(1.5);
4359        let edge_2 = steepest_flare_deg(2.0);
4360        assert!(
4361            (edge_1_5 - 11.931_217_467_660).abs() < 1e-9,
4362            "Mach 1.5 edge {edge_1_5}"
4363        );
4364        assert!(
4365            (edge_2 - 26.471_403_089_0).abs() < 1e-9,
4366            "Mach 2 edge {edge_2}"
4367        );
4368        let detach = |mach: f64| {
4369            crate::blunt_tip::wedge_detachment_angle_rad(mach)
4370                .unwrap()
4371                .to_degrees()
4372        };
4373        // Short of detachment at Mach 1.5, past it at Mach 2: the two orders both happen.
4374        assert!(
4375            (detach(1.5) - edge_1_5 - 0.181_451).abs() < 1e-6,
4376            "Mach 1.5: detaches at {}, marches to {edge_1_5}",
4377            detach(1.5)
4378        );
4379        assert!(
4380            (edge_2 - detach(2.0) - 3.497_871).abs() < 1e-6,
4381            "Mach 2: detaches at {}, marches to {edge_2}",
4382            detach(2.0)
4383        );
4384        // Where they cross, bisected: below it the method stops before the shock detaches, above
4385        // it the method runs on past a shock that is already detached.
4386        let (mut lo, mut hi) = (1.5_f64, 2.0_f64);
4387        loop {
4388            let mid = 0.5 * (lo + hi);
4389            if mid <= lo || mid >= hi {
4390                break;
4391            }
4392            if steepest_flare_deg(mid) < detach(mid) {
4393                lo = mid;
4394            } else {
4395                hi = mid;
4396            }
4397        }
4398        assert!(
4399            (lo - 1.547_787_962_528).abs() < 1e-9,
4400            "the crossing is at Mach {lo}"
4401        );
4402    }
4403
4404    /// Above Mach 2.1297 the flare's limit is not the flow at all: it is where the cone tables
4405    /// stop (NASA SP-3007 Table 2, 30°), a limit of the reference data and not of the physics
4406    /// (M1.8e14, ADR-045 in `docs/DECISIONS.md`).
4407    ///
4408    /// The crossing is bisected to f64 resolution. Above it every Mach number gives the same
4409    /// edge, 30° plus the millionth of a degree [`cone_normal_force_slope`] admits for the
4410    /// degree conversion's rounding.
4411    #[test]
4412    fn past_mach_2_13_the_flare_stops_where_the_cone_tables_do() {
4413        let (mut lo, mut hi) = (1.5_f64, 6.0_f64);
4414        loop {
4415            let mid = 0.5 * (lo + hi);
4416            if mid <= lo || mid >= hi {
4417                break;
4418            }
4419            if steepest_flare_deg(mid) < 30.0 {
4420                lo = mid;
4421            } else {
4422                hi = mid;
4423            }
4424        }
4425        assert!(
4426            (hi - 2.129_702_032_593).abs() < 1e-9,
4427            "the tables bind from Mach {hi}"
4428        );
4429        // At the crossing itself the corner's turn runs out at 30° exactly; above it the tables
4430        // bind first, and the edge sits a millionth of a degree past 30°.
4431        let at_crossing = steepest_flare_deg(hi);
4432        assert!(
4433            (at_crossing - 30.0).abs() < 1e-9,
4434            "at the crossing the edge is {at_crossing}"
4435        );
4436        for mach in [2.13, 2.5, 3.0, 4.63, 5.0] {
4437            let edge = steepest_flare_deg(mach);
4438            assert!(
4439                (edge - 30.000_001).abs() < 1e-9,
4440                "Mach {mach}: the edge is {edge}, not the tables' 30°"
4441            );
4442            // Just past the edge: it is the tables that refuse, not the corner's turn.
4443            let err = flare_marches(mach, edge * (1.0 + 1e-12))
4444                .expect_err(&format!("Mach {mach}: {edge}° plus a part in 1e12 marched"))
4445                .to_string();
4446            assert!(err.contains("cone tables"), "Mach {mach}: {err}");
4447        }
4448        // Below the crossing it is the corner's turn that stops the march, not the tables.
4449        let err = flare_marches(2.0, 27.0)
4450            .expect_err("Mach 2: a 27° flare marched")
4451            .to_string();
4452        assert!(err.contains("can't turn through"), "Mach 2: {err}");
4453    }
4454
4455    /// A slope written before the move to US spelling, with `centre_of_pressure_m`, reads as the same
4456    /// slope.
4457    #[test]
4458    fn a_slope_with_the_old_uk_key_reads_the_same() {
4459        let slope = ShockExpansionSlope {
4460            slope_per_rad: 2.1,
4461            center_of_pressure_m: 0.37,
4462        };
4463        let text = serde_json::to_string(&slope).unwrap();
4464        assert!(text.contains("\"center_of_pressure_m\""), "{text}");
4465        let old = text.replace("center_of_pressure_m", "centre_of_pressure_m");
4466        assert_eq!(
4467            serde_json::from_str::<ShockExpansionSlope>(&old).unwrap(),
4468            slope
4469        );
4470    }
4471}