1use hpr_core::DVec3;
18use serde::{Deserialize, Serialize};
19
20use crate::error::SimError;
21
22pub const MIN_STREAMER_ASPECT_RATIO: f64 = 1.0;
27
28const MAX_CANOPY_DRAG_COEFFICIENT: f64 = 2.0;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38#[non_exhaustive]
39pub enum CanopyType {
40 FlatCircular,
42 Conical,
44 Biconical,
46 Triconical,
48 ExtendedSkirt10Flat,
50 ExtendedSkirt14Full,
52 Hemispherical,
54 Annular,
56 Cross,
58 FlatRibbon,
60 ConicalRibbon,
62 Ringslot,
64 Ringsail,
66}
67
68impl CanopyType {
69 #[must_use]
72 pub const fn drag_coefficient_range(self) -> (f64, f64) {
73 match self {
74 Self::FlatCircular => (0.75, 0.80),
75 Self::Conical => (0.75, 0.90),
76 Self::Biconical => (0.75, 0.92),
77 Self::Triconical => (0.80, 0.96),
78 Self::ExtendedSkirt10Flat => (0.78, 0.87),
79 Self::ExtendedSkirt14Full => (0.75, 0.90),
80 Self::Hemispherical => (0.62, 0.77),
81 Self::Annular => (0.85, 0.95),
82 Self::Cross => (0.60, 0.85),
83 Self::FlatRibbon => (0.45, 0.50),
84 Self::ConicalRibbon => (0.50, 0.55),
85 Self::Ringslot => (0.56, 0.65),
86 Self::Ringsail => (0.75, 0.85),
87 }
88 }
89
90 #[must_use]
96 pub const fn drag_coefficient(self) -> f64 {
97 let (low, high) = self.drag_coefficient_range();
98 0.5 * (low + high)
99 }
100
101 #[must_use]
108 pub const fn fill_constant(self) -> Option<f64> {
109 match self {
110 Self::FlatCircular => Some(8.0),
111 Self::ExtendedSkirt10Flat => Some(10.0),
112 Self::ExtendedSkirt14Full => Some(12.0),
113 Self::Cross => Some(11.7),
114 Self::FlatRibbon | Self::ConicalRibbon => Some(14.0),
115 Self::Ringslot => Some(14.0),
116 Self::Ringsail => Some(7.0),
117 Self::Conical
118 | Self::Biconical
119 | Self::Triconical
120 | Self::Hemispherical
121 | Self::Annular => None,
122 }
123 }
124
125 #[must_use]
130 pub const fn growth_exponent(self) -> Option<f64> {
131 match self {
132 Self::FlatCircular
133 | Self::Conical
134 | Self::Triconical
135 | Self::ExtendedSkirt10Flat
136 | Self::ExtendedSkirt14Full => Some(2.0),
137 Self::FlatRibbon | Self::ConicalRibbon | Self::Ringslot => Some(1.0),
138 Self::Biconical
139 | Self::Hemispherical
140 | Self::Annular
141 | Self::Cross
142 | Self::Ringsail => None,
143 }
144 }
145
146 #[must_use]
150 pub const fn opening_force_coefficient(self) -> f64 {
151 match self {
152 Self::FlatCircular => 1.7,
153 Self::Conical | Self::Biconical | Self::Triconical => 1.8,
154 Self::ExtendedSkirt10Flat | Self::ExtendedSkirt14Full | Self::Annular => 1.4,
155 Self::Hemispherical => 1.6,
156 Self::Cross => 1.15,
157 Self::FlatRibbon | Self::ConicalRibbon | Self::Ringslot => 1.05,
158 Self::Ringsail => 1.10,
159 }
160 }
161
162 #[must_use]
164 pub const fn source(self) -> &'static str {
165 "Knacke, Parachute Recovery Systems Design Manual, NWC TP 6575 (1991): Table 5-1 (solid \
166 textile canopies), Table 5-2 (slotted), Table 5-6 (fill constants) and Figure 5-51 \
167 (drag-area growth)"
168 }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181#[non_exhaustive]
182pub enum StreamerModel {
183 #[default]
207 Filippone,
208 OpenRocket,
217}
218
219impl StreamerModel {
220 const FILIPPONE_CURVES: [(f64, f64, f64); 3] = [
223 (0.025, 0.561, -0.480),
224 (0.05, 0.6514, -0.6075),
225 (0.075, 0.405, -0.494),
226 ];
227
228 pub const FILIPPONE_ASPECT_RATIO_RANGE: (f64, f64) = (3.3, 30.0);
232
233 #[must_use]
239 pub fn drag_area_m2(self, length_m: f64, width_m: f64, surface_density_kg_m2: f64) -> f64 {
240 let planform_m2 = length_m * width_m;
241 match self {
242 Self::Filippone => {
243 let aspect_ratio = length_m / width_m;
244 let curve = |(_, coefficient, exponent): (f64, f64, f64)| {
245 coefficient * aspect_ratio.powf(exponent)
246 };
247 let curves = Self::FILIPPONE_CURVES;
248 let coefficient = if planform_m2 <= curves[0].0 {
250 curve(curves[0])
251 } else if planform_m2 >= curves[2].0 {
252 curve(curves[2])
253 } else {
254 let upper = usize::from(planform_m2 > curves[1].0) + 1;
255 let (low_m2, high_m2) = (curves[upper - 1].0, curves[upper].0);
256 let fraction = (planform_m2 / low_m2).ln() / (high_m2 / low_m2).ln();
257 let (low, high) = (curve(curves[upper - 1]), curve(curves[upper]));
258 low + fraction * (high - low)
259 };
260 coefficient * planform_m2
261 }
262 Self::OpenRocket => {
263 0.034
264 * ((surface_density_kg_m2 + 0.025) / 0.105)
265 * ((length_m + 1.0) / length_m)
266 * planform_m2
267 }
268 }
269 }
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
274#[serde(rename_all = "snake_case", deny_unknown_fields)]
275#[non_exhaustive]
276pub enum DeviceDrag {
277 DragArea {
279 cd_s_m2: f64,
281 },
282 Streamer {
285 length_m: f64,
287 width_m: f64,
289 surface_density_kg_m2: f64,
291 #[serde(default)]
293 model: StreamerModel,
294 },
295 Tumble {
298 drag_area_m2: f64,
300 body_profile_m2: f64,
304 fin_area_m2: f64,
306 },
307 Canopy {
310 nominal_diameter_m: f64,
312 drag_coefficient: f64,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
316 kind: Option<CanopyType>,
317 },
318}
319
320pub const TUMBLE_BODY_DRAG_COEFFICIENT: f64 = 0.56;
325
326pub const TUMBLE_FIN_DRAG_COEFFICIENT: f64 = 1.42;
330
331pub const TUMBLE_FIN_EFFICIENCY: [f64; 8] = [0.50, 1.00, 1.50, 1.41, 1.81, 1.73, 1.90, 1.85];
335
336fn side_profile_m2(component: &hpr_design::PlacedComponent) -> Result<f64, SimError> {
340 let profile = match &component.part {
341 hpr_design::Part::BodyTube(tube) => {
342 return Ok(2.0 * tube.outer_radius_m * component.length_m);
343 }
344 hpr_design::Part::NoseCone(nose) => nose.profile()?,
345 hpr_design::Part::Transition(transition) => transition.profile()?,
346 _ => return Ok(0.0),
347 };
348 Ok(hpr_design::revolve(&profile, hpr_design::Wall::Filled {})?.planform_area_m2)
349}
350
351impl DeviceDrag {
352 pub fn tumbling(assembly: &hpr_design::Assembly) -> Result<Self, SimError> {
381 Self::tumbling_stages(
382 assembly,
383 (0, assembly.layout.stages.len().saturating_sub(1)),
384 )
385 }
386
387 pub fn tumbling_stages(
397 assembly: &hpr_design::Assembly,
398 (first, last): (usize, usize),
399 ) -> Result<Self, SimError> {
400 let stages = assembly.layout.stages.len();
401 if first > last || last >= stages {
402 return Err(SimError::Domain {
403 what: "stage range of a tumbling body (first must not pass last, and last must \
404 be a stage of the design); the last given",
405 value: last as f64,
406 });
407 }
408 Self::tumbling_where(assembly, |_, component| {
409 (first..=last).contains(&component.stage)
410 })
411 }
412
413 pub(crate) fn tumbling_where(
416 assembly: &hpr_design::Assembly,
417 member: impl Fn(usize, &hpr_design::PlacedComponent) -> bool,
418 ) -> Result<Self, SimError> {
419 let mut body_profile_m2 = 0.0;
420 let mut fin_area_m2 = 0.0;
421 for (index, component) in assembly.layout.components.iter().enumerate() {
422 if !member(index, component) {
423 continue;
424 }
425 let copies = component.copies.len() as f64;
428 body_profile_m2 += copies * side_profile_m2(component)?;
429 if matches!(component.part, hpr_design::Part::TubeFinSet(_)) {
430 return Err(SimError::Domain {
433 what: "tumbling an airframe with tube fins (the model covers body tubes and \
434 fin sets only)",
435 value: 0.0,
436 });
437 }
438 if let hpr_design::Part::FinSet(fins) = &component.part {
439 let count = fins.count as usize;
440 let efficiency =
441 TUMBLE_FIN_EFFICIENCY
442 .get(count.wrapping_sub(1))
443 .ok_or(SimError::Domain {
444 what: "number of fins in a tumbling set (the fitted efficiency factors \
445 cover 1 to 8)",
446 value: fins.count.into(),
447 })?;
448 fin_area_m2 += copies * fins.planform.geometry()?.area_m2 * efficiency;
449 }
450 }
451 let drag_area_m2 = TUMBLE_FIN_DRAG_COEFFICIENT * fin_area_m2
452 + TUMBLE_BODY_DRAG_COEFFICIENT * body_profile_m2;
453 if !(drag_area_m2.is_finite() && drag_area_m2 > 0.0) {
454 return Err(SimError::Domain {
455 what: "drag area of the tumbling airframe, m²",
456 value: drag_area_m2,
457 });
458 }
459 Ok(Self::Tumble {
460 drag_area_m2,
461 body_profile_m2,
462 fin_area_m2,
463 })
464 }
465
466 #[must_use]
469 pub const fn streamer(length_m: f64, width_m: f64, surface_density_kg_m2: f64) -> Self {
470 Self::Streamer {
471 length_m,
472 width_m,
473 surface_density_kg_m2,
474 model: StreamerModel::Filippone,
475 }
476 }
477
478 #[must_use]
481 pub const fn canopy(kind: CanopyType, nominal_diameter_m: f64) -> Self {
482 Self::Canopy {
483 nominal_diameter_m,
484 drag_coefficient: kind.drag_coefficient(),
485 kind: Some(kind),
486 }
487 }
488
489 #[must_use]
491 pub fn drag_area_m2(&self) -> f64 {
492 match *self {
493 Self::DragArea { cd_s_m2 } => cd_s_m2,
494 Self::Streamer {
495 length_m,
496 width_m,
497 surface_density_kg_m2,
498 model,
499 } => model.drag_area_m2(length_m, width_m, surface_density_kg_m2),
500 Self::Tumble { drag_area_m2, .. } => drag_area_m2,
501 Self::Canopy {
502 nominal_diameter_m,
503 drag_coefficient,
504 ..
505 } => {
506 drag_coefficient * std::f64::consts::PI * nominal_diameter_m * nominal_diameter_m
507 / 4.0
508 }
509 }
510 }
511
512 #[must_use]
514 pub const fn nominal_diameter_m(&self) -> Option<f64> {
515 match *self {
516 Self::DragArea { .. } | Self::Streamer { .. } | Self::Tumble { .. } => None,
517 Self::Canopy {
518 nominal_diameter_m, ..
519 } => Some(nominal_diameter_m),
520 }
521 }
522
523 #[must_use]
525 pub const fn canopy_type(&self) -> Option<CanopyType> {
526 match *self {
527 Self::DragArea { .. } | Self::Streamer { .. } | Self::Tumble { .. } => None,
528 Self::Canopy { kind, .. } => kind,
529 }
530 }
531}
532
533#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
535#[serde(rename_all = "snake_case")]
536#[non_exhaustive]
537pub enum Trigger {
538 Apogee,
540 Altitude {
544 height_above_ground_m: f64,
546 },
547 Time {
550 time_s: f64,
552 },
553 MotorDelay {
559 motor: usize,
561 },
562 Burnout {
567 motor: usize,
569 delay_s: f64,
571 },
572}
573
574impl Trigger {
575 pub fn known_time_s(self, assembly: &hpr_design::Assembly) -> Result<Option<f64>, SimError> {
587 trigger_time_s(self, &assembly.motors, &assembly.ignition_times_s(|_| None))
588 }
589}
590
591#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
596#[serde(rename_all = "snake_case")]
597#[non_exhaustive]
598pub enum Inflation {
599 Instant,
601 FillingTime {
603 time_s: f64,
605 exponent: f64,
607 },
608 FillConstant {
619 constant: f64,
621 exponent: f64,
623 },
624}
625
626impl Inflation {
627 pub const FILL_CONSTANT_RANGE_M_S: (f64, f64) = (45.72, 152.4);
630
631 #[must_use]
633 pub const fn knacke(kind: CanopyType) -> Option<Self> {
634 match (kind.fill_constant(), kind.growth_exponent()) {
635 (Some(constant), Some(exponent)) => Some(Self::FillConstant { constant, exponent }),
636 _ => None,
637 }
638 }
639
640 fn filling_time_s(&self, diameter_m: Option<f64>, airspeed_m_s: f64) -> f64 {
643 match *self {
644 Self::Instant => 0.0,
645 Self::FillingTime { time_s, .. } => time_s,
646 Self::FillConstant { constant, .. } => match diameter_m {
647 Some(diameter_m) if airspeed_m_s > 0.0 => constant * diameter_m / airspeed_m_s,
650 _ => 0.0,
651 },
652 }
653 }
654
655 const fn exponent(&self) -> f64 {
657 match *self {
658 Self::Instant => 1.0,
659 Self::FillingTime { exponent, .. } | Self::FillConstant { exponent, .. } => exponent,
660 }
661 }
662}
663
664#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
673#[serde(deny_unknown_fields)]
674#[non_exhaustive]
675pub struct Device {
676 pub name: String,
678 pub drag: DeviceDrag,
680 pub trigger: Trigger,
682 #[serde(default)]
685 pub lag_s: f64,
686 #[serde(default = "instant")]
688 pub inflation: Inflation,
689 #[serde(default, skip_serializing_if = "Option::is_none")]
693 pub released_by: Option<usize>,
694 #[serde(default)]
699 pub body: usize,
700}
701
702fn instant() -> Inflation {
703 Inflation::Instant
704}
705
706impl Device {
707 #[must_use]
709 pub fn new(name: impl Into<String>, drag: DeviceDrag, trigger: Trigger) -> Self {
710 Self {
711 name: name.into(),
712 drag,
713 trigger,
714 lag_s: 0.0,
715 inflation: Inflation::Instant,
716 released_by: None,
717 body: 0,
718 }
719 }
720
721 #[must_use]
723 pub fn with_lag_s(mut self, lag_s: f64) -> Self {
724 self.lag_s = lag_s;
725 self
726 }
727
728 #[must_use]
730 pub fn with_inflation(mut self, inflation: Inflation) -> Self {
731 self.inflation = inflation;
732 self
733 }
734
735 #[must_use]
737 pub fn with_release_by(mut self, index: usize) -> Self {
738 self.released_by = Some(index);
739 self
740 }
741
742 #[must_use]
746 pub fn on_body(mut self, index: usize) -> Self {
747 self.body = index;
748 self
749 }
750
751 fn validate(&self, count: usize, index: usize) -> Result<(), SimError> {
753 match self.drag {
754 DeviceDrag::Streamer {
755 length_m,
756 width_m,
757 surface_density_kg_m2,
758 model,
759 } => {
760 for (what, value) in [
761 ("streamer length, m", length_m),
762 ("streamer width, m", width_m),
763 ] {
764 if !(value.is_finite() && value > 0.0) {
765 return Err(SimError::Domain { what, value });
766 }
767 }
768 if !(surface_density_kg_m2.is_finite() && surface_density_kg_m2 >= 0.0) {
769 return Err(SimError::Domain {
770 what: "streamer fabric surface density, kg/m²",
771 value: surface_density_kg_m2,
772 });
773 }
774 let _ = model;
777 if length_m / width_m < MIN_STREAMER_ASPECT_RATIO {
778 return Err(SimError::Domain {
779 what: "streamer aspect ratio, length over width (Carruthers and \
780 Filippone fit 3.3 to 30, and both correlations are meaningless \
781 below 1)",
782 value: length_m / width_m,
783 });
784 }
785 }
786 DeviceDrag::Tumble {
787 drag_area_m2,
788 body_profile_m2,
789 fin_area_m2,
790 } => {
791 for (what, value) in [
792 ("tumbling body side profile area, m²", body_profile_m2),
793 ("tumbling effective fin area, m²", fin_area_m2),
794 ] {
795 if !(value.is_finite() && value >= 0.0) {
796 return Err(SimError::Domain { what, value });
797 }
798 }
799 let parts = TUMBLE_FIN_DRAG_COEFFICIENT * fin_area_m2
800 + TUMBLE_BODY_DRAG_COEFFICIENT * body_profile_m2;
801 if (drag_area_m2 - parts).abs() > 1e-9 * drag_area_m2.abs().max(1.0) {
802 return Err(SimError::Domain {
803 what: "tumbling drag area against its body and fin areas (build one with \
804 DeviceDrag::tumbling)",
805 value: drag_area_m2,
806 });
807 }
808 }
809 DeviceDrag::DragArea { .. } | DeviceDrag::Canopy { .. } => {}
810 }
811 if let DeviceDrag::Canopy {
812 drag_coefficient, ..
813 } = self.drag
814 && !(drag_coefficient.is_finite()
818 && drag_coefficient > 0.0
819 && drag_coefficient <= MAX_CANOPY_DRAG_COEFFICIENT)
820 {
821 return Err(SimError::Domain {
822 what: "canopy drag coefficient on the nominal area (0 to 2]",
823 value: drag_coefficient,
824 });
825 }
826 let area = self.drag.drag_area_m2();
827 if !(area.is_finite() && area > 0.0) {
828 return Err(SimError::Domain {
829 what: "recovery device drag area, m²",
830 value: area,
831 });
832 }
833 if let Some(diameter) = self.drag.nominal_diameter_m()
834 && !(diameter.is_finite() && diameter > 0.0)
835 {
836 return Err(SimError::Domain {
837 what: "canopy nominal diameter, m",
838 value: diameter,
839 });
840 }
841 if !(self.lag_s.is_finite() && self.lag_s >= 0.0) {
842 return Err(SimError::Domain {
843 what: "recovery device lag, s",
844 value: self.lag_s,
845 });
846 }
847 match self.inflation {
848 Inflation::Instant => {}
849 Inflation::FillingTime { time_s, exponent } => {
850 if !(time_s.is_finite() && time_s >= 0.0) {
851 return Err(SimError::Domain {
852 what: "canopy filling time, s",
853 value: time_s,
854 });
855 }
856 check_exponent(exponent)?;
857 }
858 Inflation::FillConstant { constant, exponent } => {
859 if !(constant.is_finite() && constant > 0.0) {
860 return Err(SimError::Domain {
861 what: "canopy fill constant",
862 value: constant,
863 });
864 }
865 if self.drag.nominal_diameter_m().is_none() {
866 return Err(SimError::Domain {
867 what: "canopy fill constant without a nominal diameter (give a canopy, \
868 or a filling time)",
869 value: constant,
870 });
871 }
872 check_exponent(exponent)?;
873 }
874 }
875 match self.trigger {
876 Trigger::Apogee | Trigger::MotorDelay { .. } | Trigger::Burnout { .. } => {}
877 Trigger::Altitude {
878 height_above_ground_m,
879 } => {
880 if !(height_above_ground_m.is_finite() && height_above_ground_m > 0.0) {
881 return Err(SimError::Domain {
882 what: "deployment height above the launch site, m",
883 value: height_above_ground_m,
884 });
885 }
886 }
887 Trigger::Time { time_s } => {
888 if !(time_s.is_finite() && time_s >= 0.0) {
889 return Err(SimError::Domain {
890 what: "deployment time after launch, s",
891 value: time_s,
892 });
893 }
894 }
895 }
896 if let Some(other) = self.released_by
897 && (other >= count || other == index)
898 {
899 return Err(SimError::Domain {
900 what: "index of the device that releases this one",
901 value: other as f64,
902 });
903 }
904 Ok(())
905 }
906}
907
908fn check_exponent(exponent: f64) -> Result<(), SimError> {
909 if exponent.is_finite() && exponent > 0.0 {
910 Ok(())
911 } else {
912 Err(SimError::Domain {
913 what: "canopy drag-area growth exponent",
914 value: exponent,
915 })
916 }
917}
918
919#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
939#[serde(deny_unknown_fields)]
940pub struct Separation {
941 pub trigger: Trigger,
943 pub after_stage: usize,
945 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
953 pub drops_burning: bool,
954}
955
956impl Separation {
957 #[must_use]
959 pub const fn new(trigger: Trigger, after_stage: usize) -> Self {
960 Self {
961 trigger,
962 after_stage,
963 drops_burning: false,
964 }
965 }
966
967 #[must_use]
970 pub const fn dropping_burning(mut self) -> Self {
971 self.drops_burning = true;
972 self
973 }
974
975 #[must_use]
978 pub const fn stages_of(&self, index: usize, stage_count: usize) -> Option<(usize, usize)> {
979 let Some(aft) = self.after_stage.checked_add(1) else {
980 return None;
981 };
982 if aft >= stage_count {
983 return None;
984 }
985 match index {
986 0 => Some((0, self.after_stage)),
987 1 => Some((aft, stage_count - 1)),
988 _ => None,
989 }
990 }
991
992 #[must_use]
1002 pub fn powered_at(&self, assembly: &hpr_design::Assembly, time_s: f64) -> bool {
1003 let lit = assembly.ignition_times_s(|stage| (stage == self.after_stage).then_some(time_s));
1004 assembly.motors.iter().zip(&lit).any(|(placed, ignition)| {
1005 placed.stage <= self.after_stage
1006 && ignition.is_some_and(|ignition| {
1007 ignition + placed.mounted.motor.burnout_time_s() > time_s
1008 })
1009 })
1010 }
1011
1012 pub const BODIES: usize = 2;
1014
1015 #[must_use]
1020 pub fn body_of(separations: &[Self], stage: usize) -> usize {
1021 separations
1022 .iter()
1023 .position(|separation| stage > separation.after_stage)
1024 .map_or(0, |k| k + 1)
1025 }
1026
1027 #[must_use]
1033 pub fn stages_of_body(
1034 separations: &[Self],
1035 body: usize,
1036 stage_count: usize,
1037 ) -> Option<(usize, usize)> {
1038 let mut last = stage_count.checked_sub(1)?;
1039 for separation in separations {
1040 if separation.after_stage >= last {
1041 return None;
1042 }
1043 last = separation.after_stage;
1044 }
1045 match body {
1046 0 => Some((0, last)),
1047 _ => {
1048 let separation = separations.get(body - 1)?;
1049 let end = match body - 1 {
1050 0 => stage_count - 1,
1051 k => separations[k - 1].after_stage,
1052 };
1053 Some((separation.after_stage + 1, end))
1054 }
1055 }
1056 }
1057}
1058
1059#[cfg(test)]
1064pub(crate) fn body_mass_properties(
1065 assembly: &hpr_design::Assembly,
1066 (first, last): (usize, usize),
1067 t_s: f64,
1068 ignition_s: &[Option<f64>],
1069) -> hpr_design::MassProperties {
1070 let mut parts = vec![];
1071 for (index, stage) in assembly.layout.stages.iter().enumerate() {
1072 if (first..=last).contains(&index) {
1073 parts.push(stage.mass);
1074 }
1075 }
1076 let motors: Vec<_> = assembly
1077 .motors
1078 .iter()
1079 .zip(ignition_s)
1080 .filter(|(motor, _)| (first..=last).contains(&motor.stage))
1081 .map(|(motor, ignition)| motor.mass_properties_lit(t_s, *ignition))
1082 .collect();
1083 parts.extend(motors);
1084 hpr_design::MassProperties::combine(parts.iter())
1085}
1086
1087pub(crate) fn trigger_time_s(
1096 trigger: Trigger,
1097 motors: &[hpr_design::PlacedMotor],
1098 ignition_s: &[Option<f64>],
1099) -> Result<Option<f64>, SimError> {
1100 let burnout_s = |motor: usize| {
1101 ignition_s
1102 .get(motor)
1103 .copied()
1104 .flatten()
1105 .zip(motors.get(motor))
1106 .map(|(ignition_s, placed)| ignition_s + placed.mounted.motor.burnout_time_s())
1107 };
1108 match trigger {
1109 Trigger::Time { time_s } => {
1110 if !(time_s.is_finite() && time_s >= 0.0) {
1111 return Err(SimError::Domain {
1112 what: "deployment time after launch, s",
1113 value: time_s,
1114 });
1115 }
1116 Ok(Some(time_s))
1117 }
1118 Trigger::MotorDelay { motor } => {
1119 let placed = motors.get(motor).ok_or(SimError::Domain {
1120 what: "index of the motor whose delay fires a device",
1121 value: motor as f64,
1122 })?;
1123 let delay_s = match placed.mounted.delay {
1124 Some(hpr_motor::Delay::Seconds(delay_s)) => delay_s,
1125 _ => {
1126 return Err(SimError::Domain {
1127 what: "the motor firing a device has no ejection delay in seconds (it is \
1128 plugged, or its delay is unset)",
1129 value: motor as f64,
1130 });
1131 }
1132 };
1133 if !(delay_s.is_finite() && delay_s >= 0.0) {
1134 return Err(SimError::Domain {
1135 what: "motor ejection delay, s",
1136 value: delay_s,
1137 });
1138 }
1139 Ok(burnout_s(motor).map(|t| t + delay_s))
1140 }
1141 Trigger::Burnout { motor, delay_s } => {
1142 if motor >= motors.len() {
1143 return Err(SimError::Domain {
1144 what: "index of the motor whose burnout a trigger counts from",
1145 value: motor as f64,
1146 });
1147 }
1148 if !(delay_s.is_finite() && delay_s >= 0.0) {
1149 return Err(SimError::Domain {
1150 what: "delay after a motor's burnout, s",
1151 value: delay_s,
1152 });
1153 }
1154 Ok(burnout_s(motor).map(|t| t + delay_s))
1155 }
1156 Trigger::Apogee | Trigger::Altitude { .. } => Ok(None),
1157 }
1158}
1159
1160pub(crate) fn plan(
1167 devices: &[Device],
1168 motors: &[hpr_design::PlacedMotor],
1169 ignition_s: &[Option<f64>],
1170) -> Result<Vec<Option<f64>>, SimError> {
1171 for (index, device) in devices.iter().enumerate() {
1174 if let Some(other) = device.released_by
1175 && devices.get(other).is_some_and(|by| by.body != device.body)
1176 {
1177 return Err(SimError::Domain {
1178 what: "release across a separation (a device can only be released by one on its \
1179 own body); index of the released device",
1180 value: index as f64,
1181 });
1182 }
1183 }
1184 for start in 0..devices.len() {
1188 let mut at = start;
1189 for _ in 0..devices.len() {
1190 match devices[at].released_by {
1191 Some(next) if next < devices.len() => at = next,
1192 _ => break,
1193 }
1194 if at == start {
1195 return Err(SimError::Domain {
1196 what: "recovery device releases run in a cycle, so every one of them could \
1197 be released at once; index of a device in the cycle",
1198 value: start as f64,
1199 });
1200 }
1201 }
1202 }
1203 let mut times = Vec::with_capacity(devices.len());
1204 for (index, device) in devices.iter().enumerate() {
1205 device.validate(devices.len(), index)?;
1206 let time = trigger_time_s(device.trigger, motors, ignition_s)?;
1207 times.push(time);
1208 }
1209 Ok(times)
1210}
1211
1212#[derive(Debug, Clone, Copy, Default, PartialEq)]
1214pub(crate) struct DeviceRun {
1215 pub(crate) triggered_s: Option<f64>,
1217 pub(crate) deploy_s: Option<f64>,
1219 pub(crate) deployed_s: Option<f64>,
1221 pub(crate) filling_time_s: f64,
1223 pub(crate) released_s: Option<f64>,
1225 pub(crate) release_recorded: bool,
1227 pub(crate) abandoned: bool,
1229}
1230
1231#[derive(Debug, Clone, Default)]
1239pub(crate) struct Run {
1240 pub(crate) devices: Vec<DeviceRun>,
1241}
1242
1243impl Run {
1244 pub(crate) fn new(count: usize) -> Self {
1245 Self {
1246 devices: vec![DeviceRun::default(); count],
1247 }
1248 }
1249
1250 pub(crate) fn trigger(&mut self, devices: &[Device], index: usize, t: f64) -> f64 {
1252 let deploy_s = t + devices[index].lag_s;
1253 self.devices[index].triggered_s = Some(t);
1254 self.devices[index].deploy_s = Some(deploy_s);
1255 deploy_s
1256 }
1257
1258 pub(crate) fn deploy(
1261 &mut self,
1262 devices: &[Device],
1263 index: usize,
1264 t: f64,
1265 airspeed_m_s: f64,
1266 ) -> f64 {
1267 let device = &devices[index];
1268 let filling_time_s = device
1269 .inflation
1270 .filling_time_s(device.drag.nominal_diameter_m(), airspeed_m_s);
1271 self.devices[index].deployed_s = Some(t);
1272 self.devices[index].filling_time_s = filling_time_s;
1273 let full_s = t + filling_time_s;
1274 for (other, run) in devices.iter().zip(&mut self.devices) {
1275 if other.released_by == Some(index) && run.released_s.is_none() {
1276 run.released_s = Some(full_s);
1277 }
1278 }
1279 full_s
1280 }
1281
1282 pub(crate) fn times_of(&self, index: usize) -> Vec<f64> {
1285 let run = self.devices[index];
1286 let mut times = Vec::new();
1287 times.extend(run.deploy_s);
1288 if let Some(deployed_s) = run.deployed_s {
1289 times.push(deployed_s + run.filling_time_s);
1290 }
1291 times.extend(run.released_s);
1292 times.retain(|time| time.is_finite());
1293 times
1294 }
1295
1296 pub(crate) fn released_s(&self, index: usize) -> Option<f64> {
1298 self.devices[index].released_s
1299 }
1300
1301 pub(crate) fn hung_before(
1306 &self,
1307 devices: &[Device],
1308 member: impl Fn(usize) -> bool,
1309 t: f64,
1310 ) -> bool {
1311 devices.iter().enumerate().any(|(index, device)| {
1312 member(index)
1313 && !matches!(device.drag, DeviceDrag::Tumble { .. })
1314 && self.devices[index]
1315 .deployed_s
1316 .is_some_and(|deployed_s| deployed_s < t)
1317 && !self.devices[index]
1318 .released_s
1319 .is_some_and(|released_s| released_s < t)
1320 })
1321 }
1322
1323 pub(crate) fn release_due(&self, index: usize, t: f64) -> bool {
1325 let run = self.devices[index];
1326 !run.release_recorded && run.released_s.is_some_and(|released| t >= released)
1327 }
1328
1329 pub(crate) fn release(&mut self, index: usize) {
1331 self.devices[index].release_recorded = true;
1332 }
1333
1334 pub(crate) fn abandon(&mut self, index: usize) {
1336 self.devices[index].abandoned = true;
1337 }
1338
1339 pub(crate) fn waiting(&self, index: usize) -> bool {
1341 let run = self.devices[index];
1342 run.triggered_s.is_some() && run.deployed_s.is_none() && !run.abandoned
1343 }
1344
1345 pub(crate) fn deploy_s(&self, index: usize) -> f64 {
1347 self.devices[index].deploy_s.unwrap_or(f64::INFINITY)
1348 }
1349
1350 pub(crate) fn pending(&self, index: usize) -> bool {
1352 self.devices[index].triggered_s.is_none()
1353 }
1354
1355 pub(crate) fn body_drag_area_m2(&self, devices: &[Device], body: usize, t: f64) -> f64 {
1357 devices
1358 .iter()
1359 .enumerate()
1360 .filter(|(_, device)| device.body == body)
1361 .map(|(index, _)| self.one_drag_area_m2(devices, index, t))
1362 .sum()
1363 }
1364
1365 pub(crate) fn drag_area_m2(&self, devices: &[Device], t: f64) -> f64 {
1370 (0..devices.len())
1371 .map(|index| self.one_drag_area_m2(devices, index, t))
1372 .sum()
1373 }
1374
1375 fn one_drag_area_m2(&self, devices: &[Device], index: usize, t: f64) -> f64 {
1378 let (device, run) = (&devices[index], self.devices[index]);
1379 let Some(deployed_s) = run.deployed_s else {
1380 return 0.0;
1381 };
1382 if run.released_s.is_some_and(|released| t >= released) {
1383 return 0.0;
1384 }
1385 let full = device.drag.drag_area_m2();
1386 let elapsed = t - deployed_s;
1387 if elapsed < 0.0 {
1388 return 0.0;
1389 }
1390 if run.filling_time_s <= 0.0 {
1391 return full;
1392 }
1393 let fraction = (elapsed / run.filling_time_s).min(1.0);
1394 let growth = match device.inflation.exponent() {
1397 1.0 => fraction,
1398 2.0 => fraction * fraction,
1399 exponent => fraction.powf(exponent),
1400 };
1401 full * growth
1402 }
1403}
1404
1405#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1407pub struct BodySample {
1408 pub time_s: f64,
1410 pub cg_enu_m: DVec3,
1412 pub cg_velocity_enu_m_s: DVec3,
1414 pub height_above_ground_m: f64,
1416 pub vertical_speed_m_s: f64,
1418 pub airspeed_m_s: f64,
1420 pub recovery_drag_area_m2: f64,
1422 pub mass_kg: f64,
1424}
1425
1426#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1428pub struct BodyEvent {
1429 pub kind: crate::EventKind,
1435 pub sample: BodySample,
1437 #[serde(default, skip_serializing_if = "Option::is_none")]
1441 pub after: Option<BodySample>,
1442}
1443
1444#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1446pub struct BodyFlight {
1447 pub body: usize,
1449 pub stages: (usize, usize),
1451 #[serde(default)]
1454 pub pieces: Vec<usize>,
1455 pub mass_kg: f64,
1458 pub start_sample: BodySample,
1463 pub termination: crate::Termination,
1465 pub events: Vec<BodyEvent>,
1467 pub final_sample: BodySample,
1469 pub stats: crate::Stats,
1471 #[serde(default)]
1477 pub impulse_left_n_s: f64,
1478}
1479
1480impl BodyFlight {
1481 #[must_use]
1483 pub fn event(&self, kind: crate::EventKind) -> Option<&BodyEvent> {
1484 self.events.iter().find(|event| event.kind == kind)
1485 }
1486}
1487
1488#[must_use]
1497pub fn terminal_speed_m_s(
1498 mass_kg: f64,
1499 drag_area_m2: f64,
1500 density_kg_m3: f64,
1501 gravity_m_s2: f64,
1502) -> f64 {
1503 (2.0 * mass_kg * gravity_m_s2 / (density_kg_m3 * drag_area_m2)).sqrt()
1504}
1505
1506#[cfg(test)]
1507mod tests {
1508 use std::f64::consts::PI;
1509
1510 use hpr_atmos::ConstantWind;
1511 use hpr_core::DVec3;
1512
1513 use super::*;
1514 use crate::environment::Environment;
1515 use crate::flight::{EventKind, FlightResult, FlightSettings, Simulation, Termination};
1516 use crate::rail::Rail;
1517 use crate::recorder::{Channel, Recorder};
1518 use crate::state::State;
1519 use crate::testing::{
1520 QuadraticDragFall, UniformAir, analytic_environment, analytic_wind_environment,
1521 closed_form_quadratic_drag, design, rotating_analytic_environment,
1522 };
1523
1524 const G: f64 = 9.806_65;
1525 const START_S: f64 = 10.0;
1527
1528 fn flight(environment: Environment, devices: Vec<Device>, max_time_s: f64) -> Simulation {
1530 Simulation::new(
1531 &design("rocketpy-valetudo"),
1532 "example",
1533 environment,
1534 Rail::vertical(3.0),
1535 FlightSettings {
1536 max_time_s,
1537 ..FlightSettings::default()
1538 },
1539 )
1540 .unwrap()
1541 .with_recovery(devices)
1542 .unwrap()
1543 }
1544
1545 fn with_delay(mut rocket: hpr_design::Rocket, delay_s: f64) -> hpr_design::Rocket {
1547 for configuration in &mut rocket.configurations {
1548 for motor in &mut configuration.motors {
1549 motor.delay = Some(hpr_motor::Delay::Seconds(delay_s));
1550 }
1551 }
1552 rocket
1553 }
1554
1555 fn open_at_start(drag: DeviceDrag) -> Device {
1557 Device::new("test", drag, Trigger::Time { time_s: START_S })
1558 }
1559
1560 fn dropped(sim: &Simulation, height_m: f64, velocity_enu_m_s: DVec3) -> State {
1563 let attitude = Rail::vertical(3.0).attitude();
1564 let cg_m = sim.assembly().mass_properties(START_S).cg_m;
1565 State {
1566 position_enu_m: DVec3::new(0.0, 0.0, height_m) - attitude.mul_vec3(cg_m),
1567 velocity_enu_m_s,
1568 attitude,
1569 body_rate_rad_s: DVec3::ZERO,
1570 }
1571 }
1572
1573 fn column(recorder: &Recorder, name: &str) -> Vec<f64> {
1575 let index = recorder
1576 .columns()
1577 .iter()
1578 .position(|c| c == name)
1579 .unwrap_or_else(|| panic!("no column {name}"));
1580 recorder.rows().iter().map(|row| row[index]).collect()
1581 }
1582
1583 fn peak_load_n(result: &FlightResult, recorder: &Recorder) -> f64 {
1586 let from_events = result
1587 .events
1588 .iter()
1589 .map(|event| event.sample.dynamic_pressure_pa * event.sample.recovery_drag_area_m2);
1590 let pressure = column(recorder, "dynamic_pressure_pa");
1591 let area = column(recorder, "recovery_drag_area_m2");
1592 let from_rows = pressure.iter().zip(&area).map(|(q, s)| q * s);
1593 from_events.chain(from_rows).fold(0.0, f64::max)
1594 }
1595
1596 #[test]
1597 fn default_canopy_cd_carries_its_citation() {
1598 let flat = CanopyType::FlatCircular;
1604 assert_eq!(flat.drag_coefficient_range(), (0.75, 0.80));
1605 assert_eq!(flat.drag_coefficient(), 0.775);
1606 let (low, high) = flat.drag_coefficient_range();
1607 assert!(low <= flat.drag_coefficient() && flat.drag_coefficient() <= high);
1608 let source = flat.source();
1609 for cited in ["Knacke", "NWC TP 6575", "Table 5-1"] {
1610 assert!(source.contains(cited), "{source} does not cite {cited}");
1611 }
1612 assert_eq!(
1613 CanopyType::Hemispherical.drag_coefficient_range(),
1614 (0.62, 0.77)
1615 );
1616 assert!(CanopyType::Hemispherical.drag_coefficient() < 1.4);
1617 let canopy = DeviceDrag::canopy(flat, 2.0);
1619 assert!((canopy.drag_area_m2() - 0.775 * PI).abs() < 1e-15);
1620 assert_eq!(canopy.nominal_diameter_m(), Some(2.0));
1621 assert_eq!(canopy.canopy_type(), Some(flat));
1622 let table = [
1628 (
1629 CanopyType::FlatCircular,
1630 0.75,
1631 0.80,
1632 Some(8.0),
1633 Some(2.0),
1634 1.7,
1635 ),
1636 (CanopyType::Conical, 0.75, 0.90, None, Some(2.0), 1.8),
1637 (CanopyType::Biconical, 0.75, 0.92, None, None, 1.8),
1638 (CanopyType::Triconical, 0.80, 0.96, None, Some(2.0), 1.8),
1639 (
1640 CanopyType::ExtendedSkirt10Flat,
1641 0.78,
1642 0.87,
1643 Some(10.0),
1644 Some(2.0),
1645 1.4,
1646 ),
1647 (
1648 CanopyType::ExtendedSkirt14Full,
1649 0.75,
1650 0.90,
1651 Some(12.0),
1652 Some(2.0),
1653 1.4,
1654 ),
1655 (CanopyType::Hemispherical, 0.62, 0.77, None, None, 1.6),
1656 (CanopyType::Annular, 0.85, 0.95, None, None, 1.4),
1657 (CanopyType::Cross, 0.60, 0.85, Some(11.7), None, 1.15),
1658 (
1659 CanopyType::FlatRibbon,
1660 0.45,
1661 0.50,
1662 Some(14.0),
1663 Some(1.0),
1664 1.05,
1665 ),
1666 (
1667 CanopyType::ConicalRibbon,
1668 0.50,
1669 0.55,
1670 Some(14.0),
1671 Some(1.0),
1672 1.05,
1673 ),
1674 (
1675 CanopyType::Ringslot,
1676 0.56,
1677 0.65,
1678 Some(14.0),
1679 Some(1.0),
1680 1.05,
1681 ),
1682 (CanopyType::Ringsail, 0.75, 0.85, Some(7.0), None, 1.10),
1683 ];
1684 for (kind, low, high, fill_constant, growth_exponent, opening) in table {
1685 assert_eq!(kind.drag_coefficient_range(), (low, high), "{kind:?}");
1686 assert_eq!(kind.drag_coefficient(), 0.5 * (low + high), "{kind:?}");
1687 assert_eq!(kind.fill_constant(), fill_constant, "{kind:?}");
1688 assert_eq!(kind.growth_exponent(), growth_exponent, "{kind:?}");
1689 assert_eq!(kind.opening_force_coefficient(), opening, "{kind:?}");
1690 assert!(
1691 source.contains("Knacke") && kind.source() == source,
1692 "{kind:?}"
1693 );
1694 }
1695
1696 assert_eq!(Inflation::knacke(CanopyType::Hemispherical), None);
1697 assert_eq!(
1698 Inflation::knacke(CanopyType::FlatCircular),
1699 Some(Inflation::FillConstant {
1700 constant: 8.0,
1701 exponent: 2.0
1702 })
1703 );
1704 }
1705
1706 #[test]
1707 fn descent_rate_equals_terminal_velocity() {
1708 let cd_s = 0.8 * PI * 1.0 * 1.0 / 4.0;
1713 let loft = terminal_speed_m_s(1.1, cd_s, 1.225, G);
1714 assert!((loft - 5.294).abs() < 5e-4, "{loft}");
1715
1716 let air = UniformAir::sea_level();
1720 let rho = air.0.density_kg_m3;
1721 let device = open_at_start(DeviceDrag::canopy(CanopyType::FlatCircular, 1.5));
1722 let drag_area_m2 = device.drag.drag_area_m2();
1723 let sim = flight(analytic_environment(air, G), vec![device], 3600.0);
1724 let mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
1725 let terminal_m_s = terminal_speed_m_s(mass_kg, drag_area_m2, rho, G);
1726 let height_m = 2_000.0;
1727 let closed = closed_form_quadratic_drag(
1728 &QuadraticDragFall {
1729 gravity_mps2: G,
1730 k_per_m: rho * drag_area_m2 / (2.0 * mass_kg),
1731 },
1732 0.0,
1733 );
1734 assert!((closed.apogee_s).abs() < 1e-15);
1735
1736 let mut recorder = Recorder::new(
1737 vec![
1738 Channel::Time,
1739 Channel::VerticalSpeed,
1740 Channel::HeightAboveGround,
1741 ],
1742 Some(5.0),
1743 )
1744 .unwrap();
1745 let result = sim
1746 .run_free(START_S, dropped(&sim, height_m, DVec3::ZERO), &mut recorder)
1747 .unwrap();
1748 assert_eq!(result.termination, Termination::GroundHit);
1749 assert_eq!(result.final_sample.phase, crate::Phase::Descent);
1750
1751 let times = column(&recorder, "time_s");
1754 let speeds = column(&recorder, "vertical_speed_m_s");
1755 let mut worst: f64 = 0.0;
1756 for (t, v) in times.iter().zip(&speeds) {
1757 let expected = closed.state(t - START_S)[1];
1758 worst = worst.max((v - expected).abs() / terminal_m_s);
1759 }
1760 assert!(worst < 1e-7, "{worst} of v_t");
1762 let landing = result.event(EventKind::GroundHit).unwrap().sample;
1763 assert!(
1764 (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 1e-7,
1765 "{} vs {terminal_m_s}",
1766 landing.vertical_speed_m_s
1767 );
1768 let expected_s = START_S + closed.time_at_descending_height_s(-height_m);
1770 assert!(
1771 (landing.time_s - expected_s).abs() < 1e-5,
1772 "{} vs {expected_s}",
1773 landing.time_s
1774 );
1775 }
1776
1777 #[test]
1778 fn drift_equals_the_wind_times_the_descent_time() {
1779 let air = UniformAir::sea_level();
1783 let device = open_at_start(DeviceDrag::canopy(CanopyType::FlatCircular, 1.5));
1784 let drag_area_m2 = device.drag.drag_area_m2();
1785 let sim = flight(
1786 analytic_wind_environment(air, G, ConstantWind::new(6.5, 0.7).unwrap()),
1787 vec![device],
1788 3600.0,
1789 );
1790 let wind_enu = sim.environment().wind.wind(0.0).unwrap().velocity_enu_m_s;
1791 assert!(wind_enu.z == 0.0 && wind_enu.length() > 6.4, "{wind_enu}");
1792 let mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
1793 let closed = closed_form_quadratic_drag(
1794 &QuadraticDragFall {
1795 gravity_mps2: G,
1796 k_per_m: air.0.density_kg_m3 * drag_area_m2 / (2.0 * mass_kg),
1797 },
1798 0.0,
1799 );
1800 let height_m = 1_000.0;
1801 let start = dropped(&sim, height_m, wind_enu);
1802 let result = sim.run_free(START_S, start, &mut ()).unwrap();
1803 assert_eq!(result.termination, Termination::GroundHit);
1804 let landing = result.event(EventKind::GroundHit).unwrap().sample;
1805
1806 let flown_s = landing.time_s - START_S;
1807 let drift =
1808 landing.cg_enu_m - start.point_enu_m(sim.assembly().mass_properties(START_S).cg_m);
1809 let expected = wind_enu * flown_s;
1810 assert!(
1811 (drift.x - expected.x).abs() < 1e-8 * expected.x.abs()
1812 && (drift.y - expected.y).abs() < 1e-8 * expected.y.abs(),
1813 "{drift} vs {expected}"
1814 );
1815 let expected_s = closed.time_at_descending_height_s(-height_m);
1819 assert!(
1820 (flown_s - expected_s).abs() < 1e-4 * expected_s,
1821 "{flown_s} vs {expected_s}"
1822 );
1823 assert!(drift.length() > 600.0, "{drift}");
1824 }
1825
1826 #[test]
1827 fn coriolis_drifts_a_descent_east_by_the_analytic_amount() {
1828 let air = UniformAir::sea_level();
1833 let device = open_at_start(DeviceDrag::canopy(CanopyType::FlatCircular, 1.5));
1834 let drag_area_m2 = device.drag.drag_area_m2();
1835 let sim = flight(rotating_analytic_environment(air, G), vec![device], 3600.0);
1836 let mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
1837 let terminal_m_s = terminal_speed_m_s(mass_kg, drag_area_m2, air.0.density_kg_m3, G);
1838 let latitude_rad = sim.environment().site().latitude_rad;
1839 let rate_rad_s = 7.292_115e-5;
1840 let east_m_s = 2.0 * rate_rad_s * latitude_rad.cos() * terminal_m_s * terminal_m_s / G;
1841 assert!(east_m_s > 1e-3, "{east_m_s} m/s is too small to measure");
1842
1843 let height_m = 3_000.0;
1844 let start = dropped(&sim, height_m, DVec3::ZERO);
1845 let result = sim.run_free(START_S, start, &mut ()).unwrap();
1846 assert_eq!(result.termination, Termination::GroundHit);
1847 let landing = result.event(EventKind::GroundHit).unwrap().sample;
1848 let flown_s = landing.time_s - START_S;
1849 let drift =
1850 landing.cg_enu_m - start.point_enu_m(sim.assembly().mass_properties(START_S).cg_m);
1851 let expected_m = east_m_s * flown_s;
1852 assert!(
1856 (drift.x - expected_m).abs() < 0.05 * expected_m,
1857 "{} m east against {expected_m} m in {flown_s} s",
1858 drift.x
1859 );
1860 assert!(
1861 drift.y.abs() < 0.02 * expected_m,
1862 "{} m north, which should be second order",
1863 drift.y
1864 );
1865 let without = terminal_speed_m_s(mass_kg, drag_area_m2, air.0.density_kg_m3, G);
1867 assert!(
1868 (-landing.vertical_speed_m_s / without - 1.0).abs() < 1e-5,
1869 "{} vs {without}",
1870 landing.vertical_speed_m_s
1871 );
1872 }
1873
1874 #[test]
1875 fn inflation_time_limits_peak_opening_load() {
1876 let air = UniformAir::sea_level();
1886 let rho = air.0.density_kg_m3;
1887 let diameter_m = 1.5;
1888 let fall = DVec3::new(0.0, 0.0, -60.0);
1889 let mut loads = Vec::new();
1890 let mut mass_kg = 0.0;
1891 for inflation in [
1892 Inflation::Instant,
1893 Inflation::knacke(CanopyType::FlatCircular).unwrap(),
1894 ] {
1895 let device = open_at_start(DeviceDrag::canopy(CanopyType::FlatCircular, diameter_m))
1896 .with_inflation(inflation);
1897 let drag_area_m2 = device.drag.drag_area_m2();
1898 let sim = flight(analytic_environment(air, G), vec![device], 3600.0);
1899 let mut recorder = Recorder::new(
1900 vec![
1901 Channel::Time,
1902 Channel::DynamicPressure,
1903 Channel::RecoveryDragArea,
1904 Channel::VerticalSpeed,
1905 ],
1906 None,
1907 )
1908 .unwrap();
1909 let result = sim
1910 .run_free(START_S, dropped(&sim, 2_000.0, fall), &mut recorder)
1911 .unwrap();
1912 assert_eq!(result.termination, Termination::GroundHit);
1913 mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
1914 let terminal_m_s = terminal_speed_m_s(mass_kg, drag_area_m2, rho, G);
1915 let landing = result.event(EventKind::GroundHit).unwrap().sample;
1916 assert!(
1917 (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 1e-6,
1918 "{}",
1919 landing.vertical_speed_m_s
1920 );
1921 loads.push((peak_load_n(&result, &recorder), drag_area_m2));
1922 }
1923 let (instant_n, drag_area_m2) = loads[0];
1924 let (filled_n, _) = loads[1];
1925 let steady_n = 0.5 * rho * drag_area_m2 * fall.z * fall.z;
1927 assert!(
1928 (instant_n - steady_n).abs() < 1e-6 * steady_n,
1929 "{instant_n} vs {steady_n}"
1930 );
1931 let k_per_m = rho * drag_area_m2 / (2.0 * mass_kg);
1941 let filling_s = CanopyType::FlatCircular.fill_constant().unwrap() * diameter_m / -fall.z;
1942 let end_m_s = -fall.z / (1.0 + k_per_m * -fall.z * filling_s / 3.0);
1943 let closed_n = 0.5 * rho * drag_area_m2 * end_m_s * end_m_s;
1944 let with_gravity_n = closed_n * (1.0 + G * filling_s / end_m_s).powi(2);
1945 assert!(
1946 (closed_n..=with_gravity_n).contains(&filled_n),
1947 "{filled_n} N is outside {closed_n} to {with_gravity_n} N (t_f {filling_s} s)"
1948 );
1949 assert!(filled_n < 0.6 * instant_n, "{filled_n} against {instant_n}");
1950 }
1951
1952 #[test]
1953 fn a_whole_flight_deploys_a_drogue_at_apogee_and_a_main_that_releases_it() {
1954 let devices = vec![
1959 Device::new(
1960 "drogue",
1961 DeviceDrag::DragArea { cd_s_m2: 0.45 },
1962 Trigger::Apogee,
1963 )
1964 .with_lag_s(1.0)
1965 .with_release_by(1),
1966 Device::new(
1967 "main",
1968 DeviceDrag::canopy(CanopyType::FlatCircular, 2.5),
1969 Trigger::Altitude {
1970 height_above_ground_m: 300.0,
1971 },
1972 )
1973 .with_lag_s(1.5),
1974 ];
1975 let drogue_m2 = devices[0].drag.drag_area_m2();
1976 let main_m2 = devices[1].drag.drag_area_m2();
1977 let sim = flight(
1978 analytic_wind_environment(
1979 UniformAir::sea_level(),
1980 G,
1981 ConstantWind::new(4.0, 0.0).unwrap(),
1982 ),
1983 devices,
1984 3600.0,
1985 );
1986 let mut recorder = Recorder::new(
1987 vec![
1988 Channel::Time,
1989 Channel::HeightAboveGround,
1990 Channel::VerticalSpeed,
1991 Channel::RecoveryDragArea,
1992 ],
1993 Some(0.25),
1994 )
1995 .unwrap();
1996 let result = sim.run(&mut recorder).unwrap();
1997 assert_eq!(result.termination, Termination::GroundHit);
1998
1999 let kinds: Vec<EventKind> = result.events.iter().map(|event| event.kind).collect();
2000 assert_eq!(
2001 kinds,
2002 vec![
2003 EventKind::Liftoff,
2004 EventKind::RailExit,
2005 EventKind::Burnout,
2006 EventKind::Apogee,
2007 EventKind::Trigger(0),
2008 EventKind::Deployment(0),
2009 EventKind::Trigger(1),
2010 EventKind::Deployment(1),
2011 EventKind::Release(0),
2012 EventKind::GroundHit,
2013 ]
2014 );
2015 let at = |kind| result.event(kind).unwrap().sample;
2016 assert!(
2018 (at(EventKind::Deployment(0)).time_s - at(EventKind::Trigger(0)).time_s - 1.0).abs()
2019 < 1e-12
2020 );
2021 assert!(
2022 (at(EventKind::Deployment(1)).time_s - at(EventKind::Trigger(1)).time_s - 1.5).abs()
2023 < 1e-12
2024 );
2025 assert_eq!(at(EventKind::Apogee).phase, crate::Phase::Free);
2026 assert_eq!(at(EventKind::Deployment(0)).phase, crate::Phase::Descent);
2027 let trigger = at(EventKind::Trigger(1));
2029 assert!(
2030 (trigger.height_above_ground_m - 300.0).abs() < 1e-6,
2031 "{trigger:?}"
2032 );
2033 assert!(trigger.vertical_speed_m_s < 0.0);
2034 assert!((at(EventKind::Deployment(0)).recovery_drag_area_m2 - drogue_m2).abs() < 1e-12);
2036 assert!((at(EventKind::Trigger(1)).recovery_drag_area_m2 - drogue_m2).abs() < 1e-12);
2037 assert!((at(EventKind::Deployment(1)).recovery_drag_area_m2 - main_m2).abs() < 1e-12);
2038 assert!((at(EventKind::GroundHit).recovery_drag_area_m2 - main_m2).abs() < 1e-12);
2039
2040 let mass_kg = sim
2043 .assembly()
2044 .mass_properties(at(EventKind::Apogee).time_s)
2045 .mass_kg;
2046 let rho = UniformAir::sea_level().0.density_kg_m3;
2047 let under_drogue = terminal_speed_m_s(mass_kg, drogue_m2, rho, G);
2048 let under_main = terminal_speed_m_s(mass_kg, main_m2, rho, G);
2049 assert!(
2050 (-trigger.vertical_speed_m_s / under_drogue - 1.0).abs() < 0.02,
2051 "{} vs {under_drogue}",
2052 trigger.vertical_speed_m_s
2053 );
2054 let landing = at(EventKind::GroundHit);
2055 assert!(
2056 (-landing.vertical_speed_m_s / under_main - 1.0).abs() < 0.02,
2057 "{} vs {under_main}",
2058 landing.vertical_speed_m_s
2059 );
2060 assert!(
2061 under_main < 0.5 * under_drogue,
2062 "{under_main} {under_drogue}"
2063 );
2064 let area = column(&recorder, "recovery_drag_area_m2");
2066 assert!(area.iter().all(|a| *a <= main_m2 + 1e-12));
2067 assert!(area.iter().any(|a| (*a - drogue_m2).abs() < 1e-12));
2068 }
2069
2070 #[test]
2071 fn a_motor_delay_fires_a_device_and_a_canopy_fills_by_knackes_law() {
2072 let kind = CanopyType::FlatCircular;
2076 let diameter_m = 1.2;
2077 let sim = Simulation::new(
2079 &with_delay(design("rocketpy-valetudo"), 2.0),
2080 "example",
2081 analytic_environment(UniformAir::sea_level(), G),
2082 Rail::vertical(3.0),
2083 FlightSettings::default(),
2084 )
2085 .unwrap()
2086 .with_recovery(vec![
2087 Device::new(
2088 "ejection",
2089 DeviceDrag::canopy(kind, diameter_m),
2090 Trigger::MotorDelay { motor: 0 },
2091 )
2092 .with_inflation(Inflation::knacke(kind).unwrap()),
2093 ])
2094 .unwrap();
2095 let delay_s = match sim.assembly().motors[0].mounted.delay {
2096 Some(hpr_motor::Delay::Seconds(delay_s)) => delay_s,
2097 other => panic!("Valetudo's motor has no delay in seconds: {other:?}"),
2098 };
2099 let burnout_s = sim.assembly().motors[0].mounted.motor.burnout_time_s();
2100 let mut recorder =
2101 Recorder::new(vec![Channel::Time, Channel::RecoveryDragArea], None).unwrap();
2102 let result = sim.run(&mut recorder).unwrap();
2103 assert_eq!(result.termination, Termination::GroundHit);
2104 let trigger = result.event(EventKind::Trigger(0)).unwrap().sample;
2105 let deployment = result.event(EventKind::Deployment(0)).unwrap().sample;
2106 assert!(
2107 (trigger.time_s - (burnout_s + delay_s)).abs() < 1e-12,
2108 "{} vs {}",
2109 trigger.time_s,
2110 burnout_s + delay_s
2111 );
2112 assert_eq!(deployment.time_s, trigger.time_s, "no lag was given");
2113 assert_eq!(deployment.recovery_drag_area_m2, 0.0, "it starts empty");
2114
2115 let filling_s = kind.fill_constant().unwrap() * diameter_m / deployment.airspeed_m_s;
2117 assert!(filling_s > 0.05 && filling_s < 0.5, "{filling_s} s");
2118 let full_m2 = sim.recovery()[0].drag.drag_area_m2();
2119 let times = column(&recorder, "time_s");
2120 let areas = column(&recorder, "recovery_drag_area_m2");
2121 let mut checked = 0;
2122 for (t, area) in times.iter().zip(&areas) {
2123 let elapsed = t - deployment.time_s;
2124 if elapsed <= 0.0 {
2125 assert_eq!(*area, 0.0, "area before line stretch at {t}");
2126 continue;
2127 }
2128 let fraction = (elapsed / filling_s).min(1.0);
2129 let expected = full_m2 * fraction.powf(kind.growth_exponent().unwrap());
2130 assert!(
2131 (area - expected).abs() < 1e-9 * full_m2,
2132 "{area} vs {expected} at {t}"
2133 );
2134 if elapsed < filling_s {
2135 checked += 1;
2136 }
2137 }
2138 assert!(checked >= 3, "only {checked} rows inside the filling time");
2139 }
2140
2141 #[test]
2145 fn descent_matches_rocketpy_examples() {
2146 let fixture = include_str!("../../../validation/fixtures/recovery/rocketpy-descent.json");
2147 let document: serde_json::Value = serde_json::from_str(fixture).unwrap();
2148 assert_eq!(document["oracle"], "rocketpy 1.13.0");
2149 let cases = document["cases"].as_array().unwrap();
2150 assert!(cases.len() >= 3, "{} cases", cases.len());
2151 let number = |value: &serde_json::Value| value.as_f64().unwrap();
2152 let mut rows = Vec::new();
2153
2154 for case in cases {
2155 let name = case["name"].as_str().unwrap();
2156 let environment = case["environment"].clone();
2157
2158 let site = hpr_core::geodesy::Geodetic::from_degrees(
2162 number(&environment["latitude_deg"]),
2163 number(&environment["longitude_deg"]),
2164 number(&environment["elevation_m"]),
2165 )
2166 .unwrap();
2167 let wind = wind_of(&environment);
2168 let earth = hpr_core::earth::Earth::new(
2177 hpr_core::gravity::NormalGravity::wgs84(),
2178 site,
2179 hpr_core::earth::GravityModel::VerticalTaylor,
2180 hpr_core::earth::EarthRotation::Coriolis,
2181 )
2182 .unwrap();
2183 let sim = Simulation::new(
2184 &design(&format!("rocketpy-{name}")),
2185 "example",
2186 Environment {
2187 wind,
2188 ..Environment::new(
2189 earth,
2190 hpr_atmos::AtmosphereModel::default(),
2191 hpr_atmos::ConstantWind::calm(),
2192 )
2193 },
2194 Rail::vertical(6.0),
2197 FlightSettings {
2198 max_time_s: 6000.0,
2199 ..FlightSettings::default()
2200 },
2201 )
2202 .unwrap();
2203
2204 for sample in environment["samples"].as_array().unwrap() {
2207 let height_msl_m = number(&sample["height_msl_m"]);
2208 let air = sim.environment().atmosphere.air(height_msl_m).unwrap().air;
2209 let density = air.density_kg_m3;
2210 let oracle = number(&sample["density_kg_m3"]);
2211 assert!(
2216 (density - oracle).abs() < 5e-4 * oracle,
2217 "{name}: density {density} vs {oracle} at {height_msl_m} m"
2218 );
2219 let up = DVec3::new(0.0, 0.0, height_msl_m - number(&environment["elevation_m"]));
2226 let gravity = sim.environment().earth.gravity_enu_mps2(up).unwrap();
2227 let oracle_gravity = number(&sample["gravity_m_s2"]);
2228 assert!(
2233 (gravity.length() - oracle_gravity).abs() < 1e-8 * oracle_gravity,
2234 "{name}: gravity {gravity:?} vs {oracle_gravity} at {height_msl_m} m"
2235 );
2236 assert_eq!(
2237 (gravity.x, gravity.y),
2238 (0.0, 0.0),
2239 "{name}: gravity leans off the vertical where RocketPy's cannot"
2240 );
2241 let latitude_rad = number(&environment["latitude_deg"]).to_radians();
2254 let expected = -8.15e-9 * height_msl_m * (2.0 * latitude_rad).sin();
2255 let ellipsoidal = hpr_core::earth::Earth::new(
2256 hpr_core::gravity::NormalGravity::wgs84(),
2257 site,
2258 hpr_core::earth::GravityModel::Ellipsoidal,
2259 hpr_core::earth::EarthRotation::Coriolis,
2260 )
2261 .unwrap()
2262 .gravity_enu_mps2(up)
2263 .unwrap();
2264 assert!(
2265 ellipsoidal.x.abs() < 1e-12,
2266 "{name}: normal gravity has no east component, but this one is {}",
2267 ellipsoidal.x
2268 );
2269 assert!(
2270 (ellipsoidal.y - expected).abs() <= 0.02 * expected.abs().max(1e-9),
2271 "{name}: hpr's own gravity leans {} m/s² at {height_msl_m} m, where the \
2272 first-order normal-gravity term is {expected} m/s² (issue #27)",
2273 ellipsoidal.y
2274 );
2275 let wind = sim
2276 .environment()
2277 .wind
2278 .wind(height_msl_m)
2279 .unwrap()
2280 .velocity_enu_m_s;
2281 for (component, key) in [(wind.x, "wind_east_m_s"), (wind.y, "wind_north_m_s")] {
2282 let oracle = number(&sample[key]);
2283 assert!(
2284 (component - oracle).abs() < 1e-9,
2285 "{name}: {key} {component} vs {oracle} at {height_msl_m} m"
2286 );
2287 }
2288 assert_eq!(wind.z, 0.0);
2289 }
2290
2291 let start = case["start"].clone();
2295 let start_s = number(&start["time_s"]);
2296 let oracle_devices = case["devices"].as_array().unwrap();
2297 let mut devices = Vec::new();
2298 for (index, device) in oracle_devices.iter().enumerate() {
2299 let drag = DeviceDrag::DragArea {
2300 cd_s_m2: number(&device["cd_s_m2"]),
2301 };
2302 let trigger = if index == 0 {
2303 assert_eq!(device["trigger"]["kind"], "apogee");
2304 assert_eq!(
2305 number(&device["lag_s"]),
2306 0.0,
2307 "{name}: the first lag is zero"
2308 );
2309 Trigger::Time { time_s: start_s }
2310 } else {
2311 assert_eq!(device["trigger"]["kind"], "descending_below_height_agl");
2312 Trigger::Altitude {
2313 height_above_ground_m: number(&device["trigger"]["height_m"]),
2314 }
2315 };
2316 let mut next = Device::new(device["name"].as_str().unwrap(), drag, trigger)
2317 .with_lag_s(number(&device["lag_s"]));
2318 if index + 1 < oracle_devices.len() {
2319 next = next.with_release_by(index + 1);
2320 }
2321 devices.push(next);
2322 }
2323 let sim = sim.with_recovery(devices).unwrap();
2324
2325 let mass_kg = sim.assembly().mass_properties(start_s).mass_kg;
2329 let dry_mass_kg = number(&case["dry_mass_kg"]);
2330 assert!(
2331 (mass_kg - dry_mass_kg).abs() < 1e-9 * dry_mass_kg,
2332 "{name}: mass {mass_kg} vs the oracle's dry mass {dry_mass_kg}"
2333 );
2334
2335 let position = start["position_msl_m"].as_array().unwrap();
2337 let velocity = start["velocity_m_s"].as_array().unwrap();
2338 let cg_enu_m = DVec3::new(
2339 number(&position[0]),
2340 number(&position[1]),
2341 number(&position[2]) - number(&environment["elevation_m"]),
2342 );
2343 let attitude = Rail::vertical(6.0).attitude();
2344 let state = State {
2345 position_enu_m: cg_enu_m
2346 - attitude.mul_vec3(sim.assembly().mass_properties(start_s).cg_m),
2347 velocity_enu_m_s: DVec3::new(
2348 number(&velocity[0]),
2349 number(&velocity[1]),
2350 number(&velocity[2]),
2351 ),
2352 attitude,
2353 body_rate_rad_s: DVec3::ZERO,
2354 };
2355 let result = sim.run_free(start_s, state, &mut ()).unwrap();
2356 assert_eq!(result.termination, Termination::GroundHit, "{name}");
2357 let landing = result.event(EventKind::GroundHit).unwrap().sample;
2358
2359 let first = &case["events"].as_array().unwrap()[0];
2366 let late_s = number(&first["deploy_s"]) - start_s;
2367 let descent_s = number(&case["metrics"]["descent_time_s"]);
2368 assert!(
2369 (0.0..=0.02).contains(&late_s) && late_s < 1e-4 * descent_s,
2370 "{name}: the oracle deployed {late_s} s after the start, of a {descent_s} s descent"
2371 );
2372
2373 for (index, device) in oracle_devices.iter().enumerate().skip(1) {
2376 let trigger = result
2377 .event(EventKind::Trigger(index))
2378 .unwrap_or_else(|| panic!("{name}: device {index} never fired"))
2379 .sample;
2380 let height_m = number(&device["trigger"]["height_m"]);
2381 assert!(
2382 (trigger.height_above_ground_m - height_m).abs() < 1e-3,
2383 "{name}: device {index} fired at {} m, not its setting {height_m}",
2384 trigger.height_above_ground_m
2385 );
2386 let oracle_event = &case["events"].as_array().unwrap()[index];
2387 rows.push((
2392 format!(
2393 "{name}: device {index} trigger height, hpr against the oracle's report"
2394 ),
2395 trigger.height_above_ground_m,
2396 number(&oracle_event["height_above_ground_at_trigger_m"]),
2397 ));
2398 let oracle_speed = -number(&oracle_event["vertical_speed_at_trigger_m_s"]);
2400 let speed = -trigger.vertical_speed_m_s;
2401 rows.push((
2402 format!("{name}: descent rate under device {}", index - 1),
2403 speed,
2404 oracle_speed,
2405 ));
2406 }
2407
2408 let last = oracle_devices.last().unwrap();
2412 let air = sim
2413 .environment()
2414 .atmosphere
2415 .air(number(&environment["elevation_m"]))
2416 .unwrap()
2417 .air;
2418 let gravity_m_s2 = sim
2419 .environment()
2420 .earth
2421 .gravity_enu_mps2(DVec3::ZERO)
2422 .unwrap()
2423 .length();
2424 let equilibrium_m_s = terminal_speed_m_s(
2425 mass_kg,
2426 number(&last["cd_s_m2"]),
2427 air.density_kg_m3,
2428 gravity_m_s2,
2429 );
2430 for (who, speed) in [
2431 ("hpr", -landing.vertical_speed_m_s),
2432 ("rocketpy", number(&case["metrics"]["impact_speed_m_s"])),
2433 ] {
2434 assert!(
2435 (speed - equilibrium_m_s).abs() < 0.01 * equilibrium_m_s,
2436 "{name}: {who} lands at {speed} m/s, not the equilibrium {equilibrium_m_s}"
2437 );
2438 }
2439
2440 let metrics = case["metrics"].clone();
2441 let descent_time_s = landing.time_s - start_s;
2442 let drift = landing.cg_enu_m - cg_enu_m;
2443 rows.push((
2444 format!("{name}: descent time"),
2445 descent_time_s,
2446 number(&metrics["descent_time_s"]),
2447 ));
2448 rows.push((
2449 format!("{name}: impact descent rate"),
2450 -landing.vertical_speed_m_s,
2451 number(&metrics["impact_speed_m_s"]),
2452 ));
2453 rows.push((
2454 format!("{name}: drift"),
2455 drift.truncate().length(),
2456 number(&metrics["drift_m"]),
2457 ));
2458 if number(&metrics["drift_m"]) > 10.0 {
2459 rows.push((
2460 format!("{name}: drift east"),
2461 drift.x,
2462 number(&metrics["drift_east_m"]),
2463 ));
2464 rows.push((
2465 format!("{name}: drift north"),
2466 drift.y,
2467 number(&metrics["drift_north_m"]),
2468 ));
2469 }
2470 }
2471
2472 let mut worst: f64 = 0.0;
2474 let mut report = String::new();
2475 for (what, hpr, oracle) in &rows {
2476 let error = (hpr - oracle) / oracle;
2477 worst = worst.max(error.abs());
2478 report.push_str(&format!(
2479 "{what}: hpr {hpr:.4}, rocketpy {oracle:.4}, {:+.2}%\n",
2480 100.0 * error
2481 ));
2482 }
2483 eprintln!("{report}");
2484 assert!(worst < 0.03, "worst error {:.2}%:\n{report}", 100.0 * worst);
2485 }
2486
2487 fn wind_of(environment: &serde_json::Value) -> std::sync::Arc<dyn hpr_atmos::Wind> {
2491 fn level(height_msl_m: f64, east: f64, north: f64) -> hpr_atmos::WindLevel {
2494 hpr_atmos::WindLevel {
2495 height_msl_m,
2496 speed_m_s: east.hypot(north),
2497 direction_from_rad: (-east).atan2(-north).rem_euclid(std::f64::consts::TAU),
2498 }
2499 }
2500 let components = |key: &str| -> Option<Vec<(f64, f64)>> {
2501 environment[key].as_array().map(|rows| {
2502 rows.iter()
2503 .map(|row| {
2504 let row = row.as_array().unwrap();
2505 (row[0].as_f64().unwrap(), row[1].as_f64().unwrap())
2506 })
2507 .collect()
2508 })
2509 };
2510 let levels = match (components("wind_u"), components("wind_v")) {
2511 (Some(east), Some(north)) => {
2512 assert_eq!(east.len(), north.len());
2513 east.iter()
2514 .zip(&north)
2515 .map(|((height_msl_m, east), (other, north))| {
2516 assert_eq!(height_msl_m, other, "the profiles differ in height");
2517 level(*height_msl_m, *east, *north)
2518 })
2519 .collect()
2520 }
2521 (None, None) => vec![level(
2522 0.0,
2523 environment["wind_u"].as_f64().unwrap(),
2524 environment["wind_v"].as_f64().unwrap(),
2525 )],
2526 _ => panic!("one wind component is a profile and the other is not"),
2527 };
2528 std::sync::Arc::new(
2529 hpr_atmos::LayeredWind::new(levels, hpr_atmos::WindInterpolation::Components).unwrap(),
2530 )
2531 }
2532
2533 #[test]
2534 fn a_deployment_keeps_the_center_of_mass_moving_as_it_was() {
2535 let air = UniformAir::sea_level();
2541 let device = open_at_start(DeviceDrag::DragArea { cd_s_m2: 1.5 });
2542 let sim = flight(analytic_environment(air, G), vec![device], 3600.0);
2543 let rate_rad_s = DVec3::new(0.0, 0.7, 0.0);
2544 let mut state = dropped(&sim, 1_500.0, DVec3::new(2.0, 0.0, -12.0));
2545 state.body_rate_rad_s = rate_rad_s;
2546 let cg_m = sim.assembly().mass_properties(START_S).cg_m;
2547 let before =
2550 state.velocity_enu_m_s + state.unit_attitude().mul_vec3(rate_rad_s.cross(cg_m));
2551 assert!(
2552 (before - state.velocity_enu_m_s).length() > 0.5,
2553 "the test needs a lever arm: {before}"
2554 );
2555 let result = sim.run_free(START_S, state, &mut ()).unwrap();
2556 let deployment = result.event(EventKind::Deployment(0)).unwrap().sample;
2557 assert_eq!(deployment.time_s, START_S);
2558 assert!(
2559 (deployment.cg_velocity_enu_m_s - before).length() < 1e-12,
2560 "{} vs {before}",
2561 deployment.cg_velocity_enu_m_s
2562 );
2563 assert_eq!(deployment.state.body_rate_rad_s, DVec3::ZERO);
2564 let mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
2566 let terminal_m_s = terminal_speed_m_s(mass_kg, 1.5, air.0.density_kg_m3, G);
2567 let landing = result.event(EventKind::GroundHit).unwrap().sample;
2568 assert!(
2569 (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 1e-6,
2570 "{} vs {terminal_m_s}",
2571 landing.vertical_speed_m_s
2572 );
2573 }
2574
2575 #[test]
2576 fn a_deployment_during_a_burn_keeps_the_center_of_mass_moving_as_it_was() {
2577 let sim = flight(
2584 analytic_wind_environment(
2585 UniformAir::sea_level(),
2586 G,
2587 ConstantWind::new(8.0, 1.5).unwrap(),
2588 ),
2589 vec![Device::new(
2590 "early",
2591 DeviceDrag::DragArea { cd_s_m2: 0.2 },
2592 Trigger::Time { time_s: 2.0 },
2593 )],
2594 3600.0,
2595 );
2596 let result = sim.run(&mut ()).unwrap();
2597 let trigger = result.event(EventKind::Trigger(0)).unwrap().sample;
2598 let deployment = result.event(EventKind::Deployment(0)).unwrap().sample;
2599 assert_eq!(trigger.time_s, 2.0);
2600 assert_eq!(deployment.time_s, 2.0);
2601 assert!(
2602 trigger.thrust_n > 0.0,
2603 "the motor has to be burning: {trigger:?}"
2604 );
2605 assert!(
2606 trigger.state.body_rate_rad_s.length() > 1e-3,
2607 "the flight needs a body rate to shift by: {}",
2608 trigger.state.body_rate_rad_s
2609 );
2610 assert!(
2612 (deployment.cg_velocity_enu_m_s - trigger.cg_velocity_enu_m_s).length() < 1e-12,
2613 "{} vs {}",
2614 deployment.cg_velocity_enu_m_s,
2615 trigger.cg_velocity_enu_m_s
2616 );
2617 let cg_m = sim.assembly().mass_properties(2.0).cg_m;
2618 let shift = trigger
2619 .state
2620 .unit_attitude()
2621 .mul_vec3(trigger.state.body_rate_rad_s.cross(cg_m));
2622 assert!(shift.length() > 1e-3, "{shift}");
2623 assert!(
2624 (deployment.state.velocity_enu_m_s - trigger.state.velocity_enu_m_s - shift).length()
2625 < 1e-12,
2626 "{} vs {} + {shift}",
2627 deployment.state.velocity_enu_m_s,
2628 trigger.state.velocity_enu_m_s
2629 );
2630 assert_eq!(deployment.phase, crate::Phase::Descent);
2631 assert_eq!(result.termination, Termination::GroundHit);
2632 }
2633
2634 #[test]
2635 fn devices_that_open_together_add_their_drag_areas() {
2636 let air = UniformAir::sea_level();
2639 let devices = vec![
2640 open_at_start(DeviceDrag::DragArea { cd_s_m2: 1.0 }),
2641 open_at_start(DeviceDrag::DragArea { cd_s_m2: 2.0 }),
2642 ];
2643 let sim = flight(analytic_environment(air, G), devices, 3600.0);
2644 let result = sim
2645 .run_free(START_S, dropped(&sim, 1_000.0, DVec3::ZERO), &mut ())
2646 .unwrap();
2647 assert_eq!(result.termination, Termination::GroundHit);
2648 for index in 0..2 {
2649 let deployment = result.event(EventKind::Deployment(index)).unwrap().sample;
2650 assert_eq!(deployment.time_s, START_S, "device {index}");
2651 }
2652 let landing = result.event(EventKind::GroundHit).unwrap().sample;
2653 assert!((landing.recovery_drag_area_m2 - 3.0).abs() < 1e-12);
2654 let mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
2655 let terminal_m_s = terminal_speed_m_s(mass_kg, 3.0, air.0.density_kg_m3, G);
2656 assert!(
2657 (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 1e-6,
2658 "{} vs {terminal_m_s}",
2659 landing.vertical_speed_m_s
2660 );
2661 }
2662
2663 #[test]
2664 fn a_device_released_before_it_opens_never_pulls() {
2665 let air = UniformAir::sea_level();
2668 let devices = vec![
2669 Device::new(
2670 "drogue",
2671 DeviceDrag::DragArea { cd_s_m2: 4.0 },
2672 Trigger::Time {
2673 time_s: START_S + 5.0,
2674 },
2675 )
2676 .with_release_by(1),
2677 open_at_start(DeviceDrag::DragArea { cd_s_m2: 1.0 }),
2678 ];
2679 let sim = flight(analytic_environment(air, G), devices, 3600.0);
2680 let result = sim
2681 .run_free(START_S, dropped(&sim, 1_000.0, DVec3::ZERO), &mut ())
2682 .unwrap();
2683 assert_eq!(result.termination, Termination::GroundHit);
2684 let release = result.event(EventKind::Release(0)).unwrap().sample;
2685 assert_eq!(
2686 release.time_s, START_S,
2687 "the main opens at once, so it releases at once"
2688 );
2689 let trigger = result.event(EventKind::Trigger(0)).unwrap().sample;
2690 assert_eq!(trigger.time_s, START_S + 5.0);
2691 assert!(
2692 result.event(EventKind::Deployment(0)).is_none(),
2693 "a released device must not deploy: {:?}",
2694 result.events.iter().map(|e| e.kind).collect::<Vec<_>>()
2695 );
2696 let landing = result.event(EventKind::GroundHit).unwrap().sample;
2697 assert!((landing.recovery_drag_area_m2 - 1.0).abs() < 1e-12);
2698 let mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
2699 let terminal_m_s = terminal_speed_m_s(mass_kg, 1.0, air.0.density_kg_m3, G);
2700 assert!(
2701 (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 1e-6,
2702 "{} vs {terminal_m_s}",
2703 landing.vertical_speed_m_s
2704 );
2705 }
2706
2707 #[test]
2708 fn a_release_waits_for_the_main_to_fill_so_the_drag_area_never_dips() {
2709 let air = UniformAir::sea_level();
2713 let drogue_m2 = 0.45;
2714 let main_m2 = 6.0;
2715 let filling_s = 2.0;
2716 let devices = vec![
2717 open_at_start(DeviceDrag::DragArea { cd_s_m2: drogue_m2 }).with_release_by(1),
2718 Device::new(
2719 "main",
2720 DeviceDrag::DragArea { cd_s_m2: main_m2 },
2721 Trigger::Time {
2722 time_s: START_S + 20.0,
2723 },
2724 )
2725 .with_inflation(Inflation::FillingTime {
2726 time_s: filling_s,
2727 exponent: 2.0,
2728 }),
2729 ];
2730 let sim = flight(analytic_environment(air, G), devices, 3600.0);
2731 let mut recorder = Recorder::new(
2732 vec![
2733 Channel::Time,
2734 Channel::RecoveryDragArea,
2735 Channel::VerticalSpeed,
2736 ],
2737 None,
2738 )
2739 .unwrap();
2740 let result = sim
2741 .run_free(START_S, dropped(&sim, 2_000.0, DVec3::ZERO), &mut recorder)
2742 .unwrap();
2743 assert_eq!(result.termination, Termination::GroundHit);
2744 let main = result.event(EventKind::Deployment(1)).unwrap().sample;
2745 let release = result.event(EventKind::Release(0)).unwrap().sample;
2746 assert_eq!(main.time_s, START_S + 20.0);
2747 assert!(
2748 (release.time_s - (main.time_s + filling_s)).abs() < 1e-9,
2749 "released at {} m, not at the end of filling {}",
2750 release.time_s,
2751 main.time_s + filling_s
2752 );
2753 let areas = column(&recorder, "recovery_drag_area_m2");
2756 let times = column(&recorder, "time_s");
2757 let speeds = column(&recorder, "vertical_speed_m_s");
2758 let mut worst_area = f64::INFINITY;
2759 let mut fastest = 0.0_f64;
2760 for ((t, area), speed) in times.iter().zip(&areas).zip(&speeds) {
2761 if *t < main.time_s {
2762 continue;
2763 }
2764 worst_area = worst_area.min(*area);
2765 fastest = fastest.max(-speed);
2766 }
2767 assert!(
2768 worst_area >= drogue_m2 - 1e-12,
2769 "the drag area dipped to {worst_area} m², below the drogue's {drogue_m2}"
2770 );
2771 assert!(
2772 fastest <= -main.vertical_speed_m_s + 1e-9,
2773 "the descent sped up after the main fired: {fastest} against {}",
2774 -main.vertical_speed_m_s
2775 );
2776 let landing = result.event(EventKind::GroundHit).unwrap().sample;
2778 assert!((landing.recovery_drag_area_m2 - main_m2).abs() < 1e-12);
2779 }
2780
2781 #[test]
2782 fn a_recovered_flight_repeats_bit_identically() {
2783 let sim = flight(
2786 analytic_wind_environment(
2787 UniformAir::sea_level(),
2788 G,
2789 ConstantWind::new(3.0, 1.2).unwrap(),
2790 ),
2791 vec![
2792 Device::new(
2793 "drogue",
2794 DeviceDrag::canopy(CanopyType::FlatCircular, 0.5),
2795 Trigger::Apogee,
2796 )
2797 .with_lag_s(0.75)
2798 .with_inflation(Inflation::knacke(CanopyType::FlatCircular).unwrap())
2799 .with_release_by(1),
2800 Device::new(
2801 "main",
2802 DeviceDrag::canopy(CanopyType::FlatCircular, 2.0),
2803 Trigger::Altitude {
2804 height_above_ground_m: 200.0,
2805 },
2806 )
2807 .with_lag_s(1.25)
2808 .with_inflation(Inflation::knacke(CanopyType::FlatCircular).unwrap()),
2809 ],
2810 3600.0,
2811 );
2812 let fly = || {
2813 let mut recorder = Recorder::new(Channel::ALL.to_vec(), Some(0.1)).unwrap();
2814 let result = sim.run(&mut recorder).unwrap();
2815 (recorder.rows().to_vec(), result)
2816 };
2817 let (first_rows, first) = fly();
2818 let (second_rows, second) = fly();
2819 assert_eq!(first.termination, Termination::GroundHit);
2820 assert!(first_rows.len() > 100, "{} rows", first_rows.len());
2821 assert_eq!(first_rows, second_rows, "the rows differ between runs");
2822 assert_eq!(
2823 first.events, second.events,
2824 "the events differ between runs"
2825 );
2826 assert_eq!(first.final_sample, second.final_sample);
2827 assert_eq!(first.stats, second.stats);
2828 let kinds: Vec<EventKind> = first.events.iter().map(|event| event.kind).collect();
2830 assert_eq!(
2831 kinds,
2832 vec![
2833 EventKind::Liftoff,
2834 EventKind::RailExit,
2835 EventKind::Burnout,
2836 EventKind::Apogee,
2837 EventKind::Trigger(0),
2838 EventKind::Deployment(0),
2839 EventKind::Trigger(1),
2840 EventKind::Deployment(1),
2841 EventKind::Release(0),
2842 EventKind::GroundHit,
2843 ]
2844 );
2845 }
2846
2847 #[test]
2848 fn an_apogee_charge_fires_on_a_flight_that_starts_descending() {
2849 let air = UniformAir::sea_level();
2854 let device = Device::new(
2855 "main",
2856 DeviceDrag::DragArea { cd_s_m2: 2.0 },
2857 Trigger::Apogee,
2858 );
2859 let drag_area_m2 = device.drag.drag_area_m2();
2860 let sim = flight(analytic_environment(air, G), vec![device], 3600.0);
2861 let result = sim
2862 .run_free(
2863 START_S,
2864 dropped(&sim, 1_000.0, DVec3::new(0.0, 0.0, -5.0)),
2865 &mut (),
2866 )
2867 .unwrap();
2868 assert_eq!(result.termination, Termination::GroundHit);
2869 let deployment = result.event(EventKind::Deployment(0)).unwrap().sample;
2870 assert_eq!(deployment.time_s, START_S, "it is already descending");
2871 assert_eq!(
2872 result.event(EventKind::Apogee),
2873 None,
2874 "there is no apogee to find"
2875 );
2876 let mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
2877 let terminal_m_s = terminal_speed_m_s(mass_kg, drag_area_m2, air.0.density_kg_m3, G);
2878 let landing = result.event(EventKind::GroundHit).unwrap().sample;
2879 assert!(
2880 (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 1e-6,
2881 "{} vs {terminal_m_s}",
2882 landing.vertical_speed_m_s
2883 );
2884 let climbing = sim
2886 .run_free(
2887 START_S,
2888 dropped(&sim, 1_000.0, DVec3::new(0.0, 0.0, 30.0)),
2889 &mut (),
2890 )
2891 .unwrap();
2892 let apogee = climbing.event(EventKind::Apogee).unwrap().sample;
2893 let fired = climbing.event(EventKind::Trigger(0)).unwrap().sample;
2894 assert_eq!(fired.time_s, apogee.time_s);
2895 assert!(apogee.time_s > START_S + 1.0, "{}", apogee.time_s);
2896 }
2897
2898 #[test]
2899 fn user_events_keep_their_numbers_and_fire_during_the_descent() {
2900 let air = UniformAir::sea_level();
2904 let devices = vec![
2905 open_at_start(DeviceDrag::DragArea { cd_s_m2: 0.5 }).with_release_by(1),
2906 Device::new(
2907 "main",
2908 DeviceDrag::DragArea { cd_s_m2: 4.0 },
2909 Trigger::Altitude {
2910 height_above_ground_m: 400.0,
2911 },
2912 ),
2913 ];
2914 let sim = flight(analytic_environment(air, G), devices, 3600.0)
2915 .with_event(crate::flight::UserEvent {
2916 name: "through 700 m".to_owned(),
2917 direction: crate::events::Direction::Falling,
2918 function: Box::new(|sample| sample.height_above_ground_m - 700.0),
2919 })
2920 .with_event(crate::flight::UserEvent {
2921 name: "through 100 m".to_owned(),
2922 direction: crate::events::Direction::Falling,
2923 function: Box::new(|sample| sample.height_above_ground_m - 100.0),
2924 });
2925 let result = sim
2926 .run_free(START_S, dropped(&sim, 1_000.0, DVec3::ZERO), &mut ())
2927 .unwrap();
2928 assert_eq!(result.termination, Termination::GroundHit);
2929 for (user, height_m) in [(0usize, 700.0), (1, 100.0)] {
2930 let event = result
2931 .event(EventKind::User(user))
2932 .unwrap_or_else(|| panic!("user event {user} never fired"))
2933 .sample;
2934 assert!(
2935 (event.height_above_ground_m - height_m).abs() < 1e-6,
2936 "user event {user} fired at {} m",
2937 event.height_above_ground_m
2938 );
2939 assert_eq!(event.phase, crate::Phase::Descent);
2940 }
2941 let kinds: Vec<EventKind> = result.events.iter().map(|event| event.kind).collect();
2943 let position = |kind| kinds.iter().position(|other| *other == kind).unwrap();
2944 assert!(position(EventKind::User(0)) < position(EventKind::Trigger(1)));
2945 assert!(position(EventKind::Trigger(1)) < position(EventKind::User(1)));
2946 assert!(position(EventKind::User(1)) < position(EventKind::GroundHit));
2947 }
2948
2949 #[test]
2950 fn the_public_recovery_types_round_trip_through_json() {
2951 let devices = vec![
2952 Device::new(
2953 "drogue",
2954 DeviceDrag::DragArea { cd_s_m2: 0.45 },
2955 Trigger::Apogee,
2956 )
2957 .with_lag_s(1.5)
2958 .with_release_by(1),
2959 Device::new(
2960 "main",
2961 DeviceDrag::canopy(CanopyType::Ringsail, 3.0),
2962 Trigger::Altitude {
2963 height_above_ground_m: 250.0,
2964 },
2965 )
2966 .with_inflation(Inflation::FillingTime {
2967 time_s: 1.5,
2968 exponent: 2.0,
2969 }),
2970 Device::new(
2971 "timer",
2972 DeviceDrag::canopy(CanopyType::FlatCircular, 1.0),
2973 Trigger::Time { time_s: 12.0 },
2974 )
2975 .with_inflation(Inflation::knacke(CanopyType::FlatCircular).unwrap()),
2976 Device::new(
2977 "ejection",
2978 DeviceDrag::DragArea { cd_s_m2: 1.0 },
2979 Trigger::MotorDelay { motor: 0 },
2980 ),
2981 Device::new(
2982 "streamer",
2983 DeviceDrag::streamer(1.2, 0.12, 0.032),
2984 Trigger::Apogee,
2985 ),
2986 Device::new(
2987 "appendix C streamer",
2988 DeviceDrag::Streamer {
2989 length_m: 1.0,
2990 width_m: 0.1,
2991 surface_density_kg_m2: 0.04,
2992 model: StreamerModel::OpenRocket,
2993 },
2994 Trigger::Apogee,
2995 ),
2996 Device::new(
2997 "tumble",
2998 DeviceDrag::tumbling(&design("rocketpy-valetudo").assemble("example").unwrap())
2999 .unwrap(),
3000 Trigger::Apogee,
3001 ),
3002 ];
3003 let text = serde_json::to_string(&devices).unwrap();
3004 let back: Vec<Device> = serde_json::from_str(&text).unwrap();
3005 assert_eq!(devices, back);
3006 let terse: Device = serde_json::from_str(
3008 r#"{"name":"d","drag":{"drag_area":{"cd_s_m2":1.5}},"trigger":"apogee"}"#,
3009 )
3010 .unwrap();
3011 assert_eq!(terse.lag_s, 0.0);
3012 assert_eq!(terse.inflation, Inflation::Instant);
3013 assert_eq!(terse.released_by, None);
3014 assert!(
3015 serde_json::from_str::<Device>(
3016 r#"{"name":"d","drag":{"drag_area":{"cd_s_m2":1.5}},"trigger":"apogee","lg":1}"#
3017 )
3018 .is_err()
3019 );
3020 let typo = r#"{"name":"s","drag":{"streamer":{"length_m":1.0,"width_m":0.1,
3023 "surface_density_kg_m2":0.04,"modle":"open_rocket"}},"trigger":"apogee"}"#;
3024 assert!(serde_json::from_str::<Device>(typo).is_err(), "{typo}");
3025 let named: DeviceDrag = serde_json::from_str(
3027 r#"{"streamer":{"length_m":1.0,"width_m":0.1,"surface_density_kg_m2":0.04,
3028 "model":"open_rocket"}}"#,
3029 )
3030 .unwrap();
3031 assert_eq!(named.canopy_type(), None);
3032 assert!(
3033 (named.drag_area_m2() - StreamerModel::OpenRocket.drag_area_m2(1.0, 0.1, 0.04)).abs()
3034 < 1e-15
3035 );
3036 }
3037
3038 #[test]
3039 fn streamer_models_reproduce_their_printed_equations() {
3040 let cases: [(f64, f64, f64); 6] = [
3044 (10.0, 0.075, 0.405 * 10.0_f64.powf(-0.494)),
3045 (30.0, 0.075, 0.405 * 30.0_f64.powf(-0.494)),
3046 (10.0, 0.025, 0.561 * 10.0_f64.powf(-0.480)),
3047 (3.3, 0.025, 0.561 * 3.3_f64.powf(-0.480)),
3048 (3.3, 0.05, 0.6514 * 3.3_f64.powf(-0.6075)),
3050 (30.0, 0.05, 0.6514 * 30.0_f64.powf(-0.6075)),
3051 ];
3052 for (aspect_ratio, planform_m2, expected) in cases {
3053 let length_m = (planform_m2 * aspect_ratio).sqrt();
3055 let width_m = (planform_m2 / aspect_ratio).sqrt();
3056 let drag_area_m2 = StreamerModel::Filippone.drag_area_m2(length_m, width_m, 0.05);
3057 let coefficient = drag_area_m2 / planform_m2;
3058 assert!(
3059 (coefficient - expected).abs() < 1e-12,
3060 "AR {aspect_ratio} at {planform_m2} m²: {coefficient} vs {expected}"
3061 );
3062 }
3063 let middle_m = (0.5_f64).sqrt();
3066 let between = StreamerModel::Filippone.drag_area_m2(middle_m, middle_m / 10.0, 0.05) / 0.05;
3067 let small = 0.561 * 10.0_f64.powf(-0.480);
3068 let large = 0.405 * 10.0_f64.powf(-0.494);
3069 assert!(large < between && between < small, "{between}");
3070 let huge = StreamerModel::Filippone.drag_area_m2(3.0, 0.3, 0.05) / 0.9;
3071 assert!((huge - large).abs() < 1e-12, "{huge} vs {large}");
3072
3073 let reference = StreamerModel::OpenRocket.drag_area_m2(0.4, 0.04, 0.080) / (0.4 * 0.04);
3077 assert!(
3078 (reference - 0.034 * (0.4 + 1.0) / 0.4).abs() < 1e-12,
3079 "{reference}"
3080 );
3081 let light = StreamerModel::OpenRocket.drag_area_m2(0.4, 0.04, 0.010);
3083 let heavy = StreamerModel::OpenRocket.drag_area_m2(0.4, 0.04, 0.080);
3084 assert!(
3085 (light / heavy - (0.010 + 0.025) / (0.080 + 0.025)).abs() < 1e-12,
3086 "{light} {heavy}"
3087 );
3088 }
3089
3090 fn drop_average_m_s(
3094 mass_kg: f64,
3095 drag_area_m2: f64,
3096 density_kg_m3: f64,
3097 height_m: f64,
3098 ) -> (f64, f64) {
3099 let terminal_m_s = terminal_speed_m_s(mass_kg, drag_area_m2, density_kg_m3, G);
3100 let time_s =
3101 (terminal_m_s / G) * (G * height_m / (terminal_m_s * terminal_m_s)).exp().acosh();
3102 (height_m / time_s, terminal_m_s)
3103 }
3104
3105 #[test]
3106 fn streamer_models_against_kidwells_drop_tests() {
3107 let length_m = 40.0 * 0.0254;
3121 let width_m = 4.0 * 0.0254;
3122 let planform_m2 = length_m * width_m;
3123 let rho = 1.225;
3124 let drop_m = 20.1;
3125 let cases = [
3126 ("crepe paper", 3.3206e-3, 2.80, false),
3128 ("Micafilm", 4.3412e-3, 2.04, true),
3129 ];
3130 let mut report = String::new();
3131 for (material, streamer_kg, measured_m_s, pleated) in cases {
3132 let surface_density_kg_m2 = streamer_kg / planform_m2;
3133 let mass_kg = streamer_kg + 5.0e-3;
3134 let predict = |model: StreamerModel| {
3135 let drag_area_m2 = model.drag_area_m2(length_m, width_m, surface_density_kg_m2);
3136 let (average_m_s, terminal_m_s) =
3137 drop_average_m_s(mass_kg, drag_area_m2, rho, drop_m);
3138 (average_m_s, terminal_m_s, drag_area_m2)
3139 };
3140 let (filippone, _, filippone_m2) = predict(StreamerModel::Filippone);
3141 let (open_rocket, _, open_rocket_m2) = predict(StreamerModel::OpenRocket);
3142 let measured_m2 = {
3144 let mut low = 1e-4;
3146 let mut high = 1.0;
3147 for _ in 0..200 {
3148 let middle = 0.5 * (low + high);
3149 if drop_average_m_s(mass_kg, middle, rho, drop_m).0 > measured_m_s {
3150 low = middle;
3151 } else {
3152 high = middle;
3153 }
3154 }
3155 0.5 * (low + high)
3156 };
3157 report.push_str(&format!(
3158 "{material}: measured {measured_m_s:.2} m/s (C_D S {measured_m2:.5} m², C_D \
3159 {:.3}), Filippone {filippone:.2} ({:+.0}%, C_D S {filippone_m2:.5}), OpenRocket \
3160 {open_rocket:.2} ({:+.0}%, C_D S {open_rocket_m2:.5})\n",
3161 measured_m2 / planform_m2,
3162 100.0 * (filippone / measured_m_s - 1.0),
3163 100.0 * (open_rocket / measured_m_s - 1.0),
3164 ));
3165 assert!(filippone > measured_m_s, "{material}: {filippone}");
3169 assert!(open_rocket > filippone, "{material}: {open_rocket}");
3170 if pleated {
3171 assert!(
3173 (filippone / measured_m_s - 1.0) < 0.75,
3174 "{material}: {filippone} against {measured_m_s}"
3175 );
3176 } else {
3177 assert!(
3179 (filippone / measured_m_s - 1.0) < 0.10,
3180 "{material}: {filippone} against {measured_m_s}"
3181 );
3182 assert!(
3183 (open_rocket / measured_m_s - 1.0) > 0.5,
3184 "{material}: appendix C should be the slow one: {open_rocket}"
3185 );
3186 }
3187 }
3188 eprintln!("{report}");
3189 }
3190
3191 #[test]
3192 fn a_streamer_descends_at_its_cited_terminal_velocity() {
3193 let air = UniformAir::sea_level();
3196 let drag = DeviceDrag::streamer(1.5, 0.15, 0.040);
3197 let drag_area_m2 = drag.drag_area_m2();
3198 let expected_m2 = 0.405 * 10.0_f64.powf(-0.494) * 0.225;
3202 assert!(
3203 (drag_area_m2 - expected_m2).abs() < 1e-12,
3204 "{drag_area_m2} vs {expected_m2}"
3205 );
3206 let sim = flight(
3207 analytic_environment(air, G),
3208 vec![open_at_start(drag)],
3209 3600.0,
3210 );
3211 let mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
3212 let terminal_m_s = terminal_speed_m_s(mass_kg, drag_area_m2, air.0.density_kg_m3, G);
3213 assert!(terminal_m_s > 20.0, "{terminal_m_s}");
3216 let result = sim
3217 .run_free(START_S, dropped(&sim, 6_000.0, DVec3::ZERO), &mut ())
3218 .unwrap();
3219 assert_eq!(result.termination, Termination::GroundHit);
3220 let landing = result.event(EventKind::GroundHit).unwrap().sample;
3221 assert!(
3222 (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 1e-6,
3223 "{} vs {terminal_m_s}",
3224 landing.vertical_speed_m_s
3225 );
3226 }
3227
3228 #[test]
3229 fn the_tumble_model_against_its_own_drop_tests() {
3230 let rho = 1.31;
3237 let models = [
3239 (
3240 "#1", 3usize, 0.070, 0.040, 0.060, 0.044, 0.108, 18.0e-3, 5.6,
3241 ),
3242 ("#2", 4, 0.070, 0.040, 0.060, 0.044, 0.108, 22.0e-3, 6.3),
3243 ("#3", 3, 0.200, 0.140, 0.130, 0.103, 0.290, 160.0e-3, 6.6),
3244 ("#4", 0, 0.0, 0.0, 0.0, 0.044, 0.100, 6.8e-3, 5.4),
3245 ("#5", 4, 0.085, 0.085, 0.050, 0.0, 0.0, 11.5e-3, 5.0),
3246 ];
3247 let mut report = String::new();
3248 let mut worst: f64 = 0.0;
3249 let mut errors = Vec::new();
3250 for (name, fins, root_m, tip_m, span_m, diameter_m, body_m, mass_kg, measured_m_s) in models
3251 {
3252 let fin_area_m2 = if fins == 0 {
3253 0.0
3254 } else {
3255 let one_m2 = 0.5 * (root_m + tip_m) * span_m;
3256 one_m2 * TUMBLE_FIN_EFFICIENCY[fins - 1]
3257 };
3258 let body_profile_m2 = diameter_m * body_m;
3259 let drag_area_m2 = TUMBLE_FIN_DRAG_COEFFICIENT * fin_area_m2
3260 + TUMBLE_BODY_DRAG_COEFFICIENT * body_profile_m2;
3261 let predicted_m_s = terminal_speed_m_s(mass_kg, drag_area_m2, rho, G);
3262 let error = predicted_m_s / measured_m_s - 1.0;
3263 worst = worst.max(error.abs());
3264 errors.push(error);
3265 report.push_str(&format!(
3266 "{name}: measured {measured_m_s:.1} m/s, hpr {predicted_m_s:.2} ({:+.1}%)\n",
3267 100.0 * error
3268 ));
3269 }
3270 eprintln!("{report}");
3271 assert_eq!(
3280 errors
3281 .iter()
3282 .map(|error| format!("{:+.1}", 100.0 * error))
3283 .collect::<Vec<_>>(),
3284 ["-5.8", "-5.4", "-7.2", "+19.0", "-10.0"],
3285 "{report}"
3286 );
3287 assert!(worst < 0.20, "{worst} spread:\n{report}");
3288 }
3289
3290 #[test]
3291 fn tumbling_refuses_an_airframe_the_model_cannot_represent() {
3292 let mut rocket = design("rocketpy-valetudo");
3295 let (index, fins) = rocket.stages[0].components[1]
3296 .children
3297 .iter()
3298 .enumerate()
3299 .find_map(|(index, child)| match &child.part {
3300 hpr_design::Part::FinSet(fins) => Some((index, fins.clone())),
3301 _ => None,
3302 })
3303 .expect("Valetudo has a fin set");
3304 rocket.stages[0].components[1].children[index].part =
3305 hpr_design::Part::TubeFinSet(hpr_design::TubeFinSet {
3306 count: 3,
3307 length_m: 0.15,
3308 outer_radius_m: 0.02,
3309 thickness_m: 0.001,
3310 base_angle_rad: 0.0,
3311 material: fins.material.clone(),
3312 });
3313 let assembly = rocket.assemble("example").unwrap();
3314 let error = DeviceDrag::tumbling(&assembly).expect_err("tube fins");
3315 assert!(matches!(error, SimError::Domain { .. }), "{error:?}");
3316 assert!(
3318 DeviceDrag::tumbling(&design("rocketpy-valetudo").assemble("example").unwrap()).is_ok()
3319 );
3320
3321 let mut rocket = design("rocketpy-valetudo");
3323 let airframe = &mut rocket.stages[0].components[1];
3324 let fin_set = airframe
3325 .children
3326 .iter()
3327 .find(|c| matches!(c.part, hpr_design::Part::FinSet(_)))
3328 .expect("Valetudo has a fin set")
3329 .clone();
3330 let mut pod_tube = airframe.clone();
3331 pod_tube.id = "pod-tube".to_owned();
3332 pod_tube.auto.clear();
3333 pod_tube.motor_mount = None;
3334 pod_tube.children.clear();
3335 let hpr_design::Part::BodyTube(tube) = &pod_tube.part else {
3336 panic!("Valetudo's airframe is a body tube");
3337 };
3338 let tube_profile_m2 = 2.0 * tube.outer_radius_m * tube.length_m;
3339 let hpr_design::Part::FinSet(fins) = &fin_set.part else {
3340 unreachable!("found as a fin set");
3341 };
3342 let fin_m2 = 1.5 * fins.planform.geometry().unwrap().area_m2;
3344 let mut pod_fins = fin_set.clone();
3345 pod_fins.id = "pod-fins".to_owned();
3346 pod_tube.children.push(pod_fins);
3347 let mut pods = pod_tube.clone();
3348 pods.id = "pods".to_owned();
3349 pods.part = hpr_design::Part::PodSet(hpr_design::PodSet {
3350 count: 2,
3351 radial_offset_m: 0.2,
3352 angle_rad: 0.0,
3353 });
3354 pods.position = Some(hpr_design::Position::Top { aft_offset_m: 0.0 });
3355 pods.children = vec![pod_tube];
3356 pods.overrides = hpr_design::Overrides::default();
3357 airframe.children.push(pods);
3358 let tumble = |rocket: &hpr_design::Rocket| match DeviceDrag::tumbling(
3359 &rocket.assemble("example").unwrap(),
3360 )
3361 .unwrap()
3362 {
3363 DeviceDrag::Tumble {
3364 body_profile_m2,
3365 fin_area_m2,
3366 ..
3367 } => (body_profile_m2, fin_area_m2),
3368 other => panic!("tumbling should build a Tumble: {other:?}"),
3369 };
3370 let (bare_body, bare_fins) = tumble(&design("rocketpy-valetudo"));
3371 let (body, fins) = tumble(&rocket);
3372 assert!(
3373 (body - bare_body - 2.0 * tube_profile_m2).abs() <= 1e-15,
3374 "{body}"
3375 );
3376 assert!((fins - bare_fins - 2.0 * fin_m2).abs() <= 1e-15, "{fins}");
3377 let pods = rocket.stages[0].components[1].children.last_mut().unwrap();
3379 pods.children.clear();
3380 assert_eq!(
3381 DeviceDrag::tumbling(&rocket.assemble("example").unwrap()).unwrap(),
3382 DeviceDrag::tumbling(&design("rocketpy-valetudo").assemble("example").unwrap())
3383 .unwrap()
3384 );
3385 }
3386
3387 #[test]
3388 fn a_tumbling_body_descends_at_its_cited_terminal_velocity() {
3389 let air = UniformAir::sea_level();
3394 let rocket = design("rocketpy-valetudo");
3395 let assembly = rocket.assemble("example").unwrap();
3396 let drag = DeviceDrag::tumbling(&assembly).unwrap();
3397 let DeviceDrag::Tumble {
3398 drag_area_m2,
3399 body_profile_m2,
3400 fin_area_m2,
3401 } = drag
3402 else {
3403 panic!("tumbling should build a Tumble: {drag:?}");
3404 };
3405 let expected_fin_m2 = 1.5
3412 * match &assembly
3413 .layout
3414 .components
3415 .iter()
3416 .find(|c| matches!(c.part, hpr_design::Part::FinSet(_)))
3417 .unwrap()
3418 .part
3419 {
3420 hpr_design::Part::FinSet(fins) => fins.planform.geometry().unwrap().area_m2,
3421 _ => unreachable!(),
3422 };
3423 assert!(
3424 (fin_area_m2 - expected_fin_m2).abs() < 1e-12,
3425 "{fin_area_m2}"
3426 );
3427 assert!(
3428 (drag_area_m2 - (1.42 * fin_area_m2 + 0.56 * body_profile_m2)).abs() < 1e-15,
3429 "{drag_area_m2}"
3430 );
3431 let (nose_m, base_m) = (0.274_f64, 0.040_45_f64);
3432 let arc_m = (base_m * base_m + nose_m * nose_m) / (2.0 * base_m);
3433 let ogive_m2 = nose_m * (arc_m * arc_m - nose_m * nose_m).sqrt()
3434 + arc_m * arc_m * (nose_m / arc_m).asin()
3435 + 2.0 * (base_m - arc_m) * nose_m;
3436 assert!((ogive_m2 - 0.014_842).abs() < 5e-7, "{ogive_m2}");
3437 assert!(
3438 (body_profile_m2 - (ogive_m2 + 0.080_9 * 1.884)).abs() < 1e-12,
3439 "{body_profile_m2}"
3440 );
3441
3442 let sim = flight(
3443 analytic_environment(air, G),
3444 vec![open_at_start(drag)],
3445 3600.0,
3446 );
3447 let mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
3448 let terminal_m_s = terminal_speed_m_s(mass_kg, drag_area_m2, air.0.density_kg_m3, G);
3449 assert!((terminal_m_s - 36.38).abs() < 0.005, "{terminal_m_s}");
3453 let result = sim
3454 .run_free(START_S, dropped(&sim, 4_000.0, DVec3::ZERO), &mut ())
3455 .unwrap();
3456 assert_eq!(result.termination, Termination::GroundHit);
3457 let landing = result.event(EventKind::GroundHit).unwrap().sample;
3458 assert!(
3459 (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 1e-6,
3460 "{} vs {terminal_m_s}",
3461 landing.vertical_speed_m_s
3462 );
3463 }
3464
3465 fn two_stage() -> hpr_design::Rocket {
3467 design("synthetic-two-stage-75mm-54mm")
3468 }
3469
3470 fn staged_flight(
3472 environment: Environment,
3473 devices: Vec<Device>,
3474 separation: Separation,
3475 ) -> Simulation {
3476 Simulation::new(
3477 &two_stage(),
3478 "j760-i175",
3479 environment,
3480 Rail::vertical(6.0),
3481 FlightSettings {
3482 max_time_s: 3600.0,
3483 ..FlightSettings::default()
3484 },
3485 )
3486 .unwrap()
3487 .with_recovery(devices)
3488 .unwrap()
3489 .with_separation(separation)
3490 .unwrap()
3491 }
3492
3493 #[test]
3494 fn a_separation_lands_every_body_and_the_masses_add_up() {
3495 let air = UniformAir::sea_level();
3499 let assembly = two_stage().assemble("j760-i175").unwrap();
3500 let tumble = DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap();
3502 let devices = vec![
3503 Device::new(
3504 "sustainer main",
3505 DeviceDrag::canopy(CanopyType::FlatCircular, 1.8),
3506 Trigger::Altitude {
3507 height_above_ground_m: 1_500.0,
3508 },
3509 ),
3510 Device::new(
3511 "booster tumble",
3512 tumble,
3513 Trigger::Altitude {
3514 height_above_ground_m: 1_500.0,
3515 },
3516 )
3517 .on_body(1),
3518 ];
3519 let sim = staged_flight(
3520 analytic_environment(air, G),
3521 devices,
3522 Separation::new(Trigger::Apogee, 0),
3523 );
3524 let start = dropped(&sim, 2_000.0, DVec3::new(0.0, 0.0, -0.5));
3526 let result = sim.run_free(START_S, start, &mut ()).unwrap();
3527 assert_eq!(result.termination, Termination::Separated);
3528 assert!(result.event(EventKind::Separation).is_some());
3529 assert_eq!(result.bodies.len(), 2, "{:?}", result.bodies.len());
3530
3531 let separation = result.event(EventKind::Separation).unwrap().sample;
3533 let whole_kg = sim.assembly().mass_properties(separation.time_s).mass_kg;
3534 let sum_kg: f64 = result.bodies.iter().map(|body| body.mass_kg).sum();
3535 assert!(
3536 (sum_kg - whole_kg).abs() < 1e-12 * whole_kg,
3537 "{sum_kg} vs {whole_kg}"
3538 );
3539 assert_eq!(result.bodies[0].stages, (0, 0));
3540 assert_eq!(result.bodies[1].stages, (1, 1));
3541 for body in &result.bodies {
3542 let layout = &sim.assembly().layout;
3545 let expected_kg = layout.stages[body.stages.0].mass.mass_kg
3546 + sim
3547 .assembly()
3548 .motors
3549 .iter()
3550 .filter(|motor| motor.stage == body.stages.0)
3551 .map(|motor| motor.mass_properties(separation.time_s).mass_kg)
3552 .sum::<f64>();
3553 assert!(
3554 (body.mass_kg - expected_kg).abs() < 1e-12 * expected_kg,
3555 "body {}: {} vs {expected_kg}",
3556 body.body,
3557 body.mass_kg
3558 );
3559 }
3560 assert!(
3562 (result.bodies[0].mass_kg - 0.550_344).abs() < 1e-5,
3563 "{}",
3564 result.bodies[0].mass_kg
3565 );
3566 assert!(
3567 (result.bodies[1].mass_kg - 1.124_834).abs() < 1e-5,
3568 "{}",
3569 result.bodies[1].mass_kg
3570 );
3571
3572 let rho = air.0.density_kg_m3;
3574 for body in &result.bodies {
3575 assert_eq!(
3576 body.termination,
3577 Termination::GroundHit,
3578 "body {}",
3579 body.body
3580 );
3581 let landing = body.event(EventKind::GroundHit).unwrap().sample;
3582 assert!(landing.height_above_ground_m.abs() < 1e-6, "{landing:?}");
3583 assert!(landing.time_s > separation.time_s, "{landing:?}");
3584 let device = body.body;
3585 let drag_area_m2 = sim.recovery()[device].drag.drag_area_m2();
3586 let terminal_m_s = terminal_speed_m_s(body.mass_kg, drag_area_m2, rho, G);
3587 assert!(
3588 (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 1e-3,
3589 "body {}: {} vs {terminal_m_s}",
3590 body.body,
3591 landing.vertical_speed_m_s
3592 );
3593 assert!(
3594 (landing.recovery_drag_area_m2 - drag_area_m2).abs() < 1e-12,
3595 "body {}: {landing:?}",
3596 body.body
3597 );
3598 assert!(body.event(EventKind::Deployment(device)).is_some());
3599 }
3600 let landing_of = |body: usize| {
3604 result.bodies[body]
3605 .event(EventKind::GroundHit)
3606 .unwrap()
3607 .sample
3608 };
3609 let under_canopy = -landing_of(0).vertical_speed_m_s;
3610 let tumbling = -landing_of(1).vertical_speed_m_s;
3611 assert!(
3612 tumbling / under_canopy > 7.5,
3613 "{tumbling} vs {under_canopy}"
3614 );
3615 assert!(
3616 (landing_of(0).time_s - 729.00).abs() < 0.5,
3617 "{}",
3618 landing_of(0).time_s
3619 );
3620 assert!(
3621 (landing_of(1).time_s - 107.51).abs() < 0.2,
3622 "{}",
3623 landing_of(1).time_s
3624 );
3625 assert!(result.bodies_landed(), "{:?}", result.bodies.len());
3626 assert_eq!(result.landings().len(), 2);
3627 }
3628
3629 #[derive(Debug)]
3631 struct WindBelow {
3632 below_msl_m: f64,
3633 east_m_s: f64,
3634 }
3635
3636 impl hpr_atmos::Wind for WindBelow {
3637 fn wind(&self, height_msl_m: f64) -> Result<hpr_atmos::WindSample, hpr_atmos::AtmosError> {
3638 let east_m_s = if height_msl_m < self.below_msl_m {
3639 self.east_m_s
3640 } else {
3641 0.0
3642 };
3643 Ok(hpr_atmos::WindSample {
3644 velocity_enu_m_s: DVec3::new(east_m_s, 0.0, 0.0),
3645 extrapolated: None,
3646 })
3647 }
3648 }
3649
3650 #[test]
3651 fn a_separated_body_refuses_a_wind_that_is_not_finite() {
3652 let assembly = two_stage().assemble("j760-i175").unwrap();
3657 let tumble = DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap();
3658 let devices = || {
3659 vec![
3660 Device::new(
3661 "sustainer main",
3662 DeviceDrag::canopy(CanopyType::FlatCircular, 1.8),
3663 Trigger::Altitude {
3664 height_above_ground_m: 1_500.0,
3665 },
3666 ),
3667 Device::new(
3668 "booster tumble",
3669 tumble,
3670 Trigger::Altitude {
3671 height_above_ground_m: 1_500.0,
3672 },
3673 )
3674 .on_body(1),
3675 ]
3676 };
3677 let below_msl_m = crate::testing::site().height_m + 1_000.0;
3678 let fly = |east_m_s: f64| {
3679 let environment =
3680 analytic_environment(UniformAir::sea_level(), G).with_wind(WindBelow {
3681 below_msl_m,
3682 east_m_s,
3683 });
3684 let sim = staged_flight(environment, devices(), Separation::new(Trigger::Apogee, 0));
3685 let start = dropped(&sim, 2_000.0, DVec3::new(0.0, 0.0, -0.5));
3686 sim.run_free(START_S, start, &mut ())
3687 };
3688 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
3689 match fly(bad) {
3690 Err(SimError::Domain { what, value }) => {
3691 assert_eq!(what, crate::environment::WIND_NOT_FINITE, "{bad}");
3692 assert!(
3694 value < below_msl_m && value > below_msl_m - 50.0,
3695 "{bad}: {value}"
3696 );
3697 }
3698 other => panic!("{bad}: not the wind's refusal: {other:?}"),
3699 }
3700 }
3701 let result = fly(5.0).unwrap();
3703 assert_eq!(result.termination, Termination::Separated);
3704 assert!(result.bodies_landed(), "{:?}", result.bodies.len());
3705 }
3706
3707 #[derive(Debug)]
3710 struct AirBelow {
3711 below_msl_m: f64,
3712 field: usize,
3713 value: f64,
3714 }
3715
3716 impl hpr_atmos::Atmosphere for AirBelow {
3717 fn air(&self, height_msl_m: f64) -> Result<hpr_atmos::AirSample, hpr_atmos::AtmosError> {
3718 let mut air = UniformAir::sea_level().0;
3719 if height_msl_m < self.below_msl_m {
3720 *[
3721 &mut air.density_kg_m3,
3722 &mut air.pressure_pa,
3723 &mut air.temperature_k,
3724 &mut air.speed_of_sound_m_s,
3725 &mut air.dynamic_viscosity_pa_s,
3726 ]
3727 .into_iter()
3728 .nth(self.field)
3729 .unwrap() = self.value;
3730 }
3731 Ok(hpr_atmos::AirSample {
3732 air,
3733 extrapolated: None,
3734 })
3735 }
3736 }
3737
3738 #[test]
3739 fn a_separated_body_refuses_air_it_cannot_use() {
3740 use crate::environment::{
3747 AIR_DENSITY_REFUSED, AIR_PRESSURE_REFUSED, AIR_SPEED_OF_SOUND_REFUSED,
3748 AIR_TEMPERATURE_REFUSED, AIR_VISCOSITY_REFUSED,
3749 };
3750 let assembly = two_stage().assemble("j760-i175").unwrap();
3751 let tumble = DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap();
3752 let devices = || {
3753 vec![
3754 Device::new(
3755 "sustainer main",
3756 DeviceDrag::canopy(CanopyType::FlatCircular, 1.8),
3757 Trigger::Altitude {
3758 height_above_ground_m: 1_500.0,
3759 },
3760 ),
3761 Device::new(
3762 "booster tumble",
3763 tumble,
3764 Trigger::Altitude {
3765 height_above_ground_m: 1_500.0,
3766 },
3767 )
3768 .on_body(1),
3769 ]
3770 };
3771 let below_msl_m = crate::testing::site().height_m + 1_000.0;
3772 let fly = |field: usize, value: f64| {
3773 let mut environment = analytic_environment(UniformAir::sea_level(), G);
3774 environment.atmosphere = std::sync::Arc::new(AirBelow {
3775 below_msl_m,
3776 field,
3777 value,
3778 });
3779 let sim = staged_flight(environment, devices(), Separation::new(Trigger::Apogee, 0));
3780 let start = dropped(&sim, 2_000.0, DVec3::new(0.0, 0.0, -0.5));
3781 sim.run_free(START_S, start, &mut ())
3782 };
3783 let fields = [
3785 (AIR_DENSITY_REFUSED, true),
3786 (AIR_PRESSURE_REFUSED, true),
3787 (AIR_TEMPERATURE_REFUSED, false),
3788 (AIR_SPEED_OF_SOUND_REFUSED, false),
3789 (AIR_VISCOSITY_REFUSED, false),
3790 ];
3791 for (field, (refusal, takes_zero)) in fields.into_iter().enumerate() {
3792 let zero = if takes_zero { vec![] } else { vec![0.0] };
3793 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0]
3794 .into_iter()
3795 .chain(zero)
3796 {
3797 match fly(field, bad) {
3798 Err(SimError::Domain { what, value }) => {
3799 assert_eq!(what, refusal, "{field} {bad}");
3800 assert!(
3802 value < below_msl_m && value > below_msl_m - 50.0,
3803 "{field} {bad}: {value}"
3804 );
3805 }
3806 other => panic!("{field} {bad}: not the air's refusal: {other:?}"),
3807 }
3808 }
3809 if takes_zero {
3810 let result = fly(field, 0.0).unwrap();
3811 assert_eq!(result.termination, Termination::Separated, "{field}");
3812 assert!(result.bodies_landed(), "{field}");
3813 }
3814 }
3815 let result = fly(0, UniformAir::sea_level().0.density_kg_m3).unwrap();
3817 assert_eq!(result.termination, Termination::Separated);
3818 assert!(result.bodies_landed(), "{:?}", result.bodies.len());
3819 }
3820
3821 #[test]
3822 fn a_separation_conserves_momentum_and_gives_each_body_its_own_start() {
3823 let air = UniformAir::sea_level();
3826 let assembly = two_stage().assemble("j760-i175").unwrap();
3827 let devices = vec![
3828 Device::new(
3829 "sustainer",
3830 DeviceDrag::canopy(CanopyType::FlatCircular, 1.5),
3831 Trigger::Altitude {
3832 height_above_ground_m: 1_500.0,
3833 },
3834 ),
3835 Device::new(
3836 "booster",
3837 DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap(),
3838 Trigger::Altitude {
3839 height_above_ground_m: 1_500.0,
3840 },
3841 )
3842 .on_body(1),
3843 ];
3844 let sim = staged_flight(
3845 analytic_wind_environment(air, G, ConstantWind::new(5.0, 0.9).unwrap()),
3846 devices,
3847 Separation::new(Trigger::Apogee, 0),
3848 );
3849 let mut start = dropped(&sim, 2_000.0, DVec3::new(3.0, 0.0, -2.0));
3851 start.body_rate_rad_s = DVec3::new(0.0, 0.6, 0.0);
3852 let result = sim.run_free(START_S, start, &mut ()).unwrap();
3853 let separation = result.event(EventKind::Separation).unwrap().sample;
3854 let whole_kg = sim.assembly().mass_properties(separation.time_s).mass_kg;
3855 let momentum: DVec3 = result
3856 .bodies
3857 .iter()
3858 .map(|body| body.start_sample.cg_velocity_enu_m_s * body.mass_kg)
3859 .sum();
3860 let expected = separation.cg_velocity_enu_m_s * whole_kg;
3861 assert!(
3862 (momentum - expected).length() < 1e-9 * expected.length(),
3863 "{momentum} vs {expected}"
3864 );
3865 let state = result.final_sample.state;
3868 for body in &result.bodies {
3869 let lit = vec![Some(0.0); sim.assembly().motors.len()];
3870 let cg_m =
3871 body_mass_properties(sim.assembly(), body.stages, separation.time_s, &lit).cg_m;
3872 let expected = state.point_enu_m(cg_m);
3873 assert!(
3874 (body.start_sample.cg_enu_m - expected).length() < 1e-12,
3875 "body {}: {} vs {expected}",
3876 body.body,
3877 body.start_sample.cg_enu_m
3878 );
3879 }
3880 let gap = (result.bodies[0].start_sample.cg_enu_m - result.bodies[1].start_sample.cg_enu_m)
3881 .length();
3882 assert!((gap - 0.817).abs() < 0.01, "{gap}");
3883 }
3884
3885 #[test]
3886 fn a_body_separated_while_climbing_finds_its_own_apogee() {
3887 let air = UniformAir::sea_level();
3892 let assembly = two_stage().assemble("j760-i175").unwrap();
3893 let devices = vec![
3894 Device::new(
3895 "sustainer",
3896 DeviceDrag::canopy(CanopyType::FlatCircular, 1.5),
3897 Trigger::Apogee,
3898 ),
3899 Device::new(
3900 "booster",
3901 DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap(),
3902 Trigger::Apogee,
3903 )
3904 .on_body(1),
3905 ];
3906 let burnout_s = assembly
3907 .motors
3908 .iter()
3909 .map(|motor| motor.mounted.motor.burnout_time_s())
3910 .fold(0.0, f64::max);
3911 let sim = staged_flight(
3912 analytic_environment(air, G),
3913 devices,
3914 Separation::new(
3915 Trigger::Time {
3916 time_s: burnout_s + 1.0,
3917 },
3918 0,
3919 ),
3920 );
3921 let start = dropped(&sim, 1_000.0, DVec3::new(0.0, 0.0, 100.0));
3923 let result = sim.run_free(burnout_s + 1.0, start, &mut ()).unwrap();
3924 assert_eq!(result.termination, Termination::Separated);
3925 assert!(result.bodies_landed(), "{:?}", result.bodies.len());
3926 let rho = air.0.density_kg_m3;
3927 for body in &result.bodies {
3928 assert!(
3929 body.start_sample.vertical_speed_m_s > 50.0,
3930 "body {} should still be climbing: {:?}",
3931 body.body,
3932 body.start_sample.vertical_speed_m_s
3933 );
3934 let apogee = body
3936 .event(EventKind::Apogee)
3937 .unwrap_or_else(|| panic!("body {} found no apogee", body.body))
3938 .sample;
3939 assert!(apogee.vertical_speed_m_s.abs() < 1e-6, "{apogee:?}");
3940 assert!(
3941 apogee.height_above_ground_m > 1_400.0,
3942 "body {}: {:?}",
3943 body.body,
3944 apogee.height_above_ground_m
3945 );
3946 assert!(body.event(EventKind::Deployment(body.body)).is_some());
3947 let drag_area_m2 = sim.recovery()[body.body].drag.drag_area_m2();
3949 let terminal_m_s = terminal_speed_m_s(body.mass_kg, drag_area_m2, rho, G);
3950 let landing = body.event(EventKind::GroundHit).unwrap().sample;
3951 assert!(
3952 (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 0.01,
3953 "body {}: {} vs {terminal_m_s}",
3954 body.body,
3955 landing.vertical_speed_m_s
3956 );
3957 }
3958 }
3959
3960 #[test]
3961 fn a_timed_separation_fires_at_its_own_time() {
3962 let air = UniformAir::sea_level();
3967 let assembly = two_stage().assemble("j760-i175").unwrap();
3968 let devices = || {
3969 vec![
3970 Device::new(
3971 "sustainer",
3972 DeviceDrag::canopy(CanopyType::FlatCircular, 1.5),
3973 Trigger::Altitude {
3974 height_above_ground_m: 300.0,
3975 },
3976 ),
3977 Device::new(
3978 "booster",
3979 DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap(),
3980 Trigger::Apogee,
3981 )
3982 .on_body(1),
3983 ]
3984 };
3985 let burnout_s = assembly
3986 .motors
3987 .iter()
3988 .map(|motor| motor.mounted.motor.burnout_time_s())
3989 .fold(0.0, f64::max);
3990 let at_s = burnout_s + 7.5;
3992 let sim = staged_flight(
3993 analytic_environment(air, G),
3994 devices(),
3995 Separation::new(Trigger::Time { time_s: at_s }, 0),
3996 );
3997 let result = sim
3998 .run_free(
3999 burnout_s + 0.5,
4000 dropped(&sim, 2_000.0, DVec3::new(0.0, 0.0, -1.0)),
4001 &mut (),
4002 )
4003 .unwrap();
4004 let separation = result.event(EventKind::Separation).unwrap().sample;
4005 assert!(
4006 (separation.time_s - at_s).abs() < 1e-9,
4007 "{} vs {at_s}",
4008 separation.time_s
4009 );
4010
4011 let sim = staged_flight(
4013 analytic_environment(air, G),
4014 devices(),
4015 Separation::new(
4016 Trigger::Altitude {
4017 height_above_ground_m: 1_000.0,
4018 },
4019 0,
4020 ),
4021 );
4022 let result = sim
4023 .run_free(
4024 burnout_s + 0.5,
4025 dropped(&sim, 2_000.0, DVec3::new(0.0, 0.0, -1.0)),
4026 &mut (),
4027 )
4028 .unwrap();
4029 let separation = result.event(EventKind::Separation).unwrap().sample;
4030 assert!(
4031 (separation.height_above_ground_m - 1_000.0).abs() < 1e-6,
4032 "{:?}",
4033 separation.height_above_ground_m
4034 );
4035 assert!(result.bodies_landed(), "{:?}", result.bodies.len());
4036 }
4037
4038 #[test]
4039 fn a_body_separated_while_climbing_with_its_device_lagging_is_refused() {
4040 let air = UniformAir::sea_level();
4045 let assembly = two_stage().assemble("j760-i175").unwrap();
4046 let burnout_s = assembly
4047 .motors
4048 .iter()
4049 .map(|motor| motor.mounted.motor.burnout_time_s())
4050 .fold(0.0, f64::max);
4051 let fly = |lag_s: f64| {
4052 let sim = staged_flight(
4053 analytic_environment(air, G),
4054 vec![
4055 Device::new(
4056 "sustainer",
4057 DeviceDrag::canopy(CanopyType::FlatCircular, 1.5),
4058 Trigger::Time {
4059 time_s: burnout_s + 1.2,
4060 },
4061 )
4062 .with_lag_s(lag_s),
4063 Device::new(
4064 "booster",
4065 DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap(),
4066 Trigger::Apogee,
4067 )
4068 .on_body(1),
4069 ],
4070 Separation::new(
4071 Trigger::Time {
4072 time_s: burnout_s + 1.5,
4073 },
4074 0,
4075 ),
4076 );
4077 let start = dropped(&sim, 1_000.0, DVec3::new(0.0, 0.0, 100.0));
4078 sim.run_free(burnout_s + 1.0, start, &mut ())
4079 };
4080 let result = fly(0.1).unwrap();
4082 assert_eq!(result.termination, Termination::Separated);
4083 assert!(result.bodies_landed(), "{:?}", result.bodies.len());
4084 let error = fly(1.0).expect_err("a part climbing with nothing open");
4086 assert!(
4087 matches!(
4088 error,
4089 SimError::Domain { what, value }
4090 if what.starts_with("time of a split with nothing left to burn, before \
4091 apogee")
4092 && (value - (burnout_s + 1.5)).abs() < 1e-9
4093 ),
4094 "{error:?}"
4095 );
4096 }
4097
4098 #[test]
4099 fn a_separated_part_warns_of_179_and_of_354_while_it_has_nothing_open() {
4100 use crate::issues::{KnownIssue, separated_part_issue_warnings};
4104 use crate::metrics::Peak;
4105 let air = UniformAir::sea_level();
4106 let assembly = two_stage().assemble("j760-i175").unwrap();
4107 let burnout_s = assembly
4108 .motors
4109 .iter()
4110 .map(|motor| motor.mounted.motor.burnout_time_s())
4111 .fold(0.0, f64::max);
4112 let split_s = burnout_s + 1.5;
4113 let fly = |lag_s: f64| {
4114 let sim = staged_flight(
4115 analytic_environment(air, G),
4116 vec![
4117 Device::new(
4118 "sustainer",
4119 DeviceDrag::canopy(CanopyType::FlatCircular, 1.5),
4120 Trigger::Time {
4121 time_s: burnout_s + 1.2,
4122 },
4123 )
4124 .with_lag_s(lag_s),
4125 Device::new(
4127 "booster",
4128 DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap(),
4129 Trigger::Time { time_s: split_s },
4130 )
4131 .on_body(1),
4132 ],
4133 Separation::new(Trigger::Time { time_s: split_s }, 0),
4134 );
4135 let start = dropped(&sim, 1_000.0, DVec3::new(0.0, 0.0, -5.0));
4136 sim.run_free(burnout_s + 1.0, start, &mut ()).unwrap()
4137 };
4138 let peak = Peak {
4139 value: 0.2,
4140 time_s: 0.0,
4141 height_above_ground_m: 0.0,
4142 };
4143 let warned = |result: &FlightResult| -> Vec<KnownIssue> {
4144 separated_part_issue_warnings(result, Some(peak))
4145 .into_iter()
4146 .map(|warning| warning.issue)
4147 .collect()
4148 };
4149 let open = fly(0.2);
4151 assert_eq!(open.termination, Termination::Separated);
4152 assert!(open.bodies_landed(), "{:?}", open.bodies.len());
4153 let [sustainer, booster] = &open.bodies[..] else {
4154 panic!("{} bodies", open.bodies.len())
4155 };
4156 assert_eq!((sustainer.stages, booster.stages), ((0, 0), (1, 1)));
4157 assert!(sustainer.start_sample.recovery_drag_area_m2 > 0.0);
4158 assert_eq!(booster.start_sample.recovery_drag_area_m2, 0.0);
4160 let tumbled = booster.event(EventKind::Deployment(1)).unwrap().sample;
4161 assert_eq!(tumbled.time_s, booster.start_sample.time_s);
4162 assert!(tumbled.recovery_drag_area_m2 > 0.0);
4163 assert_eq!(warned(&open), [KnownIssue::BoosterAirframeDrag]);
4164 let lagging = fly(1.0);
4166 assert!(lagging.bodies_landed(), "{:?}", lagging.bodies.len());
4167 let sustainer = &lagging.bodies[0];
4168 assert_eq!(sustainer.start_sample.recovery_drag_area_m2, 0.0);
4169 let opened_s = sustainer
4170 .event(EventKind::Deployment(0))
4171 .unwrap()
4172 .sample
4173 .time_s;
4174 assert!((opened_s - (split_s + 0.7)).abs() < 1e-9, "{opened_s}");
4175 assert_eq!(
4176 warned(&lagging),
4177 [
4178 KnownIssue::BoosterAirframeDrag,
4179 KnownIssue::DragFreeSeparatedPart
4180 ]
4181 );
4182 let mut late = open.clone();
4186 let deployment = &mut late.bodies[1].events[1];
4187 assert_eq!(deployment.kind, EventKind::Deployment(1));
4188 deployment.sample.time_s = deployment.sample.time_s.next_up();
4189 assert_eq!(warned(&late).len(), 2);
4190 let mut broken = open.clone();
4191 broken.bodies[0].start_sample.recovery_drag_area_m2 = f64::NAN;
4192 assert_eq!(warned(&broken).len(), 2);
4193 let mut timeless = open.clone();
4194 timeless.bodies[1].events[1].sample.time_s = f64::NAN;
4195 assert_eq!(warned(&timeless).len(), 2);
4196 for (issue, name) in [
4198 (KnownIssue::BoosterAirframeDrag, "\"booster_airframe_drag\""),
4199 (
4200 KnownIssue::DragFreeSeparatedPart,
4201 "\"drag_free_separated_part\"",
4202 ),
4203 ] {
4204 assert!(!issue.is_stability() && !issue.has_mach_condition());
4205 assert_eq!(serde_json::to_string(&issue).unwrap(), name);
4206 }
4207 assert!(separated_part_issue_warnings(&lagging, None).is_empty());
4208 let mut forward = open.clone();
4210 forward.bodies.truncate(1);
4211 assert!(warned(&forward).is_empty());
4212 let mut whole = open;
4213 whole.bodies.clear();
4214 assert!(warned(&whole).is_empty());
4215 }
4216
4217 #[test]
4218 fn a_body_whose_device_never_opens_is_refused() {
4219 let air = UniformAir::sea_level();
4223 let assembly = two_stage().assemble("j760-i175").unwrap();
4224 let sim = staged_flight(
4225 analytic_environment(air, G),
4226 vec![
4227 Device::new(
4228 "sustainer",
4229 DeviceDrag::canopy(CanopyType::FlatCircular, 1.5),
4230 Trigger::Apogee,
4231 ),
4232 Device::new(
4233 "booster",
4234 DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap(),
4235 Trigger::Time { time_s: 100_000.0 },
4241 )
4242 .on_body(1),
4243 ],
4244 Separation::new(Trigger::Apogee, 0),
4245 );
4246 let error = sim
4247 .run_free(
4248 10.0,
4249 dropped(&sim, 1_000.0, DVec3::new(0.0, 0.0, -1.0)),
4250 &mut (),
4251 )
4252 .expect_err("a body with nothing open");
4253 assert!(
4255 matches!(error, SimError::Domain { value, .. } if value == 1.0),
4256 "{error:?}"
4257 );
4258 }
4259
4260 #[test]
4261 fn a_body_that_runs_out_of_time_says_so() {
4262 let air = UniformAir::sea_level();
4265 let assembly = two_stage().assemble("j760-i175").unwrap();
4266 let sim = Simulation::new(
4267 &two_stage(),
4268 "j760-i175",
4269 analytic_environment(air, G),
4270 Rail::vertical(6.0),
4271 FlightSettings {
4272 max_time_s: 60.0,
4273 ..FlightSettings::default()
4274 },
4275 )
4276 .unwrap()
4277 .with_recovery(vec![
4278 Device::new(
4279 "sustainer",
4280 DeviceDrag::canopy(CanopyType::FlatCircular, 1.8),
4281 Trigger::Apogee,
4282 ),
4283 Device::new(
4284 "booster",
4285 DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap(),
4286 Trigger::Apogee,
4287 )
4288 .on_body(1),
4289 ])
4290 .unwrap()
4291 .with_separation(Separation::new(Trigger::Apogee, 0))
4292 .unwrap();
4293 let result = sim
4294 .run_free(10.0, dropped(&sim, 2_000.0, DVec3::ZERO), &mut ())
4295 .unwrap();
4296 assert_eq!(result.termination, Termination::Separated);
4297 assert!(!result.bodies_landed(), "{:?}", result.bodies.len());
4298 assert!(
4299 result
4300 .bodies
4301 .iter()
4302 .any(|body| body.termination == Termination::TimeCap),
4303 "{:?}",
4304 result
4305 .bodies
4306 .iter()
4307 .map(|b| b.termination)
4308 .collect::<Vec<_>>()
4309 );
4310 assert!(result.landings().len() < result.bodies.len());
4311 }
4312
4313 #[test]
4314 fn separations_outside_their_domain_are_refused() {
4315 let environment = || analytic_environment(UniformAir::sea_level(), G);
4316 let canopy = DeviceDrag::canopy(CanopyType::FlatCircular, 1.5);
4317 let build = |devices: Vec<Device>, separation: Separation| {
4320 Simulation::new(
4321 &two_stage(),
4322 "j760-i175",
4323 environment(),
4324 Rail::vertical(6.0),
4325 FlightSettings::default(),
4326 )
4327 .unwrap()
4328 .with_recovery(devices)
4329 .and_then(|sim| sim.with_separation(separation))
4330 .map(|_| ())
4331 };
4332 let both = || {
4333 vec![
4334 Device::new("sustainer", canopy, Trigger::Apogee),
4335 Device::new("booster", canopy, Trigger::Apogee).on_body(1),
4336 ]
4337 };
4338 let error = build(both(), Separation::new(Trigger::Apogee, 1)).expect_err("no aft stage");
4340 assert!(matches!(error, SimError::Domain { .. }), "{error:?}");
4341 let error = build(
4343 vec![Device::new("sustainer", canopy, Trigger::Apogee)],
4344 Separation::new(Trigger::Apogee, 0),
4345 )
4346 .expect_err("the booster has nothing");
4347 assert!(matches!(error, SimError::Domain { .. }), "{error:?}");
4348 let error = Simulation::new(
4351 &two_stage(),
4352 "j760-i175",
4353 environment(),
4354 Rail::vertical(6.0),
4355 FlightSettings::default(),
4356 )
4357 .unwrap()
4358 .with_recovery(vec![
4359 Device::new("sustainer", canopy, Trigger::Apogee),
4360 Device::new("booster", canopy, Trigger::Apogee).on_body(1),
4361 Device::new("ghost", canopy, Trigger::Apogee).on_body(2),
4362 ])
4363 .and_then(|sim| sim.with_separation(Separation::new(Trigger::Apogee, 0)))
4364 .and_then(|sim| sim.run(&mut ()))
4365 .expect_err("a third body");
4366 assert!(
4367 matches!(error, SimError::Domain { what, value }
4368 if what == "body a device is attached to (the separation and ejections don't \
4369 make it)" && value == 2.0),
4370 "{error:?}"
4371 );
4372 let error = build(
4374 vec![
4375 Device::new("sustainer", canopy, Trigger::Apogee).with_release_by(1),
4376 Device::new("booster", canopy, Trigger::Apogee).on_body(1),
4377 ],
4378 Separation::new(Trigger::Apogee, 0),
4379 )
4380 .expect_err("a release across bodies");
4381 assert!(matches!(error, SimError::Domain { .. }), "{error:?}");
4382 assert!(build(both(), Separation::new(Trigger::Apogee, 0)).is_ok());
4384 }
4385
4386 #[test]
4387 fn a_separation_before_burnout_is_refused() {
4388 let air = UniformAir::sea_level();
4392 let assembly = two_stage().assemble("j760-i175").unwrap();
4393 let canopy = DeviceDrag::canopy(CanopyType::FlatCircular, 1.5);
4394 let devices = || {
4395 vec![
4396 Device::new("sustainer", canopy, Trigger::Apogee),
4397 Device::new(
4398 "booster",
4399 DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap(),
4400 Trigger::Apogee,
4401 )
4402 .on_body(1),
4403 ]
4404 };
4405 let burnout_s = assembly
4406 .motors
4407 .iter()
4408 .map(|motor| motor.mounted.motor.burnout_time_s())
4409 .fold(0.0, f64::max);
4410 assert!(burnout_s > 1.0, "{burnout_s}");
4411 let build = |separation| {
4412 Simulation::new(
4413 &two_stage(),
4414 "j760-i175",
4415 analytic_environment(air, G),
4416 Rail::vertical(6.0),
4417 FlightSettings::default(),
4418 )
4419 .unwrap()
4420 .with_recovery(devices())
4421 .unwrap()
4422 .with_separation(separation)
4423 };
4424 let error = build(Separation::new(
4426 Trigger::Time {
4427 time_s: 0.5 * burnout_s,
4428 },
4429 0,
4430 ))
4431 .expect_err("a timed separation under thrust");
4432 assert!(matches!(error, SimError::Domain { .. }), "{error:?}");
4433
4434 let sim = build(Separation::new(
4436 Trigger::Altitude {
4437 height_above_ground_m: 500.0,
4438 },
4439 0,
4440 ))
4441 .unwrap();
4442 let error = sim
4443 .run_free(
4444 0.5 * burnout_s,
4445 dropped(&sim, 400.0, DVec3::new(0.0, 0.0, -1.0)),
4446 &mut (),
4447 )
4448 .expect_err("a height separation under thrust");
4449 assert!(matches!(error, SimError::Domain { .. }), "{error:?}");
4450 }
4451
4452 #[test]
4453 fn devices_outside_their_domain_are_refused() {
4454 let environment = || analytic_environment(UniformAir::sea_level(), G);
4455 let rocket = design("rocketpy-valetudo");
4456 let build = |devices: Vec<Device>| {
4457 Simulation::new(
4458 &rocket,
4459 "example",
4460 environment(),
4461 Rail::vertical(3.0),
4462 FlightSettings::default(),
4463 )
4464 .unwrap()
4465 .with_recovery(devices)
4466 .map(|_| ())
4467 };
4468 let canopy = DeviceDrag::canopy(CanopyType::FlatCircular, 1.0);
4469 let apogee = Trigger::Apogee;
4470 for devices in [
4471 vec![Device::new(
4472 "zero",
4473 DeviceDrag::DragArea { cd_s_m2: 0.0 },
4474 apogee,
4475 )],
4476 vec![Device::new(
4477 "nan",
4478 DeviceDrag::DragArea { cd_s_m2: f64::NAN },
4479 apogee,
4480 )],
4481 vec![Device::new("negative lag", canopy, apogee).with_lag_s(-1.0)],
4482 vec![Device::new(
4483 "ground",
4484 canopy,
4485 Trigger::Altitude {
4486 height_above_ground_m: 0.0,
4487 },
4488 )],
4489 vec![Device::new(
4490 "before ignition",
4491 canopy,
4492 Trigger::Time { time_s: -1.0 },
4493 )],
4494 vec![Device::new(
4495 "no such motor",
4496 canopy,
4497 Trigger::MotorDelay { motor: 7 },
4498 )],
4499 vec![Device::new("itself", canopy, apogee).with_release_by(0)],
4500 vec![Device::new("no such device", canopy, apogee).with_release_by(3)],
4501 vec![
4502 Device::new("no diameter", DeviceDrag::DragArea { cd_s_m2: 1.0 }, apogee)
4503 .with_inflation(Inflation::FillConstant {
4504 constant: 8.0,
4505 exponent: 2.0,
4506 }),
4507 ],
4508 vec![Device::new("bad exponent", canopy, apogee).with_inflation(
4509 Inflation::FillingTime {
4510 time_s: 1.0,
4511 exponent: 0.0,
4512 },
4513 )],
4514 ] {
4515 let name = devices[0].name.clone();
4516 let error = build(devices).expect_err(&name);
4517 assert!(
4518 matches!(error, SimError::Domain { .. }),
4519 "{name}: {error:?}"
4520 );
4521 }
4522 let cycle = build(vec![
4524 Device::new("a", canopy, apogee).with_release_by(1),
4525 Device::new("b", canopy, apogee).with_release_by(0),
4526 ])
4527 .expect_err("a release cycle");
4528 assert!(matches!(cycle, SimError::Domain { .. }), "{cycle:?}");
4529 assert!(
4531 build(vec![
4532 Device::new("a", canopy, apogee).with_release_by(1),
4533 Device::new("b", canopy, apogee).with_release_by(2),
4534 Device::new("c", canopy, apogee),
4535 ])
4536 .is_ok()
4537 );
4538 let error = build(vec![Device::new(
4540 "no charge",
4541 canopy,
4542 Trigger::MotorDelay { motor: 0 },
4543 )])
4544 .expect_err("a motor with no delay");
4545 assert!(matches!(error, SimError::Domain { .. }), "{error:?}");
4546 assert!(
4548 Simulation::new(
4549 &with_delay(rocket.clone(), 3.0),
4550 "example",
4551 environment(),
4552 Rail::vertical(3.0),
4553 FlightSettings::default(),
4554 )
4555 .unwrap()
4556 .with_recovery(vec![Device::new(
4557 "charge",
4558 canopy,
4559 Trigger::MotorDelay { motor: 0 }
4560 )])
4561 .is_ok()
4562 );
4563 assert!(build(vec![Device::new("fine", canopy, apogee).with_lag_s(1.5)]).is_ok());
4565 }
4566
4567 #[test]
4568 fn oversized_canopy_and_ten_km_descent_land_without_step_collapse() {
4569 let air = UniformAir::sea_level();
4574 let device = open_at_start(DeviceDrag::canopy(CanopyType::FlatCircular, 5.0));
4575 let drag_area_m2 = device.drag.drag_area_m2();
4576 let sim = flight(analytic_environment(air, G), vec![device], 3600.0);
4577 let mass_kg = sim.assembly().mass_properties(START_S).mass_kg;
4578 let terminal_m_s = terminal_speed_m_s(mass_kg, drag_area_m2, air.0.density_kg_m3, G);
4579 assert!(terminal_m_s < 3.5, "{terminal_m_s}");
4581 let result = sim
4582 .run_free(
4583 START_S,
4584 dropped(&sim, 10_000.0, DVec3::new(0.0, 0.0, -100.0)),
4585 &mut (),
4586 )
4587 .unwrap();
4588 assert_eq!(result.termination, Termination::GroundHit);
4589 let landing = result.event(EventKind::GroundHit).unwrap().sample;
4590 assert!(
4591 (-landing.vertical_speed_m_s / terminal_m_s - 1.0).abs() < 1e-6,
4592 "{}",
4593 landing.vertical_speed_m_s
4594 );
4595 let flown_s = landing.time_s - START_S;
4598 assert!(
4599 (flown_s - 10_000.0 / terminal_m_s).abs() < 60.0,
4600 "{flown_s} s"
4601 );
4602 assert!(flown_s < 3_600.0, "{flown_s} s");
4603 let mean_step_s = flown_s / result.stats.accepted_steps as f64;
4608 assert!(
4609 mean_step_s > 0.1,
4610 "{mean_step_s} s mean step: {:?}",
4611 result.stats
4612 );
4613 assert!(result.stats.accepted_steps < 20_000, "{:?}", result.stats);
4614 assert!(result.stats.rejected_steps < 100, "{:?}", result.stats);
4615 }
4616
4617 #[test]
4618 fn several_separations_number_their_bodies_from_the_tail() {
4619 let at = |after_stage| Separation::new(Trigger::Apogee, after_stage);
4622 let two = [at(1), at(0)];
4623 assert_eq!(
4624 (0..3)
4625 .map(|stage| Separation::body_of(&two, stage))
4626 .collect::<Vec<_>>(),
4627 [0, 2, 1]
4628 );
4629 assert_eq!(Separation::stages_of_body(&two, 0, 3), Some((0, 0)));
4630 assert_eq!(Separation::stages_of_body(&two, 1, 3), Some((2, 2)));
4631 assert_eq!(Separation::stages_of_body(&two, 2, 3), Some((1, 1)));
4632 assert_eq!(Separation::stages_of_body(&two, 3, 3), None);
4633 for (after_stage, stages) in [(0, 3), (1, 3), (0, 2)] {
4635 for body in 0..3 {
4636 assert_eq!(
4637 Separation::stages_of_body(&[at(after_stage)], body, stages),
4638 at(after_stage).stages_of(body, stages)
4639 );
4640 }
4641 }
4642 assert_eq!(Separation::stages_of_body(&[], 0, 3), Some((0, 2)));
4643 assert_eq!(Separation::body_of(&[], 2), 0);
4644 assert_eq!(Separation::stages_of_body(&[at(0), at(1)], 0, 3), None);
4646 assert_eq!(Separation::stages_of_body(&[at(2)], 1, 3), None);
4647 let uneven = [at(2), at(0)];
4649 assert_eq!(Separation::stages_of_body(&uneven, 1, 5), Some((3, 4)));
4650 assert_eq!(Separation::stages_of_body(&uneven, 2, 5), Some((1, 2)));
4651 assert_eq!(
4652 (0..5)
4653 .map(|stage| Separation::body_of(&uneven, stage))
4654 .collect::<Vec<_>>(),
4655 [0, 2, 2, 1, 1]
4656 );
4657 }
4658}