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}