Skip to main content

hpr_sim/
flutter.rs

1//! Fin flutter: the speed at which a fin's bending and twisting couple and grow, from D. J.
2//! Martin's criterion ([the fin-flutter milestone][m1-10b]; the guide's [Fin flutter][page] page).
3//!
4//! **Source.** D. J. Martin, *Summary of Flutter Experiences as a Guide to the Preliminary Design
5//! of Lifting Surfaces on Missiles*, NACA TN 4197, 1958, appendix, eqs. 16 to 19, pp. 14–15, and
6//! figure 3, p. 19. Martin reduces Theodorsen and Garrick's flutter speed for a bending-torsion
7//! wing (eq. 1) to a few planform numbers. With `G_E` the fin's effective shear modulus, `A` the
8//! panel aspect ratio (span over mid-span chord), `λ` the taper ratio (tip over root chord), `t/c`
9//! the thickness ratio, `p` the static pressure and `a` the speed of sound, eq. 16 with his
10//! `1/(f₁² f₂²) ≈ (λ + 1)/2` reads
11//!
12//! ```text
13//! (V_f / a)² = G_E / D,   D = (24 ε / π) ρ a² · A³ / ((t/c)³ (A + 2)) · (λ + 1)/2
14//! ```
15//!
16//! and with `ρ a² = γ p` (eq. 17), `ε = 0.25` and `γ = 1.4` it becomes eq. 18, whose constant
17//! `24 · 0.25 · 1.4 / π · 14.696 psi = 39.29 psi` Martin prints as 39.3:
18//!
19//! ```text
20//! (V_f / a)² = G_E / (39.3 A³ / ((t/c)³ (A + 2)) · (λ + 1)/2 · p/p₀)
21//! ```
22//!
23//! The constant is derived, not fitted. What is empirical is the aspect-ratio correction
24//! `A/(A + 2)`, the best of those Martin tried, and where the line falls between wings that fluttered
25//! and wings that didn't: his figure 3 plots `D` against `G_E` for missiles and wind-tunnel models,
26//! and a band separates them at `D/G_E` from 0.25 to 0.31 ([`FIGURE_3_BAND`]), a flutter speed
27//! of 1.8 to 2.0 times the speed of sound. His open points are wings that flew to at least Mach 1.3
28//! without failing. So eq. 18's `V_f` is a parameter his data calibrates, not a speed at which a
29//! fin is known to flutter.
30//!
31//! Loft wrote the constant as `1.337 · (λ + 1)/2` psi, half of `39.3/14.696 = 2.674`, so its
32//! flutter speed was `√2` too high, on the unsafe side ([Loft lesson L32][l32]).
33//!
34//! **A flutter dynamic pressure.** Since `ρ a² = 2q/M²` for any gas, eq. 16 fixes the dynamic
35//! pressure at flutter, whatever the height:
36//!
37//! ```text
38//! q_f = ½ ρ V_f² = π G_E / (24 ε K (λ + 1)),   K = A³ / ((t/c)³ (A + 2))
39//! ```
40//!
41//! and a fin flying at dynamic pressure `q` is below eq. 18's flutter speed by the ratio
42//! `V_f / V = √(q_f / q)`. The least ratio of a flight is therefore at its peak dynamic pressure,
43//! which [`crate::metrics::FlightMetrics`] finds on the dense output.
44//!
45//! **Readings.** A trapezoidal fin's `A` is `2s/(c_r + c_t)` (span `s` over the mid-span chord) and
46//! `t/c` is the thickness over the root chord: Martin's `c` is the root chord of his
47//! constant-thickness-ratio wing, and for a flat fin of constant thickness the root's ratio is the
48//! smallest, so the flutter speed the least. `G_E` is Martin's effective shear modulus: for a
49//! solid wing he takes the material's own (his p. 6), which this module does. His definition,
50//! `G_E = 6 J G / (c t³)` (eq. 12), would give a flat plate, whose torsion constant is
51//! `J = c t³/3`, about twice that, and a flutter speed `√2` higher; the lower reading is kept. For
52//! a NACA four-digit section it gives `0.946 G`, so an airfoiled fin's `V_f` here is up to 2.7%
53//! high.
54//!
55//! **Left out.** Sweep, the fin's mounting and the body's own modes (Martin's figure 8), stall
56//! flutter at high angles of attack, Mach number effects such as a transonic dip, and every other
57//! flutter type Martin lists. It is a screening number, not a flutter analysis.
58//!
59//! [l32]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#l32
60//! [m1-10b]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-10b
61//! [page]: https://nrdptel.github.io/hpr-sim/physics/flutter.html
62
63use std::f64::consts::PI;
64
65use hpr_design::{FinPlanform, FinSet};
66use serde::{Deserialize, Serialize};
67
68use crate::error::SimError;
69use crate::metrics::FlightSummary;
70
71/// Martin's `ε`: how far the section's center of mass sits behind its quarter chord, as a fraction
72/// of the chord, assumed 0.25, which puts it at mid-chord (NACA TN 4197, p. 14).
73pub const CG_AFT_OF_QUARTER_CHORD: f64 = 0.25;
74
75/// Martin's ratio of specific heats for air, 1.4 (NACA TN 4197, eq. 17, p. 14).
76pub const HEAT_CAPACITY_RATIO: f64 = 1.4;
77
78/// Where Martin's figure 3 separates wings that fluttered from wings that didn't, as `D/G_E`,
79/// that is `(a/V_f)²`: its shaded band runs from 0.25 to 0.31.
80///
81/// Measured on the scan of NACA TN 4197's figure 3 (p. 19) rendered at 250 dpi: both log axes
82/// calibrated on their tick marks (222.5 and 224.8 pixels a decade), the band's edges traced in
83/// 69 columns from `G_E` = 0.05 to 10 × 10⁶ psi (0.34 to 69 GPa; the axis runs on to 20 × 10⁶ psi).
84/// Its middle stays at `D/G_E` = 0.28 to 0.29 all along, and it is about 0.08 of a decade wide. Above it lie
85/// mostly wings that fluttered or failed, and a few that didn't; below it, wings that flew to at
86/// least Mach 1.3 without known failure.
87pub const FIGURE_3_BAND: [f64; 2] = [0.25, 0.31];
88
89/// The planform numbers Martin's criterion takes from one fin.
90///
91/// A birch-plywood fin with a 200 mm root, a 100 mm tip, a 120 mm span and 4 mm thick has
92/// `A = 0.8`, `λ = 0.5` and `t/c = 0.02`, so `K = 0.8³ / (0.02³ · 2.8) = 22 857`. With plywood's
93/// 750 MPa it flutters at `q_f = π · 750 MPa / (6 · 22 857 · 1.5) = 11.45 kPa`: 137 m/s in
94/// sea-level air.
95///
96/// ```
97/// use hpr_design::materials;
98/// use hpr_sim::FlutterPanel;
99///
100/// let panel = FlutterPanel::new(0.8, 0.5, 0.02)?;
101/// let g = materials::shear_modulus("birch_plywood").unwrap().shear_modulus_pa;
102/// let q_f = panel.flutter_dynamic_pressure_pa(g)?;
103/// let v_f = panel.flutter_speed_m_s(g, 101_325.0, 340.294)?;
104/// assert!((q_f - 11_453.7).abs() < 0.1, "{q_f}");
105/// assert!((v_f - 136.75).abs() < 0.01, "{v_f}");
106/// // The same speed from q_f and sea-level density, 1.225 kg/m³.
107/// assert!(((2.0 * q_f / 1.225).sqrt() - v_f).abs() < 0.01);
108/// # Ok::<(), hpr_sim::SimError>(())
109/// ```
110///
111/// Its numbers are checked when it is made, by [`FlutterPanel::new`], and when it is read from
112/// JSON.
113#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
114#[serde(try_from = "PanelNumbers")]
115pub struct FlutterPanel {
116    aspect_ratio: f64,
117    taper_ratio: f64,
118    thickness_ratio: f64,
119}
120
121/// A panel's numbers as JSON holds them, before they are checked.
122#[derive(Deserialize)]
123#[serde(deny_unknown_fields)]
124struct PanelNumbers {
125    aspect_ratio: f64,
126    taper_ratio: f64,
127    thickness_ratio: f64,
128}
129
130impl TryFrom<PanelNumbers> for FlutterPanel {
131    type Error = SimError;
132
133    fn try_from(n: PanelNumbers) -> Result<Self, SimError> {
134        Self::new(n.aspect_ratio, n.taper_ratio, n.thickness_ratio)
135    }
136}
137
138impl FlutterPanel {
139    /// A panel of aspect ratio `A`, taper ratio `λ` and thickness ratio `t/c`.
140    ///
141    /// Martin's figure 4 covers `A` from 0.5 to 3 and `t/c` from 1% to 10%; outside those the
142    /// numbers are an extrapolation, which isn't refused.
143    ///
144    /// # Errors
145    ///
146    /// [`SimError::Domain`] if `A` or `t/c` isn't positive and finite, or `λ` isn't within
147    /// `[0, 1]`, the range of Martin's taper factors (NACA TN 4197, eqs. 8 and 14).
148    pub fn new(
149        aspect_ratio: f64,
150        taper_ratio: f64,
151        thickness_ratio: f64,
152    ) -> Result<Self, SimError> {
153        positive("flutter panel aspect ratio", aspect_ratio)?;
154        positive("flutter panel thickness ratio", thickness_ratio)?;
155        if !(0.0..=1.0).contains(&taper_ratio) {
156            return Err(SimError::Domain {
157                what: "flutter panel taper ratio (0 to 1)",
158                value: taper_ratio,
159            });
160        }
161        Ok(Self {
162            aspect_ratio,
163            taper_ratio,
164            thickness_ratio,
165        })
166    }
167
168    /// The panel of one of `fins`: `A = 2s/(c_r + c_t)`, `λ = c_t/c_r` and `t/c = t/c_r`.
169    ///
170    /// # Errors
171    ///
172    /// [`SimError::Unsupported`] for an elliptical or freeform planform, or a tip chord longer
173    /// than the root, which Martin's taper factors don't cover; [`SimError::Domain`] for a root
174    /// chord that isn't positive, or the panel's numbers out of range, as [`FlutterPanel::new`].
175    pub fn of_fins(fins: &FinSet) -> Result<Self, SimError> {
176        match fins.planform {
177            FinPlanform::Trapezoidal {
178                root_chord_m,
179                tip_chord_m,
180                span_m,
181                ..
182            } => {
183                positive("fin root chord", root_chord_m)?;
184                if tip_chord_m > root_chord_m {
185                    return Err(SimError::Unsupported {
186                        what: "a flutter panel of a fin whose tip chord is longer than its root",
187                    });
188                }
189                Self::new(
190                    2.0 * span_m / (root_chord_m + tip_chord_m),
191                    tip_chord_m / root_chord_m,
192                    fins.thickness_m / root_chord_m,
193                )
194            }
195            _ => Err(SimError::Unsupported {
196                what: "a flutter panel of a planform other than a trapezoid",
197            }),
198        }
199    }
200
201    /// The panel aspect ratio `A`: the span over the chord at mid-span.
202    #[must_use]
203    pub fn aspect_ratio(&self) -> f64 {
204        self.aspect_ratio
205    }
206
207    /// The taper ratio `λ`: the tip chord over the root chord, from 0 (pointed) to 1.
208    #[must_use]
209    pub fn taper_ratio(&self) -> f64 {
210        self.taper_ratio
211    }
212
213    /// The thickness ratio `t/c`: the thickness over the root chord.
214    #[must_use]
215    pub fn thickness_ratio(&self) -> f64 {
216        self.thickness_ratio
217    }
218
219    /// `K = A³ / ((t/c)³ (A + 2))`: Martin's `X` (eq. 19) without its constant.
220    #[must_use]
221    pub fn shape_factor(&self) -> f64 {
222        let a = self.aspect_ratio;
223        a.powi(3) / (self.thickness_ratio.powi(3) * (a + 2.0))
224    }
225
226    /// The denominator of eq. 18 at static pressure `p`, Pa:
227    /// `D = (24 ε γ / π) p · K · (λ + 1)/2`, the ordinate of Martin's figure 3.
228    ///
229    /// # Errors
230    ///
231    /// [`SimError::Domain`] if `p` isn't positive and finite.
232    pub fn denominator_pa(&self, pressure_pa: f64) -> Result<f64, SimError> {
233        positive("static pressure", pressure_pa)?;
234        Ok(24.0 * CG_AFT_OF_QUARTER_CHORD * HEAT_CAPACITY_RATIO / PI
235            * pressure_pa
236            * self.shape_factor()
237            * (self.taper_ratio + 1.0)
238            / 2.0)
239    }
240
241    /// Martin's figure 3 reading, `D/G_E = (a/V_f)²`, at static pressure `p`: above
242    /// [`FIGURE_3_BAND`] lie mostly his wings that fluttered, below it wings that didn't. Martin
243    /// takes `p` where the wing flies; at the launch site's, the highest a flight sees, it is the
244    /// largest. Outside his axis, `G_E` from 0.34 to 138 GPa, it is an extrapolation.
245    ///
246    /// # Errors
247    ///
248    /// [`SimError::Domain`] if `G_E` or `p` isn't positive and finite.
249    pub fn figure_3_ratio(&self, shear_modulus_pa: f64, pressure_pa: f64) -> Result<f64, SimError> {
250        positive("effective shear modulus", shear_modulus_pa)?;
251        Ok(self.denominator_pa(pressure_pa)? / shear_modulus_pa)
252    }
253
254    /// The dynamic pressure at which a fin of effective shear modulus `G_E` reaches eq. 18's
255    /// flutter speed, Pa: `q_f = π G_E / (24 ε K (λ + 1))`, the same at every height.
256    ///
257    /// # Errors
258    ///
259    /// [`SimError::Domain`] if `G_E` isn't positive and finite.
260    pub fn flutter_dynamic_pressure_pa(&self, shear_modulus_pa: f64) -> Result<f64, SimError> {
261        positive("effective shear modulus", shear_modulus_pa)?;
262        Ok(PI * shear_modulus_pa
263            / (24.0 * CG_AFT_OF_QUARTER_CHORD * self.shape_factor() * (self.taper_ratio + 1.0)))
264    }
265
266    /// Eq. 18's flutter speed in air of static pressure `p` and speed of sound `a`, m/s:
267    /// `V_f = a √(G_E / D)`.
268    ///
269    /// # Errors
270    ///
271    /// [`SimError::Domain`] if `G_E`, `p` or `a` isn't positive and finite.
272    pub fn flutter_speed_m_s(
273        &self,
274        shear_modulus_pa: f64,
275        pressure_pa: f64,
276        sound_speed_m_s: f64,
277    ) -> Result<f64, SimError> {
278        positive("speed of sound", sound_speed_m_s)?;
279        Ok(sound_speed_m_s / self.figure_3_ratio(shear_modulus_pa, pressure_pa)?.sqrt())
280    }
281
282    /// The fin's least ratio of eq. 18's flutter speed to its airspeed over the flight `summary`
283    /// describes, at its peak dynamic pressure; `None` if the rocket never flew.
284    ///
285    /// The whole flight's peak is used for every fin set on it. A booster's fins leave at the
286    /// separation, so the peak can come after they have gone; their true ratio is then at least
287    /// the one given, as long as the booster's own dynamic pressure after the separation stays
288    /// below the flight's peak, which hpr doesn't check.
289    ///
290    /// # Errors
291    ///
292    /// As [`FlutterPanel::flutter_dynamic_pressure_pa`]; [`SimError::Domain`] if the peak
293    /// dynamic pressure isn't finite.
294    pub fn margin(
295        &self,
296        shear_modulus_pa: f64,
297        summary: &FlightSummary,
298    ) -> Result<Option<FlutterMargin>, SimError> {
299        let flutter_dynamic_pressure_pa = self.flutter_dynamic_pressure_pa(shear_modulus_pa)?;
300        let Some(peak) = summary.max_dynamic_pressure_pa else {
301            return Ok(None);
302        };
303        if !peak.value.is_finite() {
304            return Err(SimError::Domain {
305                what: "peak dynamic pressure",
306                value: peak.value,
307            });
308        }
309        Ok((peak.value > 0.0).then(|| FlutterMargin {
310            time_s: peak.time_s,
311            height_above_ground_m: peak.height_above_ground_m,
312            dynamic_pressure_pa: peak.value,
313            flutter_dynamic_pressure_pa,
314            speed_ratio: (flutter_dynamic_pressure_pa / peak.value).sqrt(),
315        }))
316    }
317}
318
319/// How far below eq. 18's flutter speed a fin flew, at the flight's peak dynamic pressure.
320#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
321#[serde(deny_unknown_fields)]
322pub struct FlutterMargin {
323    /// When the peak came, s.
324    pub time_s: f64,
325    /// The center of mass's height above the launch site then, m.
326    pub height_above_ground_m: f64,
327    /// The flight's peak dynamic pressure, Pa.
328    pub dynamic_pressure_pa: f64,
329    /// The dynamic pressure at eq. 18's flutter speed, Pa.
330    pub flutter_dynamic_pressure_pa: f64,
331    /// Eq. 18's flutter speed over the airspeed there, `V_f / V = √(q_f / q)`, the flight's least.
332    /// Below 1 the fin flies faster than eq. 18's flutter speed. No fixed value is a safe line:
333    /// Martin's band ([`FIGURE_3_BAND`]) puts `V_f` at 1.8 to 2.0 times the speed of sound, so at
334    /// Mach `M` the band is at a ratio of `1.8/M` to `2.0/M`, and below it above `2.0/M`. Judge a fin by
335    /// [`FlutterPanel::figure_3_ratio`].
336    pub speed_ratio: f64,
337}
338
339fn positive(what: &'static str, value: f64) -> Result<(), SimError> {
340    if value.is_finite() && value > 0.0 {
341        Ok(())
342    } else {
343        Err(SimError::Domain { what, value })
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use hpr_design::{Component, Part};
350
351    use super::*;
352    use crate::environment::Environment;
353    use crate::flight::{FlightSettings, Simulation};
354    use crate::metrics::{FlightMetrics, Peak};
355    use crate::rail::Rail;
356    use crate::recorder::{Channel, Recorder};
357    use crate::testing::{design, site};
358
359    /// One pound per square inch, Pa (NIST SP 811, 2008, B.9: 6.894 757 E+03).
360    const PSI: f64 = 6_894.757;
361
362    /// Standard sea-level pressure, Pa (U.S. Standard Atmosphere, 1976): Martin's `p₀`, 14.696
363    /// psi.
364    const P0: f64 = 101_325.0;
365
366    fn panel(aspect_ratio: f64, taper_ratio: f64, thickness_ratio: f64) -> FlutterPanel {
367        FlutterPanel::new(aspect_ratio, taper_ratio, thickness_ratio).unwrap()
368    }
369
370    /// Martin's `X` (eq. 19) times `(λ + 1)/2 · p/p₀`: the ordinate of his figure 3, in psi.
371    fn ordinate_psi(p: &FlutterPanel, pressure_pa: f64) -> f64 {
372        p.denominator_pa(pressure_pa).unwrap() / PSI
373    }
374
375    /// Eq. 18's constant is eq. 16's `24 ε γ p₀ / π` to the three figures Martin prints, and
376    /// twice Loft's `1.337` (L32).
377    #[test]
378    fn flutter_denominator_matches_tn_4197_eq_18() {
379        let constant_psi = 24.0 * CG_AFT_OF_QUARTER_CHORD * HEAT_CAPACITY_RATIO / PI * P0 / PSI;
380        assert!((constant_psi - 39.3).abs() < 0.05, "{constant_psi}");
381        for (a, lambda, tc) in [(2.0, 1.0, 0.04), (1.3, 0.4, 0.025), (3.1, 0.0, 0.07)] {
382            let p = panel(a, lambda, tc);
383            for pressure in [P0, 0.4 * P0] {
384                let eq_18_psi = 39.3 * a.powi(3) / (tc.powi(3) * (a + 2.0)) * (lambda + 1.0) / 2.0
385                    * (pressure / P0);
386                // Only Martin's rounding of 39.29 to 39.3 apart.
387                let ours = ordinate_psi(&p, pressure);
388                assert!(
389                    (ours / eq_18_psi - 1.0).abs() < 0.05 / 39.3,
390                    "{a} {lambda} {tc}"
391                );
392            }
393        }
394        // Loft's `1.337 · (λ + 1)/2` per psi is half of `39.3 / 14.696`: its flutter speed was
395        // `√2` too high.
396        let per_psi = constant_psi / (P0 / PSI);
397        assert!((per_psi / 1.337 - 2.0).abs() < 1e-3, "{per_psi}");
398    }
399
400    /// The flutter speed goes as `(t/c)^{3/2}`, `√G_E` and `1/√p` at a fixed speed of sound, so
401    /// its dynamic pressure doesn't depend on the air, and the margin is `√(q_f/q)`.
402    #[test]
403    fn scaling_laws_in_thickness_shear_modulus_and_pressure() {
404        let g = 26e9;
405        let (p, a) = (P0, 340.3);
406        let base = panel(1.5, 0.5, 0.03);
407        let v = base.flutter_speed_m_s(g, p, a).unwrap();
408        let thicker = panel(1.5, 0.5, 0.06).flutter_speed_m_s(g, p, a).unwrap();
409        assert!((thicker / v - 2f64.powf(1.5)).abs() < 1e-12);
410        let stiffer = base.flutter_speed_m_s(4.0 * g, p, a).unwrap();
411        assert!((stiffer / v - 2.0).abs() < 1e-12);
412        let thin_air = base.flutter_speed_m_s(g, p / 4.0, a).unwrap();
413        assert!((thin_air / v - 2.0).abs() < 1e-12);
414        let faster_sound = base.flutter_speed_m_s(g, p, 2.0 * a).unwrap();
415        assert!((faster_sound / v - 2.0).abs() < 1e-12);
416        // q_f = ½ ρ V_f² with ρ = γ p / a², at any pressure and speed of sound.
417        let q_f = base.flutter_dynamic_pressure_pa(g).unwrap();
418        for (p, a) in [(P0, 340.3), (0.3 * P0, 300.0), (2.0 * P0, 360.0)] {
419            let v = base.flutter_speed_m_s(g, p, a).unwrap();
420            let q = 0.5 * HEAT_CAPACITY_RATIO * p / (a * a) * v * v;
421            assert!((q / q_f - 1.0).abs() < 1e-12, "{q} {q_f}");
422        }
423    }
424
425    /// Martin's worked examples (NACA TN 4197, pp. 6–7), read at the resolution he prints: `X`
426    /// for `A = 2` and 4% thickness is "about 1.25 × 10⁶" psi (eq. 19 gives 1.228 × 10⁶, which is
427    /// 1.25 to the nearest 0.05 × 10⁶), and a titanium wing held to an ordinate of 0.8 × 10⁶ psi
428    /// needs 2.5, 4.5 and "about 6.5" percent at `A = 1`, 2 and 3 (eq. 19 gives 2.54, 4.61 and
429    /// 6.43, each those to the nearest half percent).
430    ///
431    /// His verdicts on the first are the margin half: in solid magnesium the wing "would plot in
432    /// the flutter region", in aluminium it "would be marginal", in steel "probably safe". With
433    /// the moduli he marks on figure 3's axis, each a small box measured on the 250 dpi scan
434    /// (outer walls, × 10⁶ psi: magnesium 2.40 to 2.63, aluminium 3.82 to 4.28, titanium 5.78 to
435    /// 6.34, steel 8.92 to 11.3), and his band, magnesium's ratio lies wholly above the band,
436    /// aluminium's overlaps it, and steel's lies wholly below; his second example's titanium, held
437    /// to 0.8 × 10⁶ psi, lies below it.
438    #[test]
439    fn martins_worked_examples() {
440        let x_psi = |a: f64, tc: f64| ordinate_psi(&panel(a, 1.0, tc), P0);
441        let x = x_psi(2.0, 0.04);
442        assert!((x / 1e6 - 1.227_9).abs() < 1e-4, "{x}");
443        assert_eq!((x / 0.05e6).round() * 0.05, 1.25);
444        for (a, printed_percent) in [(1.0, 2.5), (2.0, 4.5), (3.0, 6.5)] {
445            // X ∝ (t/c)⁻³: the thickness that brings X to 0.8 × 10⁶ psi.
446            let tc = 0.01 * (x_psi(a, 0.01) / 0.8e6).cbrt();
447            assert_eq!((200.0 * tc).round() / 2.0, printed_percent, "{a}: {tc}");
448        }
449        let [low, high] = FIGURE_3_BAND;
450        // The figure 3 ratio over a material's box: the stiffest end gives the least.
451        let ratios = |p: &FlutterPanel, [soft, stiff]: [f64; 2]| {
452            [stiff, soft].map(|g| p.figure_3_ratio(g * 1e6 * PSI, P0).unwrap())
453        };
454        let first = panel(2.0, 1.0, 0.04);
455        let magnesium = ratios(&first, [2.40, 2.63]);
456        let aluminium = ratios(&first, [3.82, 4.28]);
457        let steel = ratios(&first, [8.92, 11.3]);
458        // The second example's titanium wing: the thickness that holds X at 0.8 × 10⁶ psi.
459        let held = panel(2.0, 1.0, 0.04 * (x / 0.8e6).cbrt());
460        assert!((ordinate_psi(&held, P0) / 0.8e6 - 1.0).abs() < 1e-12);
461        let titanium = ratios(&held, [5.78, 6.34]);
462        assert!(magnesium[0] > high, "{magnesium:?}");
463        assert!(aluminium[0] < high && aluminium[1] > low, "{aluminium:?}");
464        assert!(steel[1] < low, "{steel:?}");
465        assert!(titanium[1] < low, "{titanium:?}");
466    }
467
468    /// Martin replaces `1/(f₁² f₂²)`, with `f₁ = 1 + 1.87 (1 − λ)^1.6` (eq. 8) and
469    /// `f₂ = (1 + 3λ)/(2(1 + λ))` (eq. 14), by `(λ + 1)/2`: equal at `λ = 1`, 3% apart at `λ = 0`,
470    /// and up to 47% larger between (at `λ ≈ 0.31`), which lowers the flutter speed there by up to
471    /// 17.5%. The model keeps his form, since his figure 3 was drawn with it.
472    #[test]
473    fn taper_factor_against_the_frequency_factors() {
474        let exact = |lambda: f64| {
475            let f1 = 1.0 + 1.87 * (1.0 - lambda).powf(1.6);
476            let f2 = (1.0 + 3.0 * lambda) / (2.0 * (1.0 + lambda));
477            1.0 / (f1 * f2).powi(2)
478        };
479        assert!((exact(1.0) - 1.0).abs() < 1e-15);
480        assert!((0.5 / exact(0.0) - 1.029).abs() < 1e-3, "{}", exact(0.0));
481        let (worst, at) = (0..=1000)
482            .map(|i| f64::from(i) / 1000.0)
483            .map(|lambda| ((lambda + 1.0) / 2.0 / exact(lambda), lambda))
484            .fold((0.0, 0.0), |x, y| if y.0 > x.0 { y } else { x });
485        assert!(
486            (worst - 1.469).abs() < 1e-3 && (at - 0.308).abs() < 1e-9,
487            "{worst} at {at}"
488        );
489        assert!((1.0 - worst.sqrt().recip() - 0.175).abs() < 1e-3);
490    }
491
492    /// On a real flight the margin is at the peak dynamic pressure and is the least of every
493    /// millisecond. At each row, eq. 18 at the pressure and speed of sound its `q` and Mach number
494    /// imply (`p = 2q/(γM²)`, `a = V/M`) agrees with `√(q_f/q)`: the two methods are consistent.
495    #[test]
496    fn the_margin_is_the_flights_least_at_its_peak_dynamic_pressure() {
497        fn find_fins(components: &[Component]) -> Option<&FinSet> {
498            components.iter().find_map(|c| match &c.part {
499                Part::FinSet(set) => Some(set),
500                _ => find_fins(&c.children),
501            })
502        }
503        let rocket = design("rocketpy-valetudo");
504        let fins = rocket
505            .stages
506            .iter()
507            .find_map(|stage| find_fins(&stage.components))
508            .unwrap();
509        let panel = FlutterPanel::of_fins(fins).unwrap();
510        // An arbitrary shear modulus: the checks hold for any.
511        let g = 3e9;
512        let sim = Simulation::new(
513            &rocket,
514            "example",
515            Environment::standard(site()).unwrap(),
516            Rail::vertical(5.0),
517            FlightSettings {
518                max_time_s: 20.0,
519                ..FlightSettings::default()
520            },
521        )
522        .unwrap();
523        let mut metrics = FlightMetrics::new();
524        let result = sim.run(&mut metrics).unwrap();
525        let summary = metrics.summary(&result, sim.environment()).unwrap();
526        let margin = panel.margin(g, &summary).unwrap().unwrap();
527        let q_f = panel.flutter_dynamic_pressure_pa(g).unwrap();
528        let peak = summary.max_dynamic_pressure_pa.unwrap();
529        assert_eq!(margin.time_s, peak.time_s);
530        assert_eq!(margin.dynamic_pressure_pa, peak.value);
531        assert_eq!(margin.speed_ratio, (q_f / peak.value).sqrt());
532        let mut recorder = Recorder::new(
533            vec![Channel::DynamicPressure, Channel::Mach, Channel::Airspeed],
534            Some(1e-3),
535        )
536        .unwrap();
537        sim.run(&mut recorder).unwrap();
538        let mut checked = 0;
539        for row in recorder.rows() {
540            let (q, mach, speed) = (row[0], row[1], row[2]);
541            if mach < 0.05 {
542                continue;
543            }
544            let ratio = (q_f / q).sqrt();
545            assert!(
546                ratio >= margin.speed_ratio,
547                "{ratio} {}",
548                margin.speed_ratio
549            );
550            let pressure = 2.0 * q / (HEAT_CAPACITY_RATIO * mach * mach);
551            let v_f = panel.flutter_speed_m_s(g, pressure, speed / mach).unwrap();
552            assert!(
553                (v_f / speed / ratio - 1.0).abs() < 1e-12,
554                "{v_f} {speed} {ratio}"
555            );
556            checked += 1;
557        }
558        assert!(checked > 1000, "{checked}");
559        // A flight with no peak has no margin; a peak that isn't finite is refused.
560        let mut never = summary.clone();
561        never.max_dynamic_pressure_pa = None;
562        assert_eq!(panel.margin(g, &never).unwrap(), None);
563        never.max_dynamic_pressure_pa = Some(Peak {
564            value: f64::NAN,
565            ..peak
566        });
567        assert!(matches!(
568            panel.margin(g, &never),
569            Err(SimError::Domain {
570                what: "peak dynamic pressure",
571                ..
572            })
573        ));
574    }
575
576    #[test]
577    fn a_trapezoidal_fin_set_gives_its_panel() {
578        let fins: FinSet = serde_json::from_value(serde_json::json!({
579            "count": 3,
580            "planform": {"kind": "trapezoidal", "root_chord_m": 0.2, "tip_chord_m": 0.1,
581                         "span_m": 0.12, "sweep_m": 0.1},
582            "thickness_m": 0.004,
583            "material": {"name": "test", "density": {"kind": "bulk", "kg_m3": 1850.0}}
584        }))
585        .unwrap();
586        let p = FlutterPanel::of_fins(&fins).unwrap();
587        assert!((p.aspect_ratio() - 0.8).abs() < 1e-15);
588        assert!((p.taper_ratio() - 0.5).abs() < 1e-15);
589        assert!((p.thickness_ratio() - 0.02).abs() < 1e-15);
590        let with = |planform: FinPlanform, thickness_m: f64| FinSet {
591            planform,
592            thickness_m,
593            ..fins.clone()
594        };
595        let trapezoid =
596            |root_chord_m: f64, tip_chord_m: f64, span_m: f64| FinPlanform::Trapezoidal {
597                root_chord_m,
598                tip_chord_m,
599                span_m,
600                sweep_m: 0.0,
601            };
602        let unsupported = |r: Result<FlutterPanel, SimError>, expected: &str| {
603            assert!(
604                matches!(r, Err(SimError::Unsupported { what }) if what.contains(expected)),
605                "{expected}"
606            );
607        };
608        let elliptical = FinPlanform::Elliptical {
609            root_chord_m: 0.2,
610            span_m: 0.1,
611        };
612        unsupported(FlutterPanel::of_fins(&with(elliptical, 0.004)), "trapezoid");
613        unsupported(
614            FlutterPanel::of_fins(&with(trapezoid(0.1, 0.2, 0.1), 0.004)),
615            "tip chord is longer",
616        );
617        let domain = |r: Result<FlutterPanel, SimError>, expected: &str| {
618            assert!(
619                matches!(r, Err(SimError::Domain { what, .. }) if what == expected),
620                "{expected}"
621            );
622        };
623        domain(
624            FlutterPanel::of_fins(&with(trapezoid(0.0, 0.0, 0.1), 0.004)),
625            "fin root chord",
626        );
627        domain(
628            FlutterPanel::of_fins(&with(trapezoid(0.2, 0.1, -0.1), 0.004)),
629            "flutter panel aspect ratio",
630        );
631        domain(
632            FlutterPanel::of_fins(&with(trapezoid(0.2, 0.1, 0.1), 0.0)),
633            "flutter panel thickness ratio",
634        );
635    }
636
637    #[test]
638    fn out_of_range_inputs_are_refused() {
639        let refused = |r: Result<FlutterPanel, SimError>, expected: &str| {
640            assert!(
641                matches!(r, Err(SimError::Domain { what, .. }) if what == expected),
642                "{expected}"
643            );
644        };
645        refused(
646            FlutterPanel::new(0.0, 0.5, 0.02),
647            "flutter panel aspect ratio",
648        );
649        refused(
650            FlutterPanel::new(1.0, 1.2, 0.02),
651            "flutter panel taper ratio (0 to 1)",
652        );
653        refused(
654            FlutterPanel::new(1.0, -0.1, 0.02),
655            "flutter panel taper ratio (0 to 1)",
656        );
657        refused(
658            FlutterPanel::new(1.0, 0.5, f64::NAN),
659            "flutter panel thickness ratio",
660        );
661        let p = panel(1.0, 0.5, 0.02);
662        for (r, expected) in [
663            (
664                p.flutter_speed_m_s(0.0, P0, 340.0),
665                "effective shear modulus",
666            ),
667            (p.flutter_speed_m_s(1e9, -1.0, 340.0), "static pressure"),
668            (p.flutter_speed_m_s(1e9, P0, 0.0), "speed of sound"),
669            (p.figure_3_ratio(1e9, f64::INFINITY), "static pressure"),
670            (
671                p.flutter_dynamic_pressure_pa(f64::NAN),
672                "effective shear modulus",
673            ),
674        ] {
675            assert!(
676                matches!(r, Err(SimError::Domain { what, .. }) if what == expected),
677                "{expected}"
678            );
679        }
680        // JSON goes through the same checks, and round-trips.
681        let json = serde_json::to_value(p).unwrap();
682        let back: FlutterPanel = serde_json::from_value(json).unwrap();
683        assert_eq!(back, p);
684        let bad = serde_json::json!({"aspect_ratio": 1.0, "taper_ratio": -1.5,
685                                     "thickness_ratio": 0.02});
686        let err = serde_json::from_value::<FlutterPanel>(bad).unwrap_err();
687        assert!(err.to_string().contains("taper ratio"), "{err}");
688        let extra = serde_json::json!({"aspect_ratio": 1.0, "taper_ratio": 0.5,
689                                       "thickness_ratio": 0.02, "extra": 1});
690        assert!(serde_json::from_value::<FlutterPanel>(extra).is_err());
691    }
692}