Skip to main content

hpr_sim/
envelope.rs

1//! The flags a flight raises: the operating envelope's four, where a flight goes past what hpr's
2//! numbers have been checked for ([ADR-143 §1][adr-143], [ADR-179][adr-179]; the validation
3//! plan's [Operating envelope][page]), and two for a rocket that is unstable while a motor burns
4//! ([#335][i335]; the guide's [Unstable under power][unstable]), of which a flight raises at most
5//! one.
6//!
7//! Every flight still flies; a flag only says that a number past its edge deserves less trust.
8//!
9//! - *unstable under power*: a static margin below zero, in the weakest plane, from the rail exit
10//!   to apogee or the first deployment while a motor burns
11//!   ([`FlightSummary::min_powered_static_margin_cal`](crate::FlightSummary::min_powered_static_margin_cal)).
12//!   The air then turns the rocket away from its path rather than back, so the path flown, and
13//!   its apogee, follow from small disturbances and from aerodynamics that hold only at small
14//!   angles of attack: they are not a prediction.
15//! - *unstable without a margin*: where hpr can give no static margin while a motor burns (the
16//!   net normal-force slope is not positive, or too small for the quotient to mean anything), a
17//!   pitch-moment slope `C_mα` above zero
18//!   ([`FlightSummary::max_powered_moment_slope_per_rad`](crate::FlightSummary::max_powered_moment_slope_per_rad)):
19//!   the same instability, read from the moment instead. Raised only when the first isn't.
20//!
21//! The envelope's edges are by Mach number, and angle of attack is a separate condition:
22//!
23//! - *beyond the validated range*: faster than [`VALIDATED_MACH`], the fastest public flight hpr
24//!   has been compared with an independent reference on;
25//! - *at high angle of attack*: above [`HIGH_ANGLE_OF_ATTACK_RAD`] (15°) more than
26//!   [`HIGH_ANGLE_GRACE_S`] (1 s) after the rail exit and before apogee or the first deployment,
27//!   while a 15° angle would give a normal force of at least [`HIGH_ANGLE_MIN_FORCE_SHARE`] of the
28//!   rocket's weight: where the aerodynamics' small-angle assumptions stop holding and it matters;
29//! - *outside the core band*: past [`CORE_BAND_MACH`] (2.5), where accuracy work goes second;
30//! - *beyond the envelope*: past [`ENVELOPE_MACH`] (3.5), past commercial-motor flights.
31//!
32//! Each edge is exclusive: a flight that reaches it exactly, or whose margin or moment slope is
33//! exactly zero, raises no flag. The flags come from a [`FlightSummary`](crate::FlightSummary):
34//! its least static margin and largest moment slope without a margin under power, its top Mach
35//! number, and its [`max_angle_of_attack_rad`](crate::FlightSummary::max_angle_of_attack_rad).
36//!
37//! [adr-143]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0143-the-operating-envelope-and-a-stop-rule-for.md
38//! [adr-179]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0179-the-envelope-flags.md
39//! [page]: https://nrdptel.github.io/hpr-sim/VALIDATION.html#operating-envelope
40//! [i335]: https://github.com/nrdptel/hpr-sim/issues/335
41//! [unstable]: https://nrdptel.github.io/hpr-sim/physics/metrics.html#unstable-under-power
42
43use hpr_core::gravity::STANDARD_GRAVITY_MPS2;
44use serde::{Deserialize, Serialize};
45
46use crate::metrics::Peak;
47
48/// The fastest public whole flight compared with an independent reference: OpenRocket 24.12's
49/// *Dual parachute deployment* example, Mach 1.1467, its own top Mach number in
50/// `validation/reports/openrocket-flights.json`. A test in `hpr-validate` reads the committed
51/// public reports (that one, and RocketPy's in `latest.json`) and fails when this differs from
52/// their largest reference, so it rises as references are added ([the envelope's decision
53/// record][adr-143], §1). Private flights never set it.
54///
55/// [adr-143]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0143-the-operating-envelope-and-a-stop-rule-for.md
56pub const VALIDATED_MACH: f64 = 1.146_740_618_304_243_7;
57
58/// The core band's top: Mach 2.5 ([the envelope's decision record][adr-143]). Accuracy work goes
59/// below it first.
60///
61/// [adr-143]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0143-the-operating-envelope-and-a-stop-rule-for.md
62pub const CORE_BAND_MACH: f64 = 2.5;
63
64/// The envelope's top: Mach 3.5, about the fastest commercial-motor flights ([the envelope's
65/// decision record][adr-143]).
66///
67/// [adr-143]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0143-the-operating-envelope-and-a-stop-rule-for.md
68pub const ENVELOPE_MACH: f64 = 3.5;
69
70/// The angle of attack that accuracy work assumes at most: 15°, in radians ([the envelope's
71/// decision record][adr-143]).
72///
73/// [adr-143]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0143-the-operating-envelope-and-a-stop-rule-for.md
74pub const HIGH_ANGLE_OF_ATTACK_RAD: f64 = 15.0_f64.to_radians();
75
76/// How long after the rail exit a large angle of attack is the expected transient, not a flag:
77/// 1 s, chosen rather than measured ([the envelope's decision record][adr-143];
78/// [M1.14e, on large angles of attack][m1-14e], measures it).
79///
80/// [adr-143]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0143-the-operating-envelope-and-a-stop-rule-for.md
81/// [m1-14e]: https://nrdptel.github.io/hpr-sim/decisions-and-roadmap.html#m1-14e
82pub const HIGH_ANGLE_GRACE_S: f64 = 1.0;
83
84/// The share of the rocket's weight below which an angle of attack doesn't count: at that
85/// instant a 15° angle must give a normal force of at least a fifth of the weight. Chosen rather
86/// than measured ([the envelope flags' decision record][adr-179]): below it gravity, not the air,
87/// does most of the turning, so an error in that force moves the flight little. As the path turns
88/// over near apogee the angle passes 15° on an ordinary flight where 15° gives 0.7% to 5.0% of
89/// the weight (on the tests' RocketPy rockets off 84° and 85° rails); a wind layer met at speed,
90/// 62% to 141%.
91///
92/// [adr-179]: https://github.com/nrdptel/hpr-sim/blob/main/docs/decisions/0179-the-envelope-flags.md
93pub const HIGH_ANGLE_MIN_FORCE_SHARE: f64 = 0.2;
94
95/// The normal force, N, that a normal-force slope `normal_force_slope_per_rad` on a reference
96/// diameter `reference_diameter_m` gives at an angle of attack `angle_of_attack_rad` in a
97/// dynamic pressure `dynamic_pressure_pa`: `q · (π d²/4) · |C_Nα| · α`, the linear model, which
98/// is what the aerodynamics assume.
99#[must_use]
100pub fn normal_force_n(
101    dynamic_pressure_pa: f64,
102    reference_diameter_m: f64,
103    normal_force_slope_per_rad: f64,
104    angle_of_attack_rad: f64,
105) -> f64 {
106    let area_m2 = std::f64::consts::PI * reference_diameter_m.powi(2) / 4.0;
107    dynamic_pressure_pa * area_m2 * normal_force_slope_per_rad.abs() * angle_of_attack_rad
108}
109
110/// Whether an instant counts on a rocket of `mass_kg` where a 15° angle gives a normal force of
111/// `normal_force_n`: at least [`HIGH_ANGLE_MIN_FORCE_SHARE`] of its weight in standard gravity.
112/// A NaN counts, so a broken number can't hide a flag.
113#[must_use]
114pub fn force_counts(normal_force_n: f64, mass_kg: f64) -> bool {
115    normal_force_n.is_nan()
116        || normal_force_n >= HIGH_ANGLE_MIN_FORCE_SHARE * mass_kg * STANDARD_GRAVITY_MPS2
117}
118
119/// One flag a flight raises, with the peak that raised it. Serialized with a `flag` tag in
120/// snake case.
121#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
122#[serde(tag = "flag", rename_all = "snake_case")]
123pub enum EnvelopeFlag {
124    /// A static margin below zero while a motor burns, from the rail exit to apogee or the first
125    /// deployment
126    /// ([`FlightSummary::min_powered_static_margin_cal`](crate::FlightSummary::min_powered_static_margin_cal)):
127    /// the rocket is unstable under power, and its apogee is not a prediction.
128    UnstableUnderPower {
129        /// The least static margin while a motor burns, calibres.
130        static_margin_cal: Peak,
131    },
132    /// A pitch-moment slope `C_mα` above zero while a motor burns, from the rail exit to apogee or
133    /// the first deployment, at an instant where hpr can give no static margin
134    /// ([`FlightSummary::max_powered_moment_slope_per_rad`](crate::FlightSummary::max_powered_moment_slope_per_rad)):
135    /// the air turns the rocket away from its path, so it is unstable under power and its apogee
136    /// is not a prediction. Raised only when [`UnstableUnderPower`](Self::UnstableUnderPower)
137    /// isn't, so a flight raises one unstable flag at most.
138    UnstableWithoutMargin {
139        /// The largest static pitch-moment slope where the margin is undefined while a motor
140        /// burns, per radian.
141        pitch_moment_slope_per_rad: Peak,
142    },
143    /// Faster than [`VALIDATED_MACH`].
144    BeyondValidatedRange {
145        /// The flight's top Mach number.
146        max_mach: Peak,
147    },
148    /// Above [`HIGH_ANGLE_OF_ATTACK_RAD`] more than [`HIGH_ANGLE_GRACE_S`] after the rail exit and
149    /// before apogee or the first deployment, where a 15° angle would give a normal force of at
150    /// least [`HIGH_ANGLE_MIN_FORCE_SHARE`] of the weight
151    /// ([`FlightSummary::max_angle_of_attack_rad`](crate::FlightSummary::max_angle_of_attack_rad)).
152    HighAngleOfAttack {
153        /// The largest angle of attack that counts, rad.
154        angle_of_attack_rad: Peak,
155    },
156    /// Past [`CORE_BAND_MACH`].
157    OutsideCoreBand {
158        /// The flight's top Mach number.
159        max_mach: Peak,
160    },
161    /// Past [`ENVELOPE_MACH`].
162    BeyondEnvelope {
163        /// The flight's top Mach number.
164        max_mach: Peak,
165    },
166}
167
168impl EnvelopeFlag {
169    /// The flag's name in snake case, as it is serialized.
170    #[must_use]
171    pub fn name(&self) -> &'static str {
172        match self {
173            Self::UnstableUnderPower { .. } => "unstable_under_power",
174            Self::UnstableWithoutMargin { .. } => "unstable_without_margin",
175            Self::BeyondValidatedRange { .. } => "beyond_validated_range",
176            Self::HighAngleOfAttack { .. } => "high_angle_of_attack",
177            Self::OutsideCoreBand { .. } => "outside_core_band",
178            Self::BeyondEnvelope { .. } => "beyond_envelope",
179        }
180    }
181
182    /// The peak that raised the flag.
183    #[must_use]
184    pub fn peak(&self) -> Peak {
185        match *self {
186            Self::BeyondValidatedRange { max_mach }
187            | Self::OutsideCoreBand { max_mach }
188            | Self::BeyondEnvelope { max_mach } => max_mach,
189            Self::HighAngleOfAttack {
190                angle_of_attack_rad,
191            } => angle_of_attack_rad,
192            Self::UnstableUnderPower { static_margin_cal } => static_margin_cal,
193            Self::UnstableWithoutMargin {
194                pitch_moment_slope_per_rad,
195            } => pitch_moment_slope_per_rad,
196        }
197    }
198
199    /// What the flag means for this flight, in a sentence.
200    #[must_use]
201    pub fn message(&self) -> String {
202        match *self {
203            Self::UnstableUnderPower { static_margin_cal } => format!(
204                "the static margin falls to {:.2} calibres at {:.2} s while a motor burns: the \
205                 rocket is unstable under power, so the air turns it away from its path rather \
206                 than back, and its apogee is not a prediction",
207                static_margin_cal.value, static_margin_cal.time_s
208            ),
209            Self::UnstableWithoutMargin {
210                pitch_moment_slope_per_rad,
211            } => format!(
212                "while a motor burns, the pitching moment turns the rocket away from its path \
213                 (C_mα {:+.1} per radian at {:.2} s) where hpr can give no static margin: the \
214                 rocket is unstable under power, so its apogee is not a prediction",
215                pitch_moment_slope_per_rad.value, pitch_moment_slope_per_rad.time_s
216            ),
217            Self::BeyondValidatedRange { max_mach } => format!(
218                "reaches Mach {:.2} at {:.1} s, faster than any public flight hpr has been \
219                 compared with an independent reference (Mach {VALIDATED_MACH:.2}): its numbers \
220                 past that speed are unchecked",
221                max_mach.value, max_mach.time_s
222            ),
223            Self::HighAngleOfAttack {
224                angle_of_attack_rad,
225            } => format!(
226                "flies at {:.1}° angle of attack at {:.1} s, past the {:.0}° its aerodynamics \
227                 assume more than {HIGH_ANGLE_GRACE_S:.0} s after the rail exit: its numbers \
228                 from then on are rough",
229                angle_of_attack_rad.value.to_degrees(),
230                angle_of_attack_rad.time_s,
231                HIGH_ANGLE_OF_ATTACK_RAD.to_degrees()
232            ),
233            Self::OutsideCoreBand { max_mach } => format!(
234                "reaches Mach {:.2} at {:.1} s, past the core band (Mach 0 to \
235                 {CORE_BAND_MACH:.1}): its aerodynamics there are checked less",
236                max_mach.value, max_mach.time_s
237            ),
238            Self::BeyondEnvelope { max_mach } => format!(
239                "reaches Mach {:.2} at {:.1} s, beyond the envelope (Mach {ENVELOPE_MACH:.1}): \
240                 its aerodynamics there are not validated",
241                max_mach.value, max_mach.time_s
242            ),
243        }
244    }
245}
246
247/// Whether `value` is past `edge`, a NaN counting as past it.
248fn past(value: f64, edge: f64) -> bool {
249    value.is_nan() || value > edge
250}
251
252/// Whether `value` is below `edge`, a NaN counting as below it.
253fn below(value: f64, edge: f64) -> bool {
254    value.is_nan() || value < edge
255}
256
257/// The flags raised by a flight whose top Mach number is `max_mach`, whose largest counted
258/// angle of attack is `max_angle_of_attack_rad`, whose least static margin while a motor burns
259/// is `min_powered_static_margin_cal`, and whose largest static pitch-moment slope where that
260/// margin is undefined while a motor burns is `max_powered_moment_slope_per_rad`, in
261/// [`EnvelopeFlag`]'s order. Each holds on its own, so a flight past Mach 3.5 raises all three
262/// Mach flags, but of the two unstable flags only the first raised counts: a margin below zero
263/// wins over a moment slope above it. A NaN peak raises its flags, so a broken number can't hide
264/// one.
265#[must_use]
266pub fn flags(
267    max_mach: Option<Peak>,
268    max_angle_of_attack_rad: Option<Peak>,
269    min_powered_static_margin_cal: Option<Peak>,
270    max_powered_moment_slope_per_rad: Option<Peak>,
271) -> Vec<EnvelopeFlag> {
272    let mut flags = Vec::new();
273    if let Some(static_margin_cal) =
274        min_powered_static_margin_cal.filter(|peak| below(peak.value, 0.0))
275    {
276        flags.push(EnvelopeFlag::UnstableUnderPower { static_margin_cal });
277    } else if let Some(pitch_moment_slope_per_rad) =
278        max_powered_moment_slope_per_rad.filter(|peak| past(peak.value, 0.0))
279    {
280        flags.push(EnvelopeFlag::UnstableWithoutMargin {
281            pitch_moment_slope_per_rad,
282        });
283    }
284    if let Some(max_mach) = max_mach.filter(|peak| past(peak.value, VALIDATED_MACH)) {
285        flags.push(EnvelopeFlag::BeyondValidatedRange { max_mach });
286    }
287    if let Some(angle_of_attack_rad) =
288        max_angle_of_attack_rad.filter(|peak| past(peak.value, HIGH_ANGLE_OF_ATTACK_RAD))
289    {
290        flags.push(EnvelopeFlag::HighAngleOfAttack {
291            angle_of_attack_rad,
292        });
293    }
294    if let Some(max_mach) = max_mach.filter(|peak| past(peak.value, CORE_BAND_MACH)) {
295        flags.push(EnvelopeFlag::OutsideCoreBand { max_mach });
296    }
297    if let Some(max_mach) = max_mach.filter(|peak| past(peak.value, ENVELOPE_MACH)) {
298        flags.push(EnvelopeFlag::BeyondEnvelope { max_mach });
299    }
300    flags
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    fn peak(value: f64) -> Option<Peak> {
308        Some(Peak {
309            value,
310            time_s: 3.0,
311            height_above_ground_m: 400.0,
312        })
313    }
314
315    fn names(max_mach: f64, angle_rad: f64) -> Vec<&'static str> {
316        flags(peak(max_mach), peak(angle_rad), peak(1.5), peak(-2.0))
317            .iter()
318            .map(EnvelopeFlag::name)
319            .collect()
320    }
321
322    #[test]
323    fn each_mach_flag_starts_just_past_its_edge() {
324        let calm = 0.1;
325        for (edge, raised) in [
326            (VALIDATED_MACH, vec!["beyond_validated_range"]),
327            (
328                CORE_BAND_MACH,
329                vec!["beyond_validated_range", "outside_core_band"],
330            ),
331            (
332                ENVELOPE_MACH,
333                vec![
334                    "beyond_validated_range",
335                    "outside_core_band",
336                    "beyond_envelope",
337                ],
338            ),
339        ] {
340            let below = &raised[..raised.len() - 1];
341            assert_eq!(names(edge, calm), below, "at Mach {edge}");
342            assert_eq!(names(edge.next_up(), calm), raised, "past Mach {edge}");
343        }
344        assert!(names(VALIDATED_MACH.next_down(), calm).is_empty());
345    }
346
347    #[test]
348    fn the_angle_flag_starts_just_past_15_degrees() {
349        assert_eq!(HIGH_ANGLE_OF_ATTACK_RAD, 0.261_799_387_799_149_4);
350        assert!(names(0.5, HIGH_ANGLE_OF_ATTACK_RAD).is_empty());
351        assert_eq!(
352            names(0.5, HIGH_ANGLE_OF_ATTACK_RAD.next_up()),
353            ["high_angle_of_attack"]
354        );
355    }
356
357    #[test]
358    fn nothing_flown_raises_nothing_and_a_nan_raises_everything() {
359        assert!(flags(None, None, None, None).is_empty());
360        let nan = flags(
361            peak(f64::NAN),
362            peak(f64::NAN),
363            peak(f64::NAN),
364            peak(f64::NAN),
365        );
366        assert_eq!(
367            nan.iter().map(EnvelopeFlag::name).collect::<Vec<_>>(),
368            [
369                "unstable_under_power",
370                "beyond_validated_range",
371                "high_angle_of_attack",
372                "outside_core_band",
373                "beyond_envelope"
374            ]
375        );
376    }
377
378    #[test]
379    fn the_force_floor_is_a_fifth_of_the_weight() {
380        let mass_kg = 3.7;
381        let fifth_n = HIGH_ANGLE_MIN_FORCE_SHARE * mass_kg * 9.806_65;
382        assert!(force_counts(fifth_n, mass_kg));
383        assert!(!force_counts(fifth_n.next_down(), mass_kg));
384        assert!(force_counts(f64::NAN, mass_kg));
385        // q (π d²/4) |C_Nα| α: 2000 Pa on a 0.1 m diameter, a slope of −10 per radian, 0.3 rad.
386        let force = normal_force_n(2000.0, 0.1, -10.0, 0.3);
387        assert!(
388            (force - 2000.0 * 0.007_853_981_633_974_483 * 3.0).abs() < 1e-12,
389            "{force}"
390        );
391    }
392
393    #[test]
394    fn the_stability_flag_starts_just_below_a_zero_margin() {
395        // Below zero, however little, under power: unstable. Zero itself, or a stable margin,
396        // raises nothing. Which margin counts (only while a motor burns) is the watcher's, tested
397        // in `metrics`.
398        let unstable = |margin_cal: f64| {
399            flags(None, None, peak(margin_cal), None)
400                .iter()
401                .map(EnvelopeFlag::name)
402                .collect::<Vec<_>>()
403        };
404        assert_eq!(unstable((-0.0_f64).next_down()), ["unstable_under_power"]);
405        assert_eq!(unstable(-4.21), ["unstable_under_power"]);
406        assert!(unstable(0.0).is_empty());
407        assert!(unstable(-0.0).is_empty());
408        assert!(unstable(0.0_f64.next_up()).is_empty());
409        assert!(unstable(1.5).is_empty());
410        let flag = flags(None, None, peak(-4.21), None)[0];
411        assert_eq!(flag.peak(), peak(-4.21).unwrap());
412        assert_eq!(
413            flag.message(),
414            "the static margin falls to -4.21 calibres at 3.00 s while a motor burns: the rocket \
415             is unstable under power, so the air turns it away from its path rather than back, \
416             and its apogee is not a prediction"
417        );
418        let json = serde_json::to_value(flag).unwrap();
419        assert_eq!(json["flag"], "unstable_under_power");
420        assert_eq!(json["static_margin_cal"]["value"], -4.21);
421        let back: EnvelopeFlag = serde_json::from_value(json).unwrap();
422        assert_eq!(back, flag);
423    }
424
425    #[test]
426    fn the_moment_flag_starts_just_above_zero_and_only_without_the_margin_flag() {
427        // With no margin to read, a moment slope above zero, however little, under power:
428        // unstable. Zero itself, or a restoring slope, raises nothing. Which slopes count (only
429        // while a motor burns, only where the margin is undefined) is the watcher's, tested in
430        // `metrics`.
431        let unstable = |margin_cal: Option<Peak>, slope: f64| {
432            flags(None, None, margin_cal, peak(slope))
433                .iter()
434                .map(EnvelopeFlag::name)
435                .collect::<Vec<_>>()
436        };
437        assert_eq!(
438            unstable(None, 0.0_f64.next_up()),
439            ["unstable_without_margin"]
440        );
441        assert_eq!(unstable(None, 43.9), ["unstable_without_margin"]);
442        assert_eq!(unstable(None, f64::NAN), ["unstable_without_margin"]);
443        assert!(unstable(None, 0.0).is_empty());
444        assert!(unstable(None, -0.0).is_empty());
445        assert!(unstable(None, (-0.0_f64).next_down()).is_empty());
446        // A stable margin elsewhere under power doesn't hide it.
447        assert_eq!(unstable(peak(1.5), 43.9), ["unstable_without_margin"]);
448        // One unstable flag a flight: a margin below zero wins.
449        assert_eq!(unstable(peak(-4.21), 43.9), ["unstable_under_power"]);
450        assert_eq!(unstable(peak(f64::NAN), 43.9), ["unstable_under_power"]);
451        let flag = flags(None, None, None, peak(43.9))[0];
452        assert_eq!(flag.peak(), peak(43.9).unwrap());
453        assert_eq!(
454            flag.message(),
455            "while a motor burns, the pitching moment turns the rocket away from its path (C_mα \
456             +43.9 per radian at 3.00 s) where hpr can give no static margin: the rocket is \
457             unstable under power, so its apogee is not a prediction"
458        );
459        let json = serde_json::to_value(flag).unwrap();
460        assert_eq!(json["flag"], "unstable_without_margin");
461        assert_eq!(json["pitch_moment_slope_per_rad"]["value"], 43.9);
462        let back: EnvelopeFlag = serde_json::from_value(json).unwrap();
463        assert_eq!(back, flag);
464    }
465
466    #[test]
467    fn a_flag_keeps_its_peak_and_serializes_by_name() {
468        let flag = flags(peak(1.5), None, None, None)[0];
469        assert_eq!(flag.peak(), peak(1.5).unwrap());
470        assert!(flag.message().contains("Mach 1.50 at 3.0 s"));
471        let json = serde_json::to_value(flag).unwrap();
472        assert_eq!(json["flag"], "beyond_validated_range");
473        assert_eq!(json["max_mach"]["value"], 1.5);
474        let back: EnvelopeFlag = serde_json::from_value(json).unwrap();
475        assert_eq!(back, flag);
476        let angle = flags(None, peak(0.5), None, None)[0];
477        assert_eq!(serde_json::to_value(angle).unwrap()["flag"], angle.name());
478        assert!(angle.message().contains("28.6° angle of attack at 3.0 s"));
479    }
480}