hpr_aero/crossflow.rs
1//! Body lift: the viscous crossflow term of Jorgensen's method for bodies of revolution at an
2//! angle of attack (L. H. Jorgensen, NASA TR R-474, 1977), and Galejs's constant it replaces.
3//!
4//! At an angle of attack `α` the air crosses the body sideways at `V sin α`, separates behind it as
5//! it would behind a cylinder in a cross-wind, and pushes it with the drag of that crossflow:
6//!
7//! `C_N = η C_dn (A_p/A_r) sin² α` (TR R-474 eq. 2.12, printed p. 10),
8//!
9//! with `A_p` the body's planform (side-view) area, `A_r` the reference area, `C_dn` the
10//! crossflow drag coefficient of an infinitely long circular cylinder and `η` the ratio of a
11//! finite cylinder's crossflow drag to an infinite one's. Both depend on the crossflow Mach number
12//! `M_n = M sin α` (eq. 2.3, p. 8); `η` also on the body's length over its diameter. The force acts
13//! at the planform's centroid (eq. 2.21, p. 13). hpr takes each factor from Jorgensen's figures,
14//! read by hand from the page images:
15//!
16//! - **`C_dn`** ([`CROSSFLOW_DRAG`], Fig. 1, printed p. 75) below the critical crossflow Reynolds
17//! number, where "C_dn = 1.2" at low `M_n` (p. 15). From `M_n` 0.6 to 1.2 it takes the filled
18//! points "extrapolated from data obtained in Ames 2' × 2' wind tunnel", the values Fig. 6 was
19//! divided by (below); past 1.4, the faired curve through the experiments, to 4.8.
20//! - **`η` against length over diameter** ([`ETA_BY_FINENESS`], Fig. 4, printed p. 77): the
21//! circular cylinder at a crossflow Reynolds number of 88,000, measured "only at very low
22//! subsonic Mach numbers" (p. 17).
23//! - **`η` against `M_n`** ([`ETA_BY_CROSSFLOW_MACH`], Fig. 6, printed p. 78): Jorgensen's `η C_dn`
24//! back-computed from the measured normal force of two bodies of fineness 10 and 12 at 45° to
25//! 60° (his Fig. 5), divided by Fig. 1's `C_dn`, at the eleven crossflow Mach numbers from 0.4
26//! to 1.6 he computed; below 0.4 it runs to Fig. 4's value for those bodies. He uses Figs. 5
27//! and 6 "in lieu of better information" (p. 18); past 1.6, `η` "probably can be assumed to be
28//! unity" (p. 17), and hpr holds the last point, 0.984.
29//!
30//! **Combining the two `η`s, a judgement.** Fig. 6 holds for bodies of fineness 10 to 12 only.
31//! For another fineness `f`, hpr scales Fig. 6's `η` by how much longer or shorter Fig. 4 makes
32//! the body, and lets that scaling fade as the crossflow speeds up, by the share `s` Fig. 6's own
33//! bodies have risen toward 1:
34//!
35//! `η(f, M_n) = η₆(M_n) [η₄(f) + (1 − η₄(f)) r] / [η₆(0) + (1 − η₆(0)) r]`,
36//!
37//! `s = [η₆(M_n) − η₆(0)] / [1 − η₆(0)]` and `r` its running maximum over `[0, M_n]`, with
38//! `η₆(0)` = 0.69, midway between Fig. 6's starting points for fineness 10 and 12. `r` never
39//! falls back: Fig. 6 dips at `M_n` = 1 only because Jorgensen divided by Fig. 1's peak there, not
40//! because the body's length counts again. The rule gives Fig. 6 back for a body of fineness about
41//! 10.6 (where this reading of Fig. 4 gives 0.69), Fig. 4 at `M_n = 0` for any fineness, and
42//! Fig. 5's `η C_dn` for every fineness once `M_n` passes 0.8, where Fig. 6 reaches 0.99. Where
43//! `r = s`, below `M_n` 0.8, it equals `η₄ + (1 − η₄) s`.
44//!
45//! **Sampling, not smoothing.** Fig. 1's `C_dn` peaks at `M_n` ≈ 0.96 and Fig. 6's `η` dips at
46//! 1.0; each is steep there. hpr samples both at Fig. 6's points and interpolates each linearly
47//! between them, so their product is Jorgensen's own `η C_dn` at those points (his Fig. 5, within
48//! the reading, test `the_product_follows_figure_5`) and moves smoothly between them, instead of
49//! multiplying two steep curves read separately.
50//!
51//! **Left out.** Past the critical crossflow Reynolds number (about 2 × 10⁵, Fig. 2, p. 76) a
52//! cylinder's `C_dn` falls to "between about 0.15 and 0.30" at low `M_n` (p. 15); Jorgensen
53//! computes that only for illustration, with nothing to check it against (p. 27), and hpr leaves
54//! it out. hpr's potential-flow term stays its own (`sin α`, slender-body theory or TN 3527's
55//! method), not Jorgensen's `sin 2α cos(α/2)`.
56//!
57//! **Galejs's constant** ([`BodyLift::Galejs`]): hpr's body lift until the milestone that sized it
58//! ([M1.8e6](https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-8e6)) was
59//! `K (A_plan/A_ref) sin² α` with `K` = 1.1 at every Mach number (R. Galejs, *Wind Instability*,
60//! after Hoerner; Niskanen 2009 eq. 3.26), kept to reproduce earlier results.
61//!
62//! See `docs/physics/aero.md` (*Body lift*).
63
64use serde::{Deserialize, Serialize};
65
66use crate::body::BODY_LIFT_K;
67use crate::error::AeroError;
68
69/// The crossflow Mach numbers `M_n = M sin α` of [`CROSSFLOW_DRAG`].
70pub const CROSSFLOW_DRAG_MACHS: [f64; 26] = [
71 0.0, 0.2, 0.3, 0.35, 0.4, 0.45, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.4, 1.5, 1.6, 1.8,
72 2.0, 2.4, 2.8, 3.2, 3.6, 4.0, 4.4, 4.8,
73];
74
75/// A circular cylinder's crossflow drag coefficient `C_dn` at [`CROSSFLOW_DRAG_MACHS`], below the
76/// critical crossflow Reynolds number: NASA TR R-474, Fig. 1 (printed p. 75), read by hand to
77/// about ±0.01. To 0.2, the "C_dn = 1.2" of p. 15; to 0.5, the curve through Lindsey's points;
78/// from 0.6 to 1.2, the filled points extrapolated from the Ames 2' × 2' tunnel; from 1.4, the
79/// curve through the supersonic experiments. Held past 4.8.
80pub const CROSSFLOW_DRAG: [f64; 26] = [
81 1.20, 1.20, 1.21, 1.237, 1.271, 1.305, 1.334, 1.458, 1.552, 1.515, 1.560, 1.985, 1.785, 1.676,
82 1.555, 1.530, 1.489, 1.440, 1.403, 1.363, 1.336, 1.320, 1.305, 1.285, 1.272, 1.266,
83];
84
85/// The crossflow Mach numbers of [`ETA_BY_CROSSFLOW_MACH`]: `M_n` = 0 and Fig. 6's eleven
86/// computed points.
87pub const ETA_MACHS: [f64; 12] = [0.0, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.4, 1.6];
88
89/// Jorgensen's `η` against the crossflow Mach number for bodies of fineness 10 and 12, at
90/// [`ETA_MACHS`]: NASA TR R-474, Fig. 6 (printed p. 78), the circles "computed from figures 1 and
91/// 5", read by hand to about ±0.005. At 0, [`ETA_REFERENCE`]. Held past 1.6.
92pub const ETA_BY_CROSSFLOW_MACH: [f64; 12] = [
93 ETA_REFERENCE,
94 0.717,
95 0.804,
96 0.815,
97 0.845,
98 0.994,
99 0.979,
100 0.769,
101 0.910,
102 0.937,
103 0.985,
104 0.984,
105];
106
107/// Fig. 6's `η` at `M_n = 0`: 0.69, midway between its square and diamond there, about 0.68 and
108/// 0.70, which Jorgensen takes from Fig. 4 for its two bodies of fineness 10 and 12 (this module's
109/// own reading of Fig. 4, [`ETA_BY_FINENESS`], gives 0.685 and 0.701).
110pub const ETA_REFERENCE: f64 = 0.69;
111
112/// The fineness ratios (length over diameter) of [`ETA_BY_FINENESS`].
113pub const ETA_FINENESS: [f64; 12] = [
114 2.0, 4.0, 6.0, 8.0, 10.0, 12.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0,
115];
116
117/// A finite circular cylinder's crossflow drag over an infinite one's, `η`, at [`ETA_FINENESS`],
118/// at very low crossflow Mach number: NASA TR R-474, Fig. 4 (printed p. 77), the curve for a
119/// circular cylinder at a crossflow Reynolds number of 88,000 (from Goldstein), read by hand to
120/// about ±0.005. Held outside 2 to 40.
121pub const ETA_BY_FINENESS: [f64; 12] = [
122 0.577, 0.607, 0.643, 0.668, 0.685, 0.701, 0.724, 0.753, 0.775, 0.795, 0.805, 0.815,
123];
124
125/// How a body's crossflow lift is sized: its `C_N = factor · (A_plan/A_ref) sin² α`. In JSON,
126/// `{"kind": "jorgensen"}` or `{"kind": "galejs", "k": 1.1}`.
127#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
128#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
129#[non_exhaustive]
130pub enum BodyLift {
131 /// Jorgensen's `η C_dn` ([`crossflow_factor`]), from the body's fineness and the crossflow
132 /// Mach number: hpr's model since body lift was sized ([M1.8e6](https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-8e6)).
133 Jorgensen {},
134 /// Galejs's constant `K` at every Mach number: hpr's model before, with `k` =
135 /// [`BODY_LIFT_K`] (1.1). Galejs gives 1.0 to 1.5.
136 Galejs {
137 /// `K`, dimensionless.
138 k: f64,
139 },
140}
141
142impl Default for BodyLift {
143 /// Jorgensen's, hpr's current model.
144 fn default() -> Self {
145 Self::JORGENSEN
146 }
147}
148
149impl BodyLift {
150 /// Jorgensen's `η C_dn`, hpr's current model.
151 pub const JORGENSEN: Self = Self::Jorgensen {};
152
153 /// hpr's model before Jorgensen's: Galejs's `K` = [`BODY_LIFT_K`].
154 pub const GALEJS: Self = Self::Galejs { k: BODY_LIFT_K };
155
156 /// The factor on `(A_plan/A_ref) sin² α` for a body of fineness `fineness` (length over
157 /// diameter) at crossflow Mach number `crossflow_mach` (`M sin α`).
158 pub fn factor(&self, fineness: f64, crossflow_mach: f64) -> f64 {
159 match *self {
160 Self::Jorgensen {} => crossflow_factor(fineness, crossflow_mach),
161 Self::Galejs { k } => k,
162 }
163 }
164
165 /// Checks the model's own number.
166 ///
167 /// # Errors
168 ///
169 /// [`AeroError::Domain`] for a `K` that isn't finite and non-negative.
170 pub fn validate(&self) -> Result<(), AeroError> {
171 match *self {
172 Self::Jorgensen {} => Ok(()),
173 Self::Galejs { k } if k.is_finite() && k >= 0.0 => Ok(()),
174 Self::Galejs { k } => Err(AeroError::Domain {
175 what: "body-lift K",
176 value: k,
177 }),
178 }
179 }
180}
181
182/// Linear interpolation in `xs` (increasing), holding the end values outside them.
183fn held_linear(xs: &[f64], ys: &[f64], x: f64) -> f64 {
184 let x = x.clamp(xs[0], xs[xs.len() - 1]);
185 let i = xs.partition_point(|&c| c <= x).clamp(1, xs.len() - 1);
186 let w = (x - xs[i - 1]) / (xs[i] - xs[i - 1]);
187 (1.0 - w) * ys[i - 1] + w * ys[i]
188}
189
190/// A circular cylinder's crossflow drag coefficient `C_dn` at crossflow Mach number
191/// `crossflow_mach`, below the critical Reynolds number ([`CROSSFLOW_DRAG`]). A negative or NaN
192/// input reads as 0.
193pub fn crossflow_drag(crossflow_mach: f64) -> f64 {
194 held_linear(
195 &CROSSFLOW_DRAG_MACHS,
196 &CROSSFLOW_DRAG,
197 finite_or_zero(crossflow_mach),
198 )
199}
200
201/// Fig. 4's `η` for a body of fineness `fineness`, at low crossflow Mach number
202/// ([`ETA_BY_FINENESS`]).
203pub fn crossflow_eta_low(fineness: f64) -> f64 {
204 held_linear(&ETA_FINENESS, &ETA_BY_FINENESS, finite_or_zero(fineness))
205}
206
207/// `η` for a body of fineness `fineness` at crossflow Mach number `crossflow_mach`: Fig. 6's value
208/// scaled by Fig. 4's for the body's length, the scaling fading as Fig. 6 rises toward 1 (see the
209/// module's *Combining the two `η`s*).
210pub fn crossflow_eta(fineness: f64, crossflow_mach: f64) -> f64 {
211 eta_from_low(crossflow_eta_low(fineness), crossflow_mach)
212}
213
214/// [`crossflow_eta`] from Fig. 4's `low` for the body.
215fn eta_from_low(low: f64, crossflow_mach: f64) -> f64 {
216 let n = ETA_MACHS.len();
217 let m = finite_or_zero(crossflow_mach).clamp(ETA_MACHS[0], ETA_MACHS[n - 1]);
218 // The rows at or below `m`: at least the first, since `m` is at least its Mach number.
219 let below = ETA_MACHS.partition_point(|&c| c <= m).clamp(1, n);
220 let i = below.clamp(1, n - 1);
221 let w = (m - ETA_MACHS[i - 1]) / (ETA_MACHS[i] - ETA_MACHS[i - 1]);
222 let eta6 = (1.0 - w) * ETA_BY_CROSSFLOW_MACH[i - 1] + w * ETA_BY_CROSSFLOW_MACH[i];
223 // How far Fig. 6's `η` has risen toward 1 by `m`, never falling back: Fig. 6's dip at
224 // `M_n` = 1 comes from dividing by Fig. 1's peak there, not from the body's length, so the
225 // length's effect doesn't return with it.
226 let risen = RISEN_SHARE[below - 1].max(share(eta6));
227 eta6 * (low + (1.0 - low) * risen) / (ETA_REFERENCE + (1.0 - ETA_REFERENCE) * risen)
228}
229
230/// The share of the way from Fig. 6's low-speed `η` to 1: `(η − η₆(0))/(1 − η₆(0))`.
231const fn share(eta: f64) -> f64 {
232 (eta - ETA_REFERENCE) / (1.0 - ETA_REFERENCE)
233}
234
235/// The running maximum of [`share`] over Fig. 6's rows up to each: how far its `η` has risen by
236/// then, never falling back.
237const RISEN_SHARE: [f64; ETA_BY_CROSSFLOW_MACH.len()] = {
238 let mut risen = [0.0; ETA_BY_CROSSFLOW_MACH.len()];
239 let mut best = 0.0;
240 let mut i = 0;
241 while i < ETA_BY_CROSSFLOW_MACH.len() {
242 let s = share(ETA_BY_CROSSFLOW_MACH[i]);
243 if s > best {
244 best = s;
245 }
246 risen[i] = best;
247 i += 1;
248 }
249 risen
250};
251
252/// Jorgensen's `η C_dn` for a body of fineness `fineness` at crossflow Mach number
253/// `crossflow_mach`: the factor on `(A_plan/A_ref) sin² α` in its body lift.
254pub fn crossflow_factor(fineness: f64, crossflow_mach: f64) -> f64 {
255 crossflow_factor_from_eta_low(crossflow_eta_low(fineness), crossflow_mach)
256}
257
258/// [`crossflow_factor`] from the body's Fig. 4 `η` ([`crossflow_eta_low`]), which a model
259/// computes once.
260pub fn crossflow_factor_from_eta_low(eta_low: f64, crossflow_mach: f64) -> f64 {
261 eta_from_low(eta_low, crossflow_mach) * crossflow_drag(crossflow_mach)
262}
263
264fn finite_or_zero(x: f64) -> f64 {
265 if x.is_nan() { 0.0 } else { x }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use proptest::prelude::*;
272
273 #[test]
274 fn the_tables_are_well_formed() {
275 for xs in [&CROSSFLOW_DRAG_MACHS[..], &ETA_MACHS, &ETA_FINENESS] {
276 assert!(xs.windows(2).all(|w| w[0] < w[1]), "{xs:?}");
277 }
278 assert!(CROSSFLOW_DRAG.iter().all(|&c| (1.19..=2.0).contains(&c)));
279 assert!(
280 ETA_BY_CROSSFLOW_MACH
281 .iter()
282 .all(|&e| (0.68..1.0).contains(&e))
283 );
284 assert!(ETA_BY_FINENESS.windows(2).all(|w| w[0] < w[1]));
285 // Every Fig. 6 point from 0.4 has a Fig. 1 value at the same crossflow Mach number, so
286 // their product is Jorgensen's own there.
287 for m in &ETA_MACHS[1..] {
288 assert!(CROSSFLOW_DRAG_MACHS.contains(m), "{m}");
289 }
290 // From 0.6 to 1.2, where both curves are steep, Fig. 1 has no node but Fig. 6's: no
291 // steep feature of one meets a straight line of the other.
292 for m in CROSSFLOW_DRAG_MACHS
293 .iter()
294 .filter(|&&m| (0.6..=1.2).contains(&m))
295 {
296 assert!(ETA_MACHS.contains(m), "{m}");
297 }
298 }
299
300 /// Jorgensen's Fig. 5 (printed p. 78), `η C_dn` back-computed from two bodies of fineness 10
301 /// and 12 at 45° to 60°, read by hand from its faired curve. Fig. 6's points times Fig. 1's
302 /// are his division of these, so the product at a body of Fig. 6's own fineness returns them
303 /// within the two readings.
304 #[test]
305 fn the_product_follows_figure_5() {
306 let fig5 = [
307 (0.5, 1.08),
308 (0.6, 1.20),
309 (0.7, 1.33),
310 (0.8, 1.52),
311 (0.9, 1.55),
312 (1.0, 1.52),
313 (1.1, 1.63),
314 (1.2, 1.61),
315 (1.4, 1.51),
316 (1.6, 1.46),
317 ];
318 // Fineness where Fig. 4 gives Fig. 6's own starting value, about 10.6.
319 let f = 10.0 + 2.0 * (ETA_REFERENCE - 0.685) / (0.701 - 0.685);
320 assert!((f - 10.625).abs() < 1e-12);
321 for (m, want) in fig5 {
322 let got = crossflow_factor(f, m);
323 assert!(
324 ((got - want) / want).abs() < 0.03,
325 "M_n {m}: {got} against Fig. 5's {want}"
326 );
327 }
328 }
329
330 #[test]
331 fn low_crossflow_mach_is_figure_4_times_1_2() {
332 for (&f, &eta) in ETA_FINENESS.iter().zip(&ETA_BY_FINENESS) {
333 assert!((crossflow_eta(f, 0.0) - eta).abs() < 1e-15);
334 assert!((crossflow_factor(f, 0.0) - 1.2 * eta).abs() < 1e-15);
335 }
336 // Up to Mach 0.8, where Fig. 6 only rises, the rule is `η₄ + (1 − η₄) s`.
337 for m in [0.1, 0.3, 0.45, 0.62, 0.79] {
338 let s = (held_linear(&ETA_MACHS, &ETA_BY_CROSSFLOW_MACH, m) - ETA_REFERENCE)
339 / (1.0 - ETA_REFERENCE);
340 for f in [3.0, 18.2, 40.0] {
341 let low = crossflow_eta_low(f);
342 assert!((crossflow_eta(f, m) - (low + (1.0 - low) * s)).abs() < 1e-14);
343 }
344 }
345 // The Arcas Robin's two models (fineness 18.2 and 23.8), 0.74 and 0.77 to the reading.
346 assert!((crossflow_eta(18.2, 0.0) - 0.7426).abs() < 1e-4);
347 assert!((crossflow_eta(23.8, 0.0) - 0.7697).abs() < 1e-4);
348 }
349
350 #[test]
351 fn figure_6s_own_bodies_get_figure_6_back() {
352 let f = 10.0 + 2.0 * (ETA_REFERENCE - 0.685) / (0.701 - 0.685);
353 for (&m, &eta) in ETA_MACHS.iter().zip(&ETA_BY_CROSSFLOW_MACH) {
354 assert!((crossflow_eta(f, m) - eta).abs() < 1e-12, "M_n {m}");
355 }
356 }
357
358 #[test]
359 fn ends_are_held() {
360 assert_eq!(crossflow_drag(5.0), 1.266);
361 assert_eq!(crossflow_drag(-0.1), 1.2);
362 assert_eq!(crossflow_drag(f64::NAN), 1.2);
363 assert_eq!(crossflow_eta(60.0, 0.0), 0.815);
364 assert_eq!(crossflow_eta(1.0, 0.0), 0.577);
365 assert_eq!(crossflow_eta(10.0, 3.0), crossflow_eta(10.0, 1.6));
366 }
367
368 #[test]
369 fn galejs_is_the_old_constant() {
370 assert_eq!(BodyLift::GALEJS.factor(7.0, 0.9), 1.1);
371 assert_eq!(BodyLift::Galejs { k: 1.5 }.factor(30.0, 0.0), 1.5);
372 assert!(BodyLift::Galejs { k: -1.0 }.validate().is_err());
373 assert!(BodyLift::Galejs { k: f64::NAN }.validate().is_err());
374 assert!(BodyLift::JORGENSEN.validate().is_ok());
375 assert_eq!(BodyLift::default(), BodyLift::JORGENSEN);
376 }
377
378 /// The JSON form is a file format: each model round-trips, and a field a model doesn't have
379 /// is refused, not dropped.
380 #[test]
381 fn body_lift_in_json() {
382 for (model, text) in [
383 (BodyLift::JORGENSEN, r#"{"kind":"jorgensen"}"#),
384 (BodyLift::GALEJS, r#"{"kind":"galejs","k":1.1}"#),
385 ] {
386 assert_eq!(serde_json::to_string(&model).unwrap(), text);
387 assert_eq!(serde_json::from_str::<BodyLift>(text).unwrap(), model);
388 }
389 for bad in [
390 r#"{"kind":"jorgensen","k":1.5}"#,
391 r#"{"kind":"galejs"}"#,
392 r#"{"kind":"galejs","k":1.1,"eta":0.7}"#,
393 r#"{"kind":"hoerner"}"#,
394 ] {
395 assert!(serde_json::from_str::<BodyLift>(bad).is_err(), "{bad}");
396 }
397 }
398
399 proptest! {
400 /// `η` stays within Fig. 4's lowest value and 1 and grows with fineness, and the factor
401 /// is continuous: a step of 1e-9 in the crossflow Mach number moves it by no more than
402 /// its steepest slope (under 20 per unit) allows. Once the crossflow passes Mach 0.8 the
403 /// body's length no longer matters: every fineness is within 0.3% of Fig. 6's own bodies.
404 #[test]
405 fn eta_is_bounded_and_the_factor_continuous(f in 0.5f64..80.0, m in 0.0f64..5.0) {
406 let eta = crossflow_eta(f, m);
407 prop_assert!((0.577..1.0).contains(&eta));
408 prop_assert!(crossflow_eta(f + 1.0, m) >= eta - 1e-15);
409 let d = (crossflow_factor(f, m + 1e-9) - crossflow_factor(f, m)).abs();
410 prop_assert!(d < 2e-8, "jump {d} at M_n {m}");
411 if m >= 0.8 {
412 let reference = crossflow_factor(10.625, m);
413 prop_assert!((crossflow_factor(f, m) / reference - 1.0).abs() < 3e-3);
414 }
415 }
416 }
417}