1use hpr_design::{Assembly, DesignError, Part};
17use hpr_io::ork::{DeployEvent, DeviceKind, Dimension, Recovery, Staging, StagingTrigger};
18use hpr_motor::Delay;
19use hpr_sim::recovery::{MIN_STREAMER_ASPECT_RATIO, StreamerModel};
20use hpr_sim::{Device, DeviceDrag, Separation, SimError, Trigger};
21
22pub fn separation(staging: &Staging, assembly: &Assembly) -> Result<Separation, SimError> {
30 let trigger = match &staging.trigger {
31 StagingTrigger::Time { time_s } => Trigger::Time { time_s: *time_s },
32 StagingTrigger::Burnout { mount, delay_s } => Trigger::Burnout {
33 motor: assembly
36 .motors
37 .iter()
38 .position(|motor| motor.mounted.mount == *mount && !motor.fails)
39 .ok_or(SimError::Domain {
40 what: "count of lit motors in the mount a separation is timed from",
41 value: 0.0,
42 })?,
43 delay_s: *delay_s,
44 },
45 _ => {
46 return Err(SimError::Domain {
47 what: "kind of `.ork` separation trigger (one this version does not know)",
48 value: f64::NAN,
49 });
50 }
51 };
52 let separation = Separation::new(trigger, staging.after_stage);
53 Ok(if staging.drops_burning {
56 separation.dropping_burning()
57 } else {
58 separation
59 })
60}
61
62pub fn separations<'a>(
70 stagings: impl IntoIterator<Item = &'a Staging>,
71 assembly: &Assembly,
72) -> Result<Vec<Separation>, SimError> {
73 stagings
74 .into_iter()
75 .map(|staging| separation(staging, assembly))
76 .collect()
77}
78
79pub const OPENROCKET_PARACHUTE_DRAG_COEFFICIENT: f64 = 0.8;
84
85#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
87#[non_exhaustive]
88pub struct Recovered {
89 pub devices: Vec<Device>,
92 pub not_deployed: Vec<(String, NeverDeploys)>,
94 #[serde(default)]
97 pub added: usize,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
102#[serde(rename_all = "snake_case")]
103#[non_exhaustive]
104pub enum NeverDeploys {
105 SetToNever,
107 NoEjectionCharge,
110 NoLitMotor,
115}
116
117impl NeverDeploys {
118 #[must_use]
120 pub const fn reason(self) -> &'static str {
121 match self {
122 Self::SetToNever => "the file sets it to never",
123 Self::NoEjectionCharge => {
124 "no motor of its stage has an ejection charge (plugged, or no delay given)"
125 }
126 Self::NoLitMotor => {
127 "it opens at its stage's ejection charge, and no motor of its stage lights"
128 }
129 }
130 }
131}
132
133#[derive(Debug, Clone, PartialEq, thiserror::Error)]
135#[non_exhaustive]
136pub enum RecoveryRefused {
137 #[error("a {tag} at {at} is inside a `{inside}`, which hpr does not read")]
139 Unread {
140 tag: String,
142 at: String,
144 inside: String,
146 },
147 #[error(
151 "`{name}` deploys at the lower stage's separation, which hpr flies only when that stage \
152 drops away with nothing ahead of it left to burn"
153 )]
154 AtSeparation {
155 name: String,
157 },
158 #[error("`{name}` deploys at `{word}`, an event hpr does not know")]
160 UnknownEvent {
161 name: String,
163 word: String,
165 },
166 #[error("the recovery device `{id}` is not a parachute or streamer of the assembled rocket")]
168 NoPart {
169 id: String,
171 },
172 #[error("`{name}`'s {what} is {value}, outside its domain")]
174 Domain {
175 name: String,
177 what: &'static str,
179 value: f64,
181 },
182 #[error("`{name}` can't be flown: {why}")]
185 Tumbling {
186 name: String,
188 why: String,
190 },
191 #[error(
196 "{name}, the part that keeps the nose, would coast with no drag from the separation at \
197 {time_s:.3} s: with nothing ahead of it left to burn, hpr flies it as a point with only \
198 its own devices' drag, and none of them is open by then; set one of them to open at \
199 the lower stage's separation, with no delay, for hpr to fly it"
200 )]
201 Coasts {
202 name: String,
204 time_s: f64,
206 },
207 #[error("`{name}`: {source}")]
209 Design {
210 name: String,
212 source: DesignError,
214 },
215}
216
217pub fn recovery(
243 recovery: &Recovery,
244 rocket: &hpr_design::Rocket,
245 assembly: &Assembly,
246) -> Result<Recovered, RecoveryRefused> {
247 map_devices(recovery, rocket, assembly, &[])
248}
249
250pub fn separated_recovery(
270 recovery: &Recovery,
271 rocket: &hpr_design::Rocket,
272 assembly: &Assembly,
273 separations: &[Separation],
274) -> Result<Recovered, RecoveryRefused> {
275 let mut out = map_devices(recovery, rocket, assembly, separations)?;
276 let read = out.devices.len();
277 out.devices = tumbling(out.devices, rocket, assembly, separations)?;
278 out.added = out.devices.len() - read;
279 Ok(out)
280}
281
282pub fn tumbling(
309 mut devices: Vec<Device>,
310 rocket: &hpr_design::Rocket,
311 assembly: &Assembly,
312 separations: &[Separation],
313) -> Result<Vec<Device>, RecoveryRefused> {
314 let stage_count = assembly.layout.stages.len();
315 if let [separation] = separations
316 && let Some(time_s) = separation.trigger.known_time_s(assembly).ok().flatten()
317 && !separation.powered_at(assembly, time_s)
318 {
319 let open = devices
320 .iter()
321 .filter(|device| device.body == 0)
322 .any(|device| {
323 device
324 .trigger
325 .known_time_s(assembly)
326 .ok()
327 .flatten()
328 .is_some_and(|known_s| known_s + device.lag_s <= time_s)
329 });
330 if !open {
331 let name = rocket
332 .stages
333 .first()
334 .map(|stage| stage.name.trim())
335 .filter(|name| !name.is_empty())
336 .map_or_else(|| "stage 1".to_owned(), |name| format!("`{name}`"));
337 return Err(RecoveryRefused::Coasts { name, time_s });
338 }
339 }
340 for body in 0..=separations.len() {
341 let Some(stages) = Separation::stages_of_body(separations, body, stage_count) else {
342 continue;
343 };
344 let first = devices.iter().position(|device| device.body == body);
345 if body == 0 && first.is_some() {
346 continue;
347 }
348 let name = rocket
349 .stages
350 .get(stages.0)
351 .map(|stage| stage.name.as_str())
352 .filter(|name| !name.is_empty())
353 .map_or_else(
354 || match body {
355 0 => "sustainer".to_owned(),
356 1 => "booster".to_owned(),
357 _ => format!("stage {}", stages.0 + 1),
358 },
359 str::to_owned,
360 );
361 let name = format!("{name}, tumbling");
362 let drag = DeviceDrag::tumbling_stages(assembly, stages).map_err(|error| {
363 RecoveryRefused::Tumbling {
364 name: name.clone(),
365 why: error.to_string(),
366 }
367 })?;
368 let device = if body == 0 {
369 Device::new(name, drag, Trigger::Apogee)
370 } else {
371 let tumble = Device::new(name, drag, Trigger::Time { time_s: 0.0 }).on_body(body);
374 match first {
375 Some(first) => tumble.with_release_by(first),
376 None => tumble,
377 }
378 };
379 devices.push(device);
380 }
381 Ok(devices)
382}
383
384fn map_devices(
386 recovery: &Recovery,
387 rocket: &hpr_design::Rocket,
388 assembly: &Assembly,
389 separations: &[Separation],
390) -> Result<Recovered, RecoveryRefused> {
391 let configuration = assembly.configuration.as_str();
392 let ignitions_s = assembly.ignition_times_s(|_| None);
395 if let Some(unread) = recovery.unread.first() {
396 return Err(RecoveryRefused::Unread {
397 tag: unread.tag.clone(),
398 at: unread.at.clone(),
399 inside: unread.inside.clone(),
400 });
401 }
402 let mut out = Recovered {
403 devices: Vec::new(),
404 not_deployed: Vec::new(),
405 added: 0,
406 };
407 for device in &recovery.devices {
408 let (stage, part) = assembly
409 .layout
410 .find(&device.id)
411 .map(|(_, placed)| (placed.stage, &placed.part))
412 .ok_or_else(|| RecoveryRefused::NoPart {
413 id: device.id.clone(),
414 })?;
415 let name = component_name(rocket, &device.id)
416 .filter(|name| !name.is_empty())
417 .unwrap_or(&device.id)
418 .to_owned();
419 let domain = |what: &'static str, value: f64| RecoveryRefused::Domain {
420 name: name.clone(),
421 what,
422 value,
423 };
424 let deployment = device.deployment_in(configuration);
425 let delay_s = deployment.delay_s.unwrap_or(0.0);
426 if !(delay_s.is_finite() && delay_s >= 0.0) {
427 return Err(domain("deployment delay, s", delay_s));
428 }
429 let mut at_split = false;
431 let trigger = match &deployment.event {
432 Some(DeployEvent::Apogee) => Trigger::Apogee,
433 Some(DeployEvent::Altitude) => {
434 let height = deployment.altitude_m.unwrap_or(f64::NAN);
435 if !(height.is_finite() && height > 0.0) {
436 return Err(domain("deployment height above the ground, m", height));
437 }
438 Trigger::Altitude {
439 height_above_ground_m: height,
440 }
441 }
442 Some(DeployEvent::Launch) => Trigger::Time { time_s: delay_s },
443 Some(DeployEvent::Ejection) => {
444 let lit: Vec<(usize, Option<f64>)> = assembly
447 .motors
448 .iter()
449 .zip(&ignitions_s)
450 .enumerate()
451 .filter(|(_, (motor, _))| motor.stage == stage)
452 .filter_map(|(index, (motor, ignition_s))| {
453 let charge_s = match motor.mounted.delay {
454 Some(Delay::Seconds(delay_s)) => ignition_s.map(|ignition_s| {
455 ignition_s + motor.mounted.motor.burnout_time_s() + delay_s
456 }),
457 _ => None,
458 };
459 ignition_s.map(|_| (index, charge_s))
460 })
461 .collect();
462 if lit.is_empty() {
463 out.not_deployed.push((name, NeverDeploys::NoLitMotor));
464 continue;
465 }
466 let first = lit
467 .iter()
468 .filter_map(|&(index, charge_s)| Some((index, charge_s?)))
469 .min_by(|a, b| a.1.total_cmp(&b.1));
470 match first {
471 Some((motor, _)) => Trigger::MotorDelay { motor },
472 None => {
473 out.not_deployed
474 .push((name, NeverDeploys::NoEjectionCharge));
475 continue;
476 }
477 }
478 }
479 Some(DeployEvent::Never) => {
480 out.not_deployed.push((name, NeverDeploys::SetToNever));
481 continue;
482 }
483 Some(DeployEvent::LowerStageSeparation) => {
484 let split = separations
490 .iter()
491 .find(|separation| separation.after_stage == stage)
492 .filter(|separation| {
493 separation
494 .trigger
495 .known_time_s(assembly)
496 .ok()
497 .flatten()
498 .is_some_and(|time_s| !separation.powered_at(assembly, time_s))
499 });
500 match split.map(|separation| separation.trigger) {
501 Some(trigger @ (Trigger::Time { .. } | Trigger::Burnout { .. })) => {
502 at_split = true;
503 trigger
504 }
505 _ => return Err(RecoveryRefused::AtSeparation { name }),
506 }
507 }
508 Some(other) => {
509 return Err(RecoveryRefused::UnknownEvent {
510 name,
511 word: other.as_str().to_owned(),
512 });
513 }
514 None => {
515 return Err(RecoveryRefused::UnknownEvent {
516 name,
517 word: String::new(),
518 });
519 }
520 };
521 let stated = match &device.cd {
522 Some(Dimension::Stated { value }) => {
523 if !(value.is_finite() && *value > 0.0) {
524 return Err(domain("drag coefficient", *value));
525 }
526 Some(*value)
527 }
528 Some(Dimension::Automatic { .. }) | None => None,
530 Some(_) => {
531 return Err(domain(
532 "drag coefficient (a kind hpr does not know)",
533 f64::NAN,
534 ));
535 }
536 };
537 let drag = match (device.kind, part) {
538 (DeviceKind::Parachute, Part::Parachute(parachute)) => {
539 let diameter_m = parachute.diameter_m;
540 if !(diameter_m.is_finite() && diameter_m > 0.0) {
541 return Err(domain("canopy diameter, m", diameter_m));
542 }
543 let drag_coefficient = stated.unwrap_or(OPENROCKET_PARACHUTE_DRAG_COEFFICIENT);
546 DeviceDrag::DragArea {
547 cd_s_m2: drag_coefficient * std::f64::consts::PI * diameter_m * diameter_m
548 / 4.0,
549 }
550 }
551 (DeviceKind::Streamer, Part::Streamer(streamer)) => {
552 let (length_m, width_m) = (streamer.length_m, streamer.width_m);
553 for (what, value) in [
554 ("streamer length, m", length_m),
555 ("streamer width, m", width_m),
556 ] {
557 if !(value.is_finite() && value > 0.0) {
558 return Err(domain(what, value));
559 }
560 }
561 match stated {
562 Some(cd) => DeviceDrag::DragArea {
563 cd_s_m2: cd * length_m * width_m,
564 },
565 None if length_m / width_m < MIN_STREAMER_ASPECT_RATIO => {
567 return Err(domain(
568 "streamer aspect ratio, length over width",
569 length_m / width_m,
570 ));
571 }
572 None => DeviceDrag::Streamer {
573 length_m,
574 width_m,
575 surface_density_kg_m2: streamer
576 .material
577 .surface_kg_m2("streamer")
578 .map_err(|source| RecoveryRefused::Design {
579 name: name.clone(),
580 source,
581 })?,
582 model: StreamerModel::OpenRocket,
583 },
584 }
585 }
586 _ => {
587 return Err(RecoveryRefused::NoPart {
588 id: device.id.clone(),
589 });
590 }
591 };
592 let lag_s = if matches!(trigger, Trigger::Time { .. }) && !at_split {
595 0.0
596 } else {
597 delay_s
598 };
599 let body = Separation::body_of(separations, stage);
601 out.devices.push(
602 Device::new(name, drag, trigger)
603 .with_lag_s(lag_s)
604 .on_body(body),
605 );
606 }
607 Ok(out)
608}
609
610fn component_name<'a>(rocket: &'a hpr_design::Rocket, id: &str) -> Option<&'a str> {
612 fn search<'a>(components: &'a [hpr_design::tree::Component], id: &str) -> Option<&'a str> {
613 components.iter().find_map(|component| {
614 if component.id == id {
615 Some(component.name.as_str())
616 } else {
617 search(&component.children, id)
618 }
619 })
620 }
621 rocket
622 .stages
623 .iter()
624 .find_map(|stage| search(&stage.components, id))
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630
631 const ORK: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
635<openrocket version="1.10" creator="OpenRocket 24.12">
636 <rocket><name>Two-stage</name>
637 <motorconfiguration configid="burn"/><motorconfiguration configid="time"/>
638 <subcomponents>
639 <stage><name>Sustainer</name><id>upper</id><subcomponents>
640 <nosecone><name>Nose</name><id>nose</id><length>0.15</length><thickness>0.002</thickness>
641 <shape>ogive</shape><aftradius>0.0165</aftradius></nosecone>
642 <bodytube><name>Sustainer</name><id>sustainer</id><length>0.4</length>
643 <thickness>0.001</thickness><radius>0.0165</radius>
644 <motormount><ignitionevent>automatic</ignitionevent><ignitiondelay>0.0</ignitiondelay>
645 <overhang>0.0</overhang>
646 <motor configid="burn"><type>single</type><manufacturer>Estes</manufacturer>
647 <designation>F15</designation><diameter>0.029</diameter><length>0.114</length>
648 <delay>6.0</delay></motor>
649 <motor configid="time"><type>single</type><manufacturer>Estes</manufacturer>
650 <designation>F15</designation><diameter>0.029</diameter><length>0.114</length>
651 <delay>6.0</delay></motor>
652 <ignitionconfiguration configid="time"><ignitionevent>launch</ignitionevent>
653 <ignitiondelay>6.0</ignitiondelay></ignitionconfiguration>
654 </motormount></bodytube></subcomponents></stage>
655 <stage><name>Booster</name><id>lower</id>
656 <separationevent>burnout</separationevent><separationdelay>0.0</separationdelay>
657 <separationconfiguration configid="time"><separationevent>launch</separationevent>
658 <separationdelay>4.0</separationdelay></separationconfiguration>
659 <subcomponents>
660 <bodytube><name>Booster</name><id>booster</id><length>0.3</length>
661 <thickness>0.001</thickness><radius>0.0165</radius>
662 <motormount><ignitionevent>automatic</ignitionevent><ignitiondelay>0.0</ignitiondelay>
663 <overhang>0.0</overhang>
664 <motor configid="burn"><type>single</type><manufacturer>Estes</manufacturer>
665 <designation>F15</designation><diameter>0.029</diameter><length>0.114</length>
666 <delay>0.0</delay></motor>
667 <motor configid="time"><type>single</type><manufacturer>Estes</manufacturer>
668 <designation>F15</designation><diameter>0.029</diameter><length>0.114</length>
669 <delay>0.0</delay></motor>
670 </motormount></bodytube></subcomponents></stage>
671 </subcomponents></rocket>
672</openrocket>"#;
673
674 fn read(id: &str) -> (Staging, Assembly) {
676 let file = hpr_io::ork::read(ORK.as_bytes()).expect("a readable design");
677 let design = hpr_io::ork::design(&file.value).value;
678 let configuration = design
679 .motors
680 .configurations
681 .iter()
682 .find(|configuration| configuration.id == id)
683 .expect("the configuration");
684 let staging = configuration.staging.clone().expect("a separation");
685 (staging, design.rocket.assemble(id).expect("flown"))
686 }
687
688 #[test]
689 fn a_burnout_separation_names_the_booster_motor_by_its_index() {
690 let (staging, assembly) = read("burn");
691 let booster = assembly
692 .motors
693 .iter()
694 .position(|motor| motor.mount == "booster")
695 .expect("the booster's motor");
696 assert_eq!(
697 separation(&staging, &assembly).expect("maps"),
698 Separation::new(
699 Trigger::Burnout {
700 motor: booster,
701 delay_s: 0.0
702 },
703 0
704 )
705 );
706
707 let mut failing = assembly.clone();
710 let mut dud = failing.motors[booster].clone();
711 dud.fails = true;
712 failing.motors.insert(booster, dud);
713 assert_eq!(
714 separation(&staging, &failing).expect("maps").trigger,
715 Trigger::Burnout {
716 motor: booster + 1,
717 delay_s: 0.0
718 }
719 );
720
721 let mut dead = assembly.clone();
724 dead.motors[booster].fails = true;
725 assert!(matches!(
726 separation(&staging, &dead),
727 Err(SimError::Domain {
728 what: "count of lit motors in the mount a separation is timed from",
729 ..
730 })
731 ));
732 let mut other = assembly;
733 other.motors.retain(|motor| motor.mount != "booster");
734 let error = separation(&staging, &other).expect_err("no booster motor");
735 assert!(
736 matches!(
737 error,
738 SimError::Domain {
739 what: "count of lit motors in the mount a separation is timed from",
740 ..
741 }
742 ),
743 "{error}"
744 );
745 }
746
747 fn staged(
750 id: &str,
751 chutes: (bool, bool),
752 ) -> (Recovery, hpr_design::Rocket, Assembly, Separation) {
753 let chute = |name: &str| {
754 format!(
755 r#"<subcomponents><parachute><name>{name}</name><id>{name}</id>
756 <axialoffset method="top">0.05</axialoffset><packedlength>0.03</packedlength>
757 <packedradius>0.012</packedradius><cd>auto</cd>
758 <material type="surface" density="0.067">Ripstop nylon</material>
759 <deployevent>apogee</deployevent><deploydelay>0.0</deploydelay>
760 <diameter>0.3</diameter></parachute></subcomponents>"#
761 )
762 };
763 let mut text = ORK.to_owned();
764 for (wanted, tube, name) in [
766 (chutes.0, "<id>sustainer</id>", "Upper chute"),
767 (chutes.1, "<id>booster</id>", "Lower chute"),
768 ] {
769 if wanted {
770 let at = text.find(tube).expect("the tube's text") + tube.len();
771 text.insert_str(at, &chute(name));
772 }
773 }
774 assert_eq!(
775 text.matches("<parachute>").count(),
776 usize::from(chutes.0) + usize::from(chutes.1)
777 );
778 let file = hpr_io::ork::read(text.as_bytes()).expect("a readable design");
779 let design = hpr_io::ork::design(&file.value).value;
780 let configuration = design
781 .motors
782 .configurations
783 .iter()
784 .find(|configuration| configuration.id == id)
785 .expect("the configuration");
786 let assembly = design.rocket.assemble(id).expect("flown");
787 let split = separation(configuration.staging.as_ref().expect("staged"), &assembly)
788 .expect("a separation");
789 (design.recovery, design.rocket, assembly, split)
790 }
791
792 #[test]
796 fn each_device_rides_its_part_and_the_booster_tumbles_until_its_own_opens() {
797 let (recovery_, rocket, assembly, split) = staged("burn", (true, true));
798 let flown = separated_recovery(&recovery_, &rocket, &assembly, &[split]).expect("maps");
799 let tumble = DeviceDrag::tumbling_stages(&assembly, (1, 1)).expect("tumbles");
800 let chute = DeviceDrag::DragArea {
801 cd_s_m2: 0.8 * std::f64::consts::PI * 0.3 * 0.3 / 4.0,
802 };
803 assert_eq!(
804 flown.devices,
805 vec![
806 Device::new("Upper chute", chute, Trigger::Apogee),
807 Device::new("Lower chute", chute, Trigger::Apogee).on_body(1),
808 Device::new("Booster, tumbling", tumble, Trigger::Time { time_s: 0.0 })
809 .on_body(1)
810 .with_release_by(1),
811 ]
812 );
813 assert_eq!(flown.added, 1);
814 let whole = recovery(&recovery_, &rocket, &assembly).expect("maps");
816 assert!(whole.devices.iter().all(|device| device.body == 0));
817 assert_eq!((whole.devices.len(), whole.added), (2, 0));
818 }
819
820 #[test]
823 fn a_part_with_no_device_tumbles() {
824 let (recovery_, rocket, assembly, split) = staged("burn", (false, false));
825 let flown = separated_recovery(&recovery_, &rocket, &assembly, &[split]).expect("maps");
826 assert_eq!(flown.added, 2);
827 assert_eq!(
828 flown.devices,
829 vec![
830 Device::new(
831 "Sustainer, tumbling",
832 DeviceDrag::tumbling_stages(&assembly, (0, 0)).expect("tumbles"),
833 Trigger::Apogee,
834 ),
835 Device::new(
836 "Booster, tumbling",
837 DeviceDrag::tumbling_stages(&assembly, (1, 1)).expect("tumbles"),
838 Trigger::Time { time_s: 0.0 },
839 )
840 .on_body(1),
841 ]
842 );
843 let (recovery_, rocket, assembly, split) = staged("burn", (false, true));
846 let flown = separated_recovery(&recovery_, &rocket, &assembly, &[split]).expect("maps");
847 assert_eq!(flown.added, 2);
848 let described: Vec<(&str, usize, Option<usize>)> = flown
849 .devices
850 .iter()
851 .map(|d| (d.name.as_str(), d.body, d.released_by))
852 .collect();
853 assert_eq!(
854 described,
855 [
856 ("Lower chute", 1, None),
857 ("Sustainer, tumbling", 0, None),
858 ("Booster, tumbling", 1, Some(0)),
859 ]
860 );
861 }
862
863 fn payload(
868 id: &str,
869 (upper, upper_delay_s): (&str, f64),
870 ) -> (Recovery, hpr_design::Rocket, Assembly, Separation) {
871 let mut text = ORK.to_owned();
872 let start = text.find("<motormount>").expect("the sustainer's mount");
873 let end = text[start..].find("</motormount>").expect("its end") + start;
874 text.replace_range(start..end + "</motormount>".len(), "");
875 let text = text.replacen(
876 "<separationdelay>0.0</separationdelay>",
877 "<separationdelay>2.0</separationdelay>",
878 1,
879 );
880 let chute = |name: &str, event: &str, delay_s: f64| {
881 format!(
882 r#"<subcomponents><parachute><name>{name}</name><id>{name}</id>
883 <axialoffset method="top">0.05</axialoffset><packedlength>0.03</packedlength>
884 <packedradius>0.012</packedradius><cd>auto</cd>
885 <material type="surface" density="0.067">Ripstop nylon</material>
886 <deployevent>{event}</deployevent><deploydelay>{delay_s}</deploydelay>
887 <diameter>0.3</diameter></parachute></subcomponents>"#
888 )
889 };
890 let text = text
891 .replacen(
892 "<id>sustainer</id>",
893 &format!(
894 "<id>sustainer</id>{}",
895 chute("Upper chute", upper, upper_delay_s)
896 ),
897 1,
898 )
899 .replacen(
900 "<id>booster</id>",
901 &format!("<id>booster</id>{}", chute("Lower chute", "apogee", 0.0)),
902 1,
903 );
904 let file = hpr_io::ork::read(text.as_bytes()).expect("a readable design");
905 let design = hpr_io::ork::design(&file.value).value;
906 let configuration = design
907 .motors
908 .configurations
909 .iter()
910 .find(|configuration| configuration.id == id)
911 .expect("the configuration");
912 let assembly = design.rocket.assemble(id).expect("flown");
913 assert_eq!(assembly.motors.len(), 1);
914 let split = separation(configuration.staging.as_ref().expect("staged"), &assembly)
915 .expect("a separation");
916 (design.recovery, design.rocket, assembly, split)
917 }
918
919 #[test]
924 fn a_payload_opens_at_its_split_or_is_refused_as_coasting() {
925 let (recovery_, rocket, assembly, split) = payload("burn", ("lowerstageseparation", 0.0));
926 let booster = 0;
927 assert_eq!(
928 split.trigger,
929 Trigger::Burnout {
930 motor: booster,
931 delay_s: 2.0
932 }
933 );
934 let split_s = split.trigger.known_time_s(&assembly).unwrap().unwrap();
935 assert!(!split.powered_at(&assembly, split_s));
936 let flown = separated_recovery(&recovery_, &rocket, &assembly, &[split]).expect("maps");
937 let described: Vec<(&str, usize, Trigger, Option<usize>)> = flown
938 .devices
939 .iter()
940 .map(|d| (d.name.as_str(), d.body, d.trigger, d.released_by))
941 .collect();
942 assert_eq!(
943 described,
944 [
945 ("Upper chute", 0, split.trigger, None),
946 ("Lower chute", 1, Trigger::Apogee, None),
947 (
948 "Booster, tumbling",
949 1,
950 Trigger::Time { time_s: 0.0 },
951 Some(1)
952 ),
953 ]
954 );
955 assert_eq!(flown.added, 1);
956 assert_eq!(flown.devices[0].lag_s, 0.0);
957 let (recovery_, rocket, assembly, split) = payload("time", ("lowerstageseparation", 0.0));
959 assert_eq!(split.trigger, Trigger::Time { time_s: 4.0 });
960 let flown = separated_recovery(&recovery_, &rocket, &assembly, &[split]).expect("maps");
961 assert_eq!(flown.devices[0].trigger, Trigger::Time { time_s: 4.0 });
962 assert_eq!(flown.devices[0].lag_s, 0.0);
963
964 for id in ["burn", "time"] {
967 let (recovery_, rocket, assembly, split) = payload(id, ("lowerstageseparation", 1.0));
968 let mapped = map_devices(&recovery_, &rocket, &assembly, &[split]).expect("maps");
969 let upper = &mapped.devices[0];
970 assert_eq!((upper.trigger, upper.lag_s), (split.trigger, 1.0), "{id}");
971 let mut devices = mapped.devices;
973 devices.push(Device::new(
974 "streamer",
975 DeviceDrag::DragArea { cd_s_m2: 0.001 },
976 Trigger::Time { time_s: 0.0 },
977 ));
978 let devices = tumbling(devices, &rocket, &assembly, &[split]).expect("flies");
979 let result = hpr_sim::Simulation::new(
980 &rocket,
981 id,
982 hpr_sim::Environment::standard(
983 hpr_core::geodesy::Geodetic::from_degrees(45.0, 0.0, 0.0).expect("a site"),
984 )
985 .expect("standard air"),
986 hpr_sim::Rail::vertical(1.0),
987 hpr_sim::FlightSettings::default(),
988 )
989 .expect("a simulation")
990 .with_recovery(devices)
991 .expect("devices it takes")
992 .with_separations(vec![split])
993 .expect("a split it takes")
994 .run(&mut ())
995 .expect("a flight");
996 let split_s = result
997 .event(hpr_sim::EventKind::Separation)
998 .expect("split")
999 .sample
1000 .time_s;
1001 let opened_s = result
1002 .bodies
1003 .iter()
1004 .find(|body| body.body == 0)
1005 .and_then(|body| body.event(hpr_sim::EventKind::Deployment(0)))
1006 .expect("opened on the payload")
1007 .sample
1008 .time_s;
1009 assert!(
1010 (opened_s - (split_s + 1.0)).abs() < 1e-9,
1011 "{id}: {opened_s} vs {split_s}"
1012 );
1013 }
1014
1015 for upper in [
1018 ("lowerstageseparation", 0.25),
1019 ("apogee", 0.0),
1020 ("never", 0.0),
1021 ] {
1022 let (recovery_, rocket, assembly, split) = payload("burn", upper);
1023 let refused =
1024 separated_recovery(&recovery_, &rocket, &assembly, &[split]).expect_err("coasts");
1025 assert_eq!(
1026 refused,
1027 RecoveryRefused::Coasts {
1028 name: "`Sustainer`".to_owned(),
1029 time_s: split_s,
1030 },
1031 "{upper:?}"
1032 );
1033 assert!(
1034 refused
1035 .to_string()
1036 .starts_with("`Sustainer`, the part that keeps the nose, would coast"),
1037 "{refused}"
1038 );
1039 }
1040 let (recovery_, rocket, assembly, split) = payload("burn", ("launch", split_s - 0.5));
1042 let flown = separated_recovery(&recovery_, &rocket, &assembly, &[split]).expect("maps");
1043 assert_eq!(
1044 flown.devices[0].trigger,
1045 Trigger::Time {
1046 time_s: split_s - 0.5
1047 }
1048 );
1049
1050 let (recovery_, rocket, assembly, _) = payload("burn", ("lowerstageseparation", 0.0));
1052 assert_eq!(
1053 recovery(&recovery_, &rocket, &assembly).expect_err("refused"),
1054 RecoveryRefused::AtSeparation {
1055 name: "Upper chute".to_owned()
1056 }
1057 );
1058 let text = ORK.replacen(
1060 "<id>sustainer</id>",
1061 r#"<id>sustainer</id><subcomponents><parachute><name>Upper chute</name>
1062 <id>Upper chute</id><axialoffset method="top">0.05</axialoffset>
1063 <packedlength>0.03</packedlength><packedradius>0.012</packedradius><cd>auto</cd>
1064 <deployevent>lowerstageseparation</deployevent><deploydelay>0.0</deploydelay>
1065 <diameter>0.3</diameter></parachute></subcomponents>"#,
1066 1,
1067 );
1068 let file = hpr_io::ork::read(text.as_bytes()).expect("a readable design");
1069 let design = hpr_io::ork::design(&file.value).value;
1070 let staging = design.motors.configurations[0]
1071 .staging
1072 .clone()
1073 .expect("staged");
1074 let assembly = design.rocket.assemble("burn").expect("flown");
1075 let split = separation(&staging, &assembly).expect("a separation");
1076 assert!(split.powered_at(&assembly, staging.time_s));
1077 assert_eq!(
1078 separated_recovery(&design.recovery, &design.rocket, &assembly, &[split])
1079 .expect_err("refused"),
1080 RecoveryRefused::AtSeparation {
1081 name: "Upper chute".to_owned()
1082 }
1083 );
1084 }
1085
1086 #[test]
1090 fn only_a_staging_marked_so_drops_a_burning_motor() {
1091 let (_, assembly) = read("time");
1092 let staging = |drops: bool| -> Staging {
1093 serde_json::from_value(serde_json::json!({
1094 "after_stage": 0,
1095 "trigger": { "time": { "time_s": 1.0 } },
1096 "time_s": 1.0,
1097 "drops_burning": drops,
1098 }))
1099 .expect("a staging")
1100 };
1101 let plain = Separation::new(Trigger::Time { time_s: 1.0 }, 0);
1102 assert_eq!(separation(&staging(false), &assembly).unwrap(), plain);
1103 assert_eq!(
1104 separation(&staging(true), &assembly).unwrap(),
1105 plain.dropping_burning()
1106 );
1107 }
1108
1109 #[test]
1110 fn a_separation_at_a_time_stays_a_time() {
1111 let (staging, assembly) = read("time");
1112 assert_eq!(
1113 separation(&staging, &assembly).expect("maps"),
1114 Separation::new(Trigger::Time { time_s: 4.0 }, 0)
1115 );
1116 }
1117
1118 const RECOVERY_ORK: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
1124<openrocket version="1.10" creator="OpenRocket 24.12">
1125 <rocket><name>Recovery</name>
1126 <motorconfiguration configid="charge"/><motorconfiguration configid="plugged"/>
1127 <subcomponents>
1128 <stage><name>Sustainer</name><id>stage</id><subcomponents>
1129 <nosecone><name>Nose</name><id>nose</id><length>0.15</length><thickness>0.002</thickness>
1130 <shape>ogive</shape><aftradius>0.0165</aftradius></nosecone>
1131 <bodytube><name>Body</name><id>body</id><length>0.6</length>
1132 <thickness>0.001</thickness><radius>0.0165</radius>
1133 <subcomponents>
1134 <parachute><name>Drogue</name><id>drogue</id><axialoffset method="top">0.05</axialoffset><packedlength>0.03</packedlength>
1135 <packedradius>0.012</packedradius><cd>auto</cd>
1136 <material type="surface" density="0.067">Ripstop nylon</material>
1137 <deployevent>apogee</deployevent><deploydelay>1.0</deploydelay>
1138 <diameter>0.3</diameter><linecount>6</linecount><linelength>0.3</linelength>
1139 <linematerial type="line" density="0.0025">Nylon</linematerial></parachute>
1140 <parachute><name>Main</name><id>main</id><axialoffset method="top">0.05</axialoffset><packedlength>0.04</packedlength>
1141 <packedradius>0.012</packedradius><cd>0.9</cd>
1142 <material type="surface" density="0.067">Ripstop nylon</material>
1143 <deployevent>altitude</deployevent><deployaltitude>100.0</deployaltitude>
1144 <deploydelay>0.0</deploydelay>
1145 <diameter>0.6</diameter><linecount>8</linecount><linelength>0.5</linelength>
1146 <linematerial type="line" density="0.0025">Nylon</linematerial></parachute>
1147 <streamer><name>Charge streamer</name><id>charged</id><axialoffset method="top">0.05</axialoffset><packedlength>0.02</packedlength>
1148 <packedradius>0.01</packedradius><cd>auto</cd>
1149 <material type="surface" density="0.09">Mylar</material>
1150 <deployevent>ejection</deployevent><deploydelay>0.5</deploydelay>
1151 <striplength>1.2</striplength><stripwidth>0.1</stripwidth></streamer>
1152 <streamer><name></name><id>timed</id><axialoffset method="top">0.05</axialoffset><packedlength>0.02</packedlength>
1153 <packedradius>0.01</packedradius><cd>0.6</cd>
1154 <material type="surface" density="0.09">Mylar</material>
1155 <deployevent>launch</deployevent><deploydelay>2.0</deploydelay>
1156 <striplength>1.0</striplength><stripwidth>0.05</stripwidth></streamer>
1157 <parachute><name>Spare</name><id>spare</id><axialoffset method="top">0.05</axialoffset><packedlength>0.02</packedlength>
1158 <packedradius>0.01</packedradius><cd>auto</cd>
1159 <material type="surface" density="0.067">Ripstop nylon</material>
1160 <deployevent>never</deployevent><deploydelay>0.0</deploydelay>
1161 <diameter>0.3</diameter><linecount>6</linecount><linelength>0.3</linelength>
1162 <linematerial type="line" density="0.0025">Nylon</linematerial></parachute>
1163 </subcomponents>
1164 <motormount><ignitionevent>automatic</ignitionevent><ignitiondelay>0.0</ignitiondelay>
1165 <overhang>0.0</overhang>
1166 <motor configid="charge"><type>single</type><manufacturer>Estes</manufacturer>
1167 <designation>F15</designation><diameter>0.029</diameter><length>0.114</length>
1168 <delay>6.0</delay></motor>
1169 <motor configid="plugged"><type>single</type><manufacturer>Estes</manufacturer>
1170 <designation>F15</designation><diameter>0.029</diameter><length>0.114</length>
1171 <delay>none</delay></motor>
1172 </motormount></bodytube></subcomponents></stage>
1173 </subcomponents></rocket>
1174</openrocket>"#;
1175
1176 fn recovery_read(
1179 id: &str,
1180 edit: impl Fn(String) -> String,
1181 ) -> (Recovery, hpr_design::Rocket, Assembly) {
1182 let text = edit(RECOVERY_ORK.to_owned());
1183 let file = hpr_io::ork::read(text.as_bytes()).expect("a readable design");
1184 let design = hpr_io::ork::design(&file.value).value;
1185 let assembly = design.rocket.assemble(id).expect("flown");
1186 (design.recovery, design.rocket, assembly)
1187 }
1188
1189 fn mapped(id: &str, edit: impl Fn(String) -> String) -> Result<Recovered, RecoveryRefused> {
1190 let (recovery_, rocket, assembly) = recovery_read(id, edit);
1191 recovery(&recovery_, &rocket, &assembly)
1192 }
1193
1194 #[test]
1195 fn each_device_flies_as_openrocket_documents_it() {
1196 let flown = mapped("charge", |text| text).expect("maps");
1197 let motor = 0;
1198 let expected = vec![
1199 Device::new(
1201 "Drogue",
1202 DeviceDrag::DragArea {
1203 cd_s_m2: 0.8 * std::f64::consts::PI * 0.3 * 0.3 / 4.0,
1204 },
1205 Trigger::Apogee,
1206 )
1207 .with_lag_s(1.0),
1208 Device::new(
1209 "Main",
1210 DeviceDrag::DragArea {
1211 cd_s_m2: 0.9 * std::f64::consts::PI * 0.6 * 0.6 / 4.0,
1212 },
1213 Trigger::Altitude {
1214 height_above_ground_m: 100.0,
1215 },
1216 ),
1217 Device::new(
1219 "Charge streamer",
1220 DeviceDrag::Streamer {
1221 length_m: 1.2,
1222 width_m: 0.1,
1223 surface_density_kg_m2: 0.09,
1224 model: StreamerModel::OpenRocket,
1225 },
1226 Trigger::MotorDelay { motor },
1227 )
1228 .with_lag_s(0.5),
1229 Device::new(
1232 "timed",
1233 DeviceDrag::DragArea {
1234 cd_s_m2: 0.6 * 1.0 * 0.05,
1235 },
1236 Trigger::Time { time_s: 2.0 },
1237 ),
1238 ];
1239 assert_eq!(flown.devices, expected);
1240 assert_eq!(
1241 flown.not_deployed,
1242 vec![("Spare".to_owned(), NeverDeploys::SetToNever)]
1243 );
1244 }
1245
1246 #[test]
1247 fn a_coefficient_above_knackes_range_flies_as_stated() {
1248 let flown = mapped("charge", |text| {
1249 text.replace("<cd>0.9</cd>", "<cd>2.5</cd>")
1250 })
1251 .expect("maps");
1252 assert_eq!(
1253 flown.devices[1].drag,
1254 DeviceDrag::DragArea {
1255 cd_s_m2: 2.5 * std::f64::consts::PI * 0.6 * 0.6 / 4.0
1256 }
1257 );
1258 }
1259
1260 #[test]
1261 fn the_stages_first_charge_opens_the_device() {
1262 let (recovery_, rocket, mut assembly) = recovery_read("charge", |text| text);
1265 let mut sooner = assembly.motors[0].clone();
1266 sooner.mount = "second mount".to_owned();
1267 sooner.mounted.delay = Some(Delay::Seconds(4.0));
1268 assembly.motors.push(sooner);
1269 let streamer = |assembly: &Assembly| {
1270 recovery(&recovery_, &rocket, assembly)
1271 .expect("maps")
1272 .devices[2]
1273 .trigger
1274 };
1275 assert_eq!(streamer(&assembly), Trigger::MotorDelay { motor: 1 });
1276 assembly.motors[1].mounted.delay = Some(Delay::Seconds(8.0));
1278 assembly.motors[0].mounted.delay = Some(Delay::Plugged);
1279 assert_eq!(streamer(&assembly), Trigger::MotorDelay { motor: 1 });
1280 assembly.motors[0].mounted.delay = Some(Delay::Seconds(1.0));
1282 assembly.motors[0].fails = true;
1283 assert_eq!(streamer(&assembly), Trigger::MotorDelay { motor: 1 });
1284 }
1285
1286 #[test]
1287 fn a_pod_s_device_a_wide_streamer_and_a_missing_part_are_refused() {
1288 let (mut recovery_, rocket, assembly) = recovery_read("charge", |text| text);
1289 let mut unread = recovery_.clone();
1290 unread.unread.push(
1292 serde_json::from_str(
1293 r#"{"at": "/rocket/podset/parachute", "tag": "parachute", "inside": "podset"}"#,
1294 )
1295 .expect("an unread device"),
1296 );
1297 assert!(matches!(
1298 recovery(&unread, &rocket, &assembly),
1299 Err(RecoveryRefused::Unread { inside, .. }) if inside == "podset"
1300 ));
1301 recovery_.devices[0].kind = DeviceKind::Streamer;
1303 assert_eq!(
1304 recovery(&recovery_, &rocket, &assembly),
1305 Err(RecoveryRefused::NoPart {
1306 id: "drogue".to_owned()
1307 })
1308 );
1309 let wide = mapped("charge", |text| {
1310 text.replace(
1311 "<stripwidth>0.1</stripwidth>",
1312 "<stripwidth>1.5</stripwidth>",
1313 )
1314 });
1315 assert!(matches!(
1316 wide,
1317 Err(RecoveryRefused::Domain { name, what: "streamer aspect ratio, length over width", .. })
1318 if name == "Charge streamer"
1319 ));
1320 }
1321
1322 #[test]
1323 fn a_plugged_motor_opens_nothing_at_its_charge() {
1324 let flown = mapped("plugged", |text| text).expect("maps");
1325 assert_eq!(
1326 flown
1327 .devices
1328 .iter()
1329 .map(|device| device.name.as_str())
1330 .collect::<Vec<_>>(),
1331 ["Drogue", "Main", "timed"]
1332 );
1333 assert_eq!(
1334 flown.not_deployed,
1335 vec![
1336 ("Charge streamer".to_owned(), NeverDeploys::NoEjectionCharge),
1337 ("Spare".to_owned(), NeverDeploys::SetToNever),
1338 ]
1339 );
1340 }
1341
1342 #[test]
1343 fn a_configurations_own_deployment_wins() {
1344 let flown = mapped("charge", |text| {
1345 text.replace(
1346 "<deployevent>apogee</deployevent><deploydelay>1.0</deploydelay>",
1347 "<deployevent>apogee</deployevent><deploydelay>1.0</deploydelay>\
1348 <deploymentconfiguration configid=\"charge\"><deployevent>altitude</deployevent>\
1349 <deployaltitude>50.0</deployaltitude></deploymentconfiguration>",
1350 )
1351 })
1352 .expect("maps");
1353 assert_eq!(
1356 flown.devices[0].trigger,
1357 Trigger::Altitude {
1358 height_above_ground_m: 50.0
1359 }
1360 );
1361 assert_eq!(flown.devices[0].lag_s, 1.0);
1362 }
1363
1364 #[test]
1365 fn what_is_not_flown_is_refused_by_name() {
1366 let event = |word: &'static str| {
1367 move |text: String| {
1368 text.replace(
1369 "<deployevent>apogee</deployevent>",
1370 &format!("<deployevent>{word}</deployevent>"),
1371 )
1372 }
1373 };
1374 assert_eq!(
1375 mapped("charge", event("lowerstageseparation")),
1376 Err(RecoveryRefused::AtSeparation {
1377 name: "Drogue".to_owned()
1378 })
1379 );
1380 assert_eq!(
1381 mapped("charge", event("burnout")),
1382 Err(RecoveryRefused::UnknownEvent {
1383 name: "Drogue".to_owned(),
1384 word: "burnout".to_owned()
1385 })
1386 );
1387 let refused = |edit: &dyn Fn(String) -> String| match mapped("charge", edit) {
1389 Err(RecoveryRefused::Domain { name, what, .. }) => (name, what),
1390 other => panic!("not a domain error: {other:?}"),
1391 };
1392 assert_eq!(
1393 refused(&event("altitude")),
1394 ("Drogue".to_owned(), "deployment height above the ground, m")
1395 );
1396 assert_eq!(
1397 refused(&|text: String| text.replace(
1398 "<deploydelay>1.0</deploydelay>",
1399 "<deploydelay>-1.0</deploydelay>"
1400 )),
1401 ("Drogue".to_owned(), "deployment delay, s")
1402 );
1403 assert_eq!(
1404 refused(&|text: String| text.replace("<cd>0.9</cd>", "<cd>0.0</cd>")),
1405 ("Main".to_owned(), "drag coefficient")
1406 );
1407 }
1408
1409 #[test]
1413 fn a_charge_with_no_motor_in_its_stage_never_opens() {
1414 let (recovery_, rocket, mut assembly) = recovery_read("charge", |text| text);
1415 let lit = recovery(&recovery_, &rocket, &assembly).unwrap();
1416 for motor in &mut assembly.motors {
1417 motor.fails = true;
1418 }
1419 let unlit = recovery(&recovery_, &rocket, &assembly).unwrap();
1420 let spare = ("Spare".to_owned(), NeverDeploys::SetToNever);
1421 assert_eq!(lit.not_deployed, std::slice::from_ref(&spare));
1422 assert_eq!(
1423 unlit.not_deployed,
1424 [
1425 ("Charge streamer".to_owned(), NeverDeploys::NoLitMotor),
1426 spare
1427 ]
1428 );
1429 assert_eq!(unlit.devices.len() + 1, lit.devices.len());
1430 }
1431
1432 #[test]
1433 fn the_devices_fly() {
1434 let (recovery_, rocket, _) = recovery_read("charge", |text| text);
1436 let flown = recovery(
1437 &recovery_,
1438 &rocket,
1439 &rocket.assemble("charge").expect("flown"),
1440 )
1441 .expect("maps");
1442 let simulation = hpr_sim::Simulation::new(
1443 &rocket,
1444 "charge",
1445 hpr_sim::Environment::standard(
1446 hpr_core::geodesy::Geodetic::from_degrees(45.0, 0.0, 0.0).expect("a site"),
1447 )
1448 .expect("standard air"),
1449 hpr_sim::Rail::vertical(1.0),
1450 hpr_sim::FlightSettings::default(),
1451 )
1452 .expect("a simulation")
1453 .with_recovery(flown.devices)
1454 .expect("devices it takes");
1455 let result = simulation.run(&mut ()).expect("a flight");
1456 let deployed = result
1457 .events
1458 .iter()
1459 .filter(|event| matches!(event.kind, hpr_sim::EventKind::Deployment(_)))
1460 .count();
1461 assert_eq!(deployed, 4);
1462 }
1463}