Skip to main content

hpr_aero/
afterbody.rs

1//! The afterbody faster than sound: a boattail's own pressure drag, and the base pressure behind
2//! it.
3//!
4//! A boattail is a transition that narrows toward the tail. Below Mach 0.8 it keeps Niskanen's
5//! rule, a share of the base drag on its decrease in area (Niskanen 2009 eq. 3.88,
6//! [`crate::drag::boattail_factor`]). Faster than sound the air expands around the boattail's
7//! shoulder, its pressure falls below the free stream's, and it pulls back on the boattail: a
8//! wave drag the rule doesn't have. Behind the boattail the base's pressure rises, which lowers the
9//! base drag. On the boattail's fore (cylinder) area `A₁ = π d₁²/4`, for a boattail of length `l`
10//! from diameter `d₁` to `d₂`, area ratio `a = (d₂/d₁)²` and half-angle
11//! `θ = atan((d₁ − d₂)/(2l))`:
12//!
13//! - **Attached flow, from Mach 1** ([`Boattail::attached_pressure_drag`]): MIL-HDBK-762's chart
14//!   for conical boattails (Fig. 5-122, printed p. 5-187), `y = 4 C_D (l/d₁)²` against
15//!   `x = √(M² − 1)/(2 l/d₁)` for `a` from 0.25 to 0.80, read into [`conical_boattail_chart`].
16//!   The handbook cites no source for it; its values agree with Jack's second-order theory
17//!   (NACA TN 2972, 1953) within −10% to +8% for `a` up to 0.6 (`validation/fixtures/aero/`
18//!   `measured-boattails.json`). It is held to the **2D limit** `C_PM = −C_p,PM(M, θ)(1 − a)`:
19//!   the pressure behind a Prandtl–Meyer expansion through `θ`
20//!   ([`expansion_pressure_coefficient`]) over the whole annulus. On an axisymmetric boattail the
21//!   pressure recovers aft of the shoulder, so the drag stays below that limit and approaches it
22//!   as the boattail gets short against `β d₁` (the quasi-cylinder solution's recovery falls as
23//!   `1/x`). Past the chart's end at `x = 1.4`, the drag closes the chart's gap to the limit as
24//!   `1/x`: `C_D = [1 − (1 − r) 1.4/x] C_PM(M)`, with `r` the chart's share of the limit at
25//!   `x = 1.4` (at most 1).
26//! - **Separation** ([`Boattail::separation_weight`]): steep boattails separate. Cubbage's
27//!   boattails (NACA RM L57B21, 1957, Mach 0.6–1.28) stay attached at 16° and separate completely
28//!   by 30°, and a separated boattail sees about a cylinder's base pressure. Between 16° and 30°
29//!   the drag moves linearly in `θ` from the attached value to the base drag coefficient on the
30//!   annulus, `C_D,base(M)(1 − a)`.
31//! - **Through Mach 1**: MIL-HDBK-762 finds no method for boattails at transonic speeds and
32//!   advises extrapolating the supersonic drag "to peak value at a Mach number range of 1.0 ≤ M∞
33//!   ≤ 1.2, with a sharp reduction to a lower value at subsonic speeds" (p. 5-47). The chart's
34//!   near-sonic end is not used: from Mach 1 to 1.2 the attached drag is held at its Mach 1.2
35//!   value, below Mach 1 the drag falls on a straight line to the rule's value at Mach 0.8, where
36//!   the buildup's other transonic terms start (Niskanen p. 47), and the rule holds below. The
37//!   line is half-way up at Mach 0.9; measured boattails are half-way up by 0.89 (Compton, NASA
38//!   TN D-6789) to 0.92 to 0.96 (Cubbage), peak at Mach 1.0 to 1.1, and at 1.2 are 0.83 to 0.90
39//!   of that peak (Cubbage), so holding the Mach 1.2 value reads under the peak.
40//! - **The base behind a boattail** ([`boattail_base_pressure_ratio`]): MIL-HDBK-762 Fig. 5-141
41//!   (printed p. 5-210, after Rubin, Brazzel and Henderson, 1970) correlates a boattail's base
42//!   pressure with a cylinder's at Mach 2.5 to 3.5 as `p_cyl/p_bt = 0.442 + 0.558 a_b`, with
43//!   `a_b` the base's area over the cylinder's. hpr takes the cylinder's pressure from Love's
44//!   correlation (Fig. 5-139, printed p. 5-208; NACA TN 3819) and scales its own base drag by the
45//!   ratio of the two coefficients, `k = C_p,bt/C_p,cyl`. Below Mach 2.5, where the correlation
46//!   over-predicts the relief of the measured bases, `k` is held at its Mach 2.5 value, which
47//!   matches Cortright and Schroeder's at Mach 1.91 and de Moraes and Nowitzky's at 1.59; below
48//!   Mach 1 it returns to 1 by Mach 0.8, where the base drag is Niskanen's again. A separated
49//!   boattail gives no relief (weight as above).
50//!
51//! Narrowing parts of one smooth surface drag as one cone ([`crate::drag::BoattailTerm`]), and a
52//! lip behind a boattail sits in its wake ([`crate::drag::WakeTerm`]); every part between a
53//! boattail and what follows fades both, and the base's relief, so each is continuous in the
54//! geometry.
55//!
56//! Outside the data: the chart below `a = 0.25` (Jack's theory reaches 0.2), attached flow up to
57//! 16° where Jack's theory stops at 11°, Cubbage's separation angles (measured to Mach 1.28) at
58//! every Mach number, and Fig. 5-141 outside its bases' area ratios of 0.25 to 0.67 and angles to
59//! 14°. How well each piece agrees with the measurements is in the guide.
60//!
61//! Calisto's boattail, 0.472 calibres long from `a = 1` to 0.469 (18.4°), at Mach 1.5: the chart
62//! gives 0.219 and the 2D limit 0.211, so the attached drag is 0.211; separation takes it 17% of
63//! the way to the base's 0.088, to 0.190.
64//!
65//! ```
66//! use hpr_aero::afterbody::Boattail;
67//!
68//! let calisto = Boattail::new(0.06, 0.127, 0.087)?;
69//! assert!((calisto.attached_pressure_drag(1.5)? - 0.211).abs() < 5e-4);
70//! assert!((calisto.pressure_drag_coefficient(1.5)? - 0.190).abs() < 5e-4);
71//! # Ok::<(), hpr_aero::AeroError>(())
72//! ```
73//!
74//! See [Boattails faster than sound][guide] in the guide.
75//!
76//! [guide]: https://nrdptel.github.io/hpr-sim/physics/aero.html#boattails-faster-than-sound
77
78use serde::Serialize;
79
80use crate::drag::{base_drag_coefficient, boattail_factor, check_mach_any};
81use crate::error::{AeroError, check_dimension};
82
83/// The ratio of specific heats of air, `γ = 1.4`.
84pub(crate) const GAMMA: f64 = 1.4;
85
86/// `√((γ + 1)/(γ − 1))`, `√6` for `γ = 1.4`.
87const PM_K: f64 = 2.449_489_742_783_178;
88
89/// The largest turning angle of a Prandtl–Meyer expansion from Mach 1, to a vacuum:
90/// `(π/2)(√((γ + 1)/(γ − 1)) − 1)`, 130.45° (NACA Report 1135, 1953, eq. 172, p. 626).
91pub const MAX_TURNING_RAD: f64 = std::f64::consts::FRAC_PI_2 * (PM_K - 1.0);
92
93/// The boattail half-angle up to which the flow stays attached: 16°, Cubbage's steepest attached
94/// boattail (NACA RM L57B21).
95pub const SEPARATION_ONSET_RAD: f64 = 16.0 * std::f64::consts::PI / 180.0;
96
97/// The boattail half-angle from which the flow is separated: 30°, Cubbage's shallowest separated
98/// boattail (NACA RM L57B21).
99pub const SEPARATION_COMPLETE_RAD: f64 = 30.0 * std::f64::consts::PI / 180.0;
100
101/// The lowest Mach number of MIL-HDBK-762 Fig. 5-141's correlation, 2.5; below it the base
102/// pressure ratio is held.
103pub const BASE_RELIEF_MACH: f64 = 2.5;
104
105/// Where a boattail's drag starts its transonic rise and its base its relief: Mach 0.8, where
106/// the rest of the buildup starts its transonic methods ([`crate::drag::SUBSONIC_MACH_LIMIT`],
107/// Niskanen 2009 p. 47). An earlier draft used 0.9, chosen after seeing the Arcas Robin wind
108/// tunnel, a target (the decision record on the afterbody, [ADR-030][adr-030]).
109///
110/// [adr-030]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-030-the-afterbody-faster-than-sound-a-boattails-wave-drag-the-base-behind-it-and-a-lip-in-its-wake-2026-09-18
111pub const TRANSONIC_ONSET_MACH: f64 = crate::drag::SUBSONIC_MACH_LIMIT;
112
113/// Where the transonic rise ends: from Mach 1 a boattail takes its supersonic drag (held to
114/// [`SUPERSONIC_MODEL_MACH`]'s value) and its base the supersonic relief.
115pub const SUPERSONIC_MACH: f64 = 1.0;
116
117/// The lowest Mach number at which the supersonic boattail drag is evaluated, 1.2, the top of
118/// MIL-HDBK-762's "peak value" range (p. 5-47); from Mach 1 to 1.2 its value there is held.
119pub const SUPERSONIC_MODEL_MACH: f64 = 1.2;
120
121/// The chart's abscissae `x = √(M² − 1)/(2 l/d₁)` at which [`CHART_Y`] was read.
122pub const CHART_X: [f64; 20] = [
123    0.06, 0.08, 0.1, 0.125, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1,
124    1.2, 1.3, 1.4,
125];
126
127/// The chart's curves, by area ratio `(d₂/d₁)²`.
128pub const CHART_AREA_RATIOS: [f64; 8] = [0.25, 0.30, 0.35, 0.40, 0.50, 0.60, 0.70, 0.80];
129
130/// MIL-HDBK-762 Fig. 5-122 (printed p. 5-187, PDF p. 425), "Wave-Drag Coefficient of Conical
131/// Boattails at Supersonic Speeds": `y = 4 C_D (l/d₁)²` on `π d₁²/4`, one row per
132/// [`CHART_AREA_RATIOS`] at [`CHART_X`]. Read from a 200-dpi render with the grid fitted for tilt,
133/// each curve fitted in `ln y` against `ln x` and checked on an overlay: ±(0.005 + 2%), the lines
134/// being about 0.017 thick. The 0.80 curve's reading rises by 0.001 past `x = 1.2`, where the
135/// printed line doesn't; it is held at 0.0332. The chart starts at `x = 0.05`, where the readings
136/// hook; they start at 0.06.
137pub const CHART_Y: [[f64; 20]; 8] = [
138    [
139        2.1326, 1.9357, 1.7839, 1.6375, 1.5231, 1.3518, 1.2251, 1.1244, 1.0402, 0.9677, 0.8467,
140        0.7477, 0.6642, 0.5925, 0.5302, 0.4757, 0.4277, 0.3852, 0.3475, 0.3140,
141    ],
142    [
143        1.8266, 1.6867, 1.5666, 1.4424, 1.3406, 1.1822, 1.0627, 0.9673, 0.8884, 0.8213, 0.7118,
144        0.6250, 0.5538, 0.4941, 0.4431, 0.3990, 0.3606, 0.3270, 0.2972, 0.2708,
145    ],
146    [
147        1.5427, 1.4371, 1.3387, 1.2330, 1.1445, 1.0054, 0.9003, 0.8169, 0.7484, 0.6905, 0.5972,
148        0.5241, 0.4648, 0.4153, 0.3733, 0.3371, 0.3057, 0.2781, 0.2538, 0.2322,
149    ],
150    [
151        1.2846, 1.1867, 1.1073, 1.0249, 0.9557, 0.8441, 0.7567, 0.6857, 0.6265, 0.5762, 0.4952,
152        0.4324, 0.3822, 0.3412, 0.3071, 0.2782, 0.2535, 0.2322, 0.2136, 0.1973,
153    ],
154    [
155        0.8662, 0.7911, 0.7271, 0.6625, 0.6112, 0.5347, 0.4798, 0.4378, 0.4042, 0.3762, 0.3316,
156        0.2968, 0.2684, 0.2444, 0.2238, 0.2057, 0.1897, 0.1754, 0.1626, 0.1510,
157    ],
158    [
159        0.5522, 0.5015, 0.4652, 0.4292, 0.3993, 0.3509, 0.3129, 0.2822, 0.2569, 0.2358, 0.2028,
160        0.1783, 0.1596, 0.1450, 0.1333, 0.1238, 0.1161, 0.1096, 0.1042, 0.0996,
161    ],
162    [
163        0.3266, 0.2894, 0.2697, 0.2526, 0.2384, 0.2137, 0.1920, 0.1732, 0.1569, 0.1430, 0.1209,
164        0.1046, 0.0924, 0.0832, 0.0762, 0.0708, 0.0666, 0.0633, 0.0608, 0.0590,
165    ],
166    [
167        0.1610, 0.1445, 0.1385, 0.1337, 0.1287, 0.1167, 0.1038, 0.0918, 0.0812, 0.0723, 0.0587,
168        0.0494, 0.0431, 0.0388, 0.0360, 0.0343, 0.0334, 0.0332, 0.0332, 0.0332,
169    ],
170];
171
172/// Love's correlation of turbulent base pressure behind cylinders, `−C_p,b` against Mach number
173/// (MIL-HDBK-762 Fig. 5-139, printed p. 5-208, the solid line, after NACA TN 3819), read at
174/// Mach 2.5 to 5: ±0.001 (±0.0015 at Mach 3, where symbols hide the line).
175pub const LOVE_BASE_PRESSURE: [(f64, f64); 6] = [
176    (2.5, 0.1195),
177    (3.0, 0.097),
178    (3.5, 0.0805),
179    (4.0, 0.067),
180    (4.5, 0.0565),
181    (5.0, 0.0475),
182];
183
184/// The Prandtl–Meyer function `ν(M) = √((γ + 1)/(γ − 1)) atan √((γ − 1)(M² − 1)/(γ + 1)) −
185/// atan √(M² − 1)`, rad: the angle through which a flow at Mach 1 turns, expanding, to reach `M`
186/// (NACA Report 1135, 1953, eq. 171c, p. 626).
187///
188/// # Errors
189///
190/// [`AeroError::Domain`] below Mach 1 or for a non-finite Mach number.
191pub fn prandtl_meyer_angle(mach: f64) -> Result<f64, AeroError> {
192    if !(mach.is_finite() && mach >= 1.0) {
193        return Err(AeroError::Domain {
194            what: "Mach number of a Prandtl–Meyer expansion",
195            value: mach,
196        });
197    }
198    Ok(prandtl_meyer(mach))
199}
200
201pub(crate) fn prandtl_meyer(mach: f64) -> f64 {
202    let b = (mach * mach - 1.0).sqrt();
203    PM_K * (b / PM_K).atan() - b.atan()
204}
205
206/// The Mach number whose Prandtl–Meyer angle is `nu_rad`, for `0 ≤ ν < ν_max`
207/// ([`MAX_TURNING_RAD`]): Newton's method on `ν(M)`, kept inside a bracket, until a step moves
208/// `M` by a few rounding steps, so that every platform lands on the same root to rounding, not
209/// wherever a tolerance first stops it.
210pub(crate) fn inverse_prandtl_meyer(nu_rad: f64) -> f64 {
211    if nu_rad <= 0.0 {
212        return 1.0;
213    }
214    // A bracket [lo, hi] with ν(lo) ≤ ν < ν(hi).
215    let (mut lo, mut hi) = (1.0, 2.0);
216    while prandtl_meyer(hi) < nu_rad {
217        lo = hi;
218        hi *= 2.0;
219    }
220    let mut mach = 0.5 * (lo + hi);
221    for _ in 0..200 {
222        let f = prandtl_meyer(mach) - nu_rad;
223        if f == 0.0 {
224            break;
225        }
226        if f > 0.0 {
227            hi = mach;
228        } else {
229            lo = mach;
230        }
231        let m2 = mach * mach;
232        let slope = (m2 - 1.0).sqrt() / (mach * (1.0 + 0.5 * (GAMMA - 1.0) * m2));
233        let newton = mach - f / slope;
234        let next = if newton > lo && newton < hi && slope > 0.0 {
235            newton
236        } else {
237            0.5 * (lo + hi)
238        };
239        let settled = (next - mach).abs() <= 4.0 * f64::EPSILON * mach;
240        mach = next;
241        if settled {
242            break;
243        }
244    }
245    mach
246}
247
248/// The pressure coefficient behind a two-dimensional isentropic (Prandtl–Meyer) expansion of a
249/// flow at Mach `mach ≥ 1` through `turn_rad`: `M₂` from `ν(M₂) = ν(M) + θ`, then
250/// `C_p = (p₂/p − 1)/(γ M²/2)` with `p₂/p = [(1 + (γ−1)M²/2)/(1 + (γ−1)M₂²/2)]^(γ/(γ−1))`
251/// (NACA Report 1135: the pressures from eq. 44, the dynamic pressure `γ p M²/2` from eq. 31b,
252/// p. 616). Past the largest turning angle the flow reaches a vacuum, `C_p = −2/(γ M²)`.
253///
254/// # Errors
255///
256/// [`AeroError::Domain`] below Mach 1, for a non-finite Mach number, or a turning angle outside
257/// `[0, π/2]`.
258pub fn expansion_pressure_coefficient(mach: f64, turn_rad: f64) -> Result<f64, AeroError> {
259    let nu = prandtl_meyer_angle(mach)?;
260    if !(0.0..=std::f64::consts::FRAC_PI_2).contains(&turn_rad) {
261        return Err(AeroError::Domain {
262            what: "expansion turning angle",
263            value: turn_rad,
264        });
265    }
266    let q = 0.5 * GAMMA * mach * mach;
267    let target = nu + turn_rad;
268    if target >= MAX_TURNING_RAD {
269        return Ok(-1.0 / q);
270    }
271    let m2 = inverse_prandtl_meyer(target);
272    let h = 0.5 * (GAMMA - 1.0);
273    let ratio = ((1.0 + h * mach * mach) / (1.0 + h * m2 * m2)).powf(GAMMA / (GAMMA - 1.0));
274    Ok((ratio - 1.0) / q)
275}
276
277/// A curve's value at `x`: log-log between the readings, held below the first.
278fn chart_curve(row: &[f64; 20], x: f64) -> f64 {
279    if x <= CHART_X[0] {
280        return row[0];
281    }
282    let last = CHART_X.len() - 1;
283    if x >= CHART_X[last] {
284        return row[last];
285    }
286    // `CHART_X[0] < x < CHART_X[last]`, so the partition point is between 1 and `last`.
287    let i = CHART_X.partition_point(|&c| c <= x) - 1;
288    let t = (x / CHART_X[i]).ln() / (CHART_X[i + 1] / CHART_X[i]).ln();
289    (row[i].ln() * (1.0 - t) + row[i + 1].ln() * t).exp()
290}
291
292/// MIL-HDBK-762 Fig. 5-122's `y = 4 C_D (l/d₁)²` for a conical boattail of area ratio
293/// `a = (d₂/d₁)²` at `x = √(M² − 1)/(2 l/d₁)` from 0 to 1.4 ([`CHART_Y`]): log-log in `x` between
294/// the readings, held below `x = 0.06`, and linear in `a` between the curves. Past the chart's
295/// last curve, `a > 0.8`, it goes to 0 at `a = 1` as `(1 − √a)²` (linear theory's pressure is
296/// proportional to the surface slope, so at a fixed length the drag goes as the slope squared);
297/// below its first, `a < 0.25`, it continues the straight line through the 0.25 and 0.30 curves.
298///
299/// # Errors
300///
301/// [`AeroError::Domain`] for `x` outside `[0, 1.4]` or `a` outside `[0, 1]`.
302pub fn conical_boattail_chart(x: f64, area_ratio: f64) -> Result<f64, AeroError> {
303    if !(0.0..=CHART_X[CHART_X.len() - 1]).contains(&x) {
304        return Err(AeroError::Domain {
305            what: "boattail chart abscissa",
306            value: x,
307        });
308    }
309    if !(0.0..=1.0).contains(&area_ratio) {
310        return Err(AeroError::Domain {
311            what: "boattail area ratio",
312            value: area_ratio,
313        });
314    }
315    let curve = |i: usize| chart_curve(&CHART_Y[i], x);
316    let n = CHART_AREA_RATIOS.len();
317    let (first, last) = (CHART_AREA_RATIOS[0], CHART_AREA_RATIOS[n - 1]);
318    Ok(if area_ratio >= last {
319        let slope = (1.0 - area_ratio.sqrt()) / (1.0 - last.sqrt());
320        curve(n - 1) * slope * slope
321    } else if area_ratio <= first {
322        let (y0, y1) = (curve(0), curve(1));
323        y0 + (y0 - y1) * (first - area_ratio) / (CHART_AREA_RATIOS[1] - first)
324    } else {
325        // `first < a < last`, so the partition point is between 1 and `n − 1`.
326        let i = CHART_AREA_RATIOS.partition_point(|&c| c <= area_ratio) - 1;
327        let t =
328            (area_ratio - CHART_AREA_RATIOS[i]) / (CHART_AREA_RATIOS[i + 1] - CHART_AREA_RATIOS[i]);
329        curve(i) * (1.0 - t) + curve(i + 1) * t
330    })
331}
332
333/// Love's `−C_p,b` for a cylinder at `mach`, linear between [`LOVE_BASE_PRESSURE`]'s readings and
334/// held at their ends.
335fn love_base_pressure(mach: f64) -> f64 {
336    let points = &LOVE_BASE_PRESSURE;
337    let (first, last) = (points[0], points[points.len() - 1]);
338    if mach <= first.0 {
339        return first.1;
340    }
341    if mach >= last.0 {
342        return last.1;
343    }
344    let i = points.partition_point(|p| p.0 <= mach) - 1;
345    let ((m0, c0), (m1, c1)) = (points[i], points[i + 1]);
346    c0 + (c1 - c0) * (mach - m0) / (m1 - m0)
347}
348
349/// The base pressure behind a boattail over a cylinder's, as a ratio of pressure coefficients
350/// `k = C_p,bt/C_p,cyl` that scales the base drag, for a base of area ratio `a_b` (its area over
351/// the boattail's fore area) and an attached boattail (MIL-HDBK-762 Fig. 5-141, printed p. 5-210):
352///
353/// - From Mach 2.5, `p_bt = p_cyl/(0.442 + 0.558 a_b)` with the cylinder's `p_cyl/p =
354///   1 + C_p,cyl γM²/2` from Love's correlation ([`LOVE_BASE_PRESSURE`]), and
355///   `k = (1 − p_bt/p)/(1 − p_cyl/p)`, not below 0 (no measured base pressure is above the free
356///   stream's).
357/// - From Mach 1 to 2.5, `k` at Mach 2.5.
358/// - From Mach 0.8 to 1, a straight line from 1 to that value; 1 below.
359///
360/// # Errors
361///
362/// [`AeroError::Domain`] for a negative or non-finite Mach number, or `a_b` outside `[0, 1]`.
363pub fn boattail_base_pressure_ratio(mach: f64, base_area_ratio: f64) -> Result<f64, AeroError> {
364    check_mach_any(mach)?;
365    if !(0.0..=1.0).contains(&base_area_ratio) {
366        return Err(AeroError::Domain {
367            what: "base area ratio",
368            value: base_area_ratio,
369        });
370    }
371    let supersonic = |m: f64| {
372        let cylinder = love_base_pressure(m);
373        // Past Mach 5.5 Love's held value would ask for less than a vacuum.
374        let p_cyl = (1.0 - cylinder * 0.5 * GAMMA * m * m).max(0.0);
375        let p_bt = p_cyl / (0.442 + 0.558 * base_area_ratio);
376        ((1.0 - p_bt) / (1.0 - p_cyl)).max(0.0)
377    };
378    Ok(if mach <= TRANSONIC_ONSET_MACH {
379        1.0
380    } else if mach < SUPERSONIC_MACH {
381        let k = supersonic(BASE_RELIEF_MACH);
382        1.0 + (k - 1.0) * (mach - TRANSONIC_ONSET_MACH) / (SUPERSONIC_MACH - TRANSONIC_ONSET_MACH)
383    } else {
384        supersonic(mach.max(BASE_RELIEF_MACH))
385    })
386}
387
388/// A boattail's geometry and the terms of its pressure drag that don't depend on the Mach number.
389/// Coefficients are on its fore area `π d₁²/4`.
390#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
391#[non_exhaustive]
392pub struct Boattail {
393    /// Length `l`, m.
394    pub length_m: f64,
395    /// Fore diameter `d₁`, m.
396    pub fore_diameter_m: f64,
397    /// Aft diameter `d₂`, m.
398    pub aft_diameter_m: f64,
399    /// Area ratio `a = (d₂/d₁)²`.
400    pub area_ratio: f64,
401    /// Length over fore diameter, `l/d₁`.
402    pub length_ratio: f64,
403    /// Half-angle of the cone through the same ends, `θ = atan((d₁ − d₂)/(2l))`, rad.
404    pub half_angle_rad: f64,
405    /// Niskanen's boattail factor (eq. 3.88), used below Mach 0.8.
406    pub rule_factor: f64,
407    /// The share of the separated value in the supersonic drag and base pressure:
408    /// ([`SEPARATION_ONSET_RAD`], [`SEPARATION_COMPLETE_RAD`]) mapped linearly to (0, 1).
409    pub separation_weight: f64,
410    /// The chart's share of the 2D limit at its end, `x = 1.4`, at most 1: `r` in the drag past
411    /// the chart.
412    pub chart_end_ratio: f64,
413}
414
415impl Boattail {
416    /// A boattail of `length_m` narrowing from `fore_diameter_m` to `aft_diameter_m`, compared as
417    /// the cone through the same ends. For one length, area ratio and Mach number Jack found the
418    /// cone's wave drag the smallest of conical, tangent-parabolic and secant-parabolic boattails
419    /// (NACA TN 2972 p. 1), so a curved boattail likely drags more than hpr gives; its steeper
420    /// aft end may also separate where the cone's chord angle doesn't.
421    ///
422    /// # Errors
423    ///
424    /// [`AeroError::Domain`] for a non-positive or non-finite length or fore diameter, a negative
425    /// aft diameter, or diameters that don't decrease.
426    pub fn new(
427        length_m: f64,
428        fore_diameter_m: f64,
429        aft_diameter_m: f64,
430    ) -> Result<Self, AeroError> {
431        check_dimension("boattail length", length_m, false)?;
432        let rule_factor = boattail_factor(length_m, fore_diameter_m, aft_diameter_m)?;
433        let ratio = aft_diameter_m / fore_diameter_m;
434        let length_ratio = length_m / fore_diameter_m;
435        let half_angle_rad = ((fore_diameter_m - aft_diameter_m) / (2.0 * length_m)).atan();
436        let separation_weight = ((half_angle_rad - SEPARATION_ONSET_RAD)
437            / (SEPARATION_COMPLETE_RAD - SEPARATION_ONSET_RAD))
438            .clamp(0.0, 1.0);
439        let mut boattail = Self {
440            length_m,
441            fore_diameter_m,
442            aft_diameter_m,
443            area_ratio: ratio * ratio,
444            length_ratio,
445            half_angle_rad,
446            rule_factor,
447            separation_weight,
448            chart_end_ratio: 1.0,
449        };
450        // The Mach number at which `x = 1.4`: `√(M² − 1) = 2.8 l/d₁`.
451        let end = CHART_X[CHART_X.len() - 1];
452        let beta = 2.0 * end * length_ratio;
453        let mach_end = (1.0 + beta * beta).sqrt();
454        let chart = boattail.chart_drag(end)?;
455        boattail.chart_end_ratio = (chart / boattail.expansion_limit(mach_end)?).min(1.0);
456        Ok(boattail)
457    }
458
459    /// The chart's `C_D` at `x`.
460    fn chart_drag(&self, x: f64) -> Result<f64, AeroError> {
461        let l = self.length_ratio;
462        Ok(conical_boattail_chart(x, self.area_ratio)? / (4.0 * l * l))
463    }
464
465    /// The 2D limit `C_PM = −C_p,PM(M, θ)(1 − a)` at `mach ≥ 1`: a Prandtl–Meyer expansion's
466    /// pressure over the whole annulus.
467    ///
468    /// # Errors
469    ///
470    /// As [`expansion_pressure_coefficient`].
471    pub fn expansion_limit(&self, mach: f64) -> Result<f64, AeroError> {
472        Ok(-expansion_pressure_coefficient(mach, self.half_angle_rad)? * (1.0 - self.area_ratio))
473    }
474
475    /// The attached boattail's pressure drag at `mach ≥ 1`: the chart held to the 2D limit, and
476    /// past the chart's end the limit less the chart's share of it closing as `1/x` (module
477    /// docs).
478    ///
479    /// # Errors
480    ///
481    /// As [`expansion_pressure_coefficient`].
482    pub fn attached_pressure_drag(&self, mach: f64) -> Result<f64, AeroError> {
483        let limit = self.expansion_limit(mach)?;
484        let x = (mach * mach - 1.0).sqrt() / (2.0 * self.length_ratio);
485        let end = CHART_X[CHART_X.len() - 1];
486        Ok(if x <= end {
487            self.chart_drag(x)?.min(limit)
488        } else {
489            (1.0 - (1.0 - self.chart_end_ratio) * end / x) * limit
490        })
491    }
492
493    /// The boattail's pressure drag on its fore area at any Mach number (module docs): Niskanen's
494    /// rule to Mach 0.8; from Mach 1, the attached drag (held at its Mach 1.2 value to Mach 1.2)
495    /// blended toward the separated value at the Mach number itself; a straight line between.
496    /// So a boattail steep enough to separate completely drags like the step it tends to.
497    ///
498    /// # Errors
499    ///
500    /// [`AeroError::Domain`] for a negative or non-finite Mach number.
501    pub fn pressure_drag_coefficient(&self, mach: f64) -> Result<f64, AeroError> {
502        check_mach_any(mach)?;
503        let annulus = 1.0 - self.area_ratio;
504        let supersonic = |m: f64| -> Result<f64, AeroError> {
505            let separated = base_drag_coefficient(m)? * annulus;
506            let w = self.separation_weight;
507            let attached = self.attached_pressure_drag(m.max(SUPERSONIC_MODEL_MACH))?;
508            Ok((1.0 - w) * attached + w * separated)
509        };
510        if mach <= TRANSONIC_ONSET_MACH {
511            Ok(self.rule_factor * base_drag_coefficient(mach)? * annulus)
512        } else if mach < SUPERSONIC_MACH {
513            let low = self.rule_factor * base_drag_coefficient(TRANSONIC_ONSET_MACH)? * annulus;
514            let high = supersonic(SUPERSONIC_MACH)?;
515            let t = (mach - TRANSONIC_ONSET_MACH) / (SUPERSONIC_MACH - TRANSONIC_ONSET_MACH);
516            Ok(low + (high - low) * t)
517        } else {
518            supersonic(mach)
519        }
520    }
521
522    /// The factor on the base drag of a base right behind this boattail, of area ratio `a_b` (the
523    /// base's area over the boattail's fore area): [`boattail_base_pressure_ratio`], moved toward
524    /// 1 by the separation weight.
525    ///
526    /// # Errors
527    ///
528    /// As [`boattail_base_pressure_ratio`].
529    pub fn base_pressure_ratio(&self, mach: f64, base_area_ratio: f64) -> Result<f64, AeroError> {
530        let k = boattail_base_pressure_ratio(mach, base_area_ratio)?;
531        let w = self.separation_weight;
532        Ok((1.0 - w) * k + w)
533    }
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    fn close(got: f64, want: f64, tol: f64, what: &str) {
541        assert!((got - want).abs() <= tol, "{what}: {got} against {want}");
542    }
543
544    /// The Prandtl–Meyer function against NACA Report 1135's table (M = 2: 26.380°; M = 3:
545    /// 49.757°), its limit (eq. 172: 130.45°), and the inverse.
546    #[test]
547    fn prandtl_meyer_matches_tables_and_inverts() {
548        let deg = |r: f64| r.to_degrees();
549        close(deg(prandtl_meyer_angle(1.0).unwrap()), 0.0, 1e-12, "ν(1)");
550        close(deg(prandtl_meyer_angle(2.0).unwrap()), 26.380, 5e-4, "ν(2)");
551        close(deg(prandtl_meyer_angle(3.0).unwrap()), 49.757, 5e-4, "ν(3)");
552        close(deg(MAX_TURNING_RAD), 130.454, 5e-4, "ν_max");
553        assert!(prandtl_meyer_angle(0.99).is_err());
554        assert!(prandtl_meyer_angle(f64::NAN).is_err());
555        for mach in [1.0, 1.001, 1.2, 2.0, 4.6, 20.0, 300.0] {
556            let back = inverse_prandtl_meyer(prandtl_meyer(mach));
557            close(back, mach, 1e-9 * mach, "inverse");
558        }
559    }
560
561    /// Expansion pressure by hand: from Mach 1.5 through 15°, `ν` goes from 11.905° to 26.905°,
562    /// `M₂` = 2.0191, `p₂/p = (1.45/1.8154)^3.5 = 0.4554` and `C_p = −0.3458`; a turn past the
563    /// vacuum limit gives `−2/(γM²)`.
564    #[test]
565    fn expansion_pressure_by_hand_and_at_the_vacuum() {
566        let cp = expansion_pressure_coefficient(1.5, 15f64.to_radians()).unwrap();
567        close(cp, -0.3458, 5e-5, "Mach 1.5, 15°");
568        close(
569            expansion_pressure_coefficient(1.5, 0.0).unwrap(),
570            0.0,
571            1e-12,
572            "no turn",
573        );
574        let vacuum = expansion_pressure_coefficient(4.0, std::f64::consts::FRAC_PI_2).unwrap();
575        close(vacuum, -1.0 / (0.7 * 16.0), 1e-12, "vacuum");
576        assert!(expansion_pressure_coefficient(1.5, -0.1).is_err());
577    }
578
579    /// The chart gives back its readings at the grid, is log-log between them, falls with `x`
580    /// and with the area ratio, and goes to 0 at `a = 1`.
581    #[test]
582    fn chart_reproduces_its_readings_and_orders() {
583        for (i, &a) in CHART_AREA_RATIOS.iter().enumerate() {
584            for (j, &x) in CHART_X.iter().enumerate() {
585                close(
586                    conical_boattail_chart(x, a).unwrap(),
587                    CHART_Y[i][j],
588                    1e-12,
589                    "grid",
590                );
591            }
592        }
593        // Halfway in ln x between 0.4 and 0.5 on the 0.50 curve: the geometric mean.
594        let x = (0.4f64 * 0.5).sqrt();
595        close(
596            conical_boattail_chart(x, 0.5).unwrap(),
597            (0.3762f64 * 0.3316).sqrt(),
598            1e-12,
599            "log-log",
600        );
601        close(
602            conical_boattail_chart(0.0, 0.5).unwrap(),
603            0.8662,
604            1e-12,
605            "held below 0.06",
606        );
607        close(
608            conical_boattail_chart(0.7, 1.0).unwrap(),
609            0.0,
610            1e-12,
611            "a = 1",
612        );
613        let mut previous = f64::INFINITY;
614        for step in 0..=100 {
615            let a = f64::from(step) / 100.0;
616            let y = conical_boattail_chart(0.5, a).unwrap();
617            assert!(y < previous || a == 0.0, "falls with a at {a}");
618            previous = y;
619        }
620        assert!(conical_boattail_chart(1.41, 0.5).is_err());
621        assert!(conical_boattail_chart(0.5, 1.01).is_err());
622    }
623
624    /// The module's worked example, Calisto's boattail at Mach 1.5, and the pieces of the drag:
625    /// continuous at Mach 0.8, 1 and 1.2, the rule below, never above the 2D limit, and the chart's
626    /// own value inside it.
627    #[test]
628    fn calistos_boattail_by_hand() {
629        let b = Boattail::new(0.06, 0.127, 0.087).unwrap();
630        close(b.area_ratio, 0.469_28, 1e-5, "area ratio");
631        close(b.half_angle_rad.to_degrees(), 18.435, 1e-3, "angle");
632        close(b.separation_weight, (18.435 - 16.0) / 14.0, 1e-4, "weight");
633        // x = √1.25/(2 × 0.472) = 1.183; the chart at a = 0.469: 0.219 on d₁.
634        let x = 1.25f64.sqrt() / (2.0 * b.length_ratio);
635        let chart =
636            conical_boattail_chart(x, b.area_ratio).unwrap() / (4.0 * b.length_ratio.powi(2));
637        close(chart, 0.2189, 5e-4, "chart");
638        let limit = b.expansion_limit(1.5).unwrap();
639        close(limit, 0.2112, 5e-4, "2D limit");
640        close(
641            b.attached_pressure_drag(1.5).unwrap(),
642            limit.min(chart),
643            1e-12,
644            "attached",
645        );
646        let separated = 0.25 / 1.5 * (1.0 - b.area_ratio);
647        let w = b.separation_weight;
648        close(
649            b.pressure_drag_coefficient(1.5).unwrap(),
650            (1.0 - w) * limit.min(chart) + w * separated,
651            1e-12,
652            "blended",
653        );
654        let rule =
655            |m: f64| b.rule_factor * base_drag_coefficient(m).unwrap() * (1.0 - b.area_ratio);
656        close(
657            b.pressure_drag_coefficient(0.5).unwrap(),
658            rule(0.5),
659            1e-12,
660            "rule",
661        );
662        close(
663            b.pressure_drag_coefficient(0.8).unwrap(),
664            rule(0.8),
665            1e-12,
666            "rule to 0.8",
667        );
668        // From Mach 1 to 1.2 the attached part is held at its Mach 1.2 value, and the separated
669        // part follows the base drag at the Mach number itself.
670        let attached = b.attached_pressure_drag(1.2).unwrap();
671        let held = |m: f64| (1.0 - w) * attached + w * 0.25 / m * (1.0 - b.area_ratio);
672        close(
673            b.pressure_drag_coefficient(1.0).unwrap(),
674            held(1.0),
675            1e-12,
676            "held from 1",
677        );
678        close(
679            b.pressure_drag_coefficient(1.1).unwrap(),
680            held(1.1),
681            1e-12,
682            "held to 1.2",
683        );
684        close(
685            b.pressure_drag_coefficient(0.9).unwrap(),
686            0.5 * (rule(0.8) + held(1.0)),
687            1e-12,
688            "half-way at 0.9",
689        );
690        for (edge, what) in [
691            (TRANSONIC_ONSET_MACH, "0.8"),
692            (SUPERSONIC_MACH, "1"),
693            (SUPERSONIC_MODEL_MACH, "1.2"),
694        ] {
695            let below = b.pressure_drag_coefficient(edge - 1e-9).unwrap();
696            let above = b.pressure_drag_coefficient(edge + 1e-9).unwrap();
697            close(below, above, 1e-6, what);
698        }
699        for step in 0..=400 {
700            let mach = 1.0 + f64::from(step) / 100.0;
701            let attached = b.attached_pressure_drag(mach).unwrap();
702            assert!(
703                attached <= b.expansion_limit(mach).unwrap() + 1e-12,
704                "Mach {mach}"
705            );
706            assert!(attached > 0.0, "Mach {mach}");
707        }
708    }
709
710    /// Past the chart's end the drag closes on the 2D limit as `1/x`, starting from the chart's
711    /// value at `x = 1.4`: continuous there, rising toward the limit's share.
712    #[test]
713    fn past_the_chart_the_drag_closes_on_the_2d_limit() {
714        // A long, gentle boattail reaches x = 1.4 early: 2 calibres to a = 0.5.
715        let b = Boattail::new(0.2, 0.1, 0.1 * 0.5f64.sqrt()).unwrap();
716        let mach_end = (1.0 + (2.8 * b.length_ratio).powi(2)).sqrt();
717        let chart = b.chart_drag(1.4).unwrap();
718        assert!(chart < b.expansion_limit(mach_end).unwrap());
719        close(
720            b.attached_pressure_drag(mach_end).unwrap(),
721            chart,
722            1e-9,
723            "at the end",
724        );
725        close(
726            b.attached_pressure_drag(mach_end + 1e-9).unwrap(),
727            chart,
728            1e-7,
729            "just past",
730        );
731        let mut previous = 0.0;
732        for mach in [6.0, 8.0, 12.0, 20.0] {
733            let share = b.attached_pressure_drag(mach).unwrap() / b.expansion_limit(mach).unwrap();
734            assert!(share > previous && share < 1.0, "Mach {mach}: {share}");
735            previous = share;
736        }
737    }
738
739    /// A boattail that tends to a step down drags like the step, the base drag on the area it
740    /// uncovers, at every Mach number: within 0.6% where the straight line from Mach 0.8 to 1
741    /// stands in for the base drag's curve, and to rounding elsewhere.
742    #[test]
743    fn a_vanishing_boattail_drags_like_a_step() {
744        let b = Boattail::new(1e-9, 0.1, 0.06).unwrap();
745        assert_eq!(b.separation_weight, 1.0);
746        let annulus = 1.0 - b.area_ratio;
747        for step in 0..500 {
748            let mach = f64::from(step) / 100.0;
749            let step_drag = base_drag_coefficient(mach).unwrap() * annulus;
750            let got = b.pressure_drag_coefficient(mach).unwrap();
751            let tol = if mach > TRANSONIC_ONSET_MACH && mach < SUPERSONIC_MACH {
752                6e-3
753            } else {
754                1e-12
755            };
756            close(got, step_drag, tol * step_drag, "step");
757        }
758    }
759
760    /// Separation: none to 16°, all from 30°, where a boattail drags like the base it uncovers
761    /// and gives its base no relief.
762    #[test]
763    fn steep_boattails_separate() {
764        let d = 0.1;
765        let at = |deg: f64| {
766            let l = (d - 0.06) / (2.0 * deg.to_radians().tan());
767            Boattail::new(l, d, 0.06).unwrap()
768        };
769        close(at(15.0).separation_weight, 0.0, 0.0, "15°");
770        close(at(16.0).separation_weight, 0.0, 1e-12, "16°");
771        close(at(23.0).separation_weight, 0.5, 1e-12, "23°");
772        let steep = at(45.0);
773        close(steep.separation_weight, 1.0, 0.0, "45°");
774        close(
775            steep.pressure_drag_coefficient(2.0).unwrap(),
776            0.125 * (1.0 - 0.36),
777            1e-12,
778            "separated",
779        );
780        close(
781            steep.base_pressure_ratio(2.0, 0.36).unwrap(),
782            1.0,
783            0.0,
784            "no relief",
785        );
786    }
787
788    /// The base pressure ratio: 1 below Mach 0.8, held below 2.5, Fig. 5-141's line in pressure
789    /// from 2.5 (at Mach 3, `p_cyl/p_bt = 0.442 + 0.558 a_b` exactly), 1 for a base as large as the
790    /// cylinder, and none below 0.
791    #[test]
792    fn base_pressure_ratio_follows_fig_5_141() {
793        let k = |m: f64, a: f64| boattail_base_pressure_ratio(m, a).unwrap();
794        close(k(0.5, 0.469), 1.0, 0.0, "subsonic");
795        close(k(0.8, 0.469), 1.0, 0.0, "to Mach 0.8");
796        close(k(1.0, 0.469), k(2.5, 0.469), 0.0, "held");
797        close(k(1.8, 0.469), k(2.5, 0.469), 0.0, "held");
798        close(k(3.0, 1.0), 1.0, 1e-12, "no boattail");
799        // At Mach 3, Love's cylinder gives −0.097, so p_cyl/p = 1 − 0.097 × 6.3.
800        let p_cyl = 1.0 - 0.097 * 0.7 * 9.0;
801        let a = 0.338;
802        let p_bt = p_cyl / (0.442 + 0.558 * a);
803        close(k(3.0, a), (1.0 - p_bt) / (1.0 - p_cyl), 1e-12, "Fig. 5-141");
804        // Calisto's base at Mach 2.5: 0.616.
805        close(k(2.5, 0.469), 0.6157, 5e-4, "Calisto");
806        assert!(k(4.9, 0.0) >= 0.0);
807        let mid = k(0.9, 0.469);
808        close(mid, 0.5 * (1.0 + k(2.5, 0.469)), 1e-12, "join");
809        assert!(boattail_base_pressure_ratio(2.0, 1.1).is_err());
810    }
811
812    /// Every boattail from 1° to 89°, `a` from 0 to 0.95, gives a finite, non-negative drag at
813    /// every Mach number to 5, and a base ratio in `[0, 1]`.
814    #[test]
815    fn every_boattail_is_finite() {
816        for deg in [1.0f64, 3.0, 8.0, 15.0, 16.0, 20.0, 30.0, 60.0, 89.0] {
817            for a in [0.0f64, 0.05, 0.2, 0.25, 0.469, 0.8, 0.95] {
818                let d2 = 0.1 * a.sqrt();
819                let l = (0.1 - d2) / (2.0 * deg.to_radians().tan());
820                let b = Boattail::new(l, 0.1, d2).unwrap();
821                for step in 0..500 {
822                    let mach = f64::from(step) / 100.0;
823                    let c = b.pressure_drag_coefficient(mach).unwrap();
824                    assert!(c.is_finite() && c >= 0.0, "{deg}° a {a} Mach {mach}: {c}");
825                    let k = b.base_pressure_ratio(mach, a).unwrap();
826                    assert!((0.0..=1.0).contains(&k), "{deg}° a {a} Mach {mach}: {k}");
827                }
828            }
829        }
830    }
831}