Skip to main content

hpr_aero/
fins.rs

1//! Fin sets: Barrowman's subsonic normal-force slope and center of pressure, with the
2//! Prandtl–Glauert factor; supersonic linear theory and the transonic join between them
3//! ([`FinAero`]); the fin-count and roll terms, and fin–body interference.
4//!
5//! - **One fin** (Diederich's planform correlation as Barrowman applies it; Barrowman 1967
6//!   eq. 3-6, Niskanen 2009 eq. 3.40):
7//!   `(C_Nα)₁ = 2π (s²/A_ref) / (1 + √(1 + (β s² / (A_fin cos Γ_c))²))`, `β = √(1 − M²)`,
8//!   with `s` the span from the body surface, `A_fin` one fin's area and `Γ_c` the mid-chord
9//!   sweep. At `M = 0` it is Barrowman 1966 eq. 50 (eq. 57 for a trapezoid, where
10//!   `s²/(A_fin cos Γ_c) = 2ℓ/(c_r + c_t)`). At `M → 1` it tends to `π s²/A_ref`.
11//! - **Mean aerodynamic chord** (Niskanen eq. 3.30–3.32): `c̄ = (1/A)∫c² dy`,
12//!   `y_MAC = (1/A)∫y c dy`, `x_MAC,LE = (1/A)∫x_LE c dy`, and the center of pressure at the
13//!   quarter chord `X_f = x_MAC,LE + c̄/4`, fixed through subsonic flow (Barrowman 1967 p. 6). For a
14//!   trapezoid these give Barrowman 1966 eq. 76a (Niskanen eq. 3.34); for an ellipse on its root
15//!   chord `X_f = (½ − 2/(3π)) c_r`.
16//! - **Freeform fins** (Niskanen pp. 27–29): the chord runs from the leading edge to the trailing
17//!   edge, so the gap of a jagged edge counts toward the center of pressure but not toward the
18//!   area in `(C_Nα)₁`; `Γ_c` is the span average of the angle between the mid-chord points.
19//! - **N fins** (Niskanen eq. 3.51–3.53, OpenRocket technical documentation 13.05 eq. 3.54): a fin
20//!   at angle `Λ` to the lateral airflow adds `(C_Nα)₁ sin² Λ` in the plane of the flow, and
21//!   `Σ sin² Λ_k = N/2` for three or more even fins. Fin–fin interference scales 5, 6, 7 and 8
22//!   fins by 0.948, 0.913, 0.854 and 0.810: six and eight fins give 1.37 and 1.62 times four fins
23//!   (MIL-HDBK-762(MI) p. 5-24), five and seven are interpolated. More than eight fins have no
24//!   source and are refused.
25//! - **Fin–body interference** (Barrowman 1966 eq. 77, Niskanen eq. 3.56):
26//!   `K_T(B) = 1 + r_t/(s + r_t)`, with `r_t` the body radius at the fins.
27//! - **Supersonic** ([`FinOutline::supersonic`]; Barrowman 1967 appendix A, first order): the
28//!   flat plate's load `4α/β`, `β = √(M² − 1)`, halved inside the tip's Mach cone, with the root a
29//!   reflection plane: `(C_Nα)₁ = (4/β)(A_fin − A_cone/2)/A_ref` at the load's centroid.
30//! - **Through Mach 1** ([`FinAero`]): the subsonic method to Mach 0.8, linear theory from
31//!   `M_s = max(1.2, 1/cos Γ_L, 1/cos Γ_T, √(1 + 1/A²), √(1 + (c_t/2s)²))`, and slope and CP
32//!   linear in `M` between
33//!   ([ADR-027, the normal force through Mach 1][adr-027]).
34//!
35//! See `docs/physics/aero.md`.
36//!
37//! [adr-027]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-027-the-normal-force-through-mach-1-supersonic-linear-theory-a-transonic-join-and-the-measured-references-2026-09-18
38
39use std::f64::consts::{PI, TAU};
40
41use hpr_design::FinPlanform;
42use serde::{Deserialize, Serialize};
43
44use crate::error::{AeroError, check_dimension, check_mach};
45
46/// A fin's aerodynamic geometry.
47#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
48#[non_exhaustive]
49pub struct FinGeometry {
50    /// Span from the root (body surface) to the tip, m.
51    pub span_m: f64,
52    /// Area of one side of one fin, m²; the area in the normal-force slope.
53    pub area_m2: f64,
54    /// Mid-chord sweep `Γ_c`, rad, positive with the tip aft.
55    pub midchord_sweep_rad: f64,
56    /// Leading-edge sweep `Γ_L`, rad, positive with the tip aft; the span average of the edge's
57    /// angle when it is curved or kinked (Niskanen 2009 eq. 3.91).
58    pub leading_edge_sweep_rad: f64,
59    /// Trailing-edge sweep, rad, positive with the tip aft; the span average of the edge's angle
60    /// when it is curved or kinked. Negative for a trailing edge that sweeps forward to the tip.
61    pub trailing_edge_sweep_rad: f64,
62    /// Length of the mean aerodynamic chord `c̄`, m.
63    pub mac_length_m: f64,
64    /// Leading edge of the mean aerodynamic chord, m aft of the root leading edge.
65    pub mac_leading_edge_m: f64,
66    /// Spanwise station of the mean aerodynamic chord, m from the root.
67    pub mac_span_m: f64,
68}
69
70impl FinGeometry {
71    /// The geometry of a planform (Niskanen 2009 eq. 3.30–3.34; see the module docs).
72    ///
73    /// # Errors
74    ///
75    /// The planform's own validation errors, and [`AeroError::Domain`] for a fin without area.
76    pub fn from_planform(planform: &FinPlanform) -> Result<Self, AeroError> {
77        planform.validate()?;
78        match *planform {
79            FinPlanform::Trapezoidal {
80                root_chord_m: c_r,
81                tip_chord_m: c_t,
82                span_m: s,
83                sweep_m: x_t,
84            } => {
85                let sum = c_r + c_t;
86                let y_mac = s / 3.0 * (c_r + 2.0 * c_t) / sum;
87                Ok(Self {
88                    span_m: s,
89                    area_m2: 0.5 * s * sum,
90                    midchord_sweep_rad: (x_t + 0.5 * c_t - 0.5 * c_r).atan2(s),
91                    leading_edge_sweep_rad: x_t.atan2(s),
92                    trailing_edge_sweep_rad: (x_t + c_t - c_r).atan2(s),
93                    mac_length_m: 2.0 / 3.0 * (c_r * c_r + c_r * c_t + c_t * c_t) / sum,
94                    mac_leading_edge_m: x_t * y_mac / s,
95                    mac_span_m: y_mac,
96                })
97            }
98            FinPlanform::Elliptical {
99                root_chord_m: c_r,
100                span_m: s,
101            } => {
102                let mac = 8.0 * c_r / (3.0 * PI);
103                Ok(Self {
104                    span_m: s,
105                    area_m2: 0.25 * PI * c_r * s,
106                    midchord_sweep_rad: 0.0,
107                    leading_edge_sweep_rad: elliptical_leading_edge_sweep(0.5 * c_r / s),
108                    // The trailing edge is the leading edge's mirror across mid-chord.
109                    trailing_edge_sweep_rad: -elliptical_leading_edge_sweep(0.5 * c_r / s),
110                    mac_length_m: mac,
111                    mac_leading_edge_m: 0.5 * (c_r - mac),
112                    mac_span_m: 4.0 * s / (3.0 * PI),
113                })
114            }
115            FinPlanform::Freeform {
116                ref points_m,
117                ref root_m,
118            } => Self::freeform(planform, points_m.iter().chain(root_m)),
119            _ => Err(AeroError::Unsupported("this fin planform".to_owned())),
120        }
121    }
122
123    /// A freeform outline, integrated band by band between vertex heights. Edges of a simple
124    /// polygon don't cross, so inside a band the leading and trailing edges are single straight
125    /// edges: every integrand is a quadratic in `y`, and the three-point Gauss rule is exact.
126    fn freeform<'a>(
127        planform: &FinPlanform,
128        points: impl Iterator<Item = &'a [f64; 2]>,
129    ) -> Result<Self, AeroError> {
130        let mut heights: Vec<f64> = points.map(|p| p[1]).collect();
131        heights.sort_by(f64::total_cmp);
132        heights.dedup();
133        let span = planform.span_m();
134        // Gauss–Legendre nodes and weights on [−1, 1].
135        let nodes = [
136            (-(0.6f64).sqrt(), 5.0 / 9.0),
137            (0.0, 8.0 / 9.0),
138            ((0.6f64).sqrt(), 5.0 / 9.0),
139        ];
140        let mut chords = Vec::new();
141        let (mut area, mut filled, mut c2, mut yc, mut xc, mut sweep, mut le_sweep, mut te_sweep) =
142            (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
143        for band in heights.windows(2) {
144            let (lo, hi) = (band[0], band[1]);
145            // Vertex heights a few rounding steps apart (a tip given in inches and in meters) make
146            // a band too thin for its Gauss points to land inside it. Its share of any integral is
147            // below 1e-12 of the fin's.
148            if hi - lo <= 1e-12 * span {
149                continue;
150            }
151            let half = 0.5 * (hi - lo);
152            let mid = 0.5 * (hi + lo);
153            let mut mids = [0.0; 2];
154            let mut leading = [0.0; 2];
155            let mut trailing = [0.0; 2];
156            for (k, &(t, w)) in nodes.iter().enumerate() {
157                let y = mid + half * t;
158                planform.chords_at(y, &mut chords);
159                let (Some(first), Some(last)) = (chords.first(), chords.last()) else {
160                    // A valid outline has chords at every height inside its span.
161                    return Err(AeroError::Domain {
162                        what: "freeform fin height without a chord",
163                        value: y,
164                    });
165                };
166                let (le, te) = (first.0, last.1);
167                let c = te - le;
168                let wh = w * half;
169                area += wh * chords.iter().map(|(a, b)| b - a).sum::<f64>();
170                filled += wh * c;
171                c2 += wh * c * c;
172                yc += wh * y * c;
173                xc += wh * le * c;
174                if k != 1 {
175                    mids[k / 2] = 0.5 * (le + te);
176                    leading[k / 2] = le;
177                    trailing[k / 2] = te;
178                }
179            }
180            // The mid-chord line is straight in the band: its angle from the outer two nodes.
181            let dy = 2.0 * half * (0.6f64).sqrt();
182            sweep += (hi - lo) * (mids[1] - mids[0]).atan2(dy);
183            le_sweep += (hi - lo) * (leading[1] - leading[0]).atan2(dy);
184            te_sweep += (hi - lo) * (trailing[1] - trailing[0]).atan2(dy);
185        }
186        if !(area > 0.0 && filled > 0.0 && span > 0.0) {
187            return Err(AeroError::Domain {
188                what: "fin area",
189                value: area,
190            });
191        }
192        Ok(Self {
193            span_m: span,
194            area_m2: area,
195            midchord_sweep_rad: sweep / span,
196            leading_edge_sweep_rad: le_sweep / span,
197            trailing_edge_sweep_rad: te_sweep / span,
198            mac_length_m: c2 / filled,
199            mac_leading_edge_m: xc / filled,
200            mac_span_m: yc / filled,
201        })
202    }
203
204    /// Center of pressure at the quarter mean aerodynamic chord, m aft of the root leading edge
205    /// (Niskanen 2009, `X_f = x_MAC,LE + 0.25 c̄`; Barrowman 1966 eq. 76a for a trapezoid).
206    pub fn center_of_pressure_m(&self) -> f64 {
207        self.mac_leading_edge_m + 0.25 * self.mac_length_m
208    }
209
210    /// Normal-force slope of one fin, per radian of the angle between the flow and the fin
211    /// (Barrowman 1967 eq. 3-6; Niskanen 2009 eq. 3.40).
212    ///
213    /// # Errors
214    ///
215    /// [`AeroError::Mach`] outside `[0, 1)`, and [`AeroError::Domain`] for a non-positive
216    /// reference area.
217    pub fn single_fin_slope(&self, reference_area_m2: f64, mach: f64) -> Result<f64, AeroError> {
218        check_mach(mach, 1.0, "the subsonic fin slope")?;
219        check_dimension("reference area", reference_area_m2, false)?;
220        Ok(self.slope_at((1.0 - mach * mach).sqrt(), reference_area_m2))
221    }
222
223    /// [`FinGeometry::single_fin_slope`] at the Prandtl–Glauert factor `beta`, unchecked.
224    pub(crate) fn slope_at(&self, beta: f64, reference_area_m2: f64) -> f64 {
225        let s2 = self.span_m * self.span_m;
226        let f = beta * s2 / (self.area_m2 * self.midchord_sweep_rad.cos());
227        TAU * s2 / reference_area_m2 / (1.0 + (1.0 + f * f).sqrt())
228    }
229}
230
231/// The span-averaged leading-edge angle of an elliptical fin whose root chord is `2k` spans.
232///
233/// The leading edge `x = (c_r/2)(1 − √(1 − η²))`, `η = y/s`, has the angle
234/// `Γ(η) = atan(k η/√(1 − η²))`. Integrating by parts with `η = sin t`,
235/// `∫₀¹ Γ dη = π/2 − ∫₀¹ k du/(k² + (1 − k²)u²)`, which is `π/2 − acos(k)/√(1 − k²)` for `k < 1`,
236/// `π/2 − 1` at `k = 1`, and `π/2 − acosh(k)/√(k² − 1)` for `k > 1`.
237fn elliptical_leading_edge_sweep(k: f64) -> f64 {
238    let d = 1.0 - k * k;
239    let integral = if d.abs() < 1e-6 {
240        // Series about k = 1 in d = 1 − k², the same on both sides: 1 + d/6 + 3d²/40 + ….
241        1.0 + d / 6.0 + 0.075 * d * d
242    } else if d > 0.0 {
243        k.acos() / d.sqrt()
244    } else {
245        k.acosh() / (-d).sqrt()
246    };
247    std::f64::consts::FRAC_PI_2 - integral
248}
249
250/// Fin–body interference factor `K_T(B) = 1 + r_t/(s + r_t)` (Barrowman 1966 eq. 77; Niskanen 2009
251/// eq. 3.56), with `s` the span from the body surface and `r_t` the body radius at the fins.
252///
253/// # Errors
254///
255/// [`AeroError::Domain`] for a non-positive span or a negative body radius.
256pub fn interference_factor(span_m: f64, body_radius_m: f64) -> Result<f64, AeroError> {
257    check_dimension("fin span", span_m, false)?;
258    check_dimension("body radius at the fins", body_radius_m, true)?;
259    Ok(1.0 + body_radius_m / (span_m + body_radius_m))
260}
261
262/// Fin–fin interference factor for `count` fins in one set (OpenRocket technical documentation
263/// 13.05 eq. 3.54, from MIL-HDBK-762(MI) p. 5-24): 1 up to four fins, then 0.948, 0.913, 0.854
264/// and 0.810.
265///
266/// # Errors
267///
268/// [`AeroError::Domain`] for no fins or more than eight: the documentation's 0.750 for more than
269/// eight fins has no data behind it.
270pub fn fin_count_factor(count: u32) -> Result<f64, AeroError> {
271    match count {
272        1..=4 => Ok(1.0),
273        5 => Ok(0.948),
274        6 => Ok(0.913),
275        7 => Ok(0.854),
276        8 => Ok(0.810),
277        _ => Err(AeroError::Domain {
278            what: "fin count (1 to 8 have a normal-force model)",
279            value: f64::from(count),
280        }),
281    }
282}
283
284/// `Σ sin² Λ_k` over `count` evenly spaced fins, where `Λ_k` is the angle from the lateral airflow
285/// to fin `k` (Niskanen 2009 eq. 3.51–3.53). The first fin is at `base_angle_rad` and the airflow at
286/// `flow_roll_rad`, both from `x_B` toward `y_B`. Three or more fins give exactly `N/2` at any roll.
287pub fn roll_sum(count: u32, base_angle_rad: f64, flow_roll_rad: f64) -> f64 {
288    if count >= 3 {
289        return 0.5 * f64::from(count);
290    }
291    (0..count)
292        .map(|k| {
293            let lambda = base_angle_rad + TAU * f64::from(k) / f64::from(count) - flow_roll_rad;
294            lambda.sin().powi(2)
295        })
296        .sum()
297}
298
299/// `Σ sin(φ − θ_k) cos(φ − θ_k)` over `count` evenly spaced fins at `θ_k` in a lateral airflow at
300/// `φ`: the side-force share, perpendicular to the flow's plane. Each fin sees the local angle
301/// `α sin Λ_k` (Niskanen 2009 eq. 3.50) and pushes along its own normal; eq. 3.51 keeps the part of
302/// that push in the flow's plane, `sin² Λ_k`, and this is the part across it. The sum vanishes for
303/// three or more fins; for one or two it doesn't: two fins at 45° to the flow push along their
304/// common normal, `√2` times their in-plane share. Derived here from eq. 3.50; Niskanen drops it.
305pub fn side_sum(count: u32, base_angle_rad: f64, flow_roll_rad: f64) -> f64 {
306    if count >= 3 {
307        return 0.0;
308    }
309    (0..count)
310        .map(|k| {
311            let lambda = base_angle_rad + TAU * f64::from(k) / f64::from(count) - flow_roll_rad;
312            -lambda.sin() * lambda.cos()
313        })
314        .sum()
315}
316
317/// Sides of the polygon that stands for an elliptical fin in [`FinOutline`]: its area is
318/// `1 − (π/n)²/6` of the ellipse's to first order, 2.5e-5 short at 256.
319const ELLIPSE_SIDES: u32 = 256;
320
321/// A fin's outline in its own plane, for supersonic linear theory: a simple polygon of `[x, y]`
322/// vertices, m, with `x` aft of the root leading edge and `y` out from the root, closed along the
323/// root.
324///
325/// Serialize-only: an outline is built from a planform by [`FinOutline::from_planform`], which
326/// checks it.
327#[derive(Debug, Clone, PartialEq, Serialize)]
328pub struct FinOutline {
329    points_m: Vec<[f64; 2]>,
330    tip_leading_edge_m: [f64; 2],
331    tip_chord_m: f64,
332    area_m2: f64,
333    centroid_m: f64,
334}
335
336impl FinOutline {
337    /// The vertices, m, from the root leading edge `[0, 0]` around to the root trailing edge.
338    pub fn points_m(&self) -> &[[f64; 2]] {
339        &self.points_m
340    }
341
342    /// The tip's leading edge, m: the foremost point at the full span, where the tip's Mach cone
343    /// starts.
344    pub fn tip_leading_edge_m(&self) -> [f64; 2] {
345        self.tip_leading_edge_m
346    }
347
348    /// The tip's chord, m: the outline's length along the flow at the full span (0 for a pointed
349    /// tip).
350    pub fn tip_chord_m(&self) -> f64 {
351        self.tip_chord_m
352    }
353
354    /// The polygon's area, m².
355    pub fn area_m2(&self) -> f64 {
356        self.area_m2
357    }
358
359    /// Its area centroid, m aft of the root leading edge.
360    pub fn centroid_m(&self) -> f64 {
361        self.centroid_m
362    }
363
364    /// The outline of a planform: a trapezoid's four corners, a freeform fin's own points, and an
365    /// ellipse as a polygon of 256 sides.
366    ///
367    /// # Errors
368    ///
369    /// The planform's own validation errors, [`AeroError::Unsupported`] for a planform this model
370    /// doesn't know, and [`AeroError::Domain`] for an outline without area.
371    pub fn from_planform(planform: &FinPlanform) -> Result<Self, AeroError> {
372        planform.validate()?;
373        let points = match *planform {
374            FinPlanform::Trapezoidal {
375                root_chord_m: c_r,
376                tip_chord_m: c_t,
377                span_m: s,
378                sweep_m: x_t,
379            } => vec![[0.0, 0.0], [x_t, s], [x_t + c_t, s], [c_r, 0.0]],
380            FinPlanform::Elliptical {
381                root_chord_m: c_r,
382                span_m: s,
383            } => (0..=ELLIPSE_SIDES)
384                .map(|i| {
385                    let t = PI * f64::from(i) / f64::from(ELLIPSE_SIDES);
386                    [0.5 * c_r * (1.0 - t.cos()), s * t.sin()]
387                })
388                .collect(),
389            FinPlanform::Freeform {
390                ref points_m,
391                ref root_m,
392            } => points_m.iter().chain(root_m).copied().collect(),
393            _ => return Err(AeroError::Unsupported("this fin planform".to_owned())),
394        };
395        let span = points.iter().fold(0.0_f64, |m, p| m.max(p[1]));
396        // The same tolerance as a freeform fin's bands: tip vertices a few rounding steps apart
397        // are one tip.
398        let at_tip = || points.iter().filter(|p| p[1] >= span * (1.0 - 1e-12));
399        let tip_x = at_tip().fold(f64::INFINITY, |m, p| m.min(p[0]));
400        let tip_end = at_tip().fold(f64::NEG_INFINITY, |m, p| m.max(p[0]));
401        let (area, moment) = area_and_moment(&points);
402        if !(area > 0.0 && tip_x.is_finite()) {
403            return Err(AeroError::Domain {
404                what: "fin area",
405                value: area,
406            });
407        }
408        Ok(Self {
409            tip_leading_edge_m: [tip_x, span],
410            tip_chord_m: tip_end - tip_x,
411            area_m2: area,
412            centroid_m: moment / area,
413            points_m: points,
414        })
415    }
416
417    /// The area, m², and its centroid, m aft of the root leading edge, of the part of the fin
418    /// inside the Mach cone from the tip's leading edge at `β = √(M² − 1)`: aft of the Mach line
419    /// `x − x_T = β (s − y)`, which runs inboard from the tip at the Mach angle `atan(1/β)`.
420    ///
421    /// The root is a reflection plane, so a cone that crosses it comes back: the part of the cone
422    /// over the fin's mirror image counts too, as the mirror fin's cone crossing onto this one.
423    ///
424    /// `beta` must be positive and finite; [`FinAero::loading`] checks the Mach number it comes
425    /// from.
426    pub fn tip_cone(&self, beta: f64) -> (f64, f64) {
427        debug_assert!(beta > 0.0 && beta.is_finite(), "beta {beta}");
428        let [x_t, s] = self.tip_leading_edge_m;
429        // Signed distance aft of the Mach line, in x: non-negative inside the cone.
430        let aft = |p: [f64; 2]| p[0] - x_t - beta * (s - p[1]);
431        let (area, moment) = clipped_area_and_moment(&self.points_m, 1.0, aft);
432        let (area_back, moment_back) = clipped_area_and_moment(&self.points_m, -1.0, aft);
433        let (area, moment) = (area + area_back, moment + moment_back);
434        if area > 0.0 {
435            (area, moment / area)
436        } else {
437            (0.0, x_t)
438        }
439    }
440
441    /// One fin's supersonic normal-force slope per radian, on `reference_area_m2`, and its center
442    /// of pressure, m aft of the root leading edge, at `β = √(M² − 1)`.
443    ///
444    /// Linear (Ackeret) theory for a flat plate: each surface carries the pressure coefficient
445    /// `∓2α/β`, so the loading is `4α/β` over the fin, uniform along every chord. Inside the Mach
446    /// cone from the tip's leading edge it is halved, as Barrowman 1967 (appendix A, p. 84) does
447    /// for each strip; for a rectangular tip that is linear theory's exact loss,
448    /// `C_Lα = (4/β)(1 − 1/(2βA))`. The root is a reflection plane (the body). So
449    /// `(C_Nα)₁ = (4/β)(A_fin − A_cone/2)/A_ref`, and the CP is the loading's centroid.
450    ///
451    /// `beta` must be positive and finite, as for [`FinOutline::tip_cone`].
452    pub fn supersonic(&self, beta: f64, reference_area_m2: f64) -> (f64, f64) {
453        let (cone, cone_x) = self.tip_cone(beta);
454        let loaded = self.area_m2 - 0.5 * cone;
455        let moment = self.area_m2 * self.centroid_m - 0.5 * cone * cone_x;
456        (4.0 / beta * loaded / reference_area_m2, moment / loaded)
457    }
458}
459
460impl FinOutline {
461    /// The first and second moments of the fin's area about the body axis, `∫ξ dA` (m³) and
462    /// `∫ξ² dA` (m⁴), with `ξ = r_t + y` the distance from the axis and `r_t` the body radius
463    /// at the fins. For a fin on a pod, `r_t` is how far its root lies along its span from the
464    /// rocket's axis, which may be negative.
465    pub fn axis_moments(&self, body_radius_m: f64) -> (f64, f64) {
466        debug_assert!(body_radius_m.is_finite(), "body radius {body_radius_m}");
467        let m = clipped_moments(&self.points_m, 1.0, |_| 0.0);
468        about_axis(m, body_radius_m)
469    }
470
471    /// [`FinOutline::axis_moments`] of the part of the fin inside the tip's Mach cone at
472    /// `β = √(M² − 1)`, with the mirror fin's cone crossing the root, as [`FinOutline::tip_cone`]
473    /// takes it.
474    ///
475    /// `beta` must be positive and finite, as for [`FinOutline::tip_cone`].
476    pub fn tip_cone_axis_moments(&self, beta: f64, body_radius_m: f64) -> (f64, f64) {
477        debug_assert!(beta > 0.0 && beta.is_finite(), "beta {beta}");
478        debug_assert!(body_radius_m.is_finite(), "body radius {body_radius_m}");
479        let [x_t, s] = self.tip_leading_edge_m;
480        let aft = |p: [f64; 2]| p[0] - x_t - beta * (s - p[1]);
481        let here = about_axis(clipped_moments(&self.points_m, 1.0, aft), body_radius_m);
482        // The mirror image's part lies at `−y`; on this fin it is at `+y`.
483        let mut back = clipped_moments(&self.points_m, -1.0, aft);
484        back.y = -back.y;
485        let back = about_axis(back, body_radius_m);
486        (here.0 + back.0, here.1 + back.1)
487    }
488}
489
490/// `∫ξ dA` and `∫ξ² dA` with `ξ = r + y`, from the area's moments in `y`.
491fn about_axis(m: Moments, r: f64) -> (f64, f64) {
492    (r * m.area + m.y, r * r * m.area + 2.0 * r * m.y + m.yy)
493}
494
495/// One fin's rolling moment at one Mach number, about the body axis, on the reference area
496/// `A_ref` and diameter `d` ([`FinAero::roll`]); the body's interference is the fin set's
497/// ([`roll_forcing_interference`], [`roll_damping_interference`]).
498#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
499#[non_exhaustive]
500pub struct FinRoll {
501    /// `∂C_l/∂δ` per radian of the fin's incidence, its cant, in the sense its lift turns the
502    /// rocket: positive.
503    pub forcing_per_rad: f64,
504    /// `C_lp = ∂C_l/∂(p d/2V)`, per unit of the roll rate `p` made dimensionless by the airspeed
505    /// `V`: it opposes the roll, so it is negative.
506    pub damping: f64,
507}
508
509/// One fin's roll terms on one body that don't change with Mach ([`FinAero::roll_terms`]): its
510/// span moments about the axis and the ends of the transonic join, built once per fin set.
511#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
512#[non_exhaustive]
513pub struct FinRollTerms {
514    /// Radius of the body at the fins, m: where the strips start from the axis. For a fin on a
515    /// pod, taken about the rocket's axis, its root's offset from that axis along its span.
516    pub body_radius_m: f64,
517    /// Reference diameter, m.
518    pub reference_diameter_m: f64,
519    /// `∫ξ dA`, m³ ([`FinOutline::axis_moments`]).
520    pub first_moment_m3: f64,
521    /// `∫ξ² dA`, m⁴.
522    pub second_moment_m4: f64,
523    /// The roll at Mach 0.8, where the join starts.
524    pub transonic_start: FinRoll,
525    /// The roll at `M_s`, where linear theory starts.
526    pub supersonic_start: FinRoll,
527}
528
529/// Where the fin slope and CP leave the subsonic method: the top of the subsonic region, Mach 0.8
530/// (Niskanen 2009 Table 3.1, p. 19).
531pub const TRANSONIC_START_MACH: f64 = 0.8;
532
533/// The lowest Mach number for supersonic linear theory: Mach 1.2, the bottom of the supersonic
534/// region (Niskanen 2009 Table 3.1, p. 19).
535pub const SUPERSONIC_START_MACH: f64 = 1.2;
536
537/// One fin's normal-force slope and center of pressure at one Mach number.
538#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
539#[non_exhaustive]
540pub struct FinLoading {
541    /// Normal-force slope per radian of the angle between the flow and the fin, on the reference
542    /// area.
543    pub slope_per_rad: f64,
544    /// Center of pressure, m aft of the root leading edge.
545    pub cp_m: f64,
546}
547
548/// One fin's normal force through the speed regimes: its geometry and outline, and the two ends
549/// of its transonic join, computed once.
550///
551/// - **Subsonic**, `M ≤ 0.8`: Diederich's slope with Prandtl–Glauert
552///   ([`FinGeometry::single_fin_slope`]) at the quarter mean aerodynamic chord.
553/// - **Supersonic**, from `M_s = max(1.2, 1/cos Γ_L, 1/cos Γ_T, √(1 + 1/A²), √(1 + (c_t/2s)²))`:
554///   linear theory ([`FinOutline::supersonic`]). Its strips need supersonic leading and trailing
555///   edges, whose Mach numbers square to the edge, `M cos Γ_L` and `M cos Γ_T`, are past 1 (NACA
556///   TN 2114's case). Its half-load tip cone holds while the mirror fin's
557///   cone stays off this fin's tip, `β ≥ c_t/(2s)` with `c_t` the tip chord, and while
558///   `βA ≥ 1`, with `A = 2s²/A_fin` the aspect ratio of the fin and its mirror image (for a
559///   rectangle the two agree, and the slope peaks there at `2A`). So a swept, stubby or
560///   inverse-tapered fin starts later than Mach 1.2.
561/// - **Transonic**, between: slope and CP each linear in `M` between their values at the two
562///   ends. No method in the sources gives this region in closed form (MIL-HDBK-762 reads it from
563///   transonic-similarity charts, pp. 5-104–5-105); the join keeps both continuous, with the
564///   slope's peak at `M_s`, where linear theory takes over.
565#[derive(Debug, Clone, PartialEq, Serialize)]
566pub struct FinAero {
567    geometry: FinGeometry,
568    outline: FinOutline,
569    reference_area_m2: f64,
570    supersonic_mach: f64,
571    transonic_start: FinLoading,
572    supersonic_start: FinLoading,
573}
574
575impl FinAero {
576    /// The fin's subsonic geometry.
577    pub fn geometry(&self) -> &FinGeometry {
578        &self.geometry
579    }
580
581    /// Its outline.
582    pub fn outline(&self) -> &FinOutline {
583        &self.outline
584    }
585
586    /// The reference area of the slopes, m².
587    pub fn reference_area_m2(&self) -> f64 {
588        self.reference_area_m2
589    }
590
591    /// Where supersonic linear theory starts, `M_s`. It can pass Mach 5 for a stubby or a very
592    /// swept fin (a strake 0.5 m long and 0.02 m tall starts at Mach 12.5); the fin's slope and CP
593    /// then stay on the join toward that value up to the normal force's limit, and linear theory
594    /// is never used.
595    pub fn supersonic_mach(&self) -> f64 {
596        self.supersonic_mach
597    }
598
599    /// A planform's normal force on `reference_area_m2`.
600    ///
601    /// # Errors
602    ///
603    /// As [`FinGeometry::from_planform`] and [`FinOutline::from_planform`], and
604    /// [`AeroError::Domain`] for a non-positive reference area.
605    pub fn new(planform: &FinPlanform, reference_area_m2: f64) -> Result<Self, AeroError> {
606        check_dimension("reference area", reference_area_m2, false)?;
607        let geometry = FinGeometry::from_planform(planform)?;
608        let outline = FinOutline::from_planform(planform)?;
609        let aspect = 2.0 * geometry.span_m * geometry.span_m / geometry.area_m2;
610        let tip_ratio = outline.tip_chord_m / (2.0 * outline.tip_leading_edge_m[1]);
611        let supersonic_mach = SUPERSONIC_START_MACH
612            .max(1.0 / geometry.leading_edge_sweep_rad.cos())
613            .max(1.0 / geometry.trailing_edge_sweep_rad.cos())
614            .max((1.0 + 1.0 / (aspect * aspect)).sqrt())
615            .max((1.0 + tip_ratio * tip_ratio).sqrt());
616        let beta_sub = (1.0 - TRANSONIC_START_MACH * TRANSONIC_START_MACH).sqrt();
617        let transonic_start = FinLoading {
618            slope_per_rad: geometry.slope_at(beta_sub, reference_area_m2),
619            cp_m: geometry.center_of_pressure_m(),
620        };
621        let (slope_per_rad, cp_m) = outline.supersonic(
622            (supersonic_mach * supersonic_mach - 1.0).sqrt(),
623            reference_area_m2,
624        );
625        Ok(Self {
626            geometry,
627            outline,
628            reference_area_m2,
629            supersonic_mach,
630            transonic_start,
631            supersonic_start: FinLoading {
632                slope_per_rad,
633                cp_m,
634            },
635        })
636    }
637
638    /// The fin's slope and CP at `mach`.
639    ///
640    /// # Errors
641    ///
642    /// [`AeroError::Mach`] outside `[0, 5)` ([`crate::model::NORMAL_FORCE_MACH_LIMIT`]).
643    pub fn loading(&self, mach: f64) -> Result<FinLoading, AeroError> {
644        check_mach(
645            mach,
646            crate::model::NORMAL_FORCE_MACH_LIMIT,
647            "the normal force",
648        )?;
649        Ok(self.loading_at(mach))
650    }
651
652    /// [`FinAero::loading`] at a checked Mach number.
653    pub(crate) fn loading_at(&self, mach: f64) -> FinLoading {
654        if mach <= TRANSONIC_START_MACH {
655            FinLoading {
656                slope_per_rad: self
657                    .geometry
658                    .slope_at((1.0 - mach * mach).sqrt(), self.reference_area_m2),
659                cp_m: self.geometry.center_of_pressure_m(),
660            }
661        } else if mach >= self.supersonic_mach {
662            let (slope_per_rad, cp_m) = self
663                .outline
664                .supersonic((mach * mach - 1.0).sqrt(), self.reference_area_m2);
665            FinLoading {
666                slope_per_rad,
667                cp_m,
668            }
669        } else {
670            let t = (mach - TRANSONIC_START_MACH) / (self.supersonic_mach - TRANSONIC_START_MACH);
671            let (a, b) = (self.transonic_start, self.supersonic_start);
672            FinLoading {
673                slope_per_rad: a.slope_per_rad + t * (b.slope_per_rad - a.slope_per_rad),
674                cp_m: a.cp_m + t * (b.cp_m - a.cp_m),
675            }
676        }
677    }
678}
679
680impl FinAero {
681    /// One fin's roll forcing and damping at `mach` on a body of radius `body_radius_m`, on the
682    /// reference area and the reference diameter `reference_diameter_m`, by strip theory
683    /// (Barrowman 1967 §3.13–3.14 and appendix A; Niskanen 2009 §3.3):
684    ///
685    /// - **Subsonic**, to Mach 0.8: the fin's lift at its mean aerodynamic chord,
686    ///   `C_lδ = (C_Nα)₁ (r_t + y_MAC)/d` (Barrowman eq. 3-35, Niskanen eq. 3.66), and each strip
687    ///   at the local incidence `−pξ/V` with the fin's own slope per unit area,
688    ///   `a = (C_Nα)₁ A_ref/A_fin`: `C_lp = −2a ∫ξ² dA/(A_ref d²)` (Barrowman eq. 3-40–3-48,
689    ///   Niskanen eq. 3.67–3.70). Barrowman's text writes the airfoil's `C_Nα0` for `a`; his
690    ///   computed curve for the Basic Finner, −34.2 at Mach 0 (Fig. 5-7), is this, −33.5, not the
691    ///   airfoil's −69 ([the roll decision, ADR-031][adr-031]).
692    /// - **Supersonic**, from `M_s` ([`FinAero::supersonic_mach`]): the load `4α/β` of
693    ///   [`FinOutline::supersonic`], halved in the tip's Mach cone,
694    ///   `C_lδ = (4/β)(∫ξ dA − ½∫_cone ξ dA)/(A_ref d)` and
695    ///   `C_lp = −(8/β)(∫ξ² dA − ½∫_cone ξ² dA)/(A_ref d²)` (Barrowman appendix A, first order).
696    /// - **Transonic**, between: each linear in `M`, as the fin's slope is.
697    ///
698    /// `ξ = r_t + y` is the distance from the body axis.
699    ///
700    /// # Errors
701    ///
702    /// [`AeroError::Mach`] outside `[0, 5)`, as [`FinAero::loading`], and [`AeroError::Domain`]
703    /// for a negative body radius or a non-positive reference diameter.
704    ///
705    /// [adr-031]: https://github.com/nrdptel/hpr-sim/blob/main/docs/DECISIONS.md#adr-031-roll-from-canted-fins-and-roll-damping-by-barrowmans-strip-theory-2026-09-19
706    pub fn roll(
707        &self,
708        mach: f64,
709        body_radius_m: f64,
710        reference_diameter_m: f64,
711    ) -> Result<FinRoll, AeroError> {
712        let terms = self.roll_terms(body_radius_m, reference_diameter_m)?;
713        check_mach(
714            mach,
715            crate::model::NORMAL_FORCE_MACH_LIMIT,
716            "the roll moment",
717        )?;
718        Ok(self.roll_with(&terms, mach))
719    }
720
721    /// The terms of [`FinAero::roll`] that don't change with Mach, on a body of radius
722    /// `body_radius_m` and the reference diameter `reference_diameter_m`.
723    ///
724    /// # Errors
725    ///
726    /// [`AeroError::Domain`] for a negative body radius or a non-positive reference diameter.
727    pub fn roll_terms(
728        &self,
729        body_radius_m: f64,
730        reference_diameter_m: f64,
731    ) -> Result<FinRollTerms, AeroError> {
732        check_dimension("body radius at the fins", body_radius_m, true)?;
733        self.roll_terms_about(body_radius_m, reference_diameter_m)
734    }
735
736    /// [`FinAero::roll_terms`] for a fin whose root lies `root_offset_m` from the rocket's axis
737    /// along its span, which may be negative: a fin on a pod. The strips' distance from the axis
738    /// is then `root_offset_m + y`, and so is each strip's arm about it: a fin element at `P`
739    /// moves across its own plane at `p (P · ê)` under the roll rate `p`, `ê` the fin's spanwise
740    /// direction, and its normal force turns the rocket with the same arm `P · ê`. Only the
741    /// damping of such terms means anything: the forcing's arm assumes a root on the axis.
742    ///
743    /// # Errors
744    ///
745    /// [`AeroError::Domain`] for a root offset that isn't finite or a non-positive reference
746    /// diameter.
747    pub(crate) fn roll_terms_about(
748        &self,
749        root_offset_m: f64,
750        reference_diameter_m: f64,
751    ) -> Result<FinRollTerms, AeroError> {
752        if !root_offset_m.is_finite() {
753            return Err(AeroError::Domain {
754                what: "fin root offset from the axis",
755                value: root_offset_m,
756            });
757        }
758        let body_radius_m = root_offset_m;
759        check_dimension("reference diameter", reference_diameter_m, false)?;
760        let (first, second) = self.outline.axis_moments(body_radius_m);
761        let mut terms = FinRollTerms {
762            body_radius_m,
763            reference_diameter_m,
764            first_moment_m3: first,
765            second_moment_m4: second,
766            transonic_start: FinRoll {
767                forcing_per_rad: 0.0,
768                damping: 0.0,
769            },
770            supersonic_start: FinRoll {
771                forcing_per_rad: 0.0,
772                damping: 0.0,
773            },
774        };
775        let beta_sub = (1.0 - TRANSONIC_START_MACH * TRANSONIC_START_MACH).sqrt();
776        let m_s = self.supersonic_mach;
777        terms.transonic_start = self.subsonic_roll(beta_sub, &terms);
778        terms.supersonic_start = self.supersonic_roll((m_s * m_s - 1.0).sqrt(), &terms);
779        Ok(terms)
780    }
781
782    /// [`FinAero::roll`] with its terms built, at a checked Mach number.
783    pub(crate) fn roll_with(&self, terms: &FinRollTerms, mach: f64) -> FinRoll {
784        if mach <= TRANSONIC_START_MACH {
785            self.subsonic_roll((1.0 - mach * mach).sqrt(), terms)
786        } else if mach >= self.supersonic_mach {
787            self.supersonic_roll((mach * mach - 1.0).sqrt(), terms)
788        } else {
789            let (a, b) = (terms.transonic_start, terms.supersonic_start);
790            let t = (mach - TRANSONIC_START_MACH) / (self.supersonic_mach - TRANSONIC_START_MACH);
791            FinRoll {
792                forcing_per_rad: a.forcing_per_rad + t * (b.forcing_per_rad - a.forcing_per_rad),
793                damping: a.damping + t * (b.damping - a.damping),
794            }
795        }
796    }
797
798    fn subsonic_roll(&self, beta: f64, terms: &FinRollTerms) -> FinRoll {
799        let (r, d) = (terms.body_radius_m, terms.reference_diameter_m);
800        let slope = self.geometry.slope_at(beta, self.reference_area_m2);
801        let per_area = slope * self.reference_area_m2 / self.geometry.area_m2;
802        FinRoll {
803            forcing_per_rad: slope * (r + self.geometry.mac_span_m) / d,
804            damping: -2.0 * per_area * terms.second_moment_m4 / (self.reference_area_m2 * d * d),
805        }
806    }
807
808    fn supersonic_roll(&self, beta: f64, terms: &FinRollTerms) -> FinRoll {
809        let (r, d) = (terms.body_radius_m, terms.reference_diameter_m);
810        let (cone_first, cone_second) = self.outline.tip_cone_axis_moments(beta, r);
811        let load = 4.0 / beta / self.reference_area_m2;
812        FinRoll {
813            forcing_per_rad: load * (terms.first_moment_m3 - 0.5 * cone_first) / d,
814            damping: -2.0 * load * (terms.second_moment_m4 - 0.5 * cone_second) / (d * d),
815        }
816    }
817}
818
819/// The body's interference with the roll forcing of canted fins (Barrowman 1967 eq. 3-95 and
820/// 3-105, from slender-body theory, his reference 23), with `τ = (s + r_t)/r_t`:
821///
822/// ```text
823/// k_T(B) = (1/π²)[ (π²/4)(τ + 1)²/τ² + π(τ² + 1)²/(τ²(τ − 1)²) asin((τ² − 1)/(τ² + 1))
824///          − 2π(τ + 1)/(τ(τ − 1)) + (τ² + 1)²/(τ²(τ − 1)²) asin²((τ² − 1)/(τ² + 1))
825///          − 4(τ + 1)/(τ(τ − 1)) asin((τ² − 1)/(τ² + 1)) + 8/(τ − 1)² ln((τ² + 1)/2τ) ]
826/// ```
827///
828/// It is 1 with no body (`r_t = 0`), 0.940 at `τ = 2` and 0.935 for the Arcas Robin's fins
829/// (`τ = 2.87`). Below `τ = 1.001`, a fin shorter than a thousandth of the body's radius, the
830/// terms cancel to rounding, and past `τ = 10⁶` they overflow; it is held at its value at each.
831///
832/// # Errors
833///
834/// [`AeroError::Domain`] for a non-positive span or a negative body radius.
835pub fn roll_forcing_interference(span_m: f64, body_radius_m: f64) -> Result<f64, AeroError> {
836    check_dimension("fin span", span_m, false)?;
837    check_dimension("body radius at the fins", body_radius_m, true)?;
838    if body_radius_m == 0.0 {
839        return Ok(1.0);
840    }
841    let t = ((span_m + body_radius_m) / body_radius_m).clamp(1.001, 1e6);
842    let (t2, u) = (t * t, t - 1.0);
843    let a = ((t2 - 1.0) / (t2 + 1.0)).asin();
844    let q = (t2 + 1.0) * (t2 + 1.0) / (t2 * u * u);
845    let p = (t + 1.0) / (t * u);
846    Ok(
847        (PI * PI / 4.0 * (t + 1.0) * (t + 1.0) / t2 + PI * q * a - 2.0 * PI * p + q * a * a
848            - 4.0 * p * a
849            + 8.0 / (u * u) * ((t2 + 1.0) / (2.0 * t)).ln())
850            / (PI * PI),
851    )
852}
853
854/// The body's interference with the roll damping (Barrowman 1967 eq. 3-122 and 3-123), with
855/// `τ = (s + r_t)/r_t` and `λ = c_t/c_r`, for a chord falling linearly from root to tip:
856///
857/// ```text
858/// k_R(B) = 1 + ((τ − λ)/τ − (1 − λ) ln τ/(τ − 1)) / ((τ + 1)(τ − λ)/2 − (1 − λ)(τ² + τ + 1)/3)
859/// ```
860///
861/// the integral `1 + r_t³∫c/ξ² dξ / ∫ξ c dξ` over the span (eq. 3-121). It is 1 with no body, 2
862/// as the span goes to 0, and 1.20 for the Arcas Robin's fins. A fin of another shape takes it
863/// at its tip-to-root chord ratio: an elliptical fin as a triangle, about 5.5% too much damping.
864/// Below `τ = 1.001` and past `τ = 10⁶` it is held at its value there, as
865/// [`roll_forcing_interference`] is.
866///
867/// # Errors
868///
869/// [`AeroError::Domain`] for a non-positive span, a negative body radius, or a negative or
870/// non-finite taper ratio (an inverse taper, above 1, is allowed).
871pub fn roll_damping_interference(
872    span_m: f64,
873    body_radius_m: f64,
874    taper_ratio: f64,
875) -> Result<f64, AeroError> {
876    check_dimension("fin span", span_m, false)?;
877    check_dimension("body radius at the fins", body_radius_m, true)?;
878    check_dimension("fin taper ratio", taper_ratio, true)?;
879    if body_radius_m == 0.0 {
880        return Ok(1.0);
881    }
882    let (t, l) = (
883        ((span_m + body_radius_m) / body_radius_m).clamp(1.001, 1e6),
884        taper_ratio,
885    );
886    let u = t - 1.0;
887    let log_ratio = u.ln_1p() / u;
888    Ok(1.0
889        + ((t - l) / t - (1.0 - l) * log_ratio)
890            / ((t + 1.0) * (t - l) / 2.0 - (1.0 - l) * (t * t + t + 1.0) / 3.0))
891}
892
893/// The polygon's area and first moment `∫x dA` by the shoelace formula, oriented to a positive
894/// area.
895fn area_and_moment(points: &[[f64; 2]]) -> (f64, f64) {
896    clipped_area_and_moment(points, 1.0, |_| 0.0)
897}
898
899/// The area and first moment `∫x dA` of the polygon, with `y` scaled by `flip` (−1 for its mirror
900/// image across the root), cut to where `inside(p) ≥ 0`: Sutherland–Hodgman against one line,
901/// its vertices summed by the shoelace formula as they come, without storing them. A concave
902/// polygon may come back with edges doubled along the line, which carry no area, so the sums are
903/// exact.
904fn clipped_area_and_moment(
905    points: &[[f64; 2]],
906    flip: f64,
907    inside: impl Fn([f64; 2]) -> f64,
908) -> (f64, f64) {
909    let m = clipped_moments(points, flip, inside);
910    (m.area, m.x)
911}
912
913/// A polygon's area and moments, oriented to a positive area.
914#[derive(Debug, Clone, Copy, Default, PartialEq)]
915struct Moments {
916    /// `∫dA`.
917    area: f64,
918    /// `∫x dA`.
919    x: f64,
920    /// `∫y dA`.
921    y: f64,
922    /// `∫y² dA`.
923    yy: f64,
924}
925
926/// [`clipped_area_and_moment`] with the span moments too.
927fn clipped_moments(points: &[[f64; 2]], flip: f64, inside: impl Fn([f64; 2]) -> f64) -> Moments {
928    let mut sums = Shoelace::default();
929    let n = points.len();
930    for (i, p) in points.iter().enumerate() {
931        let q = points[(i + 1) % n];
932        let (p, q) = ([p[0], flip * p[1]], [q[0], flip * q[1]]);
933        let (dp, dq) = (inside(p), inside(q));
934        if dp >= 0.0 {
935            sums.push(p);
936        }
937        if (dp >= 0.0) != (dq >= 0.0) {
938            let t = dp / (dp - dq);
939            sums.push([p[0] + t * (q[0] - p[0]), p[1] + t * (q[1] - p[1])]);
940        }
941    }
942    sums.finish()
943}
944
945/// Running shoelace sums over a polygon's vertices in order.
946#[derive(Default)]
947struct Shoelace {
948    first: Option<[f64; 2]>,
949    last: [f64; 2],
950    twice_area: f64,
951    six_moment: f64,
952    six_y_moment: f64,
953    twelve_yy_moment: f64,
954}
955
956impl Shoelace {
957    fn push(&mut self, v: [f64; 2]) {
958        if self.first.is_some() {
959            self.edge(self.last, v);
960        } else {
961            self.first = Some(v);
962        }
963        self.last = v;
964    }
965
966    fn edge(&mut self, p: [f64; 2], q: [f64; 2]) {
967        let cross = p[0] * q[1] - q[0] * p[1];
968        self.twice_area += cross;
969        self.six_moment += (p[0] + q[0]) * cross;
970        self.six_y_moment += (p[1] + q[1]) * cross;
971        self.twelve_yy_moment += (p[1] * p[1] + p[1] * q[1] + q[1] * q[1]) * cross;
972    }
973
974    /// Closes the polygon: its area, positive, and moments.
975    fn finish(mut self) -> Moments {
976        if let Some(first) = self.first {
977            self.edge(self.last, first);
978        }
979        let sign = self.twice_area.signum();
980        Moments {
981            area: 0.5 * self.twice_area * sign,
982            x: self.six_moment * sign / 6.0,
983            y: self.six_y_moment * sign / 6.0,
984            yy: self.twelve_yy_moment * sign / 12.0,
985        }
986    }
987}
988
989#[cfg(test)]
990mod tests {
991    use std::f64::consts::FRAC_1_SQRT_2;
992
993    use super::*;
994
995    fn close(got: f64, want: f64, rel: f64, what: &str) {
996        let err = if want == 0.0 {
997            got.abs()
998        } else {
999            ((got - want) / want).abs()
1000        };
1001        assert!(
1002            err <= rel,
1003            "{what}: got {got}, want {want}, rel err {err:e}"
1004        );
1005    }
1006
1007    fn trapezoid(c_r: f64, c_t: f64, s: f64, x_t: f64) -> FinPlanform {
1008        FinPlanform::Trapezoidal {
1009            root_chord_m: c_r,
1010            tip_chord_m: c_t,
1011            span_m: s,
1012            sweep_m: x_t,
1013        }
1014    }
1015
1016    /// Barrowman 1966 eq. 57, for four fins with `N/2` applied by hand: `8 (s/d)² / (1 + √(1 +
1017    /// (2ℓ/(c_r + c_t))²))` per fin, with `ℓ` the mid-chord line's length.
1018    fn eq57(c_r: f64, c_t: f64, s: f64, x_t: f64, d: f64) -> f64 {
1019        let ell = (s * s + (x_t + 0.5 * c_t - 0.5 * c_r).powi(2)).sqrt();
1020        8.0 * (s / d).powi(2) / (1.0 + (1.0 + (2.0 * ell / (c_r + c_t)).powi(2)).sqrt())
1021    }
1022
1023    /// Loft lesson L8: fin-set slopes don't grow linearly past four fins. Six and eight fins give
1024    /// 1.37 and 1.62 times four (MIL-HDBK-762(MI) p. 5-24); five and seven sit between.
1025    #[test]
1026    fn six_fin_cna_applies_fin_count_factor() {
1027        let set = |n: u32| roll_sum(n, 0.3, 1.1) * fin_count_factor(n).unwrap();
1028        close(set(6) / set(4), 1.37, 5e-4, "six over four");
1029        close(set(8) / set(4), 1.62, 1e-12, "eight over four");
1030        close(set(5), 2.37, 1e-12, "five");
1031        // Niskanen's thesis prints three figures: 3.5 × 0.854 = 2.989.
1032        close(set(7), 2.99, 5e-4, "seven");
1033        assert!(set(6) < 1.5 * set(4));
1034        for n in [1, 2, 3, 4] {
1035            assert_eq!(fin_count_factor(n).unwrap(), 1.0);
1036        }
1037        assert!(fin_count_factor(0).is_err());
1038        assert!(fin_count_factor(9).is_err());
1039    }
1040
1041    /// Loft lesson L10: an elliptical fin's mid-chord line is straight along the root's middle, so
1042    /// `Γ_c = 0` and its slope uses its own area, not an equal-area trapezoid's sweep.
1043    #[test]
1044    fn elliptical_fin_cna_uses_zero_midchord_sweep() {
1045        let (c_r, s, d) = (0.1, 0.06, 0.05);
1046        let a_ref = 0.25 * PI * d * d;
1047        let ellipse = FinGeometry::from_planform(&FinPlanform::Elliptical {
1048            root_chord_m: c_r,
1049            span_m: s,
1050        })
1051        .unwrap();
1052        assert_eq!(ellipse.midchord_sweep_rad, 0.0);
1053        let area = 0.25 * PI * c_r * s;
1054        let f = s * s / area;
1055        let want = TAU * s * s / a_ref / (1.0 + (1.0 + f * f).sqrt());
1056        close(
1057            ellipse.single_fin_slope(a_ref, 0.0).unwrap(),
1058            want,
1059            1e-15,
1060            "slope",
1061        );
1062
1063        // Loft's equal-area trapezoid (tip 2A/s − c_r, sweep 0) moves the mid-chord and is 1.3%
1064        // low.
1065        let loft =
1066            FinGeometry::from_planform(&trapezoid(c_r, 2.0 * area / s - c_r, s, 0.0)).unwrap();
1067        let ratio = loft.single_fin_slope(a_ref, 0.0).unwrap() / want;
1068        assert!((0.985..0.99).contains(&ratio), "{ratio}");
1069
1070        // A 2000-gon ellipse converges on the same sweep, slope and CP.
1071        let n = 2000;
1072        let points: Vec<[f64; 2]> = (0..=n)
1073            .map(|i| {
1074                let t = PI * f64::from(i) / f64::from(n);
1075                let x = 0.5 * c_r * (1.0 - t.cos());
1076                [x, s * t.sin()]
1077            })
1078            .map(|[x, y]| [x, if y.abs() < 1e-15 { 0.0 } else { y }])
1079            .collect();
1080        let polygon = FinGeometry::from_planform(&FinPlanform::Freeform {
1081            points_m: points,
1082            root_m: Vec::new(),
1083        })
1084        .unwrap();
1085        assert!(
1086            polygon.midchord_sweep_rad.abs() < 1e-12,
1087            "{}",
1088            polygon.midchord_sweep_rad
1089        );
1090        close(
1091            polygon.single_fin_slope(a_ref, 0.0).unwrap(),
1092            want,
1093            1e-5,
1094            "polygon slope",
1095        );
1096        close(
1097            polygon.center_of_pressure_m(),
1098            ellipse.center_of_pressure_m(),
1099            1e-5,
1100            "polygon CP",
1101        );
1102    }
1103
1104    /// M4.5g4 (ADR-166): a root that follows a nose cone is part of the fin's outline. Its area,
1105    /// subsonic and supersonic, is the whole polygon's (shoelace), not the outline's closed
1106    /// straight along a chord; a straight root drawn through points on it is the same fin.
1107    #[test]
1108    fn a_root_along_the_body_bounds_the_fin() {
1109        let outline = vec![[0.0, 0.0], [0.01, 0.03], [0.04, 0.03], [0.05, 0.01]];
1110        // A root that bulges out of its chord, as a nose cone's surface does.
1111        let root = vec![[0.04, 0.0095], [0.025, 0.0075], [0.01, 0.0035]];
1112        let shoelace = |points: &[[f64; 2]]| {
1113            0.5 * (0..points.len())
1114                .map(|i| {
1115                    let (p, q) = (points[i], points[(i + 1) % points.len()]);
1116                    q[0] * p[1] - p[0] * q[1]
1117                })
1118                .sum::<f64>()
1119        };
1120        let whole: Vec<[f64; 2]> = outline.iter().chain(&root).copied().collect();
1121        let curved = FinPlanform::Freeform {
1122            points_m: outline.clone(),
1123            root_m: root,
1124        };
1125        let fin = FinGeometry::from_planform(&curved).unwrap();
1126        close(fin.area_m2, shoelace(&whole), 1e-14, "subsonic area");
1127        let supersonic = FinOutline::from_planform(&curved).unwrap();
1128        close(
1129            supersonic.area_m2,
1130            shoelace(&whole),
1131            1e-14,
1132            "supersonic area",
1133        );
1134        assert!(
1135            shoelace(&whole) < shoelace(&outline) - 1e-5,
1136            "the bulge is not the chord"
1137        );
1138        // A straight root through points on it.
1139        let straight = |root_m: Vec<[f64; 2]>| {
1140            FinGeometry::from_planform(&FinPlanform::Freeform {
1141                points_m: outline.clone(),
1142                root_m,
1143            })
1144            .unwrap()
1145        };
1146        let (bare, drawn) = (
1147            straight(Vec::new()),
1148            straight(vec![[0.04, 0.008], [0.025, 0.005], [0.01, 0.002]]),
1149        );
1150        for (got, want, what) in [
1151            (drawn.area_m2, bare.area_m2, "area"),
1152            (
1153                drawn.center_of_pressure_m(),
1154                bare.center_of_pressure_m(),
1155                "CP",
1156            ),
1157            (drawn.mac_length_m, bare.mac_length_m, "MAC"),
1158            (drawn.midchord_sweep_rad, bare.midchord_sweep_rad, "sweep"),
1159        ] {
1160            close(got, want, 1e-12, what);
1161        }
1162    }
1163
1164    /// M4.5g4 (ADR-166): the gap the cockpit of OpenRocket's *Pods--airframes and winglets* leaves,
1165    /// pinned so a change to it is seen. One fin across the airflow (OpenRocket's wind direction
1166    /// θ = 90°) at Mach 0.3, on the reference area of the nose's base, 16.8275 mm in radius:
1167    /// OpenRocket 24.12's `BarrowmanCalculator` gives the component 0.21167 per radian at 0.09842 m
1168    /// from the tip (run through JPype, ADR-166). hpr gives 31.5% more, 0.2784, at its quarter
1169    /// mean chord 0.13 mm from OpenRocket's. Its root along the ogive is not the cause: closed
1170    /// straight along the chord, hpr's is 0.2802. Its extra normal force is forward of the center
1171    /// of mass, so it shortens hpr's margin, on the safe side (issue #326 sizes the same model's
1172    /// gap on the example's wings).
1173    #[test]
1174    fn the_pods_cockpit_reads_above_openrocket_across_the_airflow() {
1175        let nose = hpr_design::Profile::nose(
1176            hpr_design::NoseShape::Ogive { radius_ratio: 1.0 },
1177            0.136525,
1178            0.0168275,
1179        )
1180        .unwrap();
1181        let (chord, fore) = (0.05, 0.136525 - 0.05);
1182        let base = nose.radius_m(fore);
1183        let root_m = (1..64)
1184            .rev()
1185            .map(|i| {
1186                let x = chord * f64::from(i) / 64.0;
1187                [x, nose.radius_m(fore + x) - base]
1188            })
1189            .collect();
1190        let fin = FinGeometry::from_planform(&FinPlanform::Freeform {
1191            points_m: vec![
1192                [0.0, 0.0],
1193                [0.009347826086956524, 0.006956521739130436],
1194                [chord, 0.0022276567072510058],
1195            ],
1196            root_m,
1197        })
1198        .unwrap();
1199        let a_ref = PI * 0.0168275 * 0.0168275;
1200        let slope = fin.single_fin_slope(a_ref, 0.3).unwrap()
1201            * interference_factor(fin.span_m, base).unwrap();
1202        close(slope, 0.2784, 1e-3, "hpr's cockpit");
1203        close(slope / 0.21167, 1.315, 2e-3, "against OpenRocket's");
1204        close(
1205            fore + fin.center_of_pressure_m(),
1206            0.09842,
1207            2e-3,
1208            "OpenRocket's CP from the tip",
1209        );
1210    }
1211
1212    /// Trapezoids match Barrowman 1966's closed forms (eq. 57 slope, eq. 76a CP), and the same
1213    /// outline as a freeform polygon matches them to round-off.
1214    #[test]
1215    fn trapezoid_matches_barrowman_and_its_polygon() {
1216        let (c_r, c_t, s, x_t, d) = (3.0, 2.0, 1.5, 1.5, 0.976);
1217        let a_ref = 0.25 * PI * d * d;
1218        let fin = FinGeometry::from_planform(&trapezoid(c_r, c_t, s, x_t)).unwrap();
1219        close(
1220            fin.single_fin_slope(a_ref, 0.0).unwrap(),
1221            eq57(c_r, c_t, s, x_t, d),
1222            1e-15,
1223            "eq. 57",
1224        );
1225        let eq76a = x_t / 3.0 * (c_r + 2.0 * c_t) / (c_r + c_t)
1226            + (c_r + c_t - c_r * c_t / (c_r + c_t)) / 6.0;
1227        close(fin.center_of_pressure_m(), eq76a, 1e-15, "eq. 76a");
1228        // Testbed II's hand value, X_F = 1.333 in (NARAM-8 p. 43).
1229        close(fin.center_of_pressure_m(), 1.333, 3e-4, "Testbed II X_F");
1230
1231        let polygon = FinGeometry::from_planform(&FinPlanform::Freeform {
1232            points_m: vec![[0.0, 0.0], [x_t, s], [x_t + c_t, s], [c_r, 0.0]],
1233            root_m: Vec::new(),
1234        })
1235        .unwrap();
1236        for (got, want, what) in [
1237            (polygon.area_m2, fin.area_m2, "area"),
1238            (polygon.midchord_sweep_rad, fin.midchord_sweep_rad, "sweep"),
1239            (polygon.mac_length_m, fin.mac_length_m, "MAC"),
1240            (polygon.mac_leading_edge_m, fin.mac_leading_edge_m, "MAC LE"),
1241            (polygon.mac_span_m, fin.mac_span_m, "MAC span"),
1242        ] {
1243            close(got, want, 1e-13, what);
1244        }
1245        // A pointed (delta) fin and a rectangle.
1246        let delta = FinGeometry::from_planform(&trapezoid(0.2, 0.0, 0.1, 0.2)).unwrap();
1247        close(
1248            delta.center_of_pressure_m(),
1249            0.2 / 3.0 * 1.0 + 0.2 / 6.0,
1250            1e-15,
1251            "delta CP",
1252        );
1253        let rectangle = FinGeometry::from_planform(&trapezoid(0.1, 0.1, 0.05, 0.0)).unwrap();
1254        assert_eq!(rectangle.midchord_sweep_rad, 0.0);
1255        close(
1256            rectangle.center_of_pressure_m(),
1257            0.025,
1258            1e-15,
1259            "rectangle CP",
1260        );
1261    }
1262
1263    /// A jagged freeform fin: the notch counts toward the CP's chord but not the slope's area
1264    /// (Niskanen 2009 pp. 27–28).
1265    #[test]
1266    fn jagged_fin_fills_its_gap_for_the_cp_only() {
1267        // A 0.1 × 0.1 square with a 0.04-wide, 0.05-deep slot cut into its tip.
1268        let points = vec![
1269            [0.0, 0.0],
1270            [0.0, 0.1],
1271            [0.03, 0.1],
1272            [0.03, 0.05],
1273            [0.07, 0.05],
1274            [0.07, 0.1],
1275            [0.1, 0.1],
1276            [0.1, 0.0],
1277        ];
1278        let fin = FinGeometry::from_planform(&FinPlanform::Freeform {
1279            points_m: points,
1280            root_m: Vec::new(),
1281        })
1282        .unwrap();
1283        close(
1284            fin.area_m2,
1285            0.01 - 0.04 * 0.05,
1286            1e-14,
1287            "area without the slot",
1288        );
1289        close(fin.mac_length_m, 0.1, 1e-14, "filled chord");
1290        close(fin.mac_leading_edge_m, 0.0, 1e-14, "LE");
1291        close(fin.mac_span_m, 0.05, 1e-14, "filled centroid span");
1292        assert_eq!(fin.midchord_sweep_rad, 0.0);
1293    }
1294
1295    /// Prandtl–Glauert: the slope rises with Mach, equals Barrowman 1967 eq. 3-6 written in the
1296    /// aspect ratio `AR = 2s²/A_fin`, and tends to `π s²/A_ref` as `M → 1`.
1297    #[test]
1298    fn fin_slope_follows_prandtl_glauert() {
1299        let fin = FinGeometry::from_planform(&trapezoid(0.12, 0.06, 0.08, 0.05)).unwrap();
1300        let a_ref = 0.25 * PI * 0.1 * 0.1;
1301        let mut last = 0.0;
1302        for mach in [0.0, 0.2, 0.5, 0.8, 0.95, 0.999] {
1303            let slope = fin.single_fin_slope(a_ref, mach).unwrap();
1304            assert!(slope > last, "{mach}");
1305            last = slope;
1306            let beta = (1.0 - mach * mach).sqrt();
1307            let ar = 2.0 * fin.span_m * fin.span_m / fin.area_m2;
1308            let eq36 = TAU * ar * (fin.area_m2 / a_ref)
1309                / (2.0 + (4.0 + (beta * ar / fin.midchord_sweep_rad.cos()).powi(2)).sqrt());
1310            close(slope, eq36, 1e-14, "eq. 3-6");
1311        }
1312        let near_one = fin.single_fin_slope(a_ref, 1.0 - 1e-12).unwrap();
1313        close(
1314            near_one,
1315            PI * fin.span_m * fin.span_m / a_ref,
1316            1e-5,
1317            "M → 1",
1318        );
1319        assert!(fin.single_fin_slope(a_ref, 1.0).is_err());
1320        assert!(fin.single_fin_slope(a_ref, -0.1).is_err());
1321        assert!(fin.single_fin_slope(a_ref, f64::NAN).is_err());
1322    }
1323
1324    /// Interference at its limits (no body: 1; a vanishing span: 2), and the roll sum: `N/2` for
1325    /// three or more fins at any roll, `sin²` terms for one and two.
1326    #[test]
1327    fn interference_and_roll_limits() {
1328        assert_eq!(interference_factor(0.1, 0.0).unwrap(), 1.0);
1329        close(
1330            interference_factor(1e-12, 0.05).unwrap(),
1331            2.0,
1332            1e-10,
1333            "tiny span",
1334        );
1335        close(
1336            interference_factor(1.5, 0.368).unwrap(),
1337            1.197,
1338            1e-3,
1339            "Testbed II K",
1340        );
1341        assert!(interference_factor(0.0, 0.05).is_err());
1342        assert!(interference_factor(0.1, -0.01).is_err());
1343
1344        for n in 3..=8 {
1345            for roll in [0.0, 0.1, 0.7, 2.0, -3.0] {
1346                let direct: f64 = (0..n)
1347                    .map(|k| {
1348                        (0.2 + TAU * f64::from(k) / f64::from(n) - roll)
1349                            .sin()
1350                            .powi(2)
1351                    })
1352                    .sum();
1353                close(roll_sum(n, 0.2, roll), direct, 1e-14, "direct sum");
1354            }
1355        }
1356        for n in 3..=8 {
1357            assert_eq!(side_sum(n, 0.2, 0.9), 0.0);
1358        }
1359        // Two fins along x_B, flow at 45°: in-plane and side shares of 1 each, a push along y_B.
1360        close(side_sum(2, 0.0, PI / 4.0), 1.0, 1e-15, "two fins, side");
1361        close(roll_sum(2, 0.0, PI / 4.0), 1.0, 1e-15, "two fins, in plane");
1362        assert!(side_sum(2, 0.0, 0.0).abs() < 1e-15 && side_sum(2, 0.0, PI / 2.0).abs() < 1e-15);
1363        assert_eq!(roll_sum(1, 0.0, 0.0), 0.0);
1364        close(
1365            roll_sum(1, 0.0, PI / 2.0),
1366            1.0,
1367            1e-15,
1368            "one fin across the flow",
1369        );
1370        close(
1371            roll_sum(2, 0.0, PI / 2.0),
1372            2.0,
1373            1e-15,
1374            "two fins across the flow",
1375        );
1376        close(roll_sum(2, 0.0, PI / 4.0), 1.0, 1e-15, "two fins at 45°");
1377    }
1378
1379    /// A tip whose two vertices are a few rounding steps apart (3.5 in written in meters and
1380    /// converted from inches) is still one tip: the sliver band between them is skipped.
1381    #[test]
1382    fn nearly_level_tip_vertices_are_one_tip() {
1383        let tip = 3.5 * 0.0254;
1384        assert_ne!(tip, 0.0889);
1385        let nudged = FinPlanform::Freeform {
1386            points_m: vec![[0.0, 0.0], [0.03, 0.0889], [0.06, tip], [0.1, 0.0]],
1387            root_m: Vec::new(),
1388        };
1389        let level = FinPlanform::Freeform {
1390            points_m: vec![[0.0, 0.0], [0.03, 0.0889], [0.06, 0.0889], [0.1, 0.0]],
1391            root_m: Vec::new(),
1392        };
1393        let (a, b) = (
1394            FinGeometry::from_planform(&nudged).unwrap(),
1395            FinGeometry::from_planform(&level).unwrap(),
1396        );
1397        close(a.area_m2, b.area_m2, 1e-12, "area");
1398        close(
1399            a.center_of_pressure_m(),
1400            b.center_of_pressure_m(),
1401            1e-12,
1402            "CP",
1403        );
1404        close(a.midchord_sweep_rad, b.midchord_sweep_rad, 1e-12, "sweep");
1405    }
1406
1407    proptest::proptest! {
1408        /// Four-point outlines, with the tip vertices nudged by up to three rounding steps: the
1409        /// area and the area centroid's span match hpr-design's own quadrature of the planform.
1410        #[test]
1411        fn freeform_integrals_match_the_design_quadrature(
1412            x1 in -0.05f64..0.15,
1413            tip in 0.0f64..0.1,
1414            y1 in 0.01f64..0.2,
1415            y2_ratio in 0.5f64..1.5,
1416            root in 0.02f64..0.3,
1417            steps in -3i32..=3,
1418            level in proptest::bool::ANY,
1419        ) {
1420            let mut y2 = if level { y1 } else { y1 * y2_ratio };
1421            for _ in 0..steps.unsigned_abs() {
1422                y2 = if steps > 0 { y2.next_up() } else { y2.next_down() };
1423            }
1424            let planform = FinPlanform::Freeform {
1425                points_m: vec![[0.0, 0.0], [x1, y1], [x1 + tip, y2], [root, 0.0]],
1426                root_m: Vec::new(),
1427            };
1428            proptest::prop_assume!(planform.validate().is_ok());
1429            let reference = planform.geometry().unwrap();
1430            let fin = FinGeometry::from_planform(&planform).unwrap();
1431            proptest::prop_assert!((fin.area_m2 / reference.area_m2 - 1.0).abs() < 1e-9);
1432            proptest::prop_assert!(
1433                (fin.mac_span_m / reference.centroid_span_m - 1.0).abs() < 1e-9
1434            );
1435        }
1436    }
1437
1438    /// The roll and side sums rebuild the direct per-fin vector sum: fin `k` at `θ_k` pushes along
1439    /// its normal `n_k = (−sin θ_k, cos θ_k)` in proportion to the air crossing it, `sin(φ − θ_k)`,
1440    /// and `Σ sin(φ − θ_k) n_k = roll_sum · ŵ + side_sum · (z_B × ŵ)` with `ŵ = (cos φ, sin φ)`
1441    /// (`frames.md`, force directions). Two fins along `x_B` in a 45° flow push along `+y_B`.
1442    #[test]
1443    fn roll_and_side_sums_rebuild_the_per_fin_vector() {
1444        for n in 1..=8u32 {
1445            for base in [0.0, 0.7, 1.0] {
1446                for roll in [0.0, 0.4, PI / 4.0, 2.5, -1.2] {
1447                    let direct = (0..n).fold([0.0, 0.0], |acc, k| {
1448                        let theta = base + TAU * f64::from(k) / f64::from(n);
1449                        let push = (roll - theta).sin();
1450                        [acc[0] - push * theta.sin(), acc[1] + push * theta.cos()]
1451                    });
1452                    let (c_n, c_y) = (roll_sum(n, base, roll), side_sum(n, base, roll));
1453                    let rebuilt = [
1454                        c_n * roll.cos() - c_y * roll.sin(),
1455                        c_n * roll.sin() + c_y * roll.cos(),
1456                    ];
1457                    for (got, want) in rebuilt.iter().zip(direct) {
1458                        assert!(
1459                            (got - want).abs() < 1e-14,
1460                            "{n} {base} {roll}: {rebuilt:?} {direct:?}"
1461                        );
1462                    }
1463                }
1464            }
1465        }
1466        let (c_n, c_y) = (roll_sum(2, 0.0, PI / 4.0), side_sum(2, 0.0, PI / 4.0));
1467        let push = [
1468            c_n * FRAC_1_SQRT_2 - c_y * FRAC_1_SQRT_2,
1469            c_n * FRAC_1_SQRT_2 + c_y * FRAC_1_SQRT_2,
1470        ];
1471        assert!(
1472            push[0].abs() < 1e-15 && (push[1] - 2.0 * FRAC_1_SQRT_2).abs() < 1e-15,
1473            "{push:?}"
1474        );
1475    }
1476
1477    /// Loft lesson L7: Loft's fin slope had no compressibility factor, so the slope and the CP never
1478    /// changed with Mach. Here both are Barrowman's at Mach 0, the slope follows Prandtl–Glauert
1479    /// through subsonic flow at a fixed CP, and past Mach 0.8 the CP moves aft to linear theory's,
1480    /// both continuous at the two ends of the transonic join.
1481    #[test]
1482    fn fin_cna_compressibility_reduces_to_barrowman_at_m0() {
1483        let (c_r, c_t, s, x_t, d) = (0.12, 0.04, 0.1, 0.08, 0.127);
1484        let a_ref = 0.25 * PI * d * d;
1485        let fin = FinAero::new(&trapezoid(c_r, c_t, s, x_t), a_ref).unwrap();
1486        let at = |mach: f64| fin.loading(mach).unwrap();
1487        // Barrowman 1966 eq. 57 per fin, and eq. 76a.
1488        close(
1489            at(0.0).slope_per_rad,
1490            eq57(c_r, c_t, s, x_t, d),
1491            1e-15,
1492            "M0 slope",
1493        );
1494        let eq76a = x_t / 3.0 * (c_r + 2.0 * c_t) / (c_r + c_t)
1495            + (c_r + c_t - c_r * c_t / (c_r + c_t)) / 6.0;
1496        close(at(0.0).cp_m, eq76a, 1e-15, "M0 CP");
1497        // Subsonic: Prandtl–Glauert on the slope, the CP fixed.
1498        close(
1499            at(0.6).slope_per_rad,
1500            fin.geometry().single_fin_slope(a_ref, 0.6).unwrap(),
1501            1e-15,
1502            "M0.6 slope",
1503        );
1504        assert!(at(0.6).slope_per_rad > 1.05 * at(0.0).slope_per_rad);
1505        assert_eq!(at(0.6).cp_m, at(0.0).cp_m);
1506        // The leading edge, swept 38.7°, becomes supersonic at 1/cos Γ_L = 1.281.
1507        let m_s = fin.supersonic_mach();
1508        close(m_s, (1.0 + (x_t / s).powi(2)).sqrt(), 1e-15, "M_s");
1509        // Continuous at both ends of the join; the CP moves aft through it.
1510        for m in [TRANSONIC_START_MACH, m_s] {
1511            let (below, above) = (at(m - 1e-9), at(m + 1e-9));
1512            close(below.slope_per_rad, above.slope_per_rad, 1e-7, "slope join");
1513            close(below.cp_m, above.cp_m, 1e-7, "CP join");
1514        }
1515        assert!(at(1.0).cp_m > at(0.8).cp_m && at(m_s).cp_m > at(1.0).cp_m);
1516        // Supersonic: linear theory, the slope falling roughly as 1/β.
1517        // At Mach 2, by hand: the trailing edge is unswept, so the tip cone is the triangle
1518        // `c_t²/(2β)` with its centroid `2c_t/3` aft of the tip's leading edge.
1519        let beta = 3.0_f64.sqrt();
1520        let (area, centroid) = (0.5 * s * (c_r + c_t), fins_centroid(c_r, c_t, s, x_t));
1521        let cone = c_t * c_t / (2.0 * beta);
1522        let loaded = area - 0.5 * cone;
1523        close(
1524            at(2.0).slope_per_rad,
1525            4.0 / beta * loaded / a_ref,
1526            1e-13,
1527            "Mach 2 slope",
1528        );
1529        close(
1530            at(2.0).cp_m,
1531            (area * centroid - 0.5 * cone * (x_t + 2.0 * c_t / 3.0)) / loaded,
1532            1e-13,
1533            "Mach 2 CP",
1534        );
1535        assert!(at(2.0).slope_per_rad < at(m_s).slope_per_rad);
1536        assert!(fin.loading(5.0).is_err() && fin.loading(-0.1).is_err());
1537    }
1538
1539    /// The worked example of `docs/physics/aero.md` ("Fins through Mach 1"), Calisto's 2018 fins on
1540    /// a 0.127 m reference: `M_s`, the tip cone at Mach 2, and the table of slopes and CPs, each to
1541    /// the digits the page prints.
1542    #[test]
1543    fn the_guide_s_worked_example() {
1544        let a_ref = 0.25 * PI * 0.127 * 0.127;
1545        let fin = FinAero::new(&trapezoid(0.12, 0.04, 0.1, 0.08), a_ref).unwrap();
1546        let round = |x: f64, digits: i32| (x * 10f64.powi(digits)).round() / 10f64.powi(digits);
1547        assert_eq!(round(a_ref, 6), 0.012668);
1548        assert_eq!(
1549            round(fin.geometry().leading_edge_sweep_rad.to_degrees(), 2),
1550            38.66
1551        );
1552        assert_eq!(round(fin.supersonic_mach(), 4), 1.2806);
1553        let beta = 3.0_f64.sqrt();
1554        let (cone, _) = fin.outline().tip_cone(beta);
1555        assert_eq!(round(cone, 6), 0.000462);
1556        assert_eq!(round(0.04 / beta, 4), 0.0231);
1557        let table = [
1558            (0.0, 1.853, 0.0550),
1559            (0.8, 2.170, 0.0550),
1560            (1.0, 2.499, 0.0632),
1561            (fin.supersonic_mach(), 2.960, 0.0747),
1562            (1.5, 2.158, 0.0753),
1563            (2.0, 1.416, 0.0758),
1564            (3.0, 0.877, 0.0761),
1565        ];
1566        for (mach, slope, cp) in table {
1567            let loading = fin.loading(mach).unwrap();
1568            assert_eq!(round(loading.slope_per_rad, 3), slope, "Mach {mach}");
1569            assert_eq!(round(loading.cp_m, 4), cp, "Mach {mach}");
1570        }
1571    }
1572
1573    /// A trapezoid's area centroid, aft of its root leading edge.
1574    fn fins_centroid(c_r: f64, c_t: f64, s: f64, x_t: f64) -> f64 {
1575        // Chords `c(y)` from `x_LE = x_t y/s`: ∫(x_LE + c/2) c dy / ∫c dy, by three-point Gauss.
1576        let nodes = [
1577            (-(0.6f64).sqrt(), 5.0 / 9.0),
1578            (0.0, 8.0 / 9.0),
1579            ((0.6f64).sqrt(), 5.0 / 9.0),
1580        ];
1581        let (mut first, mut area) = (0.0, 0.0);
1582        for (t, w) in nodes {
1583            let y = 0.5 * s * (1.0 + t);
1584            let c = c_r + (c_t - c_r) * y / s;
1585            first += w * (x_t * y / s + 0.5 * c) * c;
1586            area += w * c;
1587        }
1588        first / area
1589    }
1590
1591    /// An inverse taper, its tip chord longer than its root: the mirror fin's tip cone reaches this
1592    /// fin's tip until `β ≥ c_t/(2s)`, so linear theory starts there (Mach 1.6 for this fin, where
1593    /// `βA ≥ 1` alone would give 1.27), and from there the slope falls with Mach and the cone
1594    /// never takes more than the fin and its mirror.
1595    #[test]
1596    fn an_inverse_taper_waits_for_its_tip_cones_to_part() {
1597        let fin = FinAero::new(
1598            &FinPlanform::Freeform {
1599                points_m: vec![[0.0, 0.0], [-0.05, 0.08], [0.15, 0.08], [0.05, 0.0]],
1600                root_m: Vec::new(),
1601            },
1602            0.01,
1603        )
1604        .unwrap();
1605        close(fin.outline().tip_chord_m(), 0.2, 1e-15, "tip chord");
1606        close(
1607            fin.supersonic_mach(),
1608            (1.0 + 1.25_f64 * 1.25).sqrt(),
1609            1e-15,
1610            "M_s",
1611        );
1612        let mut last = fin.loading(fin.supersonic_mach()).unwrap().slope_per_rad;
1613        for i in 1..=30 {
1614            let mach = fin.supersonic_mach() + 0.1 * f64::from(i);
1615            if mach >= 5.0 {
1616                break;
1617            }
1618            let slope = fin.loading(mach).unwrap().slope_per_rad;
1619            assert!(slope < last, "Mach {mach}: {slope} after {last}");
1620            last = slope;
1621            let beta = (mach * mach - 1.0).sqrt();
1622            assert!(fin.outline().tip_cone(beta).0 <= 2.0 * fin.outline().area_m2());
1623        }
1624    }
1625
1626    /// A stubby strake: `βA ≥ 1` puts linear theory's start past Mach 5, so the slope and CP
1627    /// stay on the join, finite and continuous, up to the normal force's limit.
1628    #[test]
1629    fn a_strake_never_reaches_linear_theory() {
1630        let fin = FinAero::new(&trapezoid(0.5, 0.5, 0.02, 0.0), 0.01).unwrap();
1631        close(
1632            fin.supersonic_mach(),
1633            (1.0 + (0.5_f64 / 0.04).powi(2)).sqrt(),
1634            1e-15,
1635            "M_s",
1636        );
1637        assert!(fin.supersonic_mach() > 12.0);
1638        let mut last = fin.loading(0.8).unwrap();
1639        for i in 1..=419 {
1640            let loading = fin.loading(0.8 + 0.01 * f64::from(i)).unwrap();
1641            assert!(loading.slope_per_rad.is_finite() && loading.cp_m.is_finite());
1642            assert!((loading.slope_per_rad - last.slope_per_rad).abs() < 1e-3 * last.slope_per_rad);
1643            last = loading;
1644        }
1645        assert!(fin.loading(5.0).is_err());
1646    }
1647
1648    /// A concave outline: a 0.1 m square with a slot 0.04 m wide and 0.05 m deep cut into its tip,
1649    /// at Mach √5 (`β = 2`). The Mach line from the tip's leading edge, `y = 0.1 − x/2`, cuts the
1650    /// triangle 0.0025 m² from the square, of which the slot takes `∫x/2 dx` over its width,
1651    /// 0.001 m².
1652    #[test]
1653    fn a_concave_fin_clips_exactly() {
1654        let outline = FinOutline::from_planform(&FinPlanform::Freeform {
1655            points_m: vec![
1656                [0.0, 0.0],
1657                [0.0, 0.1],
1658                [0.03, 0.1],
1659                [0.03, 0.05],
1660                [0.07, 0.05],
1661                [0.07, 0.1],
1662                [0.1, 0.1],
1663                [0.1, 0.0],
1664            ],
1665            root_m: Vec::new(),
1666        })
1667        .unwrap();
1668        assert_eq!(outline.tip_leading_edge_m(), [0.0, 0.1]);
1669        let (area, centroid) = outline.tip_cone(2.0);
1670        close(area, 0.0015, 1e-13, "cone area");
1671        // First moments: the triangle's, `0.0025 · 2(0.1)/3`, less the slot's `∫x²/2 dx`.
1672        let moment = 0.0025 * 0.2 / 3.0 - (0.07_f64.powi(3) - 0.03_f64.powi(3)) / 6.0;
1673        close(centroid, moment / 0.0015, 1e-12, "cone centroid");
1674    }
1675
1676    proptest::proptest! {
1677        /// Any trapezoid with its leading edge straight or swept aft (up to 65°), tapered either
1678        /// way, with its trailing edge swept either way, at any Mach number from linear theory's
1679        /// start: the cone takes no more than the fin and its mirror, the slope is positive and
1680        /// falls with Mach (a rectangle's peaks exactly at `M_s`, where `βA = 1`), and the CP stays
1681        /// inside the outline's chord. Leading edges swept forward are outside the half-load's
1682        /// domain (`docs/physics/aero.md`).
1683        #[test]
1684        fn supersonic_loading_stays_inside_the_fin(
1685            c_r in 0.02..0.4_f64,
1686            taper in 0.0..2.0_f64,
1687            s in 0.02..0.3_f64,
1688            sweep_deg in 0.0..65.0_f64,
1689            extra in 0.0..3.0_f64,
1690        ) {
1691            let c_t = taper * c_r;
1692            let sweep = s * sweep_deg.to_radians().tan();
1693            let fin = FinAero::new(&trapezoid(c_r, c_t, s, sweep), 0.01).unwrap();
1694            let mach = fin.supersonic_mach() + extra;
1695            if mach >= 5.0 {
1696                // Past the normal force's range: nothing to check.
1697                return Ok(());
1698            }
1699            let beta = (mach * mach - 1.0).sqrt();
1700            let (cone, _) = fin.outline().tip_cone(beta);
1701            let area = fin.outline().area_m2();
1702            proptest::prop_assert!((0.0..=2.0 * area * (1.0 + 1e-12)).contains(&cone));
1703            let loading = fin.loading(mach).unwrap();
1704            proptest::prop_assert!(loading.slope_per_rad > 0.0);
1705            if mach + 0.01 < 5.0 {
1706                let faster = fin.loading(mach + 0.01).unwrap();
1707                proptest::prop_assert!(faster.slope_per_rad < loading.slope_per_rad);
1708            }
1709            let xs: Vec<f64> = fin.outline().points_m().iter().map(|p| p[0]).collect();
1710            let (lo, hi) = xs.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), &x| (a.min(x), b.max(x)));
1711            proptest::prop_assert!(loading.cp_m >= lo - 1e-12 && loading.cp_m <= hi + 1e-12);
1712        }
1713    }
1714
1715    /// Where linear theory starts: Mach 1.2 for an unswept, slender fin; later for a swept leading
1716    /// edge (`1/cos Γ_L`) or a stubby fin (`βA ≥ 1`).
1717    #[test]
1718    fn supersonic_start_follows_the_leading_edge_and_the_aspect_ratio() {
1719        let a_ref = 0.01;
1720        let start = |p: FinPlanform| FinAero::new(&p, a_ref).unwrap().supersonic_mach();
1721        assert_eq!(
1722            start(trapezoid(0.05, 0.05, 0.1, 0.0)),
1723            SUPERSONIC_START_MACH
1724        );
1725        close(
1726            start(trapezoid(0.1, 0.05, 0.1, 0.1)),
1727            2.0_f64.sqrt(),
1728            1e-15,
1729            "45° leading edge",
1730        );
1731        // A 0.2 × 0.05 rectangle: A = 2s²/A_fin = 0.5, so βA ≥ 1 from M = √5.
1732        close(
1733            start(trapezoid(0.2, 0.2, 0.05, 0.0)),
1734            5.0_f64.sqrt(),
1735            1e-15,
1736            "stubby",
1737        );
1738    }
1739
1740    /// Supersonic linear theory on a rectangle: the tip cone is the triangle `c²/(2β)`, so the
1741    /// slope is `(4/β)(1 − 1/(2βA))` with `A = 2s/c` the aspect ratio of the fin and its mirror
1742    /// image (the exact linear-theory result for a rectangular wing, `βA ≥ 1`), and the CP moves
1743    /// forward of mid-chord by the missing half of the triangle's load. At Mach 1.6 the cone
1744    /// crosses the root (`c/β > s`) and the part past it comes back from the mirror image.
1745    #[test]
1746    fn supersonic_rectangle_matches_linear_theory() {
1747        let (c, s) = (0.1, 0.08);
1748        let outline = FinOutline::from_planform(&trapezoid(c, c, s, 0.0)).unwrap();
1749        assert_eq!(outline.tip_leading_edge_m(), [0.0, s]);
1750        let a_ref = 0.25 * PI * 0.05 * 0.05;
1751        // At Mach 1.217 the cone crosses well past the root (`c/β = 1.8 s`, still `βA ≥ 1`).
1752        for mach in [1.217_f64, 1.6, 2.0, 3.0, 4.5] {
1753            let beta = (mach * mach - 1.0).sqrt();
1754            let aspect = 2.0 * s / c;
1755            let (slope, cp) = outline.supersonic(beta, a_ref);
1756            let exact = 4.0 / beta * (1.0 - 1.0 / (2.0 * beta * aspect)) * c * s / a_ref;
1757            close(slope, exact, 1e-14, "slope");
1758            // The triangle's centroid is 2c/3 aft; half its load is missing.
1759            let cone = c * c / (2.0 * beta);
1760            let want = (c * s * 0.5 * c - 0.5 * cone * 2.0 * c / 3.0) / (c * s - 0.5 * cone);
1761            close(cp, want, 1e-14, "CP");
1762            assert!(cp < 0.5 * c);
1763        }
1764        // A cone that misses the fin: a pointed tip swept far aft of its own Mach line.
1765        let delta = FinOutline::from_planform(&trapezoid(0.1, 0.0, 0.05, 0.1)).unwrap();
1766        assert_eq!(delta.tip_cone(2.0), (0.0, 0.1));
1767    }
1768
1769    /// The same trapezoid as a freeform polygon has the same outline integrals, and an ellipse's
1770    /// 256-gon has the ellipse's area to 2.5e-5 and its centroid on the root's middle.
1771    #[test]
1772    fn outlines_of_each_planform_agree() {
1773        let (c_r, c_t, s, x_t) = (0.12, 0.04, 0.1, 0.08);
1774        let trapezoid_outline = FinOutline::from_planform(&trapezoid(c_r, c_t, s, x_t)).unwrap();
1775        let polygon = FinOutline::from_planform(&FinPlanform::Freeform {
1776            points_m: vec![[0.0, 0.0], [x_t, s], [x_t + c_t, s], [c_r, 0.0]],
1777            root_m: Vec::new(),
1778        })
1779        .unwrap();
1780        assert_eq!(trapezoid_outline, polygon);
1781        close(polygon.area_m2(), 0.5 * s * (c_r + c_t), 1e-15, "area");
1782        for beta in [0.8, 1.2, 2.5] {
1783            let (a, x) = polygon.tip_cone(beta);
1784            // The cone reaches the unswept trailing edge `c_r` at `y = s − c_t/β`.
1785            close(a, 0.5 * c_t * c_t / beta, 1e-13, "cone area");
1786            close(x, x_t + 2.0 * c_t / 3.0, 1e-13, "cone centroid");
1787        }
1788        let ellipse = FinOutline::from_planform(&FinPlanform::Elliptical {
1789            root_chord_m: 0.1,
1790            span_m: 0.06,
1791        })
1792        .unwrap();
1793        let area = 0.25 * PI * 0.1 * 0.06;
1794        assert!((1.0 - ellipse.area_m2() / area - 2.5e-5).abs() < 1e-6);
1795        close(ellipse.centroid_m(), 0.05, 1e-12, "ellipse centroid");
1796        close(ellipse.tip_leading_edge_m()[0], 0.05, 1e-12, "ellipse tip");
1797    }
1798
1799    /// The span moments about the body axis against the trapezoid's and the ellipse's integrals,
1800    /// `Σ = ∫ξ² c dξ` (Niskanen 2009 eq. 3.70–3.71, Barrowman 1967 eq. 3-47) and
1801    /// `∫ξ c dξ = r A + s²(c_r + 2c_t)/6`.
1802    #[test]
1803    fn roll_moments_match_the_planform_integrals() {
1804        let (c_r, c_t, s, x_t, r) = (0.15, 0.05, 0.1, 0.09, 0.04);
1805        let outline = FinOutline::from_planform(&trapezoid(c_r, c_t, s, x_t)).unwrap();
1806        let (first, second) = outline.axis_moments(r);
1807        close(
1808            first,
1809            r * 0.5 * s * (c_r + c_t) + s * s * (c_r + 2.0 * c_t) / 6.0,
1810            1e-14,
1811            "first",
1812        );
1813        let sigma = 0.5 * (c_r + c_t) * r * r * s
1814            + (c_r + 2.0 * c_t) / 3.0 * r * s * s
1815            + (c_r + 3.0 * c_t) / 12.0 * s * s * s;
1816        close(second, sigma, 1e-14, "trapezoid Σ");
1817        let ellipse = FinOutline::from_planform(&FinPlanform::Elliptical {
1818            root_chord_m: c_r,
1819            span_m: s,
1820        })
1821        .unwrap();
1822        let sigma = c_r * (PI / 4.0 * r * r * s + 2.0 / 3.0 * r * s * s + PI / 16.0 * s * s * s);
1823        // The 256-sided polygon inside the ellipse.
1824        close(ellipse.axis_moments(r).1, sigma, 2e-4, "ellipse Σ");
1825    }
1826
1827    /// The supersonic roll by the polygon's moments against a strip-by-strip integral of the
1828    /// same load, `4α/β` halved inside the tip's Mach cone and the mirror fin's, on the Arcas
1829    /// Robin's swept fin from Mach 1.5 to 4.63.
1830    #[test]
1831    fn supersonic_roll_matches_its_strips() {
1832        let (c_r, c_t, s, x_t) = (0.085852, 0.054991, 0.0534162, 0.030861);
1833        let (r, d) = (0.028575, 0.05715);
1834        let a_ref = 0.25 * PI * d * d;
1835        let fin = FinAero::new(&trapezoid(c_r, c_t, s, x_t), a_ref).unwrap();
1836        for mach in [1.5, 1.8, 2.3, 2.96, 3.96, 4.63_f64] {
1837            let beta = (mach * mach - 1.0).sqrt();
1838            let strips = 20_000;
1839            let (mut forcing, mut damping) = (0.0, 0.0);
1840            for i in 0..strips {
1841                let y = s * (f64::from(i) + 0.5) / f64::from(strips);
1842                let (le, te) = (x_t * y / s, c_r + (x_t + c_t - c_r) * y / s);
1843                // The chord's length aft of a line at `x`.
1844                let aft_of = |x: f64| (te - x.max(le)).max(0.0);
1845                let load = (te - le)
1846                    - 0.5 * aft_of(x_t + beta * (s - y))
1847                    - 0.5 * aft_of(x_t + beta * (s + y));
1848                let xi = r + y;
1849                let dy = s / f64::from(strips);
1850                forcing += 4.0 / beta / a_ref * xi * load * dy / d;
1851                damping -= 8.0 / beta / a_ref * xi * xi * load * dy / (d * d);
1852            }
1853            let roll = fin.roll(mach, r, d).unwrap();
1854            let what = format!("Mach {mach}");
1855            close(roll.forcing_per_rad, forcing, 1e-7, &what);
1856            close(roll.damping, damping, 1e-7, &what);
1857        }
1858    }
1859
1860    /// The subsonic roll is Barrowman's (eq. 3-35 and 3-48 with the fin's own slope): the slope
1861    /// at the mean aerodynamic chord, and the strips' damping; and both are continuous at Mach
1862    /// 0.8 and at `M_s`.
1863    #[test]
1864    fn subsonic_roll_is_barrowman_s_and_joins_linear_theory() {
1865        let (c_r, c_t, s, x_t) = (0.058, 0.018, 0.077, 0.04);
1866        let (r, d) = (0.035, 0.07);
1867        let a_ref = 0.25 * PI * d * d;
1868        let fin = FinAero::new(&trapezoid(c_r, c_t, s, x_t), a_ref).unwrap();
1869        let area = 0.5 * s * (c_r + c_t);
1870        let y_mac = s * (c_r + 2.0 * c_t) / (3.0 * (c_r + c_t));
1871        let sigma = 0.5 * (c_r + c_t) * r * r * s
1872            + (c_r + 2.0 * c_t) / 3.0 * r * s * s
1873            + (c_r + 3.0 * c_t) / 12.0 * s * s * s;
1874        for mach in [0.0, 0.3, 0.6, 0.8] {
1875            let slope = fin.geometry().single_fin_slope(a_ref, mach).unwrap();
1876            let roll = fin.roll(mach, r, d).unwrap();
1877            let what = format!("Mach {mach}");
1878            close(roll.forcing_per_rad, slope * (r + y_mac) / d, 1e-14, &what);
1879            let per_area = slope * a_ref / area;
1880            close(
1881                roll.damping,
1882                -2.0 * per_area * sigma / (a_ref * d * d),
1883                1e-13,
1884                &what,
1885            );
1886        }
1887        for edge in [TRANSONIC_START_MACH, fin.supersonic_mach()] {
1888            let (a, b) = (
1889                fin.roll(edge - 1e-9, r, d).unwrap(),
1890                fin.roll(edge + 1e-9, r, d).unwrap(),
1891            );
1892            close(a.forcing_per_rad, b.forcing_per_rad, 1e-7, "forcing");
1893            close(a.damping, b.damping, 1e-7, "damping");
1894        }
1895        assert!(fin.roll(5.0, r, d).is_err() && fin.roll(0.5, -r, d).is_err());
1896    }
1897
1898    /// Barrowman's roll damping interference, eq. 3-122, against its integral, eq. 3-121,
1899    /// `1 + r³∫c/ξ² dξ / ∫ξ c dξ` by Simpson's rule; both factors are 1 without a body, and the
1900    /// damping's tends to 2 as the span goes to 0.
1901    #[test]
1902    fn roll_interference_follows_barrowman() {
1903        for (t, l) in [(1.2, 0.3), (2.0, 1.0), (2.87, 0.64), (4.0, 0.0), (7.0, 1.4)] {
1904            let (r, s) = (1.0, t - 1.0);
1905            let chord = |xi: f64| 1.0 - (1.0 - l) * (xi - r) / s;
1906            let simpson = |f: &dyn Fn(f64) -> f64| {
1907                let n = 2000;
1908                let h = s / f64::from(n);
1909                (0..=n)
1910                    .map(|i| {
1911                        let w = if i == 0 || i == n {
1912                            1.0
1913                        } else if i % 2 == 1 {
1914                            4.0
1915                        } else {
1916                            2.0
1917                        };
1918                        w * f(r + h * f64::from(i))
1919                    })
1920                    .sum::<f64>()
1921                    * h
1922                    / 3.0
1923            };
1924            let integral =
1925                1.0 + simpson(&|xi| chord(xi) / (xi * xi)) / simpson(&|xi| xi * chord(xi));
1926            close(
1927                roll_damping_interference(s, r, l).unwrap(),
1928                integral,
1929                1e-10,
1930                &format!("τ {t}, λ {l}"),
1931            );
1932        }
1933        assert_eq!(roll_damping_interference(0.1, 0.0, 0.5).unwrap(), 1.0);
1934        assert_eq!(roll_forcing_interference(0.1, 0.0).unwrap(), 1.0);
1935        close(
1936            roll_damping_interference(1e-6, 1.0, 0.5).unwrap(),
1937            2.0,
1938            2e-3,
1939            "no span",
1940        );
1941        close(
1942            roll_forcing_interference(1e3, 1.0).unwrap(),
1943            1.0,
1944            2e-3,
1945            "no body",
1946        );
1947        for t in [1.001, 1.5, 2.0, 2.87, 5.0, 20.0] {
1948            let k = roll_forcing_interference(t - 1.0, 1.0).unwrap();
1949            assert!(k > 0.9 && k <= 1.0, "τ {t}: {k}");
1950        }
1951        assert!(roll_damping_interference(0.1, 0.05, -0.1).is_err());
1952    }
1953
1954    /// Barrowman's own computed roll damping for the Basic Finner, four square fins one diameter
1955    /// in chord and span on a body one diameter across (Figs. 5-6 and 5-7), read at Mach 0.07 as
1956    /// −34.21: hpr's strips with the fin's own slope give −33.5, where the airfoil's `2π` would
1957    /// give about −81 (ADR-031).
1958    #[test]
1959    fn the_basic_finner_damps_as_barrowman_computed() {
1960        let d = 1.0;
1961        let a_ref = 0.25 * PI * d * d;
1962        let fin = FinAero::new(&trapezoid(d, d, d, 0.0), a_ref).unwrap();
1963        let k_r = roll_damping_interference(d, 0.5 * d, 1.0).unwrap();
1964        let clp = 4.0 * fin.roll(0.07, 0.5 * d, d).unwrap().damping * k_r;
1965        close(clp, -34.21, 0.03, "C_lp at Mach 0.07");
1966    }
1967}