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}