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}