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}