1use hpr_aero::{AeroModel, Flow};
30use serde::{Deserialize, Serialize};
31
32use crate::dynamics::Phase;
33use crate::envelope::{self, EnvelopeFlag, HIGH_ANGLE_GRACE_S, HIGH_ANGLE_OF_ATTACK_RAD};
34use crate::environment::Environment;
35use crate::error::SimError;
36use crate::flight::{EventKind, FlightEvent, FlightResult, Simulation, Termination};
37use crate::recorder::{FlightStep, Observer, Sample};
38use crate::recovery::BodySample;
39
40pub const MARGIN_CONDITION_LIMIT: f64 = 3.162_277_660_168_379_5;
57
58pub const STATIC_MARGIN_MACH: f64 = 0.0;
60
61#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
63pub struct Peak {
64 pub value: f64,
66 pub time_s: f64,
68 pub height_above_ground_m: f64,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
74pub struct Margin {
75 pub mach: f64,
77 pub angle_of_attack_rad: f64,
79 #[serde(default)]
82 pub roll_rad: f64,
83 pub normal_force_slope_per_rad: f64,
86 pub slope_magnitude_sum_per_rad: f64,
90 pub pitch_moment_slope_per_rad: f64,
95 pub cp_station_m: Option<f64>,
97 pub margin_cal: Option<f64>,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
105pub struct Stability {
106 pub time_s: f64,
108 pub height_above_ground_m: f64,
110 pub dynamic_pressure_pa: f64,
112 pub cg_station_m: f64,
114 pub reference_diameter_m: f64,
117 pub static_margin: Margin,
121 pub flight_margin: Margin,
127}
128
129pub fn margin(aero: &AeroModel, flow: &Flow, cg_station_m: f64) -> Result<Margin, SimError> {
136 let normal = aero.normal_force(flow)?;
137 let d = aero.reference_diameter_m();
138 let slope = normal.slope_per_rad;
139 let scale = if aero.normal_force_table().is_some() {
140 slope.abs()
142 } else {
143 aero.components(flow)?
144 .iter()
145 .map(|c| c.normal_force.slope_per_rad.abs())
146 .sum()
147 };
148 let conditioned = slope > 0.0 && slope * MARGIN_CONDITION_LIMIT >= scale;
149 let cp_station_m = normal.cp_station_m.filter(|_| conditioned);
150 Ok(Margin {
151 mach: flow.mach,
152 angle_of_attack_rad: flow.alpha_rad,
153 roll_rad: flow.roll_rad,
154 normal_force_slope_per_rad: slope,
155 slope_magnitude_sum_per_rad: scale,
156 pitch_moment_slope_per_rad: -(normal.moment_slope_m - slope * cg_station_m) / d,
157 cp_station_m,
158 margin_cal: cp_station_m.map(|cp| (cp - cg_station_m) / d),
159 })
160}
161
162pub fn weakest_margin(aero: &AeroModel, mach: f64, cg_station_m: f64) -> Result<Margin, SimError> {
185 let axial = margin(aero, &Flow::axial(mach), cg_station_m)?;
186 if !aero.rolls() {
187 return Ok(axial);
188 }
189 let mut samples = [[0.0; 3]; 3];
191 for (k, column) in [0.0, 1.0, 2.0].into_iter().enumerate() {
192 let flow = Flow::new(mach, 0.0, column * std::f64::consts::FRAC_PI_3);
193 let normal = aero.normal_force(&flow)?;
194 let scale: f64 = aero
195 .components(&flow)?
196 .iter()
197 .map(|c| c.normal_force.slope_per_rad.abs())
198 .sum();
199 samples[0][k] = normal.slope_per_rad;
200 samples[1][k] = normal.moment_slope_m;
201 samples[2][k] = scale;
202 }
203 let fit = |[f0, f1, f2]: [f64; 3]| {
204 [
205 (f0 + f1 + f2) / 3.0,
206 (2.0 * f0 - f1 - f2) / 3.0,
207 (f1 - f2) / 3.0_f64.sqrt(),
208 ]
209 };
210 let [slope, moment, scale] = samples.map(fit);
211 let mut least = axial;
212 for numerator_denominator in [(moment, slope), (slope, scale)] {
213 for u in stationary_angles(numerator_denominator) {
214 let candidate = margin(aero, &Flow::new(mach, 0.0, 0.5 * u), cg_station_m)?;
215 least = match (least.margin_cal, candidate.margin_cal) {
216 (None, _) => least,
217 (Some(_), None) => candidate,
218 (Some(a), Some(b)) if b < a => candidate,
219 _ => least,
220 };
221 }
222 }
223 Ok(least)
224}
225
226fn stationary_angles(([n0, n1, n2], [d0, d1, d2]): ([f64; 3], [f64; 3])) -> Vec<f64> {
231 let (p, q, r) = (n0 * d1 - n1 * d0, n2 * d0 - n0 * d2, n2 * d1 - n1 * d2);
232 let a = p.hypot(q);
233 if a.is_nan() || a <= 0.0 {
234 return Vec::new();
235 }
236 let ratio = -r / a;
237 if ratio.is_nan() || ratio.abs() > 1.0 + 1e-9 {
238 return Vec::new();
239 }
240 let (root, psi) = (ratio.clamp(-1.0, 1.0).asin(), q.atan2(p));
241 [root - psi, std::f64::consts::PI - root - psi]
242 .into_iter()
243 .map(|u| u.rem_euclid(std::f64::consts::TAU))
244 .collect()
245}
246
247pub fn stability(
255 aero: &AeroModel,
256 time_s: f64,
257 height_above_ground_m: f64,
258 dynamic_pressure_pa: f64,
259 cg_station_m: f64,
260 mach: f64,
261) -> Result<Stability, SimError> {
262 Ok(Stability {
263 time_s,
264 height_above_ground_m,
265 dynamic_pressure_pa,
266 cg_station_m,
267 reference_diameter_m: aero.reference_diameter_m(),
268 static_margin: weakest_margin(aero, STATIC_MARGIN_MACH, cg_station_m)?,
269 flight_margin: weakest_margin(aero, mach, cg_station_m)?,
270 })
271}
272
273#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
275pub struct Apogee {
276 pub time_s: f64,
278 pub height_above_ground_m: f64,
280 pub gain_m: Option<f64>,
284}
285
286#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
289pub struct Landing {
290 pub body: Option<usize>,
293 pub time_s: f64,
295 pub latitude_deg: f64,
297 pub longitude_deg: f64,
299 pub east_m: f64,
301 pub north_m: f64,
303 pub distance_m: f64,
305 pub ground_hit_speed_m_s: f64,
307 pub descent_rate_m_s: f64,
309}
310
311impl Landing {
312 pub fn at(
318 body: Option<usize>,
319 sample: &BodySample,
320 environment: &Environment,
321 ) -> Result<Self, SimError> {
322 let place = environment
323 .earth
324 .frame()
325 .geodetic_from_enu(sample.cg_enu_m)?;
326 let (east_m, north_m) = (sample.cg_enu_m.x, sample.cg_enu_m.y);
327 Ok(Self {
328 body,
329 time_s: sample.time_s,
330 latitude_deg: place.latitude_rad.to_degrees(),
331 longitude_deg: place.longitude_rad.to_degrees(),
332 east_m,
333 north_m,
334 distance_m: east_m.hypot(north_m),
335 ground_hit_speed_m_s: sample.cg_velocity_enu_m_s.length(),
336 descent_rate_m_s: -sample.vertical_speed_m_s,
337 })
338 }
339}
340
341#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
343pub struct FlightSummary {
344 pub termination: Termination,
346 pub launch_height_m: Option<f64>,
349 pub rail_exit_speed_m_s: Option<Peak>,
351 pub apogee: Option<Apogee>,
355 pub max_speed_m_s: Option<Peak>,
357 pub max_mach: Option<Peak>,
359 pub max_dynamic_pressure_pa: Option<Peak>,
361 pub max_acceleration_m_s2: Option<Peak>,
365 pub max_descent_acceleration_m_s2: Option<Peak>,
368 pub min_static_margin_cal: Option<Peak>,
372 pub min_flight_margin_cal: Option<Peak>,
375 #[serde(default)]
387 pub min_powered_static_margin_cal: Option<Peak>,
388 #[serde(default)]
402 pub max_powered_moment_slope_per_rad: Option<Peak>,
403 pub rail_exit_stability: Option<Stability>,
405 #[serde(default)]
415 pub max_angle_of_attack_rad: Option<Peak>,
416 pub landing: Option<Landing>,
418 pub body_landings: Vec<Landing>,
420}
421
422impl FlightSummary {
423 #[must_use]
425 pub fn envelope_flags(&self) -> Vec<EnvelopeFlag> {
426 crate::envelope::flags(
427 self.max_mach,
428 self.max_angle_of_attack_rad,
429 self.min_powered_static_margin_cal,
430 self.max_powered_moment_slope_per_rad,
431 )
432 }
433
434 #[must_use]
436 pub fn ground_hit_speed_m_s(&self) -> Option<f64> {
437 self.landing.map(|landing| landing.ground_hit_speed_m_s)
438 }
439
440 #[must_use]
445 pub fn nose_landing(&self) -> Option<&Landing> {
446 self.landing
447 .as_ref()
448 .or_else(|| self.body_landings.iter().find(|l| l.body == Some(0)))
449 }
450}
451
452#[derive(Debug, Clone, Default, PartialEq, Serialize)]
466pub struct FlightMetrics {
467 launch_height_m: Option<f64>,
468 max_speed: Option<Peak>,
469 max_mach: Option<Peak>,
470 max_q: Option<Peak>,
471 max_acceleration: Option<Peak>,
472 max_descent_acceleration: Option<Peak>,
473 last_end_s: Option<f64>,
475 steps: u64,
477 on_rail: bool,
478 past_apogee: bool,
479 separated: bool,
481 rail_exit: Option<Stability>,
482 stability: Vec<Stability>,
483 min_static_margin: Option<Peak>,
484 min_flight_margin: Option<Peak>,
485 min_powered_static_margin: Option<Peak>,
487 max_powered_moment_slope: Option<Peak>,
490 free_start_s: Option<f64>,
493 max_angle_of_attack: Option<Peak>,
494}
495
496#[derive(Debug, Clone, Copy)]
498enum Quantity {
499 Speed,
500 Mach,
501 DynamicPressure,
502 Acceleration,
503}
504
505impl Quantity {
506 const ALL: [Quantity; 4] = [
507 Quantity::Speed,
508 Quantity::Mach,
509 Quantity::DynamicPressure,
510 Quantity::Acceleration,
511 ];
512
513 fn of(self, sample: &Sample) -> f64 {
514 match self {
515 Quantity::Speed => sample.cg_velocity_enu_m_s.length(),
516 Quantity::Mach => sample.mach,
517 Quantity::DynamicPressure => sample.dynamic_pressure_pa,
518 Quantity::Acceleration => sample.acceleration_enu_m_s2.length(),
519 }
520 }
521}
522
523impl FlightMetrics {
524 #[must_use]
526 pub fn new() -> Self {
527 Self::default()
528 }
529
530 pub fn clear(&mut self) {
532 *self = Self::default();
533 }
534
535 #[must_use]
541 pub fn stability(&self) -> &[Stability] {
542 &self.stability
543 }
544
545 pub fn summary(
553 &self,
554 result: &FlightResult,
555 environment: &Environment,
556 ) -> Result<FlightSummary, SimError> {
557 if self.steps != result.stats.accepted_steps
560 || self
561 .last_end_s
562 .is_some_and(|end| end > result.final_sample.time_s)
563 {
564 return Err(SimError::Domain {
565 what: "steps this watcher saw (it must watch each step of the flight it sums up, \
566 and be cleared before another)",
567 value: self.steps as f64,
568 });
569 }
570 let stack = result
575 .event(EventKind::Apogee)
576 .map(|event| (event.sample.time_s, event.sample.height_above_ground_m));
577 let nose = || {
578 result
579 .bodies
580 .iter()
581 .find(|body| body.body == 0)
582 .and_then(|body| body.event(EventKind::Apogee))
583 .map(|event| (event.sample.time_s, event.sample.height_above_ground_m))
584 };
585 let apogee = stack
586 .or_else(|| {
587 let separated = result.termination == Termination::Separated
588 && result.event(EventKind::Separation).is_some()
589 && !result
590 .events
591 .iter()
592 .any(|event| matches!(event.kind, EventKind::Ejection(_)));
593 separated.then(nose).flatten()
594 })
595 .map(|(time_s, height)| Apogee {
596 time_s,
597 height_above_ground_m: height,
598 gain_m: self.launch_height_m.map(|start| height - start),
599 });
600 let rail_exit = result.event(EventKind::RailExit).map(|event| event.sample);
601 let landing = if result.termination == Termination::GroundHit {
602 let s = &result.final_sample;
603 Some(Landing::at(
604 None,
605 &BodySample {
606 time_s: s.time_s,
607 cg_enu_m: s.cg_enu_m,
608 cg_velocity_enu_m_s: s.cg_velocity_enu_m_s,
609 height_above_ground_m: s.height_above_ground_m,
610 vertical_speed_m_s: s.vertical_speed_m_s,
611 airspeed_m_s: s.airspeed_m_s,
612 recovery_drag_area_m2: s.recovery_drag_area_m2,
613 mass_kg: s.mass_kg,
614 },
615 environment,
616 )?)
617 } else {
618 None
619 };
620 let body_landings = result
621 .bodies
622 .iter()
623 .filter(|body| body.termination == Termination::GroundHit)
624 .map(|body| Landing::at(Some(body.body), &body.final_sample, environment))
625 .collect::<Result<Vec<_>, _>>()?;
626 Ok(FlightSummary {
627 termination: result.termination,
628 launch_height_m: self.launch_height_m,
629 rail_exit_speed_m_s: rail_exit.map(|s| Peak {
630 value: s.cg_velocity_enu_m_s.length(),
631 time_s: s.time_s,
632 height_above_ground_m: s.height_above_ground_m,
633 }),
634 apogee,
635 max_speed_m_s: self.max_speed,
636 max_mach: self.max_mach,
637 max_dynamic_pressure_pa: self.max_q,
638 max_acceleration_m_s2: self.max_acceleration,
639 max_descent_acceleration_m_s2: self.max_descent_acceleration,
640 min_static_margin_cal: self.min_static_margin,
641 min_flight_margin_cal: self.min_flight_margin,
642 min_powered_static_margin_cal: self.min_powered_static_margin,
643 max_powered_moment_slope_per_rad: self.max_powered_moment_slope,
644 rail_exit_stability: self.rail_exit,
645 max_angle_of_attack_rad: self.max_angle_of_attack,
646 landing,
647 body_landings,
648 })
649 }
650
651 fn slot(&mut self, quantity: Quantity, phase: Phase) -> &mut Option<Peak> {
652 match quantity {
653 Quantity::Speed => &mut self.max_speed,
654 Quantity::Mach => &mut self.max_mach,
655 Quantity::DynamicPressure => &mut self.max_q,
656 Quantity::Acceleration if phase == Phase::Descent => &mut self.max_descent_acceleration,
657 Quantity::Acceleration => &mut self.max_acceleration,
658 }
659 }
660}
661
662impl Observer for FlightMetrics {
663 fn step(&mut self, step: &dyn FlightStep) -> Result<(), SimError> {
664 let phase = step.phase();
665 let (a, b) = (step.start_s(), step.end_s());
666 let start = step.sample(a)?;
667 if self.last_end_s.is_none() {
668 match phase {
669 Phase::Pad | Phase::Rail => {
670 self.launch_height_m = Some(start.height_above_ground_m)
671 }
672 _ if start.vertical_speed_m_s <= 0.0 => self.past_apogee = true,
674 _ => {}
675 }
676 }
677 self.steps += 1;
678 self.last_end_s = Some(b);
679 if phase == Phase::Pad {
680 return Ok(());
681 }
682 let samples = [start, step.sample(0.5 * (a + b))?, step.sample(b)?];
683 for quantity in Quantity::ALL {
684 let best = step_peak(step, quantity, &samples)?;
685 let slot = self.slot(quantity, phase);
686 if slot.is_none_or(|peak| best.value > peak.value) {
687 *slot = Some(best);
688 }
689 }
690 if phase == Phase::Rail {
691 self.on_rail = true;
692 }
693 if !self.past_apogee && phase == Phase::Free {
695 let from_s = *self.free_start_s.get_or_insert(samples[0].time_s);
696 let first = match self.stability.last() {
699 Some(last) if !self.separated => *last,
700 _ => {
701 let first = step.stability(a)?;
702 if self.stability.is_empty() && self.on_rail {
703 self.rail_exit = Some(first);
704 }
705 self.stability.push(first);
706 first
707 }
708 };
709 let end = step.stability(b)?;
710 let entries = [first, step.stability(0.5 * (a + b))?, end];
711 for (sample, entry) in samples.iter().zip(&entries) {
712 let force_n = envelope::normal_force_n(
715 sample.dynamic_pressure_pa,
716 entry.reference_diameter_m,
717 entry.flight_margin.normal_force_slope_per_rad,
718 HIGH_ANGLE_OF_ATTACK_RAD,
719 );
720 if sample.time_s - from_s > HIGH_ANGLE_GRACE_S
722 && envelope::force_counts(force_n, sample.mass_kg)
723 && self.max_angle_of_attack.is_none_or(|peak| {
724 !peak.value.is_nan()
725 && (sample.angle_of_attack_rad.is_nan()
726 || sample.angle_of_attack_rad > peak.value)
727 })
728 {
729 self.max_angle_of_attack = Some(Peak {
730 value: sample.angle_of_attack_rad,
731 time_s: sample.time_s,
732 height_above_ground_m: sample.height_above_ground_m,
733 });
734 }
735 }
736 let least_static = least_margin(step, a, b, &entries, static_of)?;
737 for (least, slot) in [
738 (least_static, &mut self.min_static_margin),
739 (
740 least_margin(step, a, b, &entries, flight_of)?,
741 &mut self.min_flight_margin,
742 ),
743 ] {
744 if let Some(least) = least
745 && slot.is_none_or(|peak| replaces(least.value, peak.value))
746 {
747 *slot = Some(least);
748 }
749 }
750 let thrust_n = samples[1].thrust_n;
753 if thrust_n > 0.0 || thrust_n.is_nan() {
754 keep_powered(&mut self.min_powered_static_margin, &entries, least_static);
755 keep_moment_without_margin(&mut self.max_powered_moment_slope, &entries);
756 }
757 self.stability.push(end);
758 }
759 self.separated = false;
760 Ok(())
761 }
762
763 fn event(&mut self, event: &FlightEvent) {
764 match event.kind {
765 EventKind::Apogee => self.past_apogee = true,
766 EventKind::Separation => self.separated = true,
767 _ => {}
768 }
769 }
770}
771
772const INVERSE_PHI: f64 = 0.618_033_988_749_894_9;
774
775const PEAK_TIME_RESOLUTION: f64 = 1e-9;
778
779fn golden_max(
782 mut a: f64,
783 mut b: f64,
784 mut value: impl FnMut(f64) -> Result<Peak, SimError>,
785) -> Result<Peak, SimError> {
786 let mut c = b - INVERSE_PHI * (b - a);
787 let mut d = a + INVERSE_PHI * (b - a);
788 let mut fc = value(c)?;
789 let mut fd = value(d)?;
790 while b - a > PEAK_TIME_RESOLUTION * b.abs().max(1.0) {
791 if fc.value >= fd.value {
792 b = d;
793 (d, fd) = (c, fc);
794 c = b - INVERSE_PHI * (b - a);
795 fc = value(c)?;
796 } else {
797 a = c;
798 (c, fc) = (d, fd);
799 d = a + INVERSE_PHI * (b - a);
800 fd = value(d)?;
801 }
802 }
803 Ok(if fc.value >= fd.value { fc } else { fd })
804}
805
806fn top_inside(va: f64, vm: f64, vb: f64) -> bool {
809 let bend = va - 2.0 * vm + vb;
810 bend < 0.0 && (va - vb).abs() < -2.0 * bend
811}
812
813fn step_peak(
816 step: &dyn FlightStep,
817 quantity: Quantity,
818 samples: &[Sample; 3],
819) -> Result<Peak, SimError> {
820 let peak_of = |sample: &Sample| Peak {
821 value: quantity.of(sample),
822 time_s: sample.time_s,
823 height_above_ground_m: sample.height_above_ground_m,
824 };
825 let [start, middle, end] = samples.each_ref().map(peak_of);
826 let mut best = [middle, end]
827 .into_iter()
828 .fold(start, |x, y| if y.value > x.value { y } else { x });
829 if top_inside(start.value, middle.value, end.value) {
830 let found = golden_max(step.start_s(), step.end_s(), |t| {
831 step.sample(t).map(|sample| peak_of(&sample))
832 })?;
833 if found.value > best.value {
834 best = found;
835 }
836 }
837 Ok(best)
838}
839
840fn static_of(s: &Stability) -> &Margin {
841 &s.static_margin
842}
843
844fn flight_of(s: &Stability) -> &Margin {
845 &s.flight_margin
846}
847
848const MARGIN_TIE: f64 = 1e-12;
852
853fn clearly_below(a: f64, b: f64) -> bool {
855 b - a > MARGIN_TIE * b.abs().max(1.0)
856}
857
858fn replaces(a: f64, b: f64) -> bool {
861 clearly_below(a, b) || (a < 0.0 && b >= 0.0)
862}
863
864fn keep_powered(slot: &mut Option<Peak>, entries: &[Stability; 3], least: Option<Peak>) {
868 if slot.is_some_and(|peak| peak.value.is_nan()) {
869 return;
870 }
871 let broken = entries.iter().find_map(|s| {
872 s.static_margin
873 .margin_cal
874 .filter(|margin| margin.is_nan())
875 .map(|value| Peak {
876 value,
877 time_s: s.time_s,
878 height_above_ground_m: s.height_above_ground_m,
879 })
880 });
881 if let Some(broken) = broken {
882 *slot = Some(broken);
883 } else if let Some(least) = least
884 && slot.is_none_or(|peak| replaces(least.value, peak.value))
885 {
886 *slot = Some(least);
887 }
888}
889
890fn keep_moment_without_margin(slot: &mut Option<Peak>, entries: &[Stability; 3]) {
894 for entry in entries
895 .iter()
896 .filter(|s| s.static_margin.margin_cal.is_none())
897 {
898 let slope = entry.static_margin.pitch_moment_slope_per_rad;
899 if slot.is_none_or(|peak| !peak.value.is_nan() && (slope.is_nan() || slope > peak.value)) {
900 *slot = Some(Peak {
901 value: slope,
902 time_s: entry.time_s,
903 height_above_ground_m: entry.height_above_ground_m,
904 });
905 }
906 }
907}
908
909fn least_margin(
913 step: &dyn FlightStep,
914 a: f64,
915 b: f64,
916 entries: &[Stability; 3],
917 pick: fn(&Stability) -> &Margin,
918) -> Result<Option<Peak>, SimError> {
919 let at = |s: &Stability| {
920 pick(s).margin_cal.map(|value| Peak {
921 value,
922 time_s: s.time_s,
923 height_above_ground_m: s.height_above_ground_m,
924 })
925 };
926 let mut best: Option<Peak> = None;
927 let mut keep = |candidate: Peak| {
928 if best.is_none_or(|peak| replaces(candidate.value, peak.value)) {
929 best = Some(candidate);
930 }
931 };
932 let points = entries.each_ref().map(at);
933 points.into_iter().flatten().for_each(&mut keep);
934 if let [Some(start), Some(middle), Some(end)] = points
935 && top_inside(-start.value, -middle.value, -end.value)
936 {
937 let found = golden_max(a, b, |t| {
939 step.stability(t).map(|s| {
940 let peak = at(&s);
941 Peak {
942 value: peak.map_or(f64::NEG_INFINITY, |p| -p.value),
943 time_s: s.time_s,
944 height_above_ground_m: s.height_above_ground_m,
945 }
946 })
947 })?;
948 if found.value.is_finite() {
949 keep(Peak {
950 value: -found.value,
951 ..found
952 });
953 }
954 }
955 Ok(best)
956}
957
958#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
960pub struct OptimumDelay {
961 pub motor: usize,
963 pub burnout_s: f64,
965 pub apogee_s: f64,
967 pub apogee_height_above_ground_m: f64,
969 pub delay_s: f64,
971}
972
973pub fn optimum_delays(simulation: &Simulation) -> Result<Option<Vec<OptimumDelay>>, SimError> {
994 let held = simulation.with_recovery_held();
995 let result = held.run(&mut ())?;
996 let Some((apogee_s, apogee_height_above_ground_m)) = result
997 .event(EventKind::Apogee)
998 .map(|event| (event.sample.time_s, event.sample.height_above_ground_m))
999 else {
1000 return Ok(None);
1001 };
1002 let assembly = simulation.assembly();
1003 let known = assembly.ignition_times_s(|_| None);
1005 let dropped = |stage: usize| {
1006 result
1007 .bodies
1008 .iter()
1009 .any(|body| (body.stages.0..=body.stages.1).contains(&stage))
1010 };
1011 Ok(Some(
1012 assembly
1013 .motors
1014 .iter()
1015 .enumerate()
1016 .filter(|(_, placed)| !dropped(placed.stage))
1017 .filter_map(|(motor, placed)| {
1018 let ignition_s = result
1019 .event(EventKind::Ignition(motor))
1020 .map(|event| event.sample.time_s)
1021 .or(known.get(motor).copied().flatten())?;
1022 let burnout_s = ignition_s + placed.mounted.motor.burnout_time_s();
1023 (burnout_s <= apogee_s).then_some(OptimumDelay {
1024 motor,
1025 burnout_s,
1026 apogee_s,
1027 apogee_height_above_ground_m,
1028 delay_s: apogee_s - burnout_s,
1029 })
1030 })
1031 .collect(),
1032 ))
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037 use hpr_aero::{AeroModel, Flow};
1038 use hpr_atmos::ConstantWind;
1039 use hpr_design::Rocket;
1040 use serde_json::json;
1041
1042 use super::*;
1043 use crate::envelope::HIGH_ANGLE_MIN_FORCE_SHARE;
1044 use crate::flight::FlightSettings;
1045 use crate::integrator::{Adaptive, Method};
1046 use crate::rail::Rail;
1047 use crate::recorder::{Channel, Recorder};
1048 use crate::recovery::{Device, DeviceDrag, Trigger};
1049 use crate::testing::{UniformAir, analytic_environment, design, site, windy_environment};
1050
1051 const G: f64 = 9.806_65;
1052
1053 fn valetudo(environment: Environment, settings: FlightSettings) -> Simulation {
1054 Simulation::new(
1055 &design("rocketpy-valetudo"),
1056 "example",
1057 environment,
1058 Rail::vertical(3.0),
1059 settings,
1060 )
1061 .unwrap()
1062 }
1063
1064 fn capped(max_time_s: f64) -> FlightSettings {
1065 FlightSettings {
1066 max_time_s,
1067 ..FlightSettings::default()
1068 }
1069 }
1070
1071 fn fly(simulation: &Simulation) -> (FlightResult, FlightMetrics) {
1072 let mut metrics = FlightMetrics::new();
1073 let result = simulation.run(&mut metrics).unwrap();
1074 (result, metrics)
1075 }
1076
1077 fn nose_and_boattail(r_m: f64) -> AeroModel {
1082 let material = json!({"name": "test", "density": {"kind": "bulk", "kg_m3": 1000.0}});
1083 let rocket: Rocket = serde_json::from_value(json!({
1084 "name": "",
1085 "stages": [{
1086 "id": "stage",
1087 "name": "",
1088 "components": [
1089 {"id": "nose", "name": "", "part": {"nose_cone": {
1090 "shape": {"kind": "conical"}, "length_m": 0.3, "base_radius_m": 0.05,
1091 "wall": {"kind": "filled"}, "shoulder": null, "material": material}}},
1092 {"id": "tube", "name": "", "part": {"body_tube": {
1093 "length_m": 0.5, "outer_radius_m": 0.05, "thickness_m": 0.005,
1094 "material": material}}},
1095 {"id": "boattail", "name": "", "part": {"transition": {
1096 "shape": {"kind": "conical"}, "length_m": 0.4, "fore_radius_m": 0.05,
1097 "aft_radius_m": r_m, "wall": {"kind": "filled"}, "material": material}}}
1098 ]
1099 }],
1100 "reference_diameter": {"kind": "maximum"},
1101 "configurations": []
1102 }))
1103 .unwrap();
1104 AeroModel::new(&rocket.layout().unwrap()).unwrap()
1105 }
1106
1107 fn nose_and_boattail_by_hand(r_m: f64, cg_m: f64) -> (f64, f64, f64, f64) {
1109 let (big_r, d) = (0.05, 0.1);
1110 let (nose_slope, nose_x) = (2.0, 0.3 * 2.0 / 3.0);
1111 let tail_slope = 2.0 * ((r_m / big_r).powi(2) - 1.0);
1112 let tail_x = 0.8 + 0.4 / 3.0 * (1.0 + 1.0 / (1.0 + big_r / r_m));
1113 let slope = nose_slope + tail_slope;
1114 let moment = -(nose_slope * (nose_x - cg_m) + tail_slope * (tail_x - cg_m)) / d;
1115 let cp = (nose_slope * nose_x + tail_slope * tail_x) / slope;
1116 (slope, nose_slope + tail_slope.abs(), moment, cp)
1117 }
1118
1119 fn close(got: f64, want: f64, rel: f64, what: &str) {
1120 let err = ((got - want) / want).abs();
1121 assert!(
1122 err <= rel,
1123 "{what}: got {got}, want {want}, rel err {err:e}"
1124 );
1125 }
1126
1127 #[test]
1128 fn static_margin_undefined_when_cn_alpha_near_zero() {
1129 let cg_m = 0.5;
1133 let aero = nose_and_boattail(0.005);
1134 let (slope, sum, moment, cp) = nose_and_boattail_by_hand(0.005, cg_m);
1135 let raw = aero.normal_force(&Flow::axial(0.0)).unwrap();
1136 close(raw.slope_per_rad, slope, 1e-9, "net slope");
1137 close(raw.cp_station_m.unwrap(), cp, 1e-9, "the quotient");
1138 assert!(
1139 ((cp - cg_m) / 0.1).abs() > 100.0,
1140 "a margin of {} cal",
1141 (cp - cg_m) / 0.1
1142 );
1143 let static_margin = stability(&aero, 0.0, 0.0, 0.0, cg_m, 0.3)
1144 .unwrap()
1145 .static_margin;
1146 assert_eq!(static_margin.margin_cal, None);
1147 assert_eq!(static_margin.cp_station_m, None);
1148 close(
1149 static_margin.slope_magnitude_sum_per_rad,
1150 sum,
1151 1e-12,
1152 "Σ |slopes|",
1153 );
1154 close(
1156 static_margin.pitch_moment_slope_per_rad,
1157 moment,
1158 1e-9,
1159 "C_mα",
1160 );
1161 assert!(moment > 0.0);
1162
1163 for (r_m, defined) in [
1166 (0.035, true),
1167 (0.0345, false),
1168 (0.04, true),
1169 (0.0215, false),
1170 ] {
1171 let (slope, sum, moment, cp) = nose_and_boattail_by_hand(r_m, cg_m);
1172 assert_eq!(sum / slope <= MARGIN_CONDITION_LIMIT, defined, "r = {r_m}");
1173 let m = margin(&nose_and_boattail(r_m), &Flow::axial(0.0), cg_m).unwrap();
1174 close(m.pitch_moment_slope_per_rad, moment, 1e-9, "C_mα");
1175 if defined {
1176 close(m.margin_cal.unwrap(), (cp - cg_m) / 0.1, 1e-9, "margin");
1177 close(
1179 m.pitch_moment_slope_per_rad,
1180 -slope * m.margin_cal.unwrap(),
1181 1e-9,
1182 "C_mα",
1183 );
1184 } else {
1185 assert_eq!(m.margin_cal, None, "r = {r_m}");
1186 }
1187 }
1188 }
1189
1190 fn uneven_fins(single_m: f64, pair_m: f64, pair_rad: f64) -> AeroModel {
1194 let material = json!({"name": "test", "density": {"kind": "bulk", "kg_m3": 1000.0}});
1195 let fins = |id: &str, count: u32, angle_rad: f64, span_m: f64| {
1196 json!({"id": id, "name": "", "part": {"fin_set": {
1197 "count": count, "base_angle_rad": angle_rad,
1198 "planform": {"kind": "trapezoidal", "root_chord_m": 0.12, "tip_chord_m": 0.06,
1199 "span_m": span_m, "sweep_m": 0.05},
1200 "thickness_m": 0.003, "cross_section": "rounded", "tab": null,
1201 "cant_rad": 0.0, "material": material}},
1202 "position": {"from": "bottom", "aft_offset_m": 0.0}})
1203 };
1204 let rocket: Rocket = serde_json::from_value(json!({
1205 "name": "",
1206 "stages": [{"id": "stage", "name": "", "components": [
1207 {"id": "nose", "name": "", "part": {"nose_cone": {
1208 "shape": {"kind": "conical"}, "length_m": 0.2, "base_radius_m": 0.025,
1209 "wall": {"kind": "filled"}, "shoulder": null, "material": material}}},
1210 {"id": "tube", "name": "", "part": {"body_tube": {"length_m": 0.6,
1211 "outer_radius_m": 0.025, "thickness_m": 0.001, "material": material}},
1212 "children": [
1213 fins("single", 1, 0.0, single_m),
1214 fins("pair", 2, pair_rad, pair_m)
1215 ]}
1216 ]}],
1217 "reference_diameter": {"kind": "maximum"},
1218 "configurations": []
1219 }))
1220 .unwrap();
1221 AeroModel::new(&rocket.layout().unwrap()).unwrap()
1222 }
1223
1224 #[test]
1228 fn the_least_margin_is_the_weakest_plane_s() {
1229 let scan = |aero: &AeroModel, mach: f64, cg_m: f64| {
1230 (0..3600)
1231 .map(|k| {
1232 let roll = std::f64::consts::PI * f64::from(k) / 3600.0;
1233 margin(aero, &Flow::new(mach, 0.0, roll), cg_m).unwrap()
1234 })
1235 .collect::<Vec<_>>()
1236 };
1237 let right = std::f64::consts::FRAC_PI_2;
1242 let mut off_fins = 0;
1243 for (single_m, pair_m, pair_rad) in [
1244 (0.06, 0.03, right),
1245 (0.03, 0.06, right),
1246 (0.05, 0.05, right),
1247 (0.07, 0.02, right),
1248 (0.06, 0.03, 0.6),
1249 (0.03, 0.06, 0.6),
1250 ] {
1251 let aero = uneven_fins(single_m, pair_m, pair_rad);
1252 assert!(aero.rolls());
1253 for mach in [0.1, 0.6, 1.5] {
1254 for cg_m in [0.35, 0.45, 0.55] {
1255 let least = weakest_margin(&aero, mach, cg_m).unwrap();
1256 let planes = scan(&aero, mach, cg_m);
1257 let at = margin(&aero, &Flow::new(mach, 0.0, least.roll_rad), cg_m).unwrap();
1258 assert_eq!(at, least, "the least margin is its plane's");
1259 let off = [0.0, right, pair_rad, pair_rad + right]
1262 .iter()
1263 .map(|plane| {
1264 let d = (least.roll_rad - plane).rem_euclid(std::f64::consts::PI);
1265 d.min(std::f64::consts::PI - d)
1266 })
1267 .fold(f64::INFINITY, f64::min);
1268 if off > 0.05 {
1269 off_fins += 1;
1270 }
1271 match least.margin_cal {
1272 None => assert!(
1273 planes.iter().any(|m| m.margin_cal.is_none()) || {
1274 let near = |m: &Margin| {
1276 m.slope_magnitude_sum_per_rad
1277 / m.normal_force_slope_per_rad.max(f64::MIN_POSITIVE)
1278 };
1279 planes.iter().map(near).fold(0.0, f64::max)
1280 > 0.99 * MARGIN_CONDITION_LIMIT
1281 }
1282 ),
1283 Some(cal) => {
1284 for m in &planes {
1285 let plane = m.margin_cal.expect("a margin in every plane");
1286 assert!(
1287 cal <= plane + 1e-12,
1288 "{cal} above {plane} at {}",
1289 m.roll_rad
1290 );
1291 }
1292 let scanned = planes
1293 .iter()
1294 .filter_map(|m| m.margin_cal)
1295 .fold(f64::INFINITY, f64::min);
1296 assert!(scanned - cal < 1e-5, "{cal} against the scan's {scanned}");
1297 }
1298 }
1299 }
1300 }
1301 }
1302 assert!(off_fins > 0, "no weak plane off the fins' planes");
1303 let aero = uneven_fins(0.03, 0.06, right);
1306 let axial = margin(&aero, &Flow::axial(0.3), 0.45)
1307 .unwrap()
1308 .margin_cal
1309 .unwrap();
1310 let least = weakest_margin(&aero, 0.3, 0.45)
1311 .unwrap()
1312 .margin_cal
1313 .unwrap();
1314 assert!(least < axial - 0.5, "{least} against {axial}");
1315 for name in [
1317 "synthetic-54mm-three-fin",
1318 "rocketpy-valetudo",
1319 "rocketpy-juno-iii",
1320 ] {
1321 let aero = AeroModel::new(&design(name).layout().unwrap()).unwrap();
1322 assert!(!aero.rolls(), "{name}");
1323 for mach in [0.0, 0.3, 1.2] {
1324 assert_eq!(
1325 weakest_margin(&aero, mach, 1.0).unwrap(),
1326 margin(&aero, &Flow::axial(mach), 1.0).unwrap(),
1327 "{name}"
1328 );
1329 }
1330 }
1331 }
1332
1333 #[test]
1334 fn ordinary_rockets_keep_their_margin() {
1335 let mut worst: f64 = 0.0;
1338 for name in [
1339 "synthetic-54mm-three-fin",
1340 "rocketpy-valetudo",
1341 "rocketpy-calisto-tests-motor-at-minus-1.373",
1342 "rocketpy-ndrt-2020-nose-to-tail",
1343 "rocketpy-prometheus-2022-generic-motor",
1344 "rocketpy-juno-iii",
1345 "synthetic-two-stage-75mm-54mm",
1346 "mil-hdbk-762-sample-rocket",
1347 "rocketpy-bella-lui",
1348 "rocketpy-calisto-getting-started-motor-at-minus-1.255",
1349 "rocketpy-cavour",
1350 "wind-tunnel-arcas-robin-long",
1351 "wind-tunnel-arcas-robin-short",
1352 ] {
1353 let aero = AeroModel::new(&design(name).layout().unwrap()).unwrap();
1354 for mach in [0.0, 0.3, 0.8, 1.2, 2.0] {
1355 for alpha_deg in [0.0, 5.0, 10.0, 20.0] {
1356 let flow = Flow::new(mach, f64::to_radians(alpha_deg), 0.0);
1357 let m = margin(&aero, &flow, 0.0).unwrap();
1358 assert!(m.margin_cal.is_some(), "{name} at {mach}, {alpha_deg}°");
1359 worst = worst.max(m.slope_magnitude_sum_per_rad / m.normal_force_slope_per_rad);
1360 }
1361 }
1362 }
1363 assert!(worst < 1.5, "{worst}");
1365 }
1366
1367 #[test]
1368 fn a_pure_couple_keeps_its_moment() {
1369 let material = json!({"name": "test", "density": {"kind": "bulk", "kg_m3": 1000.0}});
1374 let tube = |length_m: f64| {
1375 json!({"body_tube": {"length_m": length_m, "outer_radius_m": 0.05,
1376 "thickness_m": 0.005, "material": material}})
1377 };
1378 let rocket: Rocket = serde_json::from_value(json!({
1379 "name": "",
1380 "stages": [{"id": "stage", "name": "", "components": [
1381 {"id": "nose", "name": "", "part": {"nose_cone": {
1382 "shape": {"kind": "conical"}, "length_m": 0.3, "base_radius_m": 0.05,
1383 "wall": {"kind": "filled"}, "shoulder": null, "material": material}}},
1384 {"id": "tube", "name": "", "part": tube(0.5)},
1385 {"id": "flare", "name": "", "part": {"transition": {
1386 "shape": {"kind": "conical"}, "length_m": 0.2, "fore_radius_m": 0.03,
1387 "aft_radius_m": 0.05, "wall": {"kind": "filled"}, "material": material}}},
1388 {"id": "tail", "name": "", "part": tube(0.3)}
1389 ]}],
1390 "reference_diameter": {"kind": "maximum"},
1391 "configurations": []
1392 }))
1393 .unwrap();
1394 let aero = AeroModel::new(&rocket.layout().unwrap()).unwrap();
1395 let cg_m = 0.6;
1396 let couple = -1.28 * 0.8 + 1.28 * (0.8 + 0.2 / 3.0 * (1.0 + 1.0 / 1.6));
1397 let m = margin(&aero, &Flow::axial(0.0), cg_m).unwrap();
1398 close(m.normal_force_slope_per_rad, 2.0, 1e-12, "net slope");
1399 let cp = (2.0 * 0.2 + couple) / 2.0;
1400 close(m.margin_cal.unwrap(), (cp - cg_m) / 0.1, 1e-9, "margin");
1401 let moment = -(2.0 * 0.2 + couple - 2.0 * cg_m) / 0.1;
1402 close(m.pitch_moment_slope_per_rad, moment, 1e-9, "C_mα");
1403 close(
1404 m.pitch_moment_slope_per_rad,
1405 -2.0 * m.margin_cal.unwrap(),
1406 1e-12,
1407 "−C_Nα · margin",
1408 );
1409 }
1410
1411 #[test]
1412 fn a_normal_force_table_gives_its_own_margin() {
1413 use hpr_aero::{NormalForceColumn, NormalForceTable};
1416 use hpr_core::interp::{Extrapolation, Interpolation, Table1D};
1417 let constant = |y: f64| {
1418 Table1D::new(
1419 vec![0.0, 2.0],
1420 vec![y, y],
1421 Interpolation::Linear,
1422 Extrapolation::Clamp,
1423 )
1424 .unwrap()
1425 };
1426 let table = NormalForceTable::new(vec![NormalForceColumn::new(
1427 0.0,
1428 constant(10.0),
1429 constant(1.2),
1430 )])
1431 .unwrap();
1432 let sim = valetudo(Environment::standard(site()).unwrap(), capped(60.0))
1433 .with_normal_force_table(table)
1434 .unwrap();
1435 let d = sim.aero().reference_diameter_m();
1436 let m = margin(sim.aero(), &Flow::axial(0.3), 0.8).unwrap();
1437 close(m.normal_force_slope_per_rad, 10.0, 1e-12, "slope");
1438 assert_eq!(m.slope_magnitude_sum_per_rad, m.normal_force_slope_per_rad);
1439 close(m.margin_cal.unwrap(), (1.2 - 0.8) / d, 1e-12, "margin");
1440 close(
1441 m.pitch_moment_slope_per_rad,
1442 -10.0 * (1.2 - 0.8) / d,
1443 1e-12,
1444 "C_mα",
1445 );
1446 }
1447
1448 #[test]
1449 fn peak_acceleration_is_analytic_and_excludes_opening_shock() {
1450 let sim = valetudo(analytic_environment(UniformAir::vacuum(), G), capped(4.0));
1456 let (_, metrics) = fly(&sim);
1457 let assembly = sim.assembly();
1458 let placed = &assembly.motors[0];
1459 let motor = &placed.mounted.motor;
1460 let burnout_s = motor.burnout_time_s();
1461 let h = 1e-5;
1462 let mut knots: Vec<f64> = motor
1463 .curve()
1464 .times_s()
1465 .iter()
1466 .copied()
1467 .filter(|t| *t > 0.0 && *t < burnout_s)
1468 .collect();
1469 knots.insert(0, 0.0);
1470 knots.push(burnout_s);
1471 let props = |t: f64| assembly.mass_properties(t);
1472 let by_hand = |t: f64, a: f64, b: f64| {
1474 let c = t.clamp(a + h, b - h);
1475 let (m, r) = (props(t).mass_kg, props(t).cg_m.z);
1476 let r_mid = props(c).cg_m.z;
1477 let r2 = (props(c + h).cg_m.z - 2.0 * r_mid + props(c - h).cg_m.z) / (h * h);
1478 let r1 = (props(c + h).cg_m.z - props(c - h).cg_m.z) / (2.0 * h) + (t - c) * r2;
1479 let mdot = -motor.state(t).mass_flow_kg_s;
1480 let mddot = -(motor.state(c + h).mass_flow_kg_s - motor.state(c - h).mass_flow_kg_s)
1481 / (2.0 * h);
1482 let thrust = motor.thrust_at_pressure_n(t, 0.0);
1483 (thrust - m * r2 - 2.0 * mdot * r1 + mddot * (placed.nozzle_m.z - r)) / m - G
1484 };
1485 let mut expected: f64 = 0.0;
1486 for pair in knots.windows(2) {
1487 let (a, b) = (pair[0], pair[1]);
1488 let n = 2000;
1489 for i in 0..=n {
1490 let t = a + (b - a) * f64::from(i) / f64::from(n);
1491 expected = expected.max(by_hand(t, a, b));
1492 }
1493 }
1494 let peak = metrics.max_acceleration.unwrap();
1495 close(peak.value, expected, 1e-6, "peak acceleration");
1496 assert!(peak.time_s < burnout_s);
1497 let mut recorder =
1500 Recorder::new(vec![Channel::Time, Channel::Velocity], Some(0.01)).unwrap();
1501 sim.run(&mut recorder).unwrap();
1502 let rows = recorder.rows();
1503 let differenced = rows
1504 .windows(2)
1505 .map(|w| (w[1][3] - w[0][3]) / (w[1][0] - w[0][0]))
1506 .fold(0.0, f64::max);
1507 assert!(
1509 differenced < 0.99 * peak.value,
1510 "{differenced} vs {}",
1511 peak.value
1512 );
1513
1514 let air = || analytic_environment(UniformAir::sea_level(), G);
1518 let (_, plain) = fly(&valetudo(air(), capped(60.0)));
1519 let canopy = Device::new(
1520 "main",
1521 DeviceDrag::DragArea { cd_s_m2: 4.0 },
1522 Trigger::Time { time_s: 4.0 },
1523 );
1524 let early = valetudo(air(), capped(60.0))
1525 .with_recovery(vec![canopy])
1526 .unwrap();
1527 let (result, shocked) = fly(&early);
1528 let deployed = result.event(EventKind::Deployment(0)).unwrap().sample;
1529 assert!(deployed.airspeed_m_s > 100.0, "{}", deployed.airspeed_m_s);
1530 let boost = shocked.max_acceleration.unwrap();
1531 close(
1532 boost.value,
1533 plain.max_acceleration.unwrap().value,
1534 1e-12,
1535 "boost peak",
1536 );
1537 let shock = shocked.max_descent_acceleration.unwrap();
1538 assert!(
1539 shock.value > 3.0 * boost.value,
1540 "{} vs {}",
1541 shock.value,
1542 boost.value
1543 );
1544 assert_eq!(shock.time_s, deployed.time_s);
1545 assert_eq!(plain.max_descent_acceleration, None);
1546 }
1547
1548 #[test]
1549 fn unlanded_flight_has_no_ground_hit_speed_and_outputs_name_datum() {
1550 let environment = || Environment::standard(site()).unwrap();
1553 let sim = valetudo(environment(), capped(5.0));
1554 let (result, metrics) = fly(&sim);
1555 assert_eq!(result.termination, Termination::TimeCap);
1556 let summary = metrics.summary(&result, sim.environment()).unwrap();
1557 assert_eq!(summary.landing, None);
1558 assert_eq!(summary.ground_hit_speed_m_s(), None);
1559 assert_eq!(summary.apogee, None);
1560 assert_eq!(summary.max_descent_acceleration_m_s2, None);
1561 let json = serde_json::to_value(&summary).unwrap();
1562 assert_eq!(json["landing"], serde_json::Value::Null);
1563 assert_eq!(json["apogee"], serde_json::Value::Null);
1564
1565 let sim = valetudo(environment(), capped(600.0));
1569 let (result, metrics) = fly(&sim);
1570 let summary = metrics.summary(&result, sim.environment()).unwrap();
1571 let start = summary.launch_height_m.unwrap();
1574 let cg_station_m = -sim.assembly().mass_properties(0.0).cg_m.z;
1575 close(
1576 start,
1577 sim.guides().aft_station_m - cg_station_m,
1578 1e-9,
1579 "start",
1580 );
1581 let apogee = summary.apogee.unwrap();
1582 assert_eq!(apogee.gain_m, Some(apogee.height_above_ground_m - start));
1583 let landing = summary.landing.unwrap();
1584 assert_eq!(
1585 summary.ground_hit_speed_m_s(),
1586 Some(landing.ground_hit_speed_m_s)
1587 );
1588 assert!(result.final_sample.height_above_ground_m.abs() < 1e-6);
1589 let json = serde_json::to_value(&summary).unwrap();
1590 for field in ["launch_height_m", "apogee"] {
1591 assert!(
1592 json[field].is_number() || json[field].is_object(),
1593 "{field}"
1594 );
1595 }
1596 assert!(json["apogee"]["height_above_ground_m"].is_number());
1597 assert!(json["apogee"]["gain_m"].is_number());
1598 }
1599
1600 #[test]
1601 fn optimum_delay_independent_of_flown_delay() {
1602 let environment = || Environment::standard(site()).unwrap();
1606 let flown = |delay_s: f64| {
1607 valetudo(environment(), capped(600.0))
1608 .with_recovery(vec![Device::new(
1609 "main",
1610 DeviceDrag::DragArea { cd_s_m2: 1.0 },
1611 Trigger::Burnout { motor: 0, delay_s },
1612 )])
1613 .unwrap()
1614 };
1615 let (early, late) = (flown(1.0), flown(20.0));
1616 let bare = valetudo(environment(), capped(600.0));
1617 let bare_apogee_s = bare
1618 .run(&mut ())
1619 .unwrap()
1620 .event(EventKind::Apogee)
1621 .unwrap()
1622 .sample
1623 .time_s;
1624 let early_result = early.run(&mut ()).unwrap();
1625 let opened_s = early_result
1626 .event(EventKind::Deployment(0))
1627 .unwrap()
1628 .sample
1629 .time_s;
1630 assert!(
1631 opened_s < bare_apogee_s - 5.0,
1632 "{opened_s} vs {bare_apogee_s}"
1633 );
1634
1635 let a = optimum_delays(&early).unwrap().unwrap();
1636 let b = optimum_delays(&late).unwrap().unwrap();
1637 assert_eq!(a, b);
1638 assert_eq!(a.len(), 1);
1639 let burnout_s = bare.assembly().motors[0].mounted.motor.burnout_time_s();
1640 assert_eq!(a[0].burnout_s, burnout_s);
1641 close(a[0].apogee_s, bare_apogee_s, 1e-9, "apogee");
1642 close(a[0].delay_s, bare_apogee_s - burnout_s, 1e-9, "delay");
1643 assert_eq!(early.run(&mut ()).unwrap(), early_result);
1645 }
1646
1647 #[test]
1648 fn peaks_are_refined_inside_steps() {
1649 for (name, configuration) in [
1653 ("rocketpy-valetudo", "example"),
1654 ("rocketpy-juno-iii", "example"),
1655 ("rocketpy-calisto-tests-motor-at-minus-1.373", "example"),
1656 ] {
1657 let sim = Simulation::new(
1658 &design(name),
1659 configuration,
1660 Environment::standard(site()).unwrap(),
1661 Rail::vertical(5.0),
1662 capped(20.0),
1663 )
1664 .unwrap();
1665 let mut recorder = Recorder::new(
1666 vec![Channel::Time, Channel::DynamicPressure, Channel::Mach],
1667 Some(1e-3),
1668 )
1669 .unwrap();
1670 sim.run(&mut recorder).unwrap();
1671 let (result, metrics) = fly(&sim);
1672 let summary = metrics.summary(&result, sim.environment()).unwrap();
1673 for (column, peak) in [
1674 (1, summary.max_dynamic_pressure_pa.unwrap()),
1675 (2, summary.max_mach.unwrap()),
1676 ] {
1677 let recorded = recorder
1678 .rows()
1679 .iter()
1680 .map(|row| row[column])
1681 .fold(0.0, f64::max);
1682 assert!(
1683 peak.value >= recorded,
1684 "{name}: {} < {recorded}",
1685 peak.value
1686 );
1687 assert!(
1688 peak.value - recorded < 1e-4 * peak.value,
1689 "{name}: {}",
1690 peak.value
1691 );
1692 }
1693 if name == "rocketpy-valetudo" {
1694 let (q, mach) = (
1698 summary.max_dynamic_pressure_pa.unwrap(),
1699 summary.max_mach.unwrap(),
1700 );
1701 let speed = summary.max_speed_m_s.unwrap();
1702 assert!(q.time_s < speed.time_s, "{} vs {}", q.time_s, speed.time_s);
1703 assert!(
1704 speed.time_s < mach.time_s,
1705 "{} vs {}",
1706 speed.time_s,
1707 mach.time_s
1708 );
1709 assert!(q.height_above_ground_m > 0.0);
1710 }
1711 }
1712 }
1713
1714 #[test]
1715 fn landings_are_placed_on_the_ellipsoid() {
1716 let wind = ConstantWind::new(6.0, 1.5 * std::f64::consts::PI).unwrap();
1720 let canopy = Device::new(
1721 "main",
1722 DeviceDrag::DragArea { cd_s_m2: 0.5 },
1723 Trigger::Apogee,
1724 );
1725 let sim = Simulation::new(
1726 &design("rocketpy-valetudo"),
1727 "example",
1728 windy_environment(wind),
1729 Rail::vertical(3.0),
1730 capped(600.0),
1731 )
1732 .unwrap()
1733 .with_recovery(vec![canopy])
1734 .unwrap();
1735 let (result, metrics) = fly(&sim);
1736 let summary = metrics.summary(&result, sim.environment()).unwrap();
1737 let landing = summary.landing.unwrap();
1738 assert!(landing.east_m > 100.0, "{}", landing.east_m);
1739 let place = site();
1740 let (a, f) = (6_378_137.0, 1.0 / 298.257_223_563);
1741 let e2 = f * (2.0 - f);
1742 let s = place.latitude_rad.sin();
1743 let w = (1.0 - e2 * s * s).sqrt();
1744 let n = a / w + place.height_m;
1745 let m = a * (1.0 - e2) / (w * w * w) + place.height_m;
1746 let lat = place.latitude_rad + landing.north_m / m;
1747 let lon = place.longitude_rad + landing.east_m / (n * place.latitude_rad.cos());
1748 let second_order = (landing.distance_m / a).powi(2);
1749 assert!((landing.latitude_deg.to_radians() - lat).abs() < 10.0 * second_order);
1750 assert!((landing.longitude_deg.to_radians() - lon).abs() < 10.0 * second_order);
1751 close(
1752 landing.distance_m,
1753 landing.east_m.hypot(landing.north_m),
1754 1e-15,
1755 "distance",
1756 );
1757 assert_eq!(landing.body, None);
1758 assert!(landing.descent_rate_m_s > 0.0);
1759 }
1760
1761 #[test]
1762 fn stability_is_kept_from_rail_exit_to_apogee() {
1763 let sim = valetudo(Environment::standard(site()).unwrap(), capped(600.0));
1764 let (result, metrics) = fly(&sim);
1765 let summary = metrics.summary(&result, sim.environment()).unwrap();
1766 let series = metrics.stability();
1767 let exit_s = result.event(EventKind::RailExit).unwrap().sample.time_s;
1768 let apogee_s = summary.apogee.unwrap().time_s;
1769 assert_eq!(series.first().unwrap().time_s, exit_s);
1770 assert_eq!(series.last().unwrap().time_s, apogee_s);
1771 assert!(series.windows(2).all(|w| w[0].time_s < w[1].time_s));
1772 let first = series[0];
1775 let lit = sim.assembly().ignition_times_s(|_| None);
1776 let cg_m = -sim.assembly().mass_properties_lit(exit_s, &lit).cg_m.z;
1777 close(first.cg_station_m, cg_m, 1e-12, "center of mass");
1778 let cp_m = sim
1779 .aero()
1780 .normal_force(&Flow::axial(0.0))
1781 .unwrap()
1782 .cp_station_m
1783 .unwrap();
1784 let d = sim.aero().reference_diameter_m();
1785 close(
1786 first.static_margin.margin_cal.unwrap(),
1787 (cp_m - cg_m) / d,
1788 1e-12,
1789 "static margin",
1790 );
1791 assert_eq!(summary.rail_exit_stability, Some(first));
1792 let min = summary.min_static_margin_cal.unwrap();
1793 assert!(
1794 series
1795 .iter()
1796 .all(|s| s.static_margin.margin_cal.unwrap() >= min.value)
1797 );
1798 let least = summary.min_flight_margin_cal.unwrap();
1800 assert!(
1801 series
1802 .iter()
1803 .all(|s| s.flight_margin.margin_cal.unwrap() >= least.value)
1804 );
1805 assert!(least.value > 3.0, "{least:?}");
1806
1807 let windy = valetudo(
1811 windy_environment(ConstantWind::new(5.0, 1.5 * std::f64::consts::PI).unwrap()),
1812 capped(600.0),
1813 );
1814 let (result, metrics) = fly(&windy);
1815 let summary = metrics.summary(&result, windy.environment()).unwrap();
1816 let exit = result.event(EventKind::RailExit).unwrap().sample;
1817 assert!(
1818 exit.angle_of_attack_rad > 0.2,
1819 "{}",
1820 exit.angle_of_attack_rad
1821 );
1822 let cg_m = -windy
1823 .assembly()
1824 .mass_properties_lit(exit.time_s, &lit)
1825 .cg_m
1826 .z;
1827 let expected = margin(windy.aero(), &Flow::axial(exit.mach), cg_m).unwrap();
1828 let got = summary.rail_exit_stability.unwrap().flight_margin;
1829 assert_eq!(got.angle_of_attack_rad, 0.0);
1830 close(got.mach, exit.mach, 1e-12, "Mach number");
1831 close(
1832 got.margin_cal.unwrap(),
1833 expected.margin_cal.unwrap(),
1834 1e-12,
1835 "flight margin",
1836 );
1837 }
1838
1839 #[test]
1840 fn least_margins_do_not_depend_on_where_steps_end() {
1841 let fine = |settings: FlightSettings| FlightSettings {
1847 method: Method::DormandPrince54(Adaptive {
1848 max_step_s: Some(1e-3),
1849 ..Adaptive::default()
1850 }),
1851 ..settings
1852 };
1853 let tilted = Rail {
1854 elevation_rad: 84.0_f64.to_radians(),
1855 ..Rail::vertical(2.0)
1856 };
1857 let calm = || Environment::standard(site()).unwrap();
1858 let wind =
1859 || windy_environment(ConstantWind::new(5.0, 1.5 * std::f64::consts::PI).unwrap());
1860 let cases = [
1861 ("rocketpy-valetudo", calm(), Rail::vertical(3.0), 15.0),
1862 ("rocketpy-valetudo", calm(), tilted, 15.0),
1863 ("rocketpy-valetudo", wind(), Rail::vertical(3.0), 15.0),
1864 (
1865 "rocketpy-prometheus-2022-generic-motor",
1866 calm(),
1867 Rail::vertical(5.0),
1868 60.0,
1869 ),
1870 ];
1871 for (name, environment, rail, max_time_s) in cases {
1872 let least = |settings: FlightSettings| {
1873 let sim = Simulation::new(
1874 &design(name),
1875 "example",
1876 environment.clone(),
1877 rail,
1878 settings,
1879 )
1880 .unwrap();
1881 let (result, metrics) = fly(&sim);
1882 let summary = metrics.summary(&result, sim.environment()).unwrap();
1883 let series = metrics.stability().to_vec();
1884 (
1885 summary.min_static_margin_cal.unwrap(),
1886 summary.min_flight_margin_cal.unwrap(),
1887 series,
1888 )
1889 };
1890 let (coarse_static, coarse_flight, series) = least(capped(max_time_s));
1891 assert_eq!(coarse_flight.time_s, series[0].time_s, "{name}");
1892 assert_eq!(coarse_static.time_s, series[0].time_s, "{name}");
1893 let (fine_static, fine_flight, _) = least(fine(capped(max_time_s)));
1894 for (coarse, fine, what) in [
1895 (coarse_static, fine_static, "static"),
1896 (coarse_flight, fine_flight, "flight"),
1897 ] {
1898 assert!(
1899 (coarse.value - fine.value).abs() < 1e-6,
1900 "{name} {what}: {coarse:?} against {fine:?}"
1901 );
1902 }
1903 }
1904 }
1905
1906 struct MarginStep {
1908 margin_cal: fn(f64) -> Option<f64>,
1909 }
1910
1911 impl FlightStep for MarginStep {
1912 fn phase(&self) -> Phase {
1913 Phase::Free
1914 }
1915 fn start_s(&self) -> f64 {
1916 0.0
1917 }
1918 fn end_s(&self) -> f64 {
1919 1.0
1920 }
1921 fn state_at(&self, _t_s: f64) -> crate::state::State {
1922 unreachable!("the margin search reads only the stability")
1923 }
1924 fn sample(&self, _t_s: f64) -> Result<Sample, SimError> {
1925 unreachable!("the margin search reads only the stability")
1926 }
1927 fn stability(&self, t_s: f64) -> Result<Stability, SimError> {
1928 let margin_cal = (self.margin_cal)(t_s);
1929 let margin = Margin {
1930 mach: 0.0,
1931 angle_of_attack_rad: 0.0,
1932 roll_rad: 0.0,
1933 normal_force_slope_per_rad: 1.0,
1934 slope_magnitude_sum_per_rad: 1.0,
1935 pitch_moment_slope_per_rad: -margin_cal.unwrap_or(0.0),
1936 cp_station_m: margin_cal,
1937 margin_cal,
1938 };
1939 Ok(Stability {
1940 time_s: t_s,
1941 height_above_ground_m: 100.0 * t_s,
1942 dynamic_pressure_pa: 0.0,
1943 cg_station_m: 0.0,
1944 reference_diameter_m: 1.0,
1945 static_margin: margin,
1946 flight_margin: margin,
1947 })
1948 }
1949 }
1950
1951 fn least_of(margin_cal: fn(f64) -> Option<f64>) -> Option<Peak> {
1952 let step = MarginStep { margin_cal };
1953 let entries = [0.0, 0.5, 1.0].map(|t| step.stability(t).unwrap());
1954 least_margin(&step, 0.0, 1.0, &entries, flight_of).unwrap()
1955 }
1956
1957 #[test]
1958 fn a_least_margin_between_step_ends_is_found() {
1959 let least = least_of(|t| Some(2.0 + (t - 0.37).powi(2))).unwrap();
1962 close(least.value, 2.0, 1e-15, "least");
1963 assert!((least.time_s - 0.37).abs() < 1e-7, "{least:?}");
1964 close(least.height_above_ground_m, 37.0, 1e-5, "height");
1965 let least = least_of(|t| Some(3.0 - t)).unwrap();
1967 assert_eq!((least.value, least.time_s), (2.0, 1.0));
1968 let least = least_of(|t| (t != 0.5).then_some(2.0 + (t - 0.37).powi(2))).unwrap();
1970 assert_eq!(least.time_s, 0.0);
1971 assert_eq!(least_of(|_| None), None);
1972 }
1973
1974 #[test]
1975 fn a_summary_needs_the_flight_it_watched() {
1976 let refused = |summary: Result<FlightSummary, SimError>, steps: u64| match summary {
1980 Err(SimError::Domain { what, value }) => {
1981 assert!(what.starts_with("steps this watcher saw"), "{what}");
1982 assert_eq!(value, steps as f64);
1983 }
1984 other => panic!("{other:?}"),
1985 };
1986 let sim = valetudo(Environment::standard(site()).unwrap(), capped(20.0));
1987 let (result, mut metrics) = fly(&sim);
1988 let steps = result.stats.accepted_steps;
1989 refused(FlightMetrics::new().summary(&result, sim.environment()), 0);
1990 let mut early = result.clone();
1992 early.final_sample.time_s -= 1.0;
1993 refused(metrics.summary(&early, sim.environment()), steps);
1994 let other = valetudo(Environment::standard(site()).unwrap(), capped(15.0));
1995 let other_result = other.run(&mut metrics).unwrap();
1996 refused(
1997 metrics.summary(&other_result, other.environment()),
1998 steps + other_result.stats.accepted_steps,
1999 );
2000 metrics.clear();
2001 let other_result = other.run(&mut metrics).unwrap();
2002 let summary = metrics.summary(&other_result, other.environment()).unwrap();
2003 assert!(summary.rail_exit_stability.is_some());
2004
2005 let burnout = result.event(EventKind::Burnout).unwrap().sample;
2006 let mut aloft = FlightMetrics::new();
2007 let free = sim
2008 .run_free(burnout.time_s, burnout.state, &mut aloft)
2009 .unwrap();
2010 let summary = aloft.summary(&free, sim.environment()).unwrap();
2011 assert_eq!(summary.launch_height_m, None);
2012 assert_eq!(summary.apogee.unwrap().gain_m, None);
2013 assert_eq!(summary.rail_exit_stability, None);
2014
2015 let short = valetudo(Environment::standard(site()).unwrap(), capped(5.0));
2017 let (short_result, mut reused) = fly(&short);
2018 let apogee = result.event(EventKind::Apogee).unwrap().sample;
2019 let free = sim
2020 .run_free(apogee.time_s, apogee.state, &mut reused)
2021 .unwrap();
2022 refused(
2023 reused.summary(&free, sim.environment()),
2024 short_result.stats.accepted_steps + free.stats.accepted_steps,
2025 );
2026
2027 let mut falling = FlightMetrics::new();
2029 let mut state = apogee.state;
2030 state.velocity_enu_m_s.z = -1.0;
2031 let free = sim.run_free(apogee.time_s, state, &mut falling).unwrap();
2032 falling.summary(&free, sim.environment()).unwrap();
2033 assert!(falling.stability().is_empty());
2034
2035 let stepless = valetudo(
2038 Environment::standard(site()).unwrap(),
2039 FlightSettings {
2040 step_limit: 0,
2041 ..capped(20.0)
2042 },
2043 );
2044 let (result, metrics) = fly(&stepless);
2045 assert_eq!(result.stats.accepted_steps, 0);
2046 let summary = metrics.summary(&result, stepless.environment()).unwrap();
2047 assert_eq!(summary.termination, Termination::StepLimit);
2048 assert_eq!(summary.max_speed_m_s, None);
2049 let cap_s = f64::from_bits(5.0_f64.to_bits() + 3);
2050 let at_cap = valetudo(Environment::standard(site()).unwrap(), capped(cap_s))
2051 .with_recovery(vec![Device::new(
2052 "main",
2053 DeviceDrag::canopy(crate::recovery::CanopyType::FlatCircular, 1.0),
2054 Trigger::Time { time_s: 5.0 },
2055 )])
2056 .unwrap();
2057 let (result, metrics) = fly(&at_cap);
2058 assert_eq!(result.termination, Termination::TimeCap);
2059 assert_eq!(result.final_sample.time_s, cap_s);
2060 metrics.summary(&result, at_cap.environment()).unwrap();
2061 }
2062
2063 struct AngleStep {
2066 template: Sample,
2067 span: (f64, f64),
2068 phase: Phase,
2069 dynamic_pressure_pa: fn(f64) -> f64,
2070 angle_of_attack_rad: fn(f64) -> f64,
2071 }
2072
2073 impl FlightStep for AngleStep {
2074 fn phase(&self) -> Phase {
2075 self.phase
2076 }
2077 fn start_s(&self) -> f64 {
2078 self.span.0
2079 }
2080 fn end_s(&self) -> f64 {
2081 self.span.1
2082 }
2083 fn state_at(&self, _t_s: f64) -> crate::state::State {
2084 unreachable!("the watcher reads samples and stability")
2085 }
2086 fn sample(&self, t_s: f64) -> Result<Sample, SimError> {
2087 Ok(Sample {
2088 time_s: t_s,
2089 dynamic_pressure_pa: (self.dynamic_pressure_pa)(t_s),
2090 angle_of_attack_rad: (self.angle_of_attack_rad)(t_s),
2091 ..self.template
2092 })
2093 }
2094 fn stability(&self, t_s: f64) -> Result<Stability, SimError> {
2095 MarginStep {
2096 margin_cal: |_| Some(2.0),
2097 }
2098 .stability(t_s)
2099 }
2100 }
2101
2102 fn largest_angle(
2105 ends: &[f64],
2106 dynamic_pressure_pa: fn(f64) -> f64,
2107 angle_of_attack_rad: fn(f64) -> f64,
2108 ) -> Option<Peak> {
2109 let sim = valetudo(Environment::standard(site()).unwrap(), capped(2.0));
2110 let template = fly(&sim).0.final_sample;
2111 let mut metrics = FlightMetrics::new();
2112 let mut start = 0.0;
2113 for &end in ends {
2114 let step = AngleStep {
2115 template,
2116 span: (start, end),
2117 phase: Phase::Free,
2118 dynamic_pressure_pa,
2119 angle_of_attack_rad,
2120 };
2121 metrics.step(&step).unwrap();
2122 start = end;
2123 }
2124 metrics.event(&FlightEvent {
2126 kind: EventKind::Apogee,
2127 sample: template,
2128 });
2129 let after = AngleStep {
2130 template,
2131 span: (start, start + 1.0),
2132 phase: Phase::Free,
2133 dynamic_pressure_pa: |_| 1e5,
2134 angle_of_attack_rad: |_| 3.0,
2135 };
2136 metrics.step(&after).unwrap();
2137 metrics.max_angle_of_attack
2138 }
2139
2140 #[test]
2141 fn the_angle_of_attack_counts_from_just_past_one_second_after_the_rail() {
2142 let spike = |t: f64| if t == 1.0 { 40_f64 } else { 20.0 }.to_radians();
2145 let peak = largest_angle(&[0.5, 1.0, 2.0], |_| 1e3, spike).unwrap();
2146 assert_eq!((peak.value, peak.time_s), (20_f64.to_radians(), 1.5));
2147 let spike = |t: f64| if t == 1.0_f64.next_up() { 40_f64 } else { 20.0 }.to_radians();
2149 let ends = [0.5, 1.0_f64.next_up(), 2.0];
2150 let peak = largest_angle(&ends, |_| 1e3, spike).unwrap();
2151 assert_eq!(
2152 (peak.value, peak.time_s),
2153 (40_f64.to_radians(), 1.0_f64.next_up())
2154 );
2155 assert_eq!(largest_angle(&[0.5, 1.0], |_| 1e3, spike), None);
2157 }
2158
2159 #[test]
2160 fn the_angle_of_attack_counts_only_with_a_fifth_of_the_weight_in_normal_force() {
2161 let angle = |t: f64| t / 10.0;
2165 let peak = largest_angle(&[1.0, 2.0, 3.0], |_| 1000.0, angle).unwrap();
2166 assert_eq!(peak.time_s, 3.0);
2167 let fading = |t: f64| if t > 2.0 { 1e-3 } else { 1000.0 };
2168 let peak = largest_angle(&[1.0, 2.0, 3.0], fading, angle).unwrap();
2169 assert_eq!(peak.time_s, 2.0);
2170 let broken = |t: f64| if t == 1.5 { f64::NAN } else { t / 10.0 };
2172 let peak = largest_angle(&[1.0, 2.0, 3.0], |_| 1000.0, broken).unwrap();
2173 assert!(peak.value.is_nan() && peak.time_s == 1.5, "{peak:?}");
2174 }
2175
2176 #[derive(Debug)]
2178 struct Layer {
2179 height_msl_m: f64,
2180 speed_m_s: f64,
2181 }
2182
2183 impl hpr_atmos::Wind for Layer {
2184 fn wind(&self, height_msl_m: f64) -> Result<hpr_atmos::WindSample, hpr_atmos::AtmosError> {
2185 let speed = if height_msl_m < self.height_msl_m {
2186 0.0
2187 } else {
2188 self.speed_m_s
2189 };
2190 Ok(hpr_atmos::WindSample {
2191 velocity_enu_m_s: hpr_atmos::wind::velocity_from_speed_direction(speed, 0.0),
2192 extrapolated: None,
2193 })
2194 }
2195 }
2196
2197 #[derive(Default)]
2200 struct Angles {
2201 climbing: Vec<(f64, f64, f64)>,
2202 past_apogee: bool,
2203 }
2204
2205 impl Observer for Angles {
2206 fn step(&mut self, step: &dyn FlightStep) -> Result<(), SimError> {
2207 let sample = step.sample(step.end_s())?;
2208 if !self.past_apogee && step.phase() == Phase::Free {
2209 let stability = step.stability(step.end_s())?;
2210 let force_n = envelope::normal_force_n(
2211 sample.dynamic_pressure_pa,
2212 stability.reference_diameter_m,
2213 stability.flight_margin.normal_force_slope_per_rad,
2214 HIGH_ANGLE_OF_ATTACK_RAD,
2215 );
2216 let weight_n = sample.mass_kg * hpr_core::gravity::STANDARD_GRAVITY_MPS2;
2217 self.climbing.push((
2218 sample.time_s,
2219 sample.angle_of_attack_rad,
2220 force_n / weight_n,
2221 ));
2222 }
2223 Ok(())
2224 }
2225 fn event(&mut self, event: &FlightEvent) {
2226 self.past_apogee |= event.kind == EventKind::Apogee;
2227 }
2228 }
2229
2230 #[test]
2231 fn a_tilted_calm_climb_passes_15_degrees_only_with_too_little_force() {
2232 let tilted = Rail {
2237 elevation_rad: 84.0_f64.to_radians(),
2238 ..Rail::vertical(3.0)
2239 };
2240 let sim = Simulation::new(
2241 &design("rocketpy-valetudo"),
2242 "example",
2243 Environment::standard(site()).unwrap(),
2244 tilted,
2245 FlightSettings::default(),
2246 )
2247 .unwrap();
2248 let mut watchers = (FlightMetrics::new(), Angles::default());
2249 let result = sim.run(&mut watchers).unwrap();
2250 let (metrics, angles) = watchers;
2251 let summary = metrics.summary(&result, sim.environment()).unwrap();
2252 let steep: Vec<_> = angles
2253 .climbing
2254 .iter()
2255 .filter(|(_, angle, _)| *angle > HIGH_ANGLE_OF_ATTACK_RAD)
2256 .collect();
2257 assert!(!steep.is_empty(), "the climb passes 15° before apogee");
2258 for (_, _, share) in &steep {
2259 assert!(*share < 0.15 * HIGH_ANGLE_MIN_FORCE_SHARE, "{steep:?}");
2260 }
2261 let counted = summary.max_angle_of_attack_rad.unwrap();
2262 assert!(counted.value < HIGH_ANGLE_OF_ATTACK_RAD, "{counted:?}");
2263 assert!(summary.envelope_flags().is_empty());
2264 }
2265
2266 #[test]
2267 fn a_wind_layer_met_at_speed_raises_the_high_angle_flag() {
2268 let environment = Environment::standard(site()).unwrap().with_wind(Layer {
2271 height_msl_m: site().height_m + 300.0,
2272 speed_m_s: 30.0,
2273 });
2274 let sim = valetudo(environment, FlightSettings::default());
2275 let (result, metrics) = fly(&sim);
2276 let summary = metrics.summary(&result, sim.environment()).unwrap();
2277 let flags = summary.envelope_flags();
2278 let exit = summary.rail_exit_speed_m_s.unwrap();
2279 let [
2280 EnvelopeFlag::HighAngleOfAttack {
2281 angle_of_attack_rad,
2282 },
2283 ] = flags[..]
2284 else {
2285 panic!("{flags:?}");
2286 };
2287 assert!(angle_of_attack_rad.value > HIGH_ANGLE_OF_ATTACK_RAD);
2288 assert!(angle_of_attack_rad.time_s > exit.time_s + HIGH_ANGLE_GRACE_S);
2289 assert!(
2290 (angle_of_attack_rad.height_above_ground_m - 300.0).abs() < 50.0,
2291 "{angle_of_attack_rad:?}"
2292 );
2293 let calm = valetudo(
2295 Environment::standard(site()).unwrap(),
2296 FlightSettings::default(),
2297 );
2298 let (result, metrics) = fly(&calm);
2299 let summary = metrics.summary(&result, calm.environment()).unwrap();
2300 assert!(summary.envelope_flags().is_empty(), "{summary:?}");
2301 }
2302
2303 #[test]
2304 fn a_wind_layer_met_in_a_fast_coast_raises_the_high_angle_flag() {
2305 let environment = Environment::standard(site()).unwrap().with_wind(Layer {
2310 height_msl_m: site().height_m + 2000.0,
2311 speed_m_s: 40.0,
2312 });
2313 let sim = Simulation::new(
2314 &design("synthetic-two-stage-75mm-54mm"),
2315 "j760-i175",
2316 environment,
2317 Rail::vertical(3.0),
2318 FlightSettings::default(),
2319 )
2320 .unwrap();
2321 let (result, metrics) = fly(&sim);
2322 let summary = metrics.summary(&result, sim.environment()).unwrap();
2323 let flags: Vec<_> = summary
2324 .envelope_flags()
2325 .iter()
2326 .map(EnvelopeFlag::name)
2327 .collect();
2328 assert_eq!(flags, ["beyond_validated_range", "high_angle_of_attack"]);
2329 let angle = summary.max_angle_of_attack_rad.unwrap();
2330 assert!(
2331 (angle.height_above_ground_m - 2000.0).abs() < 100.0,
2332 "{angle:?}"
2333 );
2334 }
2335
2336 struct PoweredStep {
2340 template: Sample,
2341 span: (f64, f64),
2342 thrust_n: fn(f64) -> f64,
2343 margin_cal: fn(f64) -> Option<f64>,
2344 moment_slope_per_rad: Option<fn(f64) -> f64>,
2345 }
2346
2347 impl FlightStep for PoweredStep {
2348 fn phase(&self) -> Phase {
2349 Phase::Free
2350 }
2351 fn start_s(&self) -> f64 {
2352 self.span.0
2353 }
2354 fn end_s(&self) -> f64 {
2355 self.span.1
2356 }
2357 fn state_at(&self, _t_s: f64) -> crate::state::State {
2358 unreachable!("the watcher reads samples and stability")
2359 }
2360 fn sample(&self, t_s: f64) -> Result<Sample, SimError> {
2361 Ok(Sample {
2362 time_s: t_s,
2363 thrust_n: (self.thrust_n)(t_s),
2364 ..self.template
2365 })
2366 }
2367 fn stability(&self, t_s: f64) -> Result<Stability, SimError> {
2368 let mut stability = MarginStep {
2369 margin_cal: self.margin_cal,
2370 }
2371 .stability(t_s)?;
2372 if let Some(slope) = self.moment_slope_per_rad {
2373 stability.static_margin.pitch_moment_slope_per_rad = slope(t_s);
2374 stability.flight_margin.pitch_moment_slope_per_rad = slope(t_s);
2375 }
2376 Ok(stability)
2377 }
2378 }
2379
2380 fn least_powered(margin_cal: fn(f64) -> Option<f64>) -> (Option<Peak>, Option<Peak>) {
2384 let metrics = watch_powered(margin_cal, None);
2385 (metrics.min_powered_static_margin, metrics.min_static_margin)
2386 }
2387
2388 fn watch_powered(
2391 margin_cal: fn(f64) -> Option<f64>,
2392 moment_slope_per_rad: Option<fn(f64) -> f64>,
2393 ) -> FlightMetrics {
2394 let sim = valetudo(Environment::standard(site()).unwrap(), capped(2.0));
2395 let template = fly(&sim).0.final_sample;
2396 let mut metrics = FlightMetrics::new();
2397 let mut start = 0.0;
2398 for end in [0.5, 1.0, 2.0] {
2399 let step = PoweredStep {
2400 template,
2401 span: (start, end),
2402 thrust_n: |t| if t < 1.0 { 50.0 } else { 0.0 },
2403 margin_cal,
2404 moment_slope_per_rad,
2405 };
2406 metrics.step(&step).unwrap();
2407 start = end;
2408 }
2409 metrics
2410 }
2411
2412 #[test]
2413 fn the_powered_margin_counts_only_while_a_motor_burns() {
2414 let tiny = 2_f64.powi(-30);
2415 let names = |least: Option<Peak>| {
2416 envelope::flags(None, None, least, None)
2417 .iter()
2418 .map(EnvelopeFlag::name)
2419 .collect::<Vec<_>>()
2420 };
2421 let (powered, least) = least_powered(|t| Some(1.0 - 2_f64.powi(-30) - t));
2424 let powered = powered.unwrap();
2425 assert_eq!((powered.value, powered.time_s), (-tiny, 1.0));
2426 assert_eq!(names(Some(powered)), ["unstable_under_power"]);
2427 assert_eq!(least.unwrap().time_s, 2.0);
2428 let (powered, least) = least_powered(|t| Some(1.0 + 2_f64.powi(-30) - t));
2431 let powered = powered.unwrap();
2432 assert_eq!((powered.value, powered.time_s), (tiny, 1.0));
2433 assert!(names(Some(powered)).is_empty());
2434 assert!(least.unwrap().value < -0.99, "{least:?}");
2435 let (powered, _) = least_powered(|t| Some((t - 0.37).powi(2) - 0.01));
2437 let powered = powered.unwrap();
2438 assert!((powered.value + 0.01).abs() < 1e-15, "{powered:?}");
2439 assert!((powered.time_s - 0.37).abs() < 1e-7, "{powered:?}");
2440 let (powered, _) = least_powered(|t| Some(if t == 0.25 { f64::NAN } else { 2.0 - t }));
2442 let powered = powered.unwrap();
2443 assert!(
2444 powered.value.is_nan() && powered.time_s == 0.25,
2445 "{powered:?}"
2446 );
2447 assert_eq!(names(Some(powered)), ["unstable_under_power"]);
2448 let (powered, _) = least_powered(|t| Some(if t == 1.5 { f64::NAN } else { 2.0 - t }));
2449 assert_eq!(powered.unwrap().value, 1.0);
2450 let (powered, _) = least_powered(|t| (t > 1.0).then_some(-1.0));
2452 assert_eq!(powered, None);
2453 let (powered, _) = least_powered(|t| Some(if t < 0.2 { 1e-13 } else { -1e-13 }));
2456 let powered = powered.unwrap();
2457 assert_eq!((powered.value, powered.time_s), (-1e-13, 0.25));
2458 assert_eq!(names(Some(powered)), ["unstable_under_power"]);
2459 }
2460
2461 #[test]
2462 fn a_moment_slope_without_a_margin_counts_only_while_a_motor_burns() {
2463 const TINY: f64 = 1.0 / 1_073_741_824.0;
2465 let flag_names = |metrics: &FlightMetrics| {
2466 envelope::flags(
2467 None,
2468 None,
2469 metrics.min_powered_static_margin,
2470 metrics.max_powered_moment_slope,
2471 )
2472 .iter()
2473 .map(EnvelopeFlag::name)
2474 .collect::<Vec<_>>()
2475 };
2476 let metrics = watch_powered(|_| None, Some(|t| if t == 0.75 { TINY } else { -1.0 }));
2479 let peak = metrics.max_powered_moment_slope.unwrap();
2480 assert_eq!((peak.value, peak.time_s), (TINY, 0.75));
2481 assert_eq!(flag_names(&metrics), ["unstable_without_margin"]);
2482 let metrics = watch_powered(|_| None, Some(|t| if t == 0.75 { -TINY } else { -1.0 }));
2484 let peak = metrics.max_powered_moment_slope.unwrap();
2485 assert_eq!((peak.value, peak.time_s), (-TINY, 0.75));
2486 assert!(flag_names(&metrics).is_empty());
2487 let metrics = watch_powered(|_| None, Some(|t| if t > 1.0 { TINY } else { -1.0 }));
2489 assert_eq!(metrics.max_powered_moment_slope.unwrap().value, -1.0);
2490 assert!(flag_names(&metrics).is_empty());
2491 let metrics = watch_powered(
2494 |_| None,
2495 Some(|t| {
2496 if t == 0.25 || t == 0.75 {
2497 f64::NAN
2498 } else {
2499 5.0 * t
2500 }
2501 }),
2502 );
2503 let peak = metrics.max_powered_moment_slope.unwrap();
2504 assert!(peak.value.is_nan() && peak.time_s == 0.25, "{peak:?}");
2505 assert_eq!(flag_names(&metrics), ["unstable_without_margin"]);
2506 let metrics = watch_powered(|t| (t != 0.75).then_some(1.0), Some(|_| 3.0));
2509 let peak = metrics.max_powered_moment_slope.unwrap();
2510 assert_eq!((peak.value, peak.time_s), (3.0, 0.75));
2511 let metrics = watch_powered(|_| Some(1.0), Some(|_| 3.0));
2512 assert_eq!(metrics.max_powered_moment_slope, None);
2513 assert!(flag_names(&metrics).is_empty());
2514 let metrics = watch_powered(|t| (t != 0.75).then_some(-1.0), Some(|_| 3.0));
2516 assert_eq!(flag_names(&metrics), ["unstable_under_power"]);
2517 }
2518
2519 #[test]
2520 fn a_rocket_without_fins_is_unstable_under_power() {
2521 let mut rocket = serde_json::to_value(design("rocketpy-valetudo")).unwrap();
2525 let children = rocket["stages"][0]["components"][1]["children"]
2526 .as_array_mut()
2527 .unwrap();
2528 let before = children.len();
2529 children.retain(|child| child["id"] != "fins-1");
2530 assert_eq!(children.len(), before - 1);
2531 let rocket: Rocket = serde_json::from_value(rocket).unwrap();
2532 let sim = Simulation::new(
2533 &rocket,
2534 "example",
2535 Environment::standard(site()).unwrap(),
2536 Rail::vertical(3.0),
2537 capped(2.0),
2538 )
2539 .unwrap();
2540 let (result, metrics) = fly(&sim);
2541 let summary = metrics.summary(&result, sim.environment()).unwrap();
2542 let powered = summary.min_powered_static_margin_cal.unwrap();
2543 assert!(powered.value < -1.0, "{powered:?}");
2544 let burnout_s = sim.assembly().motors[0].mounted.motor.burnout_time_s();
2545 assert!(powered.time_s <= burnout_s, "{powered:?}");
2546 let flags = summary.envelope_flags();
2547 assert_eq!(
2548 flags[0],
2549 EnvelopeFlag::UnstableUnderPower {
2550 static_margin_cal: powered
2551 }
2552 );
2553 let sim = valetudo(Environment::standard(site()).unwrap(), capped(2.0));
2555 let (result, metrics) = fly(&sim);
2556 let summary = metrics.summary(&result, sim.environment()).unwrap();
2557 let powered = summary.min_powered_static_margin_cal.unwrap();
2558 assert_eq!(Some(powered), summary.min_static_margin_cal);
2559 assert!(powered.value > 1.0, "{powered:?}");
2560 assert!(summary.envelope_flags().is_empty());
2561 }
2562}