Skip to main content

hpr_aero/
tube_fins.rs

1//! Tube fins: a ring of short open tubes around the body, each flown as an annular wing (a ring
2//! airfoil).
3//!
4//! **Normal force.** One tube of mean diameter `d` and length `L`, with `λ = L/d`, takes
5//! Weissinger's approximation for a thin ring wing (Weissinger 1955, as quoted by Wagner 2021
6//! eq. 15), on the area `d L`:
7//!
8//! `C_Lα = π² / (1 + πλ/2 + λ arctan(1.2 λ))` per radian.
9//!
10//! Short rings tend to Ribner's lifting-line result `π²` (Wagner eq. 13), and long ones to
11//! slender-body theory's `π/λ`, which is Hoerner's `L = q d² π α` for a ring of small aspect ratio
12//! (Hoerner 1965 p. 7-13): the ring deflects the air inside it as well as the air around it, so it
13//! lifts twice as much as a solid body of its diameter. Fletcher's measured slopes on five rings
14//! (NACA TN 4117, 1957, Fig. 11, at Mach 0.13) lie within 3% of it, taken at his diameter (the
15//! rings' inner one) and on his area; for a paper tube the inner and mean diameters differ by
16//! about 1%. Wagner gives the formula for `λ < 5`; past that it runs on to the slender-body limit,
17//! which is exact for a long ring.
18//!
19//! Compressibility follows Göthert's rule, as Barrowman's fin slope does: the slope at Mach `M` is
20//! the incompressible slope of the ring stretched to `λ/β`, over `β = √(1 − M²)`. It leaves the
21//! slender limit unchanged and turns the lifting-line one into Prandtl–Glauert's. Nothing measures
22//! tube fins near the speed of sound, where the flow through a tube may choke, so the model
23//! refuses Mach [`TUBE_FIN_MACH_LIMIT`] and above.
24//!
25//! **Center of pressure.** Against the ring's aspect ratio `A = d/L`, at the stretched ring's
26//! `β A` faster than Mach 0 ([`ring_center_fraction`]): Fletcher's measured aerodynamic center
27//! from `A = 2/3` to 3 ([`FLETCHER_AERODYNAMIC_CENTER`], his Fig. 8); below `A = 2/3`, a straight
28//! line to the leading edge at `A = 0`. That end point is hpr's derivation from slender-body
29//! theory, in which a section's lift is the growth of its apparent mass along the body: a thin
30//! ring's appears whole at its leading edge and stays, so all its lift is there. Hoerner and Borst
31//! (*Fluid-Dynamic Lift*, 1985, p. 19-16) assume the same of the air turned inside an open tube,
32//! that it turns "at or near the rim of the inlet"; they had no measurement of it. Fletcher's fifth
33//! ring, at `A = 1/3`, is left out, a judgement: its center sits 0.11 of its chord ahead of its
34//! leading edge, which he puts down to its low aspect ratio making it act like a body of
35//! revolution (p. 4). hpr infers, beyond his text, that its thick section (a Clark Y 11.7% of a
36//! chord three bores long, outside a straight bore, so walls 0.35 of the bore thick) is what
37//! makes it so, that a paper tube's center lies aft of it, and that his thinner-walled rings may
38//! carry the same forward bias in smaller measure. Holding his point below `A = 1/3` instead put
39//! OpenRocket's *Tube fin rocket* at a margin of 0.29 calibres rather than 0.79, in a one-off run
40//! not kept in the report. No thin tube's center is measured. Rings shorter than a third of their
41//! diameter, past `A = 3`, are refused.
42//!
43//! **The set.** `N` tubes add `N` times one tube's slope, with no interference from the body or
44//! between the tubes: none is measured. The body's own crossflow disturbance at the tubes goes as
45//! `R²/s² e^{−2iφ}` around it, which sums to zero over three or more tubes evenly spaced, so the
46//! model refuses fewer than three. That is a first-order derivation, not a measurement: it takes
47//! the body's flow at each tube's center and leaves out the images and the lift carried onto the
48//! body. Slender-body theory with the body included gives the set more lift than `N` isolated
49//! rings, by an unchecked estimate 1.13 to 1.96 times on three OpenRocket probes, at a gap of
50//! 0.005 radii and still rising as it closes ([#234](https://github.com/nrdptel/hpr-sim/issues/234);
51//! `docs/physics/aero.md`, *Tube fins*).
52
53use std::f64::consts::PI;
54
55use hpr_design::{PlacedComponent, TubeFinSet};
56use serde::Serialize;
57
58use crate::error::{AeroError, check_dimension, check_mach};
59
60/// The top of the tube-fin model's range, Mach 0.8, where hpr's fin model leaves its subsonic
61/// method ([`crate::fins::TRANSONIC_START_MACH`]). No source covers tube fins faster; a judgement.
62pub const TUBE_FIN_MACH_LIMIT: f64 = crate::fins::TRANSONIC_START_MACH;
63
64/// Fletcher's measured aerodynamic center of five annular airfoils, `(A, x_ac/c)`: the aspect
65/// ratio `A = d/c` (diameter over chord) and the aerodynamic center's distance aft of the leading
66/// edge as a fraction of the chord, from α = 0° to 10° at Mach 0.13 (NACA TN 4117, 1957, Fig. 8,
67/// p. 16). Read from the chart, two independent readings within 0.003 of the chord, and checked against
68/// the text: the center moves aft as `A` rises, and sits ahead of the leading edge at `A = 1/3`
69/// (p. 4).
70pub const FLETCHER_AERODYNAMIC_CENTER: [(f64, f64); 5] = [
71    (1.0 / 3.0, -0.11),
72    (2.0 / 3.0, 0.143),
73    (1.0, 0.203),
74    (1.5, 0.253),
75    (3.0, 0.355),
76];
77
78/// The points [`ring_center_fraction`] joins, `(A, x_ac/c)`: slender-body theory's leading edge
79/// at `A = 0`, then Fletcher's four rings that act as wings ([`FLETCHER_AERODYNAMIC_CENTER`] from
80/// `A = 2/3`).
81pub const THIN_RING_CENTER: [(f64, f64); 5] = [
82    (0.0, 0.0),
83    FLETCHER_AERODYNAMIC_CENTER[1],
84    FLETCHER_AERODYNAMIC_CENTER[2],
85    FLETCHER_AERODYNAMIC_CENTER[3],
86    FLETCHER_AERODYNAMIC_CENTER[4],
87];
88
89/// A thin ring wing's normal-force slope per radian on the area `d L`, at a length-to-diameter
90/// ratio `λ = L/d`: Weissinger's `π² / (1 + πλ/2 + λ arctan(1.2 λ))` (Wagner 2021 eq. 15).
91///
92/// # Errors
93///
94/// [`AeroError::Domain`] for a `λ` that is not finite and positive.
95pub fn ring_lift_slope(length_over_diameter: f64) -> Result<f64, AeroError> {
96    check_dimension("tube length over diameter", length_over_diameter, false)?;
97    Ok(weissinger(length_over_diameter))
98}
99
100/// [`ring_lift_slope`] at a checked `λ`.
101fn weissinger(l: f64) -> f64 {
102    PI * PI / (1.0 + 0.5 * PI * l + l * (1.2 * l).atan())
103}
104
105/// A thin ring's aerodynamic center aft of its leading edge, as a fraction of its length, at an
106/// aspect ratio `A = d/L`: [`THIN_RING_CENTER`] interpolated linearly in `A`, and held at `A = 3`
107/// beyond it ([`TubeFinSetAero::new`] refuses a ring that short).
108///
109/// # Errors
110///
111/// [`AeroError::Domain`] for an `A` that is not finite and positive.
112pub fn ring_center_fraction(aspect_ratio: f64) -> Result<f64, AeroError> {
113    check_dimension("tube diameter over length", aspect_ratio, false)?;
114    Ok(fletcher_center(aspect_ratio))
115}
116
117/// [`ring_center_fraction`] at a checked `A`.
118fn fletcher_center(aspect_ratio: f64) -> f64 {
119    let table = &THIN_RING_CENTER;
120    let last = table[table.len() - 1];
121    for pair in table.windows(2) {
122        let [(a0, x0), (a1, x1)] = [pair[0], pair[1]];
123        if aspect_ratio <= a1 {
124            return x0 + (x1 - x0) * (aspect_ratio - a0) / (a1 - a0);
125        }
126    }
127    last.1
128}
129
130/// A tube fin set's precomputed terms.
131///
132/// Serialize-only, like [`crate::AeroModel`].
133#[derive(Debug, Clone, PartialEq, Serialize)]
134#[non_exhaustive]
135pub struct TubeFinSetAero {
136    /// The component's id.
137    pub id: String,
138    /// Number of tubes, at least 3.
139    pub count: u32,
140    /// Station of the tubes' leading edges, m aft of the nose tip.
141    pub fore_station_m: f64,
142    /// A tube's length `L`, m.
143    pub length_m: f64,
144    /// A tube's mean diameter `d`, m: its outer and inner radii added.
145    pub mean_diameter_m: f64,
146    /// Distance of each tube's axis from the rocket's axis, m: the body's radius plus the tube's
147    /// outer radius.
148    pub axis_radius_m: f64,
149    /// One tube's area `d L` over the reference area.
150    pub area_ratio: f64,
151}
152
153impl TubeFinSetAero {
154    /// The terms of `set` on `component`, on a rocket of reference area `reference_area_m2`.
155    ///
156    /// # Errors
157    ///
158    /// [`AeroError::Unsupported`] for fewer than three tubes, solid tubes, tubes shorter than a
159    /// third of their diameter (past Fletcher's measurements) and tubes that overlap their
160    /// neighbours; [`AeroError::Layout`] for a set without the radius of its body tube, and
161    /// [`AeroError::Domain`] for a bad dimension.
162    pub fn new(
163        component: &PlacedComponent,
164        set: &TubeFinSet,
165        reference_area_m2: f64,
166    ) -> Result<Self, AeroError> {
167        if set.count < 3 {
168            return Err(AeroError::Unsupported(format!(
169                "{} tube fins (the model needs three or more, evenly spaced, for the body's flow \
170                 to cancel around them)",
171                set.count
172            )));
173        }
174        let body_radius = component.body_radius_m.ok_or_else(|| {
175            AeroError::Layout("a tube fin set needs the radius of the body tube it is on".into())
176        })?;
177        check_dimension("body radius", body_radius, false)?;
178        check_dimension("tube fin length", set.length_m, false)?;
179        check_dimension("tube fin outer radius", set.outer_radius_m, false)?;
180        check_dimension("tube fin thickness", set.thickness_m, true)?;
181        check_dimension("reference area", reference_area_m2, false)?;
182        // A wall thicker than the radius is a solid rod, which is not a ring wing.
183        let inner = set.outer_radius_m - set.thickness_m;
184        if inner <= 0.0 {
185            return Err(AeroError::Unsupported(
186                "solid tube fins (a wall as thick as the tube's radius)".to_owned(),
187            ));
188        }
189        let mean_diameter = set.outer_radius_m + inner;
190        if mean_diameter > 3.0 * set.length_m {
191            return Err(AeroError::Unsupported(
192                "tube fins shorter than a third of their diameter (past Fletcher's measured rings)"
193                    .to_owned(),
194            ));
195        }
196        // Neighbouring axes, `s = R + r` from the rocket's, are `2 s sin(π/N)` apart; tubes that
197        // touch (the closing radius) are `2r` apart, and closer ones overlap, which `N` isolated
198        // rings don't describe. The tolerance takes the closing radius's rounding.
199        let axis = body_radius + set.outer_radius_m;
200        let gap = axis * (PI / f64::from(set.count)).sin();
201        if gap < set.outer_radius_m * (1.0 - 1e-9) {
202            return Err(AeroError::Unsupported(
203                "tube fins that overlap their neighbours".to_owned(),
204            ));
205        }
206        Ok(Self {
207            id: component.id.clone(),
208            count: set.count,
209            fore_station_m: component.fore_station_m,
210            length_m: set.length_m,
211            mean_diameter_m: mean_diameter,
212            axis_radius_m: axis,
213            area_ratio: mean_diameter * set.length_m / reference_area_m2,
214        })
215    }
216
217    /// The whole set's normal-force slope per radian on the reference area at `mach`, and its
218    /// center of pressure, m aft of the nose tip: `N C_Lα(λ/β)/β · d L/A_ref` at
219    /// `x_ac(β A) L` behind the leading edge.
220    ///
221    /// # Errors
222    ///
223    /// [`AeroError::Mach`] outside `[0, 0.8)`.
224    pub fn loading(&self, mach: f64) -> Result<(f64, f64), AeroError> {
225        check_tube_fin_mach(mach)?;
226        Ok(self.loading_at(mach))
227    }
228
229    /// [`Self::loading`] at a Mach number already checked.
230    pub(crate) fn loading_at(&self, mach: f64) -> (f64, f64) {
231        let beta = (1.0 - mach * mach).sqrt();
232        // Both positive: the dimensions are checked in `new` and `β > 0.6` below Mach 0.8.
233        let length_over_diameter = self.length_m / self.mean_diameter_m;
234        let slope = f64::from(self.count) * weissinger(length_over_diameter / beta) / beta
235            * self.area_ratio;
236        let center = fletcher_center(beta / length_over_diameter);
237        (slope, self.fore_station_m + center * self.length_m)
238    }
239
240    /// The set's roll damping `C_lp` at `mach` on a reference diameter `reference_diameter_m`:
241    /// each tube crosses the air at `p ρ` under the roll rate `p`, `ρ` its axis's distance from the
242    /// rocket's, so its normal force about the axis gives `−2 C_Nα ρ²/d²`, as a pod's does
243    /// ([`crate::AeroModel::roll`]).
244    ///
245    /// # Errors
246    ///
247    /// As [`Self::loading`].
248    pub fn roll_damping(&self, mach: f64, reference_diameter_m: f64) -> Result<f64, AeroError> {
249        check_tube_fin_mach(mach)?;
250        check_dimension("reference diameter", reference_diameter_m, false)?;
251        let (slope, _) = self.loading_at(mach);
252        let rho = self.axis_radius_m;
253        Ok(-2.0 * slope * rho * rho / (reference_diameter_m * reference_diameter_m))
254    }
255}
256
257/// Checks a Mach number in the tube-fin model's range, `[0, 0.8)`.
258///
259/// # Errors
260///
261/// [`AeroError::Mach`] outside it.
262pub(crate) fn check_tube_fin_mach(mach: f64) -> Result<(), AeroError> {
263    check_mach(mach, TUBE_FIN_MACH_LIMIT, "the tube-fin model")
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    /// Fletcher's measured lift-curve slopes, per degree on the area `d c`, against `A = d/c`
271    /// (NACA TN 4117, 1957, Fig. 11, p. 19), read to the chart's finest grid line, 0.002.
272    const FLETCHER_SLOPE_PER_DEG: [(f64, f64); 5] = [
273        (1.0 / 3.0, 0.018),
274        (2.0 / 3.0, 0.034),
275        (1.0, 0.049),
276        (1.5, 0.069),
277        (3.0, 0.102),
278    ];
279
280    #[test]
281    fn weissinger_s_slope_lies_within_three_per_cent_of_fletcher_s_rings() {
282        for (a, measured) in FLETCHER_SLOPE_PER_DEG {
283            let ours = ring_lift_slope(1.0 / a).unwrap() * PI / 180.0;
284            // The largest gap is 2.3%, at A = 2/3 and 3.
285            assert!(
286                (ours - measured).abs() <= 0.03 * measured,
287                "A = {a}: {ours} against {measured}"
288            );
289        }
290        // At A = 1 by hand: π²/(1 + π/2 + arctan 1.2) = 2.8633 per radian.
291        let by_hand = PI * PI / (1.0 + 0.5 * PI + 1.2_f64.atan());
292        assert!((ring_lift_slope(1.0).unwrap() - by_hand).abs() < 1e-15);
293        assert!((by_hand - 2.8633).abs() < 1e-4);
294    }
295
296    #[test]
297    fn a_ring_tends_to_lifting_line_and_slender_body_theory() {
298        // Short: Ribner's lifting-line π² (Wagner 2021 eq. 13, at λ → 0).
299        assert!((ring_lift_slope(1e-9).unwrap() - PI * PI).abs() < 1e-7);
300        // Long: slender-body theory's L = q d² π α (Hoerner 1965 p. 7-13), `π/λ` on `d L`, which
301        // the arctan approaches as `1/(1.2 λ)`.
302        for l in [1e3, 1e5] {
303            let ratio = ring_lift_slope(l).unwrap() / (PI / l);
304            assert!((ratio - 1.0).abs() < 2.0 / l, "λ = {l}: {ratio}");
305        }
306    }
307
308    #[test]
309    fn fletcher_s_center_is_interpolated_to_the_leading_edge_and_held_past_a_3() {
310        for (a, x) in &FLETCHER_AERODYNAMIC_CENTER[1..] {
311            assert!((ring_center_fraction(*a).unwrap() - x).abs() < 1e-15);
312        }
313        // Halfway between A = 1 and 1.5.
314        assert!((ring_center_fraction(1.25).unwrap() - 0.228).abs() < 1e-15);
315        // Below A = 2/3, toward the leading edge: at Fletcher's thick A = 1/3 ring, half his
316        // A = 2/3 fraction, aft of the thick ring's measured -0.11.
317        let third = ring_center_fraction(1.0 / 3.0).unwrap();
318        assert!((third - 0.0715).abs() < 1e-15, "{third}");
319        assert!(third > FLETCHER_AERODYNAMIC_CENTER[0].1);
320        assert!(ring_center_fraction(1e-12).unwrap().abs() < 1e-12);
321        assert_eq!(ring_center_fraction(3.0).unwrap(), 0.355);
322        assert_eq!(ring_center_fraction(10.0).unwrap(), 0.355);
323        for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
324            assert!(matches!(
325                ring_center_fraction(bad),
326                Err(AeroError::Domain {
327                    what: "tube diameter over length",
328                    ..
329                })
330            ));
331        }
332        assert!(matches!(
333            ring_lift_slope(0.0),
334            Err(AeroError::Domain {
335                what: "tube length over diameter",
336                ..
337            })
338        ));
339    }
340}