Skip to main content

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}