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}