Skip to main content

hpr_aero/
body.rs

1//! Bodies of revolution (nose cones, body tubes, transitions): Barrowman's normal-force slope and
2//! center of pressure, and the planform body lift acts on.
3//!
4//! - **Potential flow** (slender-body theory). A body whose cross-section area runs from `A(0)` at
5//!   its fore end to `A(l)` at its aft end has
6//!   `(C_Nα)_B = (2/A_ref)[A(l) − A(0)]` (Barrowman 1966 eq. 10, 1967 eq. 3-65; Niskanen 2009
7//!   eq. 3.19) and its center of pressure `X_B = [l A(l) − V] / [A(l) − A(0)]` aft of its fore end,
8//!   with `V` its volume (Barrowman 1966 eq. 28, 1967 eq. 3-89; Niskanen eq. 3.28). The moment
9//!   slope `(2/A_ref)[l A(l) − V]` (Niskanen eq. 3.25 times `d`) stays well conditioned when
10//!   `A(l) ≈ A(0)`. At an angle of attack `α`, Niskanen keeps the `sin α / α` factor of the
11//!   crossflow `v₀ sin α` (eq. 3.19). No Mach term: Barrowman 1967 p. 18 leaves body
12//!   compressibility out, and Niskanen p. 22 takes the body's normal force as the same at all
13//!   speeds.
14//! - **Body lift**: `C_N = f (A_plan/A_ref) sin² α`, acting at the centroid of the side-view
15//!   (planform) area (Niskanen eq. 3.26–3.27). Its factor `f` is [`crate::crossflow`]'s:
16//!   Jorgensen's `η C_dn` since body lift was sized
17//!   ([M1.8e6](https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-8e6)), Galejs's
18//!   `K = 1.1` before (after Hoerner p. 3-11). It is zero at `α = 0`, so it doesn't change `C_Nα` there.
19//!
20//! See `docs/physics/aero.md`.
21
22use std::f64::consts::{FRAC_PI_2, PI};
23
24use hpr_design::{Profile, Wall, revolve};
25use serde::{Deserialize, Serialize};
26
27use crate::error::{AeroError, check_dimension};
28
29/// Galejs's body-lift constant `K` (Niskanen 2009 eq. 3.26: "K ≈ 1.1"; Galejs quotes Hoerner's
30/// 1.1 to 1.5 and fitted 1.0 to his own data): hpr's body lift before Jorgensen's, kept as
31/// [`crate::crossflow::BodyLift::GALEJS`].
32pub const BODY_LIFT_K: f64 = 1.1;
33
34/// The aerodynamic geometry of one body component, in its own frame (fore end at 0, stations
35/// positive aft).
36#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
37#[non_exhaustive]
38pub struct BodyGeometry {
39    /// Length, m.
40    pub length_m: f64,
41    /// Cross-section area at the fore end, m².
42    pub fore_area_m2: f64,
43    /// Cross-section area at the aft end, m².
44    pub aft_area_m2: f64,
45    /// Volume enclosed by the outer surface, m³.
46    pub volume_m3: f64,
47    /// Side-view (planform) area, m².
48    pub planform_area_m2: f64,
49    /// Centroid of the planform area, m aft of the fore end.
50    pub planform_centroid_m: f64,
51    /// Angle of the outer surface to the axis at the aft end, `atan(dr/dx)`, rad: positive where
52    /// the radius grows aft, and `±π/2` where the profile ends in a blunt tip (the aft end of a
53    /// narrowing elliptical or Haack transition).
54    pub aft_angle_rad: f64,
55}
56
57impl BodyGeometry {
58    /// A cylinder of `radius_m` and `length_m`.
59    ///
60    /// # Errors
61    ///
62    /// [`AeroError::Domain`] for a non-positive or non-finite dimension.
63    pub fn cylinder(length_m: f64, radius_m: f64) -> Result<Self, AeroError> {
64        check_dimension("body length", length_m, false)?;
65        check_dimension("body radius", radius_m, false)?;
66        let area = PI * radius_m * radius_m;
67        Ok(Self {
68            length_m,
69            fore_area_m2: area,
70            aft_area_m2: area,
71            volume_m3: area * length_m,
72            planform_area_m2: 2.0 * radius_m * length_m,
73            planform_centroid_m: 0.5 * length_m,
74            aft_angle_rad: 0.0,
75        })
76    }
77
78    /// A nose cone's or transition's geometry from its outer profile, filled
79    /// ([`hpr_design::revolve`]).
80    ///
81    /// # Errors
82    ///
83    /// Numerical errors from the volume integral, and [`AeroError::Domain`] if the result is out
84    /// of range.
85    pub fn from_profile(profile: &Profile) -> Result<Self, AeroError> {
86        let g = revolve(profile, Wall::Filled {})?;
87        let area = |r: f64| PI * r * r;
88        let geometry = Self {
89            length_m: profile.length_m(),
90            fore_area_m2: area(profile.fore_radius_m()),
91            aft_area_m2: area(profile.aft_radius_m()),
92            volume_m3: g.volume_m3,
93            planform_area_m2: g.planform_area_m2,
94            planform_centroid_m: g.planform_centroid_m,
95            aft_angle_rad: profile.radius_and_slope(profile.length_m()).1.atan(),
96        };
97        geometry.validate()?;
98        Ok(geometry)
99    }
100
101    /// Checks that every field is finite and in range.
102    ///
103    /// # Errors
104    ///
105    /// [`AeroError::Domain`] for a non-finite or negative value, or a length, volume or planform
106    /// area that isn't positive.
107    pub fn validate(&self) -> Result<(), AeroError> {
108        check_dimension("body length", self.length_m, false)?;
109        check_dimension("body fore area", self.fore_area_m2, true)?;
110        check_dimension("body aft area", self.aft_area_m2, true)?;
111        check_dimension("body volume", self.volume_m3, false)?;
112        check_dimension("body planform area", self.planform_area_m2, false)?;
113        if !(-FRAC_PI_2..=FRAC_PI_2).contains(&self.aft_angle_rad) {
114            return Err(AeroError::Domain {
115                what: "body angle at the aft end",
116                value: self.aft_angle_rad,
117            });
118        }
119        if !self.planform_centroid_m.is_finite() {
120            return Err(AeroError::Domain {
121                what: "body planform centroid",
122                value: self.planform_centroid_m,
123            });
124        }
125        Ok(())
126    }
127
128    /// Normal-force slope at `α → 0`, per radian: `(C_Nα)_B = (2/A_ref)[A(l) − A(0)]`
129    /// (Barrowman 1967 eq. 3-65; Niskanen 2009 eq. 3.19). Negative for a boattail.
130    pub fn normal_force_slope(&self, reference_area_m2: f64) -> f64 {
131        2.0 * (self.aft_area_m2 - self.fore_area_m2) / reference_area_m2
132    }
133
134    /// Moment slope about the fore end at `α → 0`, m per radian:
135    /// `(C_Nα)_B X_B = (2/A_ref)[l A(l) − V]` (Niskanen 2009 eq. 3.25 times the reference length).
136    pub fn moment_slope_m(&self, reference_area_m2: f64) -> f64 {
137        2.0 * (self.length_m * self.aft_area_m2 - self.volume_m3) / reference_area_m2
138    }
139
140    /// Center of pressure of the potential-flow term, m aft of the fore end:
141    /// `X_B = [l A(l) − V] / [A(l) − A(0)]` (Barrowman 1966 eq. 28; Niskanen 2009 eq. 3.28).
142    /// `None` when the two end areas are equal, where the body has no potential-flow normal force.
143    pub fn center_of_pressure_m(&self) -> Option<f64> {
144        let delta = self.aft_area_m2 - self.fore_area_m2;
145        (delta != 0.0).then(|| (self.length_m * self.aft_area_m2 - self.volume_m3) / delta)
146    }
147
148    /// The body-lift normal-force coefficient at `alpha_rad` with factor `factor`:
149    /// `C_N = factor · (A_plan/A_ref) sin² α` (Niskanen 2009 eq. 3.26), acting at
150    /// [`BodyGeometry::planform_centroid_m`] (eq. 3.27). The factor is
151    /// [`crate::crossflow::BodyLift::factor`]'s: Jorgensen's `η C_dn`, or Galejs's
152    /// [`BODY_LIFT_K`].
153    pub fn lift_coefficient(&self, reference_area_m2: f64, alpha_rad: f64, factor: f64) -> f64 {
154        let s = alpha_rad.sin();
155        factor * self.planform_area_m2 / reference_area_m2 * s * s
156    }
157}
158
159/// `sin x / x`, with its series near zero.
160pub(crate) fn sinc(x: f64) -> f64 {
161    if x.abs() < 1e-4 {
162        1.0 - x * x / 6.0
163    } else {
164        x.sin() / x
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use hpr_design::NoseShape;
171
172    use super::*;
173
174    fn close(got: f64, want: f64, rel: f64, what: &str) {
175        let err = ((got - want) / want).abs();
176        assert!(
177            err <= rel,
178            "{what}: got {got}, want {want}, rel err {err:e}"
179        );
180    }
181
182    fn from_profile(profile: &Profile) -> BodyGeometry {
183        BodyGeometry::from_profile(profile).unwrap()
184    }
185
186    /// Conical frustum CP from Barrowman 1966 eq. 44 (with the p. 20 correction), `d₁` fore:
187    /// `X = (L/3)[1 + 1/(1 + d₁/d₂)]`. It holds for boattails too (p. 21).
188    fn frustum_cp(length: f64, fore_radius: f64, aft_radius: f64) -> f64 {
189        length / 3.0 * (1.0 + 1.0 / (1.0 + fore_radius / aft_radius))
190    }
191
192    /// Loft lesson L9: the CP of a non-conical transition comes from its own volume, not from the
193    /// conical-frustum formula.
194    #[test]
195    fn ogive_transition_cp_uses_volume_form() {
196        let (length, fore, aft) = (0.12, 0.02, 0.04);
197        let ogive = from_profile(
198            &Profile::transition(
199                NoseShape::Ogive { radius_ratio: 1.0 },
200                length,
201                fore,
202                aft,
203                false,
204            )
205            .unwrap(),
206        );
207        let cone = from_profile(
208            &Profile::transition(NoseShape::Conical {}, length, fore, aft, false).unwrap(),
209        );
210        // The conical transition reproduces eq. 44 exactly.
211        close(
212            cone.center_of_pressure_m().unwrap(),
213            frustum_cp(length, fore, aft),
214            1e-11,
215            "conical transition CP",
216        );
217        // The ogive's volume form, written out independently: X = [l A(l) − V] / ΔA with V from
218        // Simpson's rule on the profile's radius.
219        let profile = Profile::transition(
220            NoseShape::Ogive { radius_ratio: 1.0 },
221            length,
222            fore,
223            aft,
224            false,
225        )
226        .unwrap();
227        let n = 20_000;
228        let h = length / f64::from(n);
229        let volume: f64 = (0..=n)
230            .map(|i| {
231                let r = profile.radius_m(f64::from(i) * h);
232                let w = if i == 0 || i == n {
233                    1.0
234                } else if i % 2 == 1 {
235                    4.0
236                } else {
237                    2.0
238                };
239                w * PI * r * r
240            })
241            .sum::<f64>()
242            * h
243            / 3.0;
244        let want = (length * PI * aft * aft - volume) / (PI * (aft * aft - fore * fore));
245        close(
246            ogive.center_of_pressure_m().unwrap(),
247            want,
248            1e-9,
249            "ogive transition CP",
250        );
251        // The two differ by far more than round-off: the conical formula is wrong for an ogive.
252        let gap = ogive.center_of_pressure_m().unwrap() - frustum_cp(length, fore, aft);
253        assert!(gap.abs() > 1e-3 * length, "gap {gap}");
254        // Same slope: it depends on the end areas only.
255        assert_eq!(ogive.normal_force_slope(1.0), cone.normal_force_slope(1.0));
256    }
257
258    /// The potential-flow terms at their limits: a cone, a cylinder (no force, no CP), a boattail
259    /// (negative slope, eq. 44 still holds), a thin transition (the moment stays finite), and the
260    /// cone's moment slope `(2/A_ref)(l A − V) = (4/3) l` for `A_ref = A`.
261    #[test]
262    fn potential_flow_terms_at_their_limits() {
263        let cone = from_profile(&Profile::nose(NoseShape::Conical {}, 0.3, 0.05).unwrap());
264        let a_ref = PI * 0.05 * 0.05;
265        close(cone.normal_force_slope(a_ref), 2.0, 1e-15, "cone slope");
266        close(cone.center_of_pressure_m().unwrap(), 0.2, 1e-11, "cone CP");
267        close(cone.moment_slope_m(a_ref), 0.4, 1e-11, "cone moment");
268
269        let tube = BodyGeometry::cylinder(0.5, 0.05).unwrap();
270        assert_eq!(tube.normal_force_slope(a_ref), 0.0);
271        assert_eq!(tube.center_of_pressure_m(), None);
272        assert!(tube.moment_slope_m(a_ref).abs() < 1e-15);
273
274        let boattail = from_profile(
275            &Profile::transition(NoseShape::Conical {}, 0.1, 0.05, 0.03, false).unwrap(),
276        );
277        close(
278            boattail.normal_force_slope(a_ref),
279            2.0 * (0.36 - 1.0),
280            1e-14,
281            "boattail slope",
282        );
283        close(
284            boattail.center_of_pressure_m().unwrap(),
285            frustum_cp(0.1, 0.05, 0.03),
286            1e-11,
287            "boattail CP",
288        );
289
290        // A transition 1 µm wide has a vanishing slope and a CP near its middle; its moment
291        // slope stays tiny and finite.
292        let thin = from_profile(
293            &Profile::transition(NoseShape::Conical {}, 0.1, 0.05, 0.050_001, false).unwrap(),
294        );
295        assert!(thin.normal_force_slope(a_ref) > 0.0);
296        close(thin.center_of_pressure_m().unwrap(), 0.05, 1e-4, "thin CP");
297        assert!(thin.moment_slope_m(a_ref).abs() < 1e-5);
298    }
299
300    /// Body lift: zero at `α = 0`, `K A_plan/A_ref` broadside, symmetric in the sign of `α`, and
301    /// a cylinder's planform `2 r l` acting at its middle (Galejs Table 1).
302    #[test]
303    fn body_lift_limits() {
304        let tube = BodyGeometry::cylinder(0.8, 0.04).unwrap();
305        let a_ref = PI * 0.04 * 0.04;
306        assert_eq!(tube.lift_coefficient(a_ref, 0.0, BODY_LIFT_K), 0.0);
307        let broadside = BODY_LIFT_K * 2.0 * 0.04 * 0.8 / a_ref;
308        close(
309            tube.lift_coefficient(a_ref, std::f64::consts::FRAC_PI_2, BODY_LIFT_K),
310            broadside,
311            1e-15,
312            "broadside",
313        );
314        close(
315            tube.lift_coefficient(a_ref, 0.1, BODY_LIFT_K),
316            tube.lift_coefficient(a_ref, -0.1, BODY_LIFT_K),
317            1e-15,
318            "symmetric",
319        );
320        close(
321            tube.lift_coefficient(a_ref, 0.01, BODY_LIFT_K),
322            broadside * 0.01f64.sin().powi(2),
323            1e-15,
324            "small angle",
325        );
326        assert_eq!(tube.planform_centroid_m, 0.4);
327        // A cone's planform is ½ L D at 2L/3 (Galejs Table 1).
328        let cone = from_profile(&Profile::nose(NoseShape::Conical {}, 0.3, 0.05).unwrap());
329        close(
330            cone.planform_area_m2,
331            0.5 * 0.3 * 0.1,
332            1e-12,
333            "cone planform",
334        );
335        close(
336            cone.planform_centroid_m,
337            0.2,
338            1e-12,
339            "cone planform centroid",
340        );
341    }
342
343    #[test]
344    fn sinc_is_continuous() {
345        assert_eq!(sinc(0.0), 1.0);
346        for x in [1e-5, 9.9e-5, 1e-4, 1.01e-4] {
347            close(sinc(x), x.sin() / x, 1e-15, "sinc");
348        }
349    }
350
351    #[test]
352    fn bad_geometry_is_refused() {
353        assert!(BodyGeometry::cylinder(0.0, 0.1).is_err());
354        assert!(BodyGeometry::cylinder(1.0, f64::NAN).is_err());
355        let mut g = BodyGeometry::cylinder(1.0, 0.1).unwrap();
356        g.volume_m3 = -1.0;
357        assert!(g.validate().is_err());
358    }
359}