1use hpr_design::{Assembly, Component, MassProperties, Rocket};
18use serde::{Deserialize, Serialize};
19
20use crate::error::SimError;
21use crate::recovery::{DeviceDrag, Separation, Trigger};
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case", deny_unknown_fields)]
26#[non_exhaustive]
27pub enum Parting {
28 AftOf {
32 component: String,
34 },
35 Payload {
39 component: String,
41 },
42}
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76#[non_exhaustive]
77pub struct Ejection {
78 pub trigger: Trigger,
80 pub parting: Parting,
82 #[serde(default, skip_serializing_if = "is_zero")]
84 pub impulse_n_s: f64,
85}
86
87fn is_zero(impulse_n_s: &f64) -> bool {
89 *impulse_n_s == 0.0
90}
91
92impl Ejection {
93 #[must_use]
95 pub fn aft_of(trigger: Trigger, component: impl Into<String>) -> Self {
96 Self {
97 trigger,
98 parting: Parting::AftOf {
99 component: component.into(),
100 },
101 impulse_n_s: 0.0,
102 }
103 }
104
105 #[must_use]
107 pub fn payload(trigger: Trigger, component: impl Into<String>) -> Self {
108 Self {
109 trigger,
110 parting: Parting::Payload {
111 component: component.into(),
112 },
113 impulse_n_s: 0.0,
114 }
115 }
116
117 #[must_use]
124 pub fn with_impulse(mut self, impulse_n_s: f64) -> Self {
125 self.impulse_n_s = impulse_n_s;
126 self
127 }
128}
129
130#[derive(Debug, Clone)]
136pub(crate) struct Pieces {
137 host: Vec<Option<usize>>,
140 beside: Vec<bool>,
143 sections: Vec<usize>,
145 structure: Vec<Vec<MassProperties>>,
147 motor_piece: Vec<usize>,
149 stages: Vec<(usize, usize)>,
151 piece_of: Vec<usize>,
153}
154
155impl Pieces {
156 pub(crate) fn new(
167 rocket: &Rocket,
168 assembly: &Assembly,
169 separations: &[Separation],
170 ejections: &[Ejection],
171 ) -> Result<Self, SimError> {
172 let components = &assembly.layout.components;
173 let refuse = |what: &'static str, component: &str| SimError::Parting {
174 what,
175 component: component.to_owned(),
176 };
177 let find = |id: &str| {
178 components
179 .iter()
180 .position(|component| component.id == id)
181 .ok_or_else(|| refuse("an ejection names a component the design doesn't have", id))
182 };
183 let top: Vec<usize> = (0..components.len())
184 .filter(|&index| components[index].parent.is_none())
185 .collect();
186 let count = 1 + separations.len() + ejections.len();
187
188 let mut starts: Vec<(usize, usize)> = Vec::new();
190 let mut payloads: Vec<(usize, usize)> = Vec::new();
191 let mut beside = vec![false; count];
192 let mut piece = 1;
193 for separation in separations {
194 let first = separation.after_stage + 1;
195 if assembly
196 .layout
197 .stages
198 .get(first)
199 .is_some_and(|stage| stage.hung_on.is_some())
200 {
201 let pods = (0..components.len()).find(|&index| {
205 components[index].stage == first
206 && components[index]
207 .parent
208 .is_some_and(|parent| components[parent].stage != first)
209 });
210 let Some(pods) = pods else {
211 return Err(SimError::Domain {
212 what: "stage boundary of a separation (the parallel stage aft of it has no \
213 parts)",
214 value: separation.after_stage as f64,
215 });
216 };
217 payloads.push((pods, piece));
218 beside[piece] = true;
219 piece += 1;
220 continue;
221 }
222 let Some(at) = top
223 .iter()
224 .position(|&index| components[index].stage > separation.after_stage)
225 else {
226 return Err(SimError::Domain {
227 what: "stage boundary of a separation (there is no stage aft of it)",
228 value: separation.after_stage as f64,
229 });
230 };
231 starts.push((at, piece));
232 piece += 1;
233 }
234 for ejection in ejections {
235 match &ejection.parting {
236 Parting::AftOf { component } => {
237 let index = find(component)?;
238 if components[index].parent.is_some() && components[index].part.is_body() {
239 return Err(refuse(
240 "a pod's body component leaves with the tube its pod set hangs \
241 from, at no joint of its own and not as a payload",
242 component,
243 ));
244 }
245 if components[index].parent.is_some() {
246 return Err(refuse(
247 "a joint is aft of a body component, and this one is inside another \
248 (a part carried inside is a `Parting::Payload`)",
249 component,
250 ));
251 }
252 let Some(at) = top.iter().position(|&body| body == index).map(|at| at + 1)
254 else {
255 return Err(refuse(
256 "a body component missing from the layout",
257 component,
258 ));
259 };
260 if at >= top.len() {
261 return Err(refuse(
262 "a joint aft of the last body component (nothing is aft of it)",
263 component,
264 ));
265 }
266 if starts.iter().any(|(start, _)| *start == at) {
267 return Err(refuse(
268 "two partings at the joint aft of this component (a separation's \
269 stage boundary is a joint too)",
270 component,
271 ));
272 }
273 starts.push((at, piece));
274 }
275 Parting::Payload { component } => {
276 let index = find(component)?;
277 if components[index].part.is_body() && components[index].parent.is_some() {
278 return Err(refuse(
279 "a pod's body component leaves with the tube its pod set hangs \
280 from, at no joint of its own and not as a payload",
281 component,
282 ));
283 }
284 if components[index].parent.is_none() {
285 return Err(refuse(
286 "a payload is carried inside the airframe, and this is a body \
287 component (a body component leaves at a joint, `Parting::AftOf`)",
288 component,
289 ));
290 }
291 if components[index].body_radius_m.is_some() {
292 return Err(refuse(
293 "a payload is carried inside the airframe, and this part is outside it",
294 component,
295 ));
296 }
297 if components[index].copies.len() != 1 {
298 return Err(refuse(
299 "a payload that isn't exactly one part (one of several copies in a \
300 cluster of tubes, or none)",
301 component,
302 ));
303 }
304 if payloads.iter().any(|(payload, _)| *payload == index) {
305 return Err(refuse("two ejections of one payload", component));
306 }
307 payloads.push((index, piece));
308 }
309 }
310 piece += 1;
311 }
312
313 for &(payload, _) in &payloads {
315 let mut ancestor = components[payload].parent;
316 while let Some(index) = ancestor {
317 if payloads.iter().any(|(other, _)| *other == index) {
318 return Err(refuse(
319 "a payload inside another payload",
320 &components[payload].id,
321 ));
322 }
323 ancestor = components[index].parent;
324 }
325 }
326
327 let mut piece_of = vec![0; components.len()];
329 let mut host = vec![None; count];
330 let mut sections = vec![0];
331 let mut section = 0;
332 let mut position = 0;
333 for index in 0..components.len() {
334 let carried = match components[index].parent {
335 None => {
336 if let Some(&(_, start)) = starts.iter().find(|(at, _)| *at == position) {
337 section = start;
338 sections.push(start);
339 }
340 position += 1;
341 section
342 }
343 Some(parent) => piece_of[parent],
344 };
345 piece_of[index] = match payloads.iter().find(|(payload, _)| *payload == index) {
346 Some(&(_, payload)) => {
347 host[payload] = Some(carried);
348 payload
349 }
350 None => carried,
351 };
352 }
353
354 let same_stage =
357 |index: usize, parent: usize| components[index].stage == components[parent].stage;
358 let mut mixed = vec![false; components.len()];
359 for index in (0..components.len()).rev() {
360 if let Some(parent) = components[index].parent
361 && same_stage(index, parent)
362 && (mixed[index] || piece_of[index] != piece_of[parent])
363 {
364 mixed[parent] = true;
365 }
366 }
367 let mut children: Vec<Vec<usize>> = vec![Vec::new(); components.len()];
368 for (index, component) in components.iter().enumerate() {
369 if let Some(parent) = component.parent
370 && same_stage(index, parent)
371 {
372 children[parent].push(index);
373 }
374 }
375
376 let mut structure: Vec<Vec<MassProperties>> = vec![Vec::new(); count];
380 for (stage, placed) in assembly.layout.stages.iter().enumerate() {
381 let pieces: Vec<usize> = (0..components.len())
382 .filter(|&index| components[index].stage == stage)
383 .map(|index| piece_of[index])
384 .collect();
385 if let Some(&first) = pieces.first()
386 && pieces.iter().all(|&piece| piece == first)
387 {
388 structure[first].push(placed.mass);
389 continue;
390 }
391 if rocket
392 .stages
393 .iter()
394 .find(|stage| stage.id == placed.id)
395 .is_some_and(|stage| !stage.overrides.is_empty())
396 {
397 return Err(refuse(
398 "a parting through a stage whose mass is overridden (the override doesn't \
399 say which piece its mass is in)",
400 &placed.id,
401 ));
402 }
403 let mut pending: Vec<usize> = (0..components.len())
405 .filter(|&index| {
406 components[index].stage == stage
407 && components[index]
408 .parent
409 .is_none_or(|parent| !same_stage(index, parent))
410 })
411 .rev()
412 .collect();
413 while let Some(index) = pending.pop() {
414 let component = &components[index];
415 if !mixed[index] {
416 structure[piece_of[index]].push(component.with_children);
417 continue;
418 }
419 if node(rocket, &component.id).is_some_and(|node| {
420 node.overrides_include_children && !node.overrides.is_empty()
421 }) {
422 return Err(refuse(
423 "a parting inside a component whose overridden mass covers what it holds \
424 (the override doesn't say which piece that mass is in)",
425 &component.id,
426 ));
427 }
428 structure[piece_of[index]].push(component.own);
429 pending.extend(children[index].iter().rev());
430 }
431 }
432
433 let motor_piece = assembly
434 .motors
435 .iter()
436 .map(|motor| {
437 components
438 .iter()
439 .position(|component| component.id == motor.mount)
440 .map(|index| piece_of[index])
441 .ok_or_else(|| refuse("a motor's mount is not in the design", &motor.mount))
442 })
443 .collect::<Result<Vec<_>, _>>()?;
444
445 let mut stages = vec![(usize::MAX, 0); count];
446 for (index, component) in components.iter().enumerate() {
447 let (first, last) = &mut stages[piece_of[index]];
448 *first = (*first).min(component.stage);
449 *last = (*last).max(component.stage);
450 }
451 for (first, last) in &mut stages {
452 if *first == usize::MAX {
453 (*first, *last) = (0, 0);
455 }
456 }
457
458 Ok(Self {
459 host,
460 beside,
461 sections,
462 structure,
463 motor_piece,
464 stages,
465 piece_of,
466 })
467 }
468
469 pub(crate) fn count(&self) -> usize {
471 self.host.len()
472 }
473
474 pub(crate) fn leaders(&self, open: &[bool]) -> Vec<usize> {
478 let mut leader: Vec<usize> = (0..self.count()).collect();
479 let mut current = 0;
480 for §ion in &self.sections {
481 if section == 0 || open[section - 1] {
482 current = section;
483 }
484 leader[section] = current;
485 }
486 for piece in 0..self.count() {
487 leader[piece] = self.lead(piece, open, &leader);
488 }
489 leader
490 }
491
492 fn lead(&self, piece: usize, open: &[bool], sections_led: &[usize]) -> usize {
494 match self.host[piece] {
495 None => sections_led[piece],
496 Some(_) if open[piece - 1] => piece,
497 Some(host) => self.lead(host, open, sections_led),
498 }
499 }
500
501 pub(crate) fn mass_properties(
504 &self,
505 member: impl Fn(usize) -> bool,
506 assembly: &Assembly,
507 t_s: f64,
508 ignition_s: &[Option<f64>],
509 ) -> MassProperties {
510 let mut parts: Vec<MassProperties> = Vec::new();
511 for (piece, structure) in self.structure.iter().enumerate() {
512 if member(piece) {
513 parts.extend(structure);
514 }
515 }
516 for ((motor, ignition), piece) in assembly
517 .motors
518 .iter()
519 .zip(ignition_s)
520 .zip(&self.motor_piece)
521 {
522 if member(*piece) {
523 parts.push(motor.mass_properties_lit(t_s, *ignition));
524 }
525 }
526 MassProperties::combine(parts.iter())
527 }
528
529 pub(crate) fn impulse_left_n_s(
535 &self,
536 member: impl Fn(usize) -> bool,
537 assembly: &Assembly,
538 t_s: f64,
539 ignition_s: &[Option<f64>],
540 ) -> f64 {
541 let mut left_n_s = 0.0;
542 for ((motor, ignition), piece) in assembly
543 .motors
544 .iter()
545 .zip(ignition_s)
546 .zip(&self.motor_piece)
547 {
548 if let (true, Some(ignition_s)) = (member(*piece), ignition)
549 && *ignition_s <= t_s
550 {
551 let curve = motor.mounted.motor.curve();
552 left_n_s += curve.total_impulse_ns() - curve.impulse_ns(t_s - ignition_s);
553 }
554 }
555 left_n_s
556 }
557
558 pub(crate) fn check_pushed_payloads(&self, ejections: &[Ejection]) -> Result<(), SimError> {
567 let first_ejection = self.count() - ejections.len();
569 for (index, ejection) in ejections.iter().enumerate() {
570 if let Parting::Payload { component } = &ejection.parting
571 && ejection.impulse_n_s > 0.0
572 && self.host[first_ejection + index] == Some(0)
573 {
574 return Err(SimError::Parting {
575 what: "a pushed payload leaves forward, and this one is in the nose's piece, \
576 which is closed at the nose (part a joint forward of it, or give it no \
577 impulse)",
578 component: component.clone(),
579 });
580 }
581 }
582 Ok(())
583 }
584
585 pub(crate) fn host(&self, piece: usize) -> Option<usize> {
587 self.host[piece]
588 }
589
590 pub(crate) fn across(&self, piece: usize) -> (usize, bool) {
594 match self.host[piece] {
595 Some(host) => (host, true),
596 None => {
597 let at = self.sections.iter().position(|§ion| section == piece);
598 debug_assert!(
601 at.is_some_and(|at| at > 0),
602 "piece {piece} is no split's section"
603 );
604 (self.sections[at.unwrap_or(0).saturating_sub(1)], false)
605 }
606 }
607 }
608
609 pub(crate) fn tumbling(
618 &self,
619 piece: usize,
620 assembly: &Assembly,
621 ) -> Result<DeviceDrag, SimError> {
622 if piece >= self.count() {
623 return Err(SimError::Domain {
624 what: "piece to tumble (there is one per split, and the nose's)",
625 value: piece as f64,
626 });
627 }
628 if self.host[piece].is_some() && !self.beside[piece] {
629 return Err(SimError::Domain {
630 what: "piece to tumble (a payload has no body tube or fin of its own, which the \
631 tumble model covers)",
632 value: piece as f64,
633 });
634 }
635 DeviceDrag::tumbling_where(assembly, |index, _| self.piece_of[index] == piece)
636 }
637
638 pub(crate) fn stages(&self, member: impl Fn(usize) -> bool) -> (usize, usize) {
640 let mut span: Option<(usize, usize)> = None;
641 for (piece, &(first, last)) in self.stages.iter().enumerate() {
642 if member(piece) {
643 span = Some(span.map_or((first, last), |(a, b)| (a.min(first), b.max(last))));
644 }
645 }
646 span.unwrap_or((0, 0))
647 }
648}
649
650pub(crate) fn node<'a>(rocket: &'a Rocket, id: &str) -> Option<&'a Component> {
652 fn within<'a>(components: &'a [Component], id: &str) -> Option<&'a Component> {
653 components.iter().find_map(|component| {
654 if component.id == id {
655 Some(component)
656 } else {
657 within(&component.children, id)
658 }
659 })
660 }
661 rocket
662 .stages
663 .iter()
664 .find_map(|stage| within(&stage.components, id))
665}
666
667#[cfg(test)]
668mod tests {
669 use hpr_atmos::{ConstantWind, Wind};
670 use hpr_core::DVec3;
671 use hpr_design::{Part, Position};
672
673 use super::*;
674 use crate::flight::{EventKind, FlightResult, FlightSettings, Simulation, Termination};
675 use crate::rail::Rail;
676 use crate::recovery::{
677 BodyEvent, CanopyType, Device, DeviceDrag, Inflation, terminal_speed_m_s,
678 };
679 use crate::state::State;
680 use crate::testing::{UniformAir, analytic_environment, analytic_wind_environment, design};
681
682 const G: f64 = 9.806_65;
683 const START_S: f64 = 10.0;
685 const PAYLOAD_KG: f64 = 0.25;
686
687 fn with_payload() -> Rocket {
690 let mut rocket = design("synthetic-54mm-three-fin");
691 let airframe = &mut rocket.stages[0].components[1];
692 assert_eq!(airframe.id, "sustainer-airframe");
693 let mut payload = airframe
694 .children
695 .iter()
696 .find(|child| child.id == "altimeter")
697 .cloned()
698 .unwrap();
699 payload.id = "payload".to_owned();
700 let Part::MassComponent(mass) = &mut payload.part else {
701 panic!("the altimeter is a mass component");
702 };
703 mass.mass_kg = PAYLOAD_KG;
704 payload.position = Some(Position::Top { aft_offset_m: 0.15 });
705 airframe.children.push(payload);
706 rocket
707 }
708
709 fn strapped(count: u32) -> Rocket {
712 let mut rocket = design("synthetic-two-stage-75mm-54mm");
713 let booster = &mut rocket.stages[1];
714 booster.components.retain(|c| c.id != "interstage");
715 booster.parallel = Some(hpr_design::ParallelStage {
716 on: "sustainer-airframe".to_owned(),
717 position: Position::Bottom { aft_offset_m: 0.0 },
718 pods: hpr_design::PodSet {
719 count,
720 radial_offset_m: 0.06815,
721 angle_rad: 0.0,
722 },
723 });
724 rocket
725 }
726
727 #[test]
731 fn the_pieces_of_a_parallel_stage_weigh_the_rocket() {
732 let apogee = Trigger::Apogee;
733 let drop = Separation::new(apogee, 0);
734 for (count, separations, ejections) in [
735 (
736 2,
737 vec![],
738 vec![Ejection::payload(apogee, "sustainer-parachute")],
739 ),
740 (
741 2,
742 vec![drop],
743 vec![Ejection::payload(apogee, "sustainer-parachute")],
744 ),
745 (2, vec![drop], vec![Ejection::aft_of(apogee, "nose")]),
746 (
747 1,
748 vec![],
749 vec![Ejection::payload(apogee, "booster-electronics")],
750 ),
751 (1, vec![drop], vec![]),
752 ] {
753 let rocket = strapped(count);
754 let assembly = rocket.assemble("j760-i175").unwrap();
755 let pieces = Pieces::new(&rocket, &assembly, &separations, &ejections).unwrap();
756 let total = MassProperties::combine(pieces.structure.iter().flatten());
757 let want = assembly.layout.structure;
758 let label = format!(
759 "{count} pods, {} separations, {ejections:?}",
760 separations.len()
761 );
762 assert!(
763 (total.mass_kg - want.mass_kg).abs() < 1e-12 * want.mass_kg,
764 "{label}: {} against {}",
765 total.mass_kg,
766 want.mass_kg
767 );
768 assert!((total.cg_m - want.cg_m).length() < 1e-12, "{label}");
769 let inertia = total.inertia_kg_m2 - want.inertia_kg_m2;
770 for column in 0..3 {
771 assert!(inertia.col(column).length() < 1e-12, "{label}");
772 }
773 }
774 }
775
776 fn canopy(body: usize, diameter_m: f64, trigger: Trigger) -> Device {
778 Device::new(
779 "canopy",
780 DeviceDrag::canopy(CanopyType::FlatCircular, diameter_m),
781 trigger,
782 )
783 .on_body(body)
784 }
785
786 fn devices() -> Vec<Device> {
789 let payload_height = Trigger::Altitude {
790 height_above_ground_m: 300.0,
791 };
792 vec![
793 canopy(0, 0.45, Trigger::Apogee),
794 canopy(1, 0.9, Trigger::Apogee),
795 canopy(2, 0.6, payload_height),
796 ]
797 }
798
799 fn ejections() -> Vec<Ejection> {
801 vec![
802 Ejection::aft_of(Trigger::Apogee, "nose"),
803 Ejection::payload(
804 Trigger::Altitude {
805 height_above_ground_m: 300.0,
806 },
807 "payload",
808 ),
809 ]
810 }
811
812 fn simulation(environment: crate::Environment) -> Simulation {
813 Simulation::new(
814 &with_payload(),
815 "i175",
816 environment,
817 Rail::vertical(3.0),
818 FlightSettings {
819 max_time_s: 3600.0,
820 ..FlightSettings::default()
821 },
822 )
823 .unwrap()
824 .with_recovery(devices())
825 .unwrap()
826 .with_ejections(ejections())
827 .unwrap()
828 }
829
830 fn dropped(
833 sim: &Simulation,
834 height_m: f64,
835 velocity_enu_m_s: DVec3,
836 body_rate_rad_s: DVec3,
837 ) -> State {
838 let attitude = Rail::vertical(3.0).attitude();
839 let cg_m = sim.assembly().mass_properties(START_S).cg_m;
840 State {
841 position_enu_m: DVec3::new(0.0, 0.0, height_m) - attitude.mul_vec3(cg_m),
842 velocity_enu_m_s,
843 attitude,
844 body_rate_rad_s,
845 }
846 }
847
848 fn component_kg(sim: &Simulation, id: &str) -> f64 {
850 sim.assembly()
851 .layout
852 .components
853 .iter()
854 .find(|component| component.id == id)
855 .unwrap()
856 .with_children
857 .mass_kg
858 }
859
860 fn close(a: f64, b: f64, relative: f64, what: &str) {
861 assert!((a - b).abs() <= relative * b.abs(), "{what}: {a} vs {b}");
862 }
863
864 #[test]
865 fn an_ejected_nose_cone_and_payload_each_land_under_their_own_canopy() {
866 let wind = ConstantWind::new(4.0, 1.5 * std::f64::consts::PI).unwrap();
870 let sim = simulation(analytic_wind_environment(UniformAir::sea_level(), G, wind));
871 let result = sim.run(&mut ()).unwrap();
872 assert_eq!(result.termination, Termination::Separated);
873 let apogee = result.event(EventKind::Apogee).unwrap().sample;
874 let ejected = result.event(EventKind::Ejection(0)).unwrap().sample;
875 assert!((ejected.time_s - apogee.time_s).abs() < 1e-9, "{ejected:?}");
876 assert!(result.event(EventKind::Ejection(1)).is_none());
877 assert_eq!(result.bodies.len(), 3);
878 for (index, body) in result.bodies.iter().enumerate() {
879 assert_eq!(body.body, index);
880 assert_eq!(body.termination, Termination::GroundHit, "body {index}");
881 assert_eq!(body.pieces, vec![index]);
882 let deployment = EventKind::Deployment(index);
885 assert!(
886 body.event(deployment).is_some() || result.event(deployment).is_some(),
887 "body {index}"
888 );
889 }
890 let airframe = &result.bodies[1];
892 let payload = &result.bodies[2];
893 let parting = airframe.event(EventKind::Ejection(1)).unwrap().sample;
894 assert!(
895 (parting.height_above_ground_m - 300.0).abs() < 1e-6,
896 "{parting:?}"
897 );
898 assert_eq!(payload.start_sample.time_s, parting.time_s);
899 assert!(result.bodies_landed());
900 assert!(result.bodies.iter().all(|body| body.stages == (0, 0)));
903 let peak = crate::metrics::Peak {
904 value: 0.5,
905 time_s: 0.0,
906 height_above_ground_m: 0.0,
907 };
908 assert_eq!(
909 crate::issues::separated_part_issue_warnings(&result, Some(peak)),
910 []
911 );
912 let landings = result.landings();
913 assert_eq!(landings.len(), 3);
914 for landing in &landings {
915 assert!(landing.height_above_ground_m.abs() < 1e-6, "{landing:?}");
916 }
917 let pinned = [
921 (559.212, 2_047.87),
922 (331.760, 1_136.24),
923 (331.332, 1_134.53),
924 ];
925 for (landing, (time_s, east_m)) in landings.iter().zip(pinned) {
926 assert!((landing.time_s - time_s).abs() < 0.05, "{landing:?}");
927 assert!((landing.cg_enu_m.x - east_m).abs() < 0.5, "{landing:?}");
928 }
929 let apart_m = landings[0].cg_enu_m.x - landings[1].cg_enu_m.x;
932 let drift_m = 4.0 * (landings[0].time_s - landings[1].time_s);
933 close(apart_m, drift_m, 0.01, "the nose cone's extra drift");
934 }
935
936 #[test]
940 fn an_ejection_on_the_rail_is_refused() {
941 let environment =
942 || analytic_wind_environment(UniformAir::sea_level(), G, ConstantWind::calm());
943 let burnout_s = simulation(environment())
944 .run(&mut ())
945 .unwrap()
946 .event(EventKind::Burnout)
947 .unwrap()
948 .sample
949 .time_s;
950 let charge = Trigger::Time {
951 time_s: burnout_s + 0.2,
952 };
953 let error = Simulation::new(
954 &with_payload(),
955 "i175",
956 environment(),
957 Rail::vertical(2000.0),
958 FlightSettings::default(),
959 )
960 .unwrap()
961 .with_recovery(vec![
962 canopy(0, 0.45, charge),
963 canopy(1, 0.9, Trigger::Apogee),
964 ])
965 .unwrap()
966 .with_ejections(vec![Ejection::aft_of(charge, "nose")])
967 .unwrap()
968 .run(&mut ())
969 .expect_err("an ejection on the rail");
970 assert!(
971 matches!(error, SimError::Domain { what, value }
972 if what == "time of an ejection, s (it must come once the rocket has left the rail)"
973 && value == burnout_s + 0.2),
974 "{error:?}"
975 );
976 }
977
978 #[test]
979 fn a_nose_cone_ejected_before_apogee_leaves_the_summary_with_no_apogee() {
980 let environment =
984 || analytic_wind_environment(UniformAir::sea_level(), G, ConstantWind::calm());
985 let apogee_s = simulation(environment())
986 .run(&mut ())
987 .unwrap()
988 .event(EventKind::Apogee)
989 .unwrap()
990 .sample
991 .time_s;
992 let early = Trigger::Time {
993 time_s: apogee_s - 3.0,
994 };
995 let sim = Simulation::new(
996 &with_payload(),
997 "i175",
998 environment(),
999 Rail::vertical(3.0),
1000 FlightSettings {
1001 max_time_s: 3600.0,
1002 ..FlightSettings::default()
1003 },
1004 )
1005 .unwrap()
1006 .with_recovery(vec![
1007 canopy(0, 0.45, early),
1008 canopy(1, 0.9, Trigger::Apogee),
1009 ])
1010 .unwrap()
1011 .with_ejections(vec![Ejection::aft_of(early, "nose")])
1012 .unwrap();
1013 let mut metrics = crate::metrics::FlightMetrics::new();
1014 let result = sim.run(&mut metrics).unwrap();
1015 assert_eq!(result.termination, Termination::Separated);
1016 assert!(result.event(EventKind::Ejection(0)).is_some());
1017 assert!(result.event(EventKind::Apogee).is_none());
1018 let nose = result.bodies.iter().find(|body| body.body == 0).unwrap();
1019 assert!(nose.event(EventKind::Apogee).is_some());
1020 let summary = metrics.summary(&result, sim.environment()).unwrap();
1021 assert_eq!(summary.apogee, None);
1022
1023 let error = Simulation::new(
1026 &with_payload(),
1027 "i175",
1028 environment(),
1029 Rail::vertical(3.0),
1030 FlightSettings {
1031 max_time_s: 3600.0,
1032 ..FlightSettings::default()
1033 },
1034 )
1035 .unwrap()
1036 .with_recovery(vec![
1037 canopy(0, 0.45, early).with_lag_s(0.5),
1038 canopy(1, 0.9, Trigger::Apogee),
1039 ])
1040 .unwrap()
1041 .with_ejections(vec![Ejection::aft_of(early, "nose")])
1042 .unwrap()
1043 .run(&mut ())
1044 .expect_err("a nose climbing with nothing open");
1045 assert!(
1046 matches!(
1047 error,
1048 SimError::Domain { what, value }
1049 if what.starts_with("time of a split with nothing left to burn, before \
1050 apogee")
1051 && (value - (apogee_s - 3.0)).abs() < 1e-9
1052 ),
1053 "{error:?}"
1054 );
1055 }
1056
1057 #[test]
1058 fn the_pieces_masses_add_up_and_each_split_conserves_momentum() {
1059 let air = UniformAir::sea_level();
1062 let sim = simulation(analytic_wind_environment(
1063 air,
1064 G,
1065 ConstantWind::new(5.0, 0.9).unwrap(),
1066 ));
1067 let start = dropped(
1068 &sim,
1069 1_500.0,
1070 DVec3::new(3.0, 0.0, -2.0),
1071 DVec3::new(0.0, 0.6, 0.0),
1072 );
1073 let result = sim.run_free(START_S, start, &mut ()).unwrap();
1074 let [nose, airframe, payload] = &result.bodies[..] else {
1075 panic!("{} bodies", result.bodies.len());
1076 };
1077
1078 let first = result.event(EventKind::Ejection(0)).unwrap().sample;
1081 let whole = sim.assembly().mass_properties(first.time_s);
1082 close(
1083 nose.start_sample.mass_kg + airframe.start_sample.mass_kg,
1084 whole.mass_kg,
1085 1e-12,
1086 "the two bodies' mass",
1087 );
1088 close(
1089 nose.mass_kg,
1090 component_kg(&sim, "nose"),
1091 1e-12,
1092 "the nose cone",
1093 );
1094 close(payload.mass_kg, PAYLOAD_KG, 1e-12, "the payload");
1095 close(
1096 nose.mass_kg + airframe.mass_kg + payload.mass_kg,
1097 whole.mass_kg,
1098 1e-12,
1099 "the three pieces' mass",
1100 );
1101
1102 let momentum = nose.start_sample.cg_velocity_enu_m_s * nose.start_sample.mass_kg
1105 + airframe.start_sample.cg_velocity_enu_m_s * airframe.start_sample.mass_kg;
1106 let expected = first.cg_velocity_enu_m_s * whole.mass_kg;
1107 assert!(
1108 (momentum - expected).length() < 1e-9 * expected.length(),
1109 "{momentum} vs {expected}"
1110 );
1111 let state = result.final_sample.state;
1113 let nose_cg_m = sim
1114 .assembly()
1115 .layout
1116 .components
1117 .iter()
1118 .find(|component| component.id == "nose")
1119 .unwrap()
1120 .with_children
1121 .cg_m;
1122 let expected = state.point_enu_m(nose_cg_m);
1123 assert!(
1124 (nose.start_sample.cg_enu_m - expected).length() < 1e-12,
1125 "{} vs {expected}",
1126 nose.start_sample.cg_enu_m
1127 );
1128 let gap = (nose.start_sample.cg_enu_m - airframe.start_sample.cg_enu_m).length();
1129 assert!(gap > 0.3, "{gap}");
1130
1131 let parting = airframe.event(EventKind::Ejection(1)).unwrap().sample;
1134 close(
1135 parting.mass_kg,
1136 airframe.mass_kg + payload.mass_kg,
1137 1e-12,
1138 "the airframe's body before the payload left",
1139 );
1140 assert_eq!(payload.start_sample.cg_enu_m, parting.cg_enu_m);
1141 let before = parting.cg_velocity_enu_m_s * parting.mass_kg;
1142 let after = parting.cg_velocity_enu_m_s * airframe.mass_kg
1143 + payload.start_sample.cg_velocity_enu_m_s * payload.mass_kg;
1144 assert!(
1145 (after - before).length() < 1e-9 * before.length(),
1146 "{after} vs {before}"
1147 );
1148 }
1149
1150 #[test]
1151 fn each_piece_comes_down_at_its_own_terminal_speed() {
1152 let air = UniformAir::sea_level();
1155 let sim = simulation(analytic_environment(air, G));
1156 let start = dropped(&sim, 1_500.0, DVec3::new(0.0, 0.0, -0.5), DVec3::ZERO);
1157 let result = sim.run_free(START_S, start, &mut ()).unwrap();
1158 assert!(result.bodies_landed());
1159 let rho = air.0.density_kg_m3;
1160 for body in &result.bodies {
1161 let landing = body.event(EventKind::GroundHit).unwrap().sample;
1162 let drag_area_m2 = sim.recovery()[body.body].drag.drag_area_m2();
1163 let terminal_m_s = terminal_speed_m_s(body.mass_kg, drag_area_m2, rho, G);
1164 close(
1165 -landing.vertical_speed_m_s,
1166 terminal_m_s,
1167 1e-3,
1168 &format!("body {}", body.body),
1169 );
1170 assert_eq!(landing.mass_kg, body.mass_kg);
1171 assert!((landing.recovery_drag_area_m2 - drag_area_m2).abs() < 1e-12);
1172 }
1173 }
1174
1175 fn simulation_with(environment: crate::Environment, ejections: Vec<Ejection>) -> Simulation {
1177 Simulation::new(
1178 &with_payload(),
1179 "i175",
1180 environment,
1181 Rail::vertical(3.0),
1182 FlightSettings {
1183 max_time_s: 3600.0,
1184 ..FlightSettings::default()
1185 },
1186 )
1187 .unwrap()
1188 .with_recovery(devices())
1189 .unwrap()
1190 .with_ejections(ejections)
1191 .unwrap()
1192 }
1193
1194 fn pushed(impulse_n_s: f64) -> Vec<Ejection> {
1196 ejections()
1197 .into_iter()
1198 .map(|ejection| ejection.with_impulse(impulse_n_s))
1199 .collect()
1200 }
1201
1202 fn close_vec(a: DVec3, b: DVec3, relative: f64, what: &str) {
1203 assert!(
1204 (a - b).length() <= relative * b.length(),
1205 "{what}: {a} vs {b}"
1206 );
1207 }
1208
1209 fn dropped_at(
1211 sim: &Simulation,
1212 (elevation_deg, azimuth_deg): (f64, f64),
1213 height_m: f64,
1214 velocity_enu_m_s: DVec3,
1215 body_rate_rad_s: DVec3,
1216 ) -> State {
1217 let attitude = Rail {
1218 elevation_rad: elevation_deg.to_radians(),
1219 azimuth_rad: azimuth_deg.to_radians(),
1220 ..Rail::vertical(3.0)
1221 }
1222 .attitude();
1223 let cg_m = sim.assembly().mass_properties(START_S).cg_m;
1224 State {
1225 position_enu_m: DVec3::new(0.0, 0.0, height_m) - attitude.mul_vec3(cg_m),
1226 velocity_enu_m_s,
1227 attitude,
1228 body_rate_rad_s,
1229 }
1230 }
1231
1232 fn start_change(pushed: &FlightResult, unpushed: &FlightResult, body: usize) -> DVec3 {
1235 pushed.bodies[body].start_sample.cg_velocity_enu_m_s
1236 - unpushed.bodies[body].start_sample.cg_velocity_enu_m_s
1237 }
1238
1239 #[test]
1240 fn an_impulse_changes_each_pieces_velocity_by_j_over_m_and_conserves_momentum() {
1241 const J: f64 = 1.0;
1246 let wind = ConstantWind::new(5.0, 0.9).unwrap();
1247 let wind_enu = wind.wind(0.0).unwrap().velocity_enu_m_s;
1248 let fly = |impulse_n_s: f64| {
1249 let environment = analytic_wind_environment(UniformAir::sea_level(), G, wind);
1250 let sim = simulation_with(environment, pushed(impulse_n_s));
1251 let start = dropped_at(
1252 &sim,
1253 (60.0, 30.0),
1254 1_500.0,
1255 DVec3::new(3.0, 0.0, -2.0),
1256 DVec3::new(0.0, 0.6, 0.0),
1257 );
1258 let result = sim.run_free(START_S, start, &mut ()).unwrap();
1259 (sim, result)
1260 };
1261 let (sim, pushed) = fly(J);
1262 let (_, unpushed) = fly(0.0);
1263 let [nose, airframe, payload] = &pushed.bodies[..] else {
1264 panic!("{} bodies", pushed.bodies.len());
1265 };
1266
1267 let first = pushed.event(EventKind::Ejection(0)).unwrap().sample;
1272 assert_eq!(
1273 first,
1274 unpushed.event(EventKind::Ejection(0)).unwrap().sample
1275 );
1276 let (elevation, azimuth) = (60_f64.to_radians(), 30_f64.to_radians());
1277 let axis = DVec3::new(
1278 elevation.cos() * azimuth.sin(),
1279 elevation.cos() * azimuth.cos(),
1280 elevation.sin(),
1281 );
1282 let whole = sim.assembly().mass_properties(first.time_s);
1283 let nose_kg = component_kg(&sim, "nose");
1284 let rest_kg = whole.mass_kg - nose_kg;
1285 close(nose.start_sample.mass_kg, nose_kg, 1e-12, "the nose cone");
1286 close(airframe.start_sample.mass_kg, rest_kg, 1e-12, "the rest");
1287 close_vec(
1288 start_change(&pushed, &unpushed, 0),
1289 axis * (J / nose_kg),
1290 1e-9,
1291 "the nose cone's push",
1292 );
1293 close_vec(
1294 start_change(&pushed, &unpushed, 1),
1295 -axis * (J / rest_kg),
1296 1e-9,
1297 "the airframe's push",
1298 );
1299 close(J / nose_kg, 15.85, 1e-3, "the nose cone's change of speed");
1301 let momentum = nose.start_sample.cg_velocity_enu_m_s * nose.start_sample.mass_kg
1302 + airframe.start_sample.cg_velocity_enu_m_s * airframe.start_sample.mass_kg;
1303 close_vec(
1304 momentum,
1305 first.cg_velocity_enu_m_s * whole.mass_kg,
1306 1e-9,
1307 "the momentum at the first parting",
1308 );
1309
1310 let parting = airframe.event(EventKind::Ejection(1)).unwrap();
1313 let (before, after) = (parting.sample, parting.after.unwrap());
1314 assert!(before.recovery_drag_area_m2 > 0.0, "{before:?}");
1315 let through_air = (before.cg_velocity_enu_m_s - wind_enu).normalize();
1316 close(
1317 payload.start_sample.mass_kg,
1318 PAYLOAD_KG,
1319 1e-12,
1320 "the payload",
1321 );
1322 close(
1323 after.mass_kg,
1324 before.mass_kg - PAYLOAD_KG,
1325 1e-12,
1326 "the airframe after",
1327 );
1328 close_vec(
1329 payload.start_sample.cg_velocity_enu_m_s - before.cg_velocity_enu_m_s,
1330 -through_air * 4.0,
1331 1e-9,
1332 "the payload's push",
1333 );
1334 close_vec(
1335 after.cg_velocity_enu_m_s - before.cg_velocity_enu_m_s,
1336 through_air * (J / after.mass_kg),
1337 1e-9,
1338 "the airframe's push",
1339 );
1340 assert_eq!(after.cg_enu_m, before.cg_enu_m);
1341 assert_eq!(payload.start_sample.cg_enu_m, before.cg_enu_m);
1342 close_vec(
1343 after.cg_velocity_enu_m_s * after.mass_kg
1344 + payload.start_sample.cg_velocity_enu_m_s * payload.start_sample.mass_kg,
1345 before.cg_velocity_enu_m_s * before.mass_kg,
1346 1e-9,
1347 "the momentum at the second parting",
1348 );
1349
1350 assert!(pushed.bodies_landed());
1352 let rho = UniformAir::sea_level().0.density_kg_m3;
1353 for body in &pushed.bodies {
1354 let landing = body.event(EventKind::GroundHit).unwrap().sample;
1355 let drag_area_m2 = sim.recovery()[body.body].drag.drag_area_m2();
1356 let terminal_m_s = terminal_speed_m_s(body.mass_kg, drag_area_m2, rho, G);
1357 close(
1358 landing.airspeed_m_s,
1359 terminal_m_s,
1360 1e-3,
1361 &format!("body {}", body.body),
1362 );
1363 }
1364 }
1365
1366 #[test]
1367 fn a_body_with_nothing_open_is_pushed_along_its_flight() {
1368 let at_200_m = Trigger::Altitude {
1372 height_above_ground_m: 200.0,
1373 };
1374 let sim = Simulation::new(
1375 &with_payload(),
1376 "i175",
1377 analytic_environment(UniformAir::sea_level(), G),
1378 Rail::vertical(3.0),
1379 FlightSettings {
1380 max_time_s: 3600.0,
1381 ..FlightSettings::default()
1382 },
1383 )
1384 .unwrap()
1385 .with_recovery(vec![
1386 canopy(0, 0.45, Trigger::Apogee),
1387 canopy(1, 0.9, at_200_m),
1388 canopy(2, 0.6, at_200_m),
1389 ])
1390 .unwrap()
1391 .with_ejections(pushed(1.0))
1392 .unwrap();
1393 let start = dropped(&sim, 1_500.0, DVec3::new(2.0, 1.0, -0.5), DVec3::ZERO);
1394 let result = sim.run_free(START_S, start, &mut ()).unwrap();
1395 let airframe = &result.bodies[1];
1396 let payload = &result.bodies[2];
1397 let parting = airframe.event(EventKind::Ejection(1)).unwrap();
1398 let (before, after) = (parting.sample, parting.after.unwrap());
1399 assert_eq!(before.recovery_drag_area_m2, 0.0);
1400 let through_air = before.cg_velocity_enu_m_s.normalize();
1401 close_vec(
1402 payload.start_sample.cg_velocity_enu_m_s - before.cg_velocity_enu_m_s,
1403 through_air * 4.0,
1404 1e-9,
1405 "the payload's push",
1406 );
1407 close_vec(
1408 after.cg_velocity_enu_m_s - before.cg_velocity_enu_m_s,
1409 -through_air * (1.0 / after.mass_kg),
1410 1e-9,
1411 "the airframe's push",
1412 );
1413 assert!(result.bodies_landed());
1414 }
1415
1416 #[test]
1417 fn a_stack_already_hanging_from_a_device_is_pushed_up_its_flight() {
1418 let at_300_m = Trigger::Altitude {
1424 height_above_ground_m: 300.0,
1425 };
1426 let wind = ConstantWind::new(4.0, 2.0).unwrap();
1427 let wind_enu = wind.wind(0.0).unwrap().velocity_enu_m_s;
1428 let fly = |impulse_n_s: f64| {
1429 let sim = Simulation::new(
1430 &with_payload(),
1431 "i175",
1432 analytic_wind_environment(UniformAir::sea_level(), G, wind),
1433 Rail::vertical(3.0),
1434 FlightSettings {
1435 max_time_s: 3600.0,
1436 ..FlightSettings::default()
1437 },
1438 )
1439 .unwrap()
1440 .with_recovery(vec![
1441 canopy(0, 0.3, Trigger::Apogee),
1442 canopy(1, 0.9, at_300_m),
1443 ])
1444 .unwrap()
1445 .with_ejections(vec![
1446 Ejection::aft_of(at_300_m, "nose").with_impulse(impulse_n_s),
1447 ])
1448 .unwrap();
1449 let start = dropped_at(
1450 &sim,
1451 (60.0, 30.0),
1452 1_500.0,
1453 DVec3::new(3.0, 0.0, -2.0),
1454 DVec3::ZERO,
1455 );
1456 let result = sim.run_free(START_S, start, &mut ()).unwrap();
1457 (sim, result)
1458 };
1459 let (sim, pushed) = fly(1.0);
1460 let (_, unpushed) = fly(0.0);
1461 let first = pushed.event(EventKind::Ejection(0)).unwrap().sample;
1462 assert!(
1463 (first.height_above_ground_m - 300.0).abs() < 1e-6,
1464 "{first:?}"
1465 );
1466 let through_air = (first.cg_velocity_enu_m_s - wind_enu).normalize();
1467 let nose_kg = component_kg(&sim, "nose");
1468 let rest_kg = sim.assembly().mass_properties(first.time_s).mass_kg - nose_kg;
1469 let nose_push = start_change(&pushed, &unpushed, 0);
1470 close_vec(
1471 nose_push,
1472 -through_air * (1.0 / nose_kg),
1473 1e-9,
1474 "the nose cone's push",
1475 );
1476 close_vec(
1477 start_change(&pushed, &unpushed, 1),
1478 through_air * (1.0 / rest_kg),
1479 1e-9,
1480 "the airframe's push",
1481 );
1482 let frozen_axis = pushed.final_sample.state.attitude.mul_vec3(DVec3::Z);
1484 assert!(nose_push.normalize().dot(frozen_axis) < 0.95, "{nose_push}");
1485 assert!(pushed.bodies_landed());
1486 }
1487
1488 #[test]
1489 fn a_push_in_still_air_is_taken_as_up() {
1490 let sim = Simulation::new(
1495 &with_payload(),
1496 "i175",
1497 analytic_environment(UniformAir::sea_level(), G),
1498 Rail::vertical(3.0),
1499 FlightSettings {
1500 max_time_s: 3600.0,
1501 ..FlightSettings::default()
1502 },
1503 )
1504 .unwrap()
1505 .with_recovery(vec![
1506 canopy(0, 0.45, Trigger::Apogee),
1507 canopy(
1508 1,
1509 0.9,
1510 Trigger::Altitude {
1511 height_above_ground_m: 200.0,
1512 },
1513 ),
1514 canopy(2, 0.6, Trigger::Apogee),
1515 ])
1516 .unwrap()
1517 .with_ejections(vec![
1518 Ejection::aft_of(Trigger::Time { time_s: START_S }, "nose"),
1519 Ejection::payload(Trigger::Apogee, "payload").with_impulse(1.0),
1520 ])
1521 .unwrap();
1522 let start = dropped(&sim, 1_000.0, DVec3::new(0.0, 0.0, 20.0), DVec3::ZERO);
1523 let result = sim.run_free(START_S, start, &mut ()).unwrap();
1524 let airframe = &result.bodies[1];
1525 let payload = &result.bodies[2];
1526 let parting = airframe.event(EventKind::Ejection(1)).unwrap().sample;
1527 assert!(parting.airspeed_m_s < 1e-3, "{parting:?}");
1528 assert_eq!(parting.recovery_drag_area_m2, 0.0);
1529 close_vec(
1530 payload.start_sample.cg_velocity_enu_m_s - parting.cg_velocity_enu_m_s,
1531 DVec3::new(0.0, 0.0, 4.0),
1532 1e-9,
1533 "the payload's push",
1534 );
1535 assert!(result.bodies_landed());
1536 }
1537
1538 #[test]
1539 fn a_separation_with_pushed_ejections_pushes_only_the_ejections_sides() {
1540 let fly = |impulse_n_s: f64| {
1545 let rocket = design("synthetic-two-stage-75mm-54mm");
1546 let assembly = rocket.assemble("j760-i175").unwrap();
1547 let tumble = DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap();
1548 let sim = Simulation::new(
1549 &rocket,
1550 "j760-i175",
1551 analytic_environment(UniformAir::sea_level(), G),
1552 Rail::vertical(6.0),
1553 FlightSettings {
1554 max_time_s: 3600.0,
1555 ..FlightSettings::default()
1556 },
1557 )
1558 .unwrap()
1559 .with_separation(Separation::new(Trigger::Apogee, 0))
1560 .unwrap()
1561 .with_ejections(vec![
1562 Ejection::aft_of(Trigger::Apogee, "nose").with_impulse(impulse_n_s),
1563 ])
1564 .unwrap()
1565 .with_recovery(vec![
1566 canopy(0, 0.6, Trigger::Apogee),
1567 Device::new("booster", tumble, Trigger::Apogee).on_body(1),
1568 canopy(2, 1.2, Trigger::Apogee),
1569 ])
1570 .unwrap();
1571 let start = dropped_at(
1572 &sim,
1573 (70.0, 200.0),
1574 1_500.0,
1575 DVec3::new(0.0, 0.0, -0.5),
1576 DVec3::ZERO,
1577 );
1578 let result = sim.run_free(START_S, start, &mut ()).unwrap();
1579 (sim, result)
1580 };
1581 let (sim, pushed) = fly(1.0);
1582 let (_, unpushed) = fly(0.0);
1583 let axis = pushed.final_sample.state.attitude.mul_vec3(DVec3::Z);
1584 assert_eq!(start_change(&pushed, &unpushed, 1), DVec3::ZERO);
1585 let nose_kg = pushed.bodies[0].start_sample.mass_kg;
1586 let airframe_kg = pushed.bodies[2].start_sample.mass_kg;
1587 close(nose_kg, component_kg(&sim, "nose"), 1e-12, "the nose cone");
1588 close_vec(
1589 start_change(&pushed, &unpushed, 0),
1590 axis * (1.0 / nose_kg),
1591 1e-9,
1592 "the nose cone's push",
1593 );
1594 close_vec(
1595 start_change(&pushed, &unpushed, 2),
1596 -axis * (1.0 / airframe_kg),
1597 1e-9,
1598 "the airframe's push",
1599 );
1600 let first = pushed.event(EventKind::Separation).unwrap().sample;
1601 let whole_kg = sim.assembly().mass_properties(first.time_s).mass_kg;
1602 let momentum: DVec3 = pushed
1603 .bodies
1604 .iter()
1605 .map(|body| body.start_sample.cg_velocity_enu_m_s * body.start_sample.mass_kg)
1606 .sum();
1607 close_vec(
1608 momentum,
1609 first.cg_velocity_enu_m_s * whole_kg,
1610 1e-9,
1611 "the momentum",
1612 );
1613 }
1614
1615 #[test]
1616 fn pushed_sections_parting_together_on_the_way_down_push_as_the_stack_does() {
1617 let at_300_m = Trigger::Altitude {
1625 height_above_ground_m: 300.0,
1626 };
1627 let orders = [
1628 vec![
1629 Ejection::aft_of(Trigger::Apogee, "interstage"),
1630 Ejection::aft_of(at_300_m, "nose").with_impulse(1.0),
1631 Ejection::aft_of(at_300_m, "sustainer-airframe").with_impulse(1.0),
1632 ],
1633 vec![
1634 Ejection::aft_of(Trigger::Apogee, "interstage"),
1635 Ejection::aft_of(at_300_m, "sustainer-airframe").with_impulse(1.0),
1636 Ejection::aft_of(at_300_m, "nose").with_impulse(1.0),
1637 ],
1638 ];
1639 let mut landings = Vec::new();
1640 for (order, ejections) in orders.into_iter().enumerate() {
1641 let (sim, result) = two_stage_pieces(ejections, 4);
1642 assert!(result.bodies_landed(), "order {order}");
1643 let nose = &result.bodies[0];
1645 let partings: Vec<&BodyEvent> = nose
1646 .events
1647 .iter()
1648 .filter(|event| matches!(event.kind, EventKind::Ejection(_)))
1649 .collect();
1650 assert_eq!(partings.len(), 2, "order {order}");
1651 let (before, after) = (partings[0].sample, partings[0].after.unwrap());
1652 assert_eq!(partings[1].sample, before);
1653 let nose_ward = -before.cg_velocity_enu_m_s.normalize();
1654 let (airframe, interstage) = if order == 0 {
1655 (&result.bodies[2], &result.bodies[3])
1656 } else {
1657 (&result.bodies[3], &result.bodies[2])
1658 };
1659 close(
1660 after.mass_kg,
1661 component_kg(&sim, "nose"),
1662 1e-12,
1663 "the nose cone",
1664 );
1665 let change = |velocity: DVec3| velocity - before.cg_velocity_enu_m_s;
1666 close_vec(
1667 change(after.cg_velocity_enu_m_s),
1668 nose_ward * (1.0 / after.mass_kg),
1669 1e-9,
1670 "the nose cone's push",
1671 );
1672 assert!(
1673 change(airframe.start_sample.cg_velocity_enu_m_s).length() < 1e-12,
1674 "order {order}: {}",
1675 change(airframe.start_sample.cg_velocity_enu_m_s)
1676 );
1677 close_vec(
1678 change(interstage.start_sample.cg_velocity_enu_m_s),
1679 -nose_ward * (1.0 / interstage.start_sample.mass_kg),
1680 1e-9,
1681 "the interstage's push",
1682 );
1683 let momentum = after.cg_velocity_enu_m_s * after.mass_kg
1684 + airframe.start_sample.cg_velocity_enu_m_s * airframe.start_sample.mass_kg
1685 + interstage.start_sample.cg_velocity_enu_m_s * interstage.start_sample.mass_kg;
1686 close_vec(
1687 momentum,
1688 before.cg_velocity_enu_m_s * before.mass_kg,
1689 1e-9,
1690 "the momentum",
1691 );
1692 landings.push([
1693 nose.final_sample.cg_enu_m,
1694 airframe.final_sample.cg_enu_m,
1695 interstage.final_sample.cg_enu_m,
1696 ]);
1697 }
1698 for (first, second) in landings[0].iter().zip(&landings[1]) {
1699 assert!((*first - *second).length() < 1e-6, "{first} vs {second}");
1700 }
1701 }
1702
1703 #[test]
1704 fn a_device_opening_as_its_body_parts_is_not_yet_hung_from() {
1705 let at_300_m = Trigger::Altitude {
1710 height_above_ground_m: 300.0,
1711 };
1712 let changes: Vec<DVec3> = [
1713 Inflation::Instant,
1714 Inflation::FillingTime {
1715 time_s: 1.0,
1716 exponent: 2.0,
1717 },
1718 ]
1719 .into_iter()
1720 .map(|inflation| {
1721 let sim = Simulation::new(
1722 &with_payload(),
1723 "i175",
1724 analytic_environment(UniformAir::sea_level(), G),
1725 Rail::vertical(3.0),
1726 FlightSettings {
1727 max_time_s: 3600.0,
1728 ..FlightSettings::default()
1729 },
1730 )
1731 .unwrap()
1732 .with_recovery(vec![
1733 canopy(0, 0.45, Trigger::Apogee),
1734 canopy(1, 0.9, at_300_m).with_inflation(inflation),
1735 canopy(2, 0.6, at_300_m),
1736 ])
1737 .unwrap()
1738 .with_ejections(pushed(1.0))
1739 .unwrap();
1740 let start = dropped(&sim, 1_500.0, DVec3::new(2.0, 1.0, -0.5), DVec3::ZERO);
1741 let result = sim.run_free(START_S, start, &mut ()).unwrap();
1742 let parting = result.bodies[1]
1743 .event(EventKind::Ejection(1))
1744 .unwrap()
1745 .sample;
1746 let change =
1747 result.bodies[2].start_sample.cg_velocity_enu_m_s - parting.cg_velocity_enu_m_s;
1748 close_vec(
1749 change,
1750 parting.cg_velocity_enu_m_s.normalize() * 4.0,
1751 1e-9,
1752 "the payload's push",
1753 );
1754 change
1755 })
1756 .collect();
1757 assert!(changes[0].z < -3.9, "{}", changes[0]);
1758 }
1759
1760 #[test]
1761 fn a_pushed_payload_whose_way_forward_is_still_closed_is_refused_in_flight() {
1762 let sim = simulation_with(
1765 analytic_environment(UniformAir::sea_level(), G),
1766 vec![
1767 Ejection::aft_of(
1768 Trigger::Altitude {
1769 height_above_ground_m: 300.0,
1770 },
1771 "nose",
1772 ),
1773 Ejection::payload(Trigger::Apogee, "payload").with_impulse(1.0),
1774 ],
1775 );
1776 let start = dropped(&sim, 1_500.0, DVec3::new(0.0, 0.0, -0.5), DVec3::ZERO);
1777 let error = sim.run_free(START_S, start, &mut ()).unwrap_err();
1778 assert!(
1779 matches!(error, SimError::Domain { what, value }
1780 if what.starts_with("time of a pushed payload's ejection") && value == START_S),
1781 "{error:?}"
1782 );
1783 }
1784
1785 fn nose_off_at_300_m(devices: Vec<Device>, impulse_n_s: f64) -> (Simulation, FlightResult) {
1788 let at_300_m = Trigger::Altitude {
1789 height_above_ground_m: 300.0,
1790 };
1791 let sim = Simulation::new(
1792 &with_payload(),
1793 "i175",
1794 analytic_wind_environment(
1795 UniformAir::sea_level(),
1796 G,
1797 ConstantWind::new(4.0, 2.0).unwrap(),
1798 ),
1799 Rail::vertical(3.0),
1800 FlightSettings {
1801 max_time_s: 3600.0,
1802 ..FlightSettings::default()
1803 },
1804 )
1805 .unwrap()
1806 .with_recovery(devices)
1807 .unwrap()
1808 .with_ejections(vec![
1809 Ejection::aft_of(at_300_m, "nose").with_impulse(impulse_n_s),
1810 ])
1811 .unwrap();
1812 let start = dropped_at(
1813 &sim,
1814 (60.0, 30.0),
1815 1_500.0,
1816 DVec3::new(3.0, 0.0, -2.0),
1817 DVec3::ZERO,
1818 );
1819 let result = sim.run_free(START_S, start, &mut ()).unwrap();
1820 (sim, result)
1821 }
1822
1823 fn nose_push_through_air(devices: impl Fn() -> Vec<Device>) -> f64 {
1827 let (sim, pushed) = nose_off_at_300_m(devices(), 1.0);
1828 let (_, unpushed) = nose_off_at_300_m(devices(), 0.0);
1829 let first = pushed.event(EventKind::Ejection(0)).unwrap().sample;
1830 let wind_enu = ConstantWind::new(4.0, 2.0)
1831 .unwrap()
1832 .wind(0.0)
1833 .unwrap()
1834 .velocity_enu_m_s;
1835 let through_air = (first.cg_velocity_enu_m_s - wind_enu).normalize();
1836 let push = start_change(&pushed, &unpushed, 0);
1837 let size_m_s = 1.0 / component_kg(&sim, "nose");
1838 let along = push.dot(through_air) / size_m_s;
1840 close_vec(
1841 push,
1842 through_air * along * size_m_s,
1843 1e-9,
1844 "the push's line",
1845 );
1846 along
1847 }
1848
1849 #[test]
1850 fn a_stack_tumbling_since_apogee_is_pushed_along_its_flight() {
1851 let along = nose_push_through_air(|| {
1855 let tumble = DeviceDrag::tumbling(&with_payload().assemble("i175").unwrap()).unwrap();
1856 vec![
1857 Device::new("tumble", tumble, Trigger::Apogee),
1858 canopy(
1859 1,
1860 0.9,
1861 Trigger::Altitude {
1862 height_above_ground_m: 300.0,
1863 },
1864 ),
1865 ]
1866 });
1867 close(along, 1.0, 1e-9, "along the flight");
1868 }
1869
1870 #[test]
1871 fn a_drogue_released_as_the_body_parts_is_still_hung_from() {
1872 for inflation in [
1877 Inflation::Instant,
1878 Inflation::FillingTime {
1879 time_s: 1.0,
1880 exponent: 2.0,
1881 },
1882 ] {
1883 let along = nose_push_through_air(|| {
1884 vec![
1885 canopy(0, 0.3, Trigger::Apogee).with_release_by(1),
1886 canopy(
1887 0,
1888 0.9,
1889 Trigger::Altitude {
1890 height_above_ground_m: 300.0,
1891 },
1892 )
1893 .with_inflation(inflation),
1894 canopy(
1895 1,
1896 0.9,
1897 Trigger::Altitude {
1898 height_above_ground_m: 300.0,
1899 },
1900 ),
1901 ]
1902 });
1903 close(along, -1.0, 1e-9, &format!("{inflation:?}"));
1904 }
1905 }
1906
1907 #[test]
1908 fn one_charge_can_push_off_the_nose_cone_and_let_the_payload_out() {
1909 let fly = |impulse_n_s: f64| {
1913 let sim = Simulation::new(
1914 &with_payload(),
1915 "i175",
1916 analytic_environment(UniformAir::sea_level(), G),
1917 Rail::vertical(3.0),
1918 FlightSettings {
1919 max_time_s: 3600.0,
1920 ..FlightSettings::default()
1921 },
1922 )
1923 .unwrap()
1924 .with_recovery(vec![
1925 canopy(0, 0.45, Trigger::Apogee),
1926 canopy(1, 0.9, Trigger::Apogee),
1927 canopy(2, 0.6, Trigger::Apogee),
1928 ])
1929 .unwrap()
1930 .with_ejections(vec![
1931 Ejection::aft_of(Trigger::Apogee, "nose").with_impulse(impulse_n_s),
1932 Ejection::payload(Trigger::Apogee, "payload").with_impulse(impulse_n_s),
1933 ])
1934 .unwrap();
1935 let start = dropped_at(
1936 &sim,
1937 (70.0, 120.0),
1938 1_500.0,
1939 DVec3::new(0.0, 0.0, -0.5),
1940 DVec3::ZERO,
1941 );
1942 sim.run_free(START_S, start, &mut ()).unwrap()
1943 };
1944 let (pushed, unpushed) = (fly(1.0), fly(0.0));
1945 assert!(pushed.bodies_landed());
1946 let axis = pushed.final_sample.state.attitude.mul_vec3(DVec3::Z);
1947 for (body, impulse_n_s) in [(0, 1.0), (1, -2.0), (2, 1.0)] {
1948 let kg = pushed.bodies[body].start_sample.mass_kg;
1949 close_vec(
1950 start_change(&pushed, &unpushed, body),
1951 axis * (impulse_n_s / kg),
1952 1e-9,
1953 &format!("body {body}"),
1954 );
1955 }
1956 close(
1957 pushed.bodies[2].start_sample.mass_kg,
1958 PAYLOAD_KG,
1959 1e-12,
1960 "the payload",
1961 );
1962 }
1963
1964 #[test]
1965 fn a_pushed_payload_behind_a_separation_is_accepted_in_either_builder_order() {
1966 let rocket = design("synthetic-two-stage-75mm-54mm");
1970 let tumble =
1971 DeviceDrag::tumbling_stages(&rocket.assemble("j760-i175").unwrap(), (1, 1)).unwrap();
1972 let devices = || {
1973 vec![
1974 canopy(0, 1.2, Trigger::Apogee),
1975 Device::new("booster", tumble, Trigger::Apogee).on_body(1),
1976 canopy(2, 0.3, Trigger::Apogee),
1977 ]
1978 };
1979 let ejections =
1980 || vec![Ejection::payload(Trigger::Apogee, "booster-electronics").with_impulse(1.0)];
1981 let base = || {
1982 Simulation::new(
1983 &rocket,
1984 "j760-i175",
1985 analytic_environment(UniformAir::sea_level(), G),
1986 Rail::vertical(6.0),
1987 FlightSettings {
1988 max_time_s: 3600.0,
1989 ..FlightSettings::default()
1990 },
1991 )
1992 .unwrap()
1993 };
1994 let separation = Separation::new(Trigger::Apogee, 0);
1995 let orders = [
1996 base()
1997 .with_separation(separation)
1998 .unwrap()
1999 .with_ejections(ejections())
2000 .unwrap()
2001 .with_recovery(devices())
2002 .unwrap(),
2003 base()
2004 .with_ejections(ejections())
2005 .unwrap()
2006 .with_separation(separation)
2007 .unwrap()
2008 .with_recovery(devices())
2009 .unwrap(),
2010 ];
2011 for sim in orders {
2012 let start = dropped(&sim, 1_500.0, DVec3::new(0.0, 0.0, -0.5), DVec3::ZERO);
2013 let result = sim.run_free(START_S, start, &mut ()).unwrap();
2014 assert!(result.bodies_landed());
2015 }
2016 let error = base()
2017 .with_ejections(ejections())
2018 .unwrap()
2019 .with_recovery(vec![
2020 canopy(0, 1.2, Trigger::Apogee),
2021 canopy(1, 0.3, Trigger::Apogee),
2022 ])
2023 .unwrap()
2024 .run(&mut ())
2025 .unwrap_err();
2026 assert!(
2027 matches!(&error, SimError::Parting { what, component }
2028 if what.starts_with("a pushed payload leaves forward")
2029 && component == "booster-electronics"),
2030 "{error:?}"
2031 );
2032 let error = base()
2034 .with_separation(separation)
2035 .unwrap()
2036 .with_ejections(vec![
2037 Ejection::payload(Trigger::Apogee, "sustainer-parachute").with_impulse(1.0),
2038 ])
2039 .unwrap()
2040 .with_recovery(devices())
2041 .unwrap()
2042 .run(&mut ())
2043 .unwrap_err();
2044 assert!(
2045 matches!(&error, SimError::Parting { what, component }
2046 if what.starts_with("a pushed payload leaves forward")
2047 && component == "sustainer-parachute"),
2048 "{error:?}"
2049 );
2050 }
2051
2052 #[test]
2053 fn a_drogue_cut_away_before_the_parting_is_not_hung_from() {
2054 let along = nose_push_through_air(|| {
2058 let tumble = DeviceDrag::tumbling(&with_payload().assemble("i175").unwrap()).unwrap();
2059 vec![
2060 canopy(0, 0.3, Trigger::Apogee).with_release_by(1),
2061 Device::new(
2062 "tumble",
2063 tumble,
2064 Trigger::Altitude {
2065 height_above_ground_m: 600.0,
2066 },
2067 ),
2068 canopy(
2069 1,
2070 0.9,
2071 Trigger::Altitude {
2072 height_above_ground_m: 300.0,
2073 },
2074 ),
2075 ]
2076 });
2077 close(along, 1.0, 1e-9, "along the flight");
2078 }
2079
2080 #[test]
2081 fn a_parting_without_an_impulse_records_the_body_after_it_unpushed() {
2082 let sim = simulation(analytic_environment(UniformAir::sea_level(), G));
2085 let start = dropped(&sim, 1_500.0, DVec3::new(0.0, 0.0, -0.5), DVec3::ZERO);
2086 let result = sim.run_free(START_S, start, &mut ()).unwrap();
2087 let airframe = &result.bodies[1];
2088 let parting = airframe.event(EventKind::Ejection(1)).unwrap();
2089 let after = parting.after.unwrap();
2090 assert_eq!(
2091 after.cg_velocity_enu_m_s,
2092 parting.sample.cg_velocity_enu_m_s
2093 );
2094 assert_eq!(after.cg_enu_m, parting.sample.cg_enu_m);
2095 close(after.mass_kg, airframe.mass_kg, 1e-12, "the airframe after");
2096 for body in &result.bodies {
2098 for event in &body.events {
2099 let parts = matches!(event.kind, EventKind::Ejection(_) | EventKind::Separation);
2100 assert_eq!(event.after.is_some(), parts, "{event:?}");
2101 }
2102 }
2103 }
2104
2105 #[test]
2106 fn a_tumbling_nose_cone_lands_at_its_tumble_models_terminal_speed() {
2107 let air = UniformAir::sea_level();
2110 let base = Simulation::new(
2111 &with_payload(),
2112 "i175",
2113 analytic_environment(air, G),
2114 Rail::vertical(3.0),
2115 FlightSettings {
2116 max_time_s: 3600.0,
2117 ..FlightSettings::default()
2118 },
2119 )
2120 .unwrap()
2121 .with_ejections(vec![
2122 Ejection::aft_of(Trigger::Apogee, "nose").with_impulse(0.5),
2123 ])
2124 .unwrap();
2125 let tumble = base.tumbling_piece(0).unwrap();
2126 let DeviceDrag::Tumble {
2127 drag_area_m2,
2128 body_profile_m2,
2129 fin_area_m2,
2130 } = tumble
2131 else {
2132 panic!("{tumble:?}");
2133 };
2134 let nose = sim_component(&base, "nose");
2138 let (length_m, radius_m) = (0.25_f64, nose.part.aft_radius_m().unwrap());
2139 let arc_m = (radius_m * radius_m + length_m * length_m) / (2.0 * radius_m);
2140 let side_m2 = length_m * (arc_m * arc_m - length_m * length_m).sqrt()
2141 + arc_m * arc_m * (length_m / arc_m).asin()
2142 + 2.0 * (radius_m - arc_m) * length_m;
2143 close(body_profile_m2, side_m2, 1e-12, "the nose cone's side area");
2144 assert_eq!(fin_area_m2, 0.0);
2145 close(
2146 drag_area_m2,
2147 0.56 * side_m2,
2148 1e-12,
2149 "the nose cone's drag area",
2150 );
2151
2152 let sim = base
2153 .with_recovery(vec![
2154 Device::new("nose tumble", tumble, Trigger::Apogee).on_body(0),
2155 canopy(1, 0.9, Trigger::Apogee),
2156 ])
2157 .unwrap();
2158 let start = dropped(&sim, 1_500.0, DVec3::new(0.0, 0.0, -0.5), DVec3::ZERO);
2159 let result = sim.run_free(START_S, start, &mut ()).unwrap();
2160 assert!(result.bodies_landed());
2161 let nose_body = &result.bodies[0];
2162 close(
2163 nose_body.mass_kg,
2164 component_kg(&sim, "nose"),
2165 1e-12,
2166 "the nose cone",
2167 );
2168 let landing = nose_body.event(EventKind::GroundHit).unwrap().sample;
2169 let terminal_m_s =
2170 terminal_speed_m_s(nose_body.mass_kg, drag_area_m2, air.0.density_kg_m3, G);
2171 close(
2172 -landing.vertical_speed_m_s,
2173 terminal_m_s,
2174 1e-6,
2175 "the nose cone's landing",
2176 );
2177 assert!((terminal_m_s - 13.849).abs() < 1e-3, "{terminal_m_s}");
2179 }
2180
2181 fn sim_component<'a>(sim: &'a Simulation, id: &str) -> &'a hpr_design::PlacedComponent {
2183 sim.assembly()
2184 .layout
2185 .components
2186 .iter()
2187 .find(|component| component.id == id)
2188 .unwrap()
2189 }
2190
2191 #[test]
2192 fn the_pieces_tumbling_areas_add_up_to_the_whole_airframes() {
2193 let sim = Simulation::new(
2196 &with_payload(),
2197 "i175",
2198 analytic_environment(UniformAir::sea_level(), G),
2199 Rail::vertical(3.0),
2200 FlightSettings::default(),
2201 )
2202 .unwrap()
2203 .with_ejections(vec![
2204 Ejection::aft_of(Trigger::Apogee, "nose"),
2205 Ejection::payload(Trigger::Apogee, "payload"),
2206 ])
2207 .unwrap();
2208 let area = |drag: DeviceDrag| match drag {
2209 DeviceDrag::Tumble {
2210 drag_area_m2,
2211 body_profile_m2,
2212 fin_area_m2,
2213 } => [drag_area_m2, body_profile_m2, fin_area_m2],
2214 other => panic!("{other:?}"),
2215 };
2216 let whole = area(DeviceDrag::tumbling(sim.assembly()).unwrap());
2217 let nose = area(sim.tumbling_piece(0).unwrap());
2218 let rest = area(sim.tumbling_piece(1).unwrap());
2219 for k in 0..3 {
2220 close(nose[k] + rest[k], whole[k], 1e-12, "the sum");
2221 }
2222 assert!(nose[2] == 0.0 && rest[2] > 0.0, "{nose:?} {rest:?}");
2223
2224 for (piece, starts) in [
2226 (2, "piece to tumble (a payload has no body tube or fin"),
2227 (3, "piece to tumble (there is one per split"),
2228 ] {
2229 let error = sim.tumbling_piece(piece).unwrap_err();
2230 assert!(
2231 matches!(error, SimError::Domain { what, value }
2232 if what.starts_with(starts) && value == piece as f64),
2233 "{error:?}"
2234 );
2235 }
2236 }
2237
2238 #[test]
2239 fn an_ejection_with_a_separation_numbers_the_bodies_after_it() {
2240 let air = UniformAir::sea_level();
2244 let rocket = design("synthetic-two-stage-75mm-54mm");
2245 let assembly = rocket.assemble("j760-i175").unwrap();
2246 let tumble = DeviceDrag::tumbling_stages(&assembly, (1, 1)).unwrap();
2247 let sim = Simulation::new(
2248 &rocket,
2249 "j760-i175",
2250 analytic_environment(air, G),
2251 Rail::vertical(6.0),
2252 FlightSettings {
2253 max_time_s: 3600.0,
2254 ..FlightSettings::default()
2255 },
2256 )
2257 .unwrap()
2258 .with_ejections(vec![Ejection::aft_of(Trigger::Apogee, "nose")])
2259 .unwrap()
2260 .with_recovery(vec![
2261 canopy(0, 0.6, Trigger::Apogee),
2262 Device::new("booster", tumble, Trigger::Apogee).on_body(1),
2263 canopy(2, 1.2, Trigger::Apogee),
2264 ])
2265 .unwrap()
2266 .with_separation(Separation::new(Trigger::Apogee, 0))
2267 .unwrap();
2268 let start = dropped(&sim, 1_500.0, DVec3::new(0.0, 0.0, -0.5), DVec3::ZERO);
2269 let result = sim.run_free(START_S, start, &mut ()).unwrap();
2270 assert!(result.event(EventKind::Separation).is_some());
2271 assert!(result.event(EventKind::Ejection(0)).is_some());
2272 assert!(result.bodies_landed());
2273 let stages: Vec<(usize, usize)> = result.bodies.iter().map(|body| body.stages).collect();
2274 assert_eq!(stages, vec![(0, 0), (1, 1), (0, 0)]);
2275 let whole_kg = sim.assembly().mass_properties(START_S).mass_kg;
2276 let sum_kg: f64 = result.bodies.iter().map(|body| body.mass_kg).sum();
2277 close(sum_kg, whole_kg, 1e-12, "the bodies' mass");
2278 close(
2279 result.bodies[0].mass_kg,
2280 component_kg(&sim, "nose"),
2281 1e-12,
2282 "the nose cone",
2283 );
2284 let lit = vec![Some(0.0); sim.assembly().motors.len()];
2286 let booster = crate::recovery::body_mass_properties(sim.assembly(), (1, 1), START_S, &lit);
2287 close(
2288 result.bodies[1].mass_kg,
2289 booster.mass_kg,
2290 1e-12,
2291 "the booster",
2292 );
2293 }
2294
2295 #[test]
2296 fn a_split_on_the_way_down_waits_for_its_own_trigger() {
2297 let sim = simulation(analytic_environment(UniformAir::sea_level(), G));
2300 let start = dropped(&sim, 1_500.0, DVec3::new(0.0, 0.0, -0.5), DVec3::ZERO);
2301 let result = sim.run_free(START_S, start, &mut ()).unwrap();
2302 let parting = result.bodies[1]
2303 .event(EventKind::Ejection(1))
2304 .unwrap()
2305 .sample;
2306 assert!(parting.time_s > START_S + 10.0, "{parting:?}");
2307 assert!(parting.vertical_speed_m_s < 0.0);
2308
2309 let late = Simulation::new(
2311 &with_payload(),
2312 "i175",
2313 analytic_environment(UniformAir::sea_level(), G),
2314 Rail::vertical(3.0),
2315 FlightSettings {
2316 max_time_s: 3600.0,
2317 ..FlightSettings::default()
2318 },
2319 )
2320 .unwrap()
2321 .with_recovery(devices())
2322 .unwrap()
2323 .with_ejections(vec![
2324 Ejection::aft_of(Trigger::Apogee, "nose"),
2325 Ejection::payload(Trigger::Time { time_s: 3_000.0 }, "payload"),
2326 ])
2327 .unwrap();
2328 let result = late.run_free(START_S, start, &mut ()).unwrap();
2329 assert_eq!(result.bodies.len(), 2);
2330 assert_eq!(result.bodies[1].pieces, vec![1, 2]);
2331 assert!(result.bodies[1].event(EventKind::Ejection(1)).is_none());
2332 let whole_kg = late.assembly().mass_properties(START_S).mass_kg;
2333 close(
2334 result.bodies[0].mass_kg + result.bodies[1].mass_kg,
2335 whole_kg,
2336 1e-12,
2337 "the two bodies",
2338 );
2339 }
2340
2341 fn two_stage_pieces(ejections: Vec<Ejection>, bodies: usize) -> (Simulation, FlightResult) {
2344 let air = UniformAir::sea_level();
2345 let sim = Simulation::new(
2346 &design("synthetic-two-stage-75mm-54mm"),
2347 "j760-i175",
2348 analytic_environment(air, G),
2349 Rail::vertical(6.0),
2350 FlightSettings {
2351 max_time_s: 3600.0,
2352 ..FlightSettings::default()
2353 },
2354 )
2355 .unwrap()
2356 .with_recovery(
2357 (0..bodies)
2358 .map(|body| canopy(body, 0.8, Trigger::Apogee))
2359 .collect(),
2360 )
2361 .unwrap()
2362 .with_ejections(ejections)
2363 .unwrap();
2364 let start = dropped(&sim, 1_500.0, DVec3::new(0.0, 0.0, -0.5), DVec3::ZERO);
2365 let result = sim.run_free(START_S, start, &mut ()).unwrap();
2366 (sim, result)
2367 }
2368
2369 #[test]
2370 fn partings_that_fire_together_on_the_way_down_count_each_piece_once() {
2371 let at_300_m = Trigger::Altitude {
2377 height_above_ground_m: 300.0,
2378 };
2379 let orders = [
2380 vec![
2381 Ejection::aft_of(Trigger::Apogee, "interstage"),
2382 Ejection::aft_of(at_300_m, "nose"),
2383 Ejection::aft_of(at_300_m, "sustainer-airframe"),
2384 ],
2385 vec![
2386 Ejection::aft_of(Trigger::Apogee, "interstage"),
2387 Ejection::aft_of(at_300_m, "sustainer-airframe"),
2388 Ejection::aft_of(at_300_m, "nose"),
2389 ],
2390 ];
2391 for (order, ejections) in orders.into_iter().enumerate() {
2392 let (sim, result) = two_stage_pieces(ejections, 4);
2393 assert!(result.bodies_landed(), "order {order}");
2394 let pieces: Vec<Vec<usize>> = result
2395 .bodies
2396 .iter()
2397 .map(|body| body.pieces.clone())
2398 .collect();
2399 assert_eq!(
2400 pieces,
2401 vec![vec![0], vec![1], vec![2], vec![3]],
2402 "order {order}"
2403 );
2404 let whole_kg = sim.assembly().mass_properties(START_S).mass_kg;
2405 let sum_kg: f64 = result.bodies.iter().map(|body| body.mass_kg).sum();
2406 close(
2407 sum_kg,
2408 whole_kg,
2409 1e-12,
2410 &format!("order {order}: the bodies' mass"),
2411 );
2412 close(
2414 result.bodies[0].mass_kg,
2415 component_kg(&sim, "nose"),
2416 1e-12,
2417 "the nose cone",
2418 );
2419 let interstage = if order == 0 { 3 } else { 2 };
2420 close(
2421 result.bodies[interstage].mass_kg,
2422 component_kg(&sim, "interstage"),
2423 1e-12,
2424 &format!("order {order}: the interstage"),
2425 );
2426 for kind in [EventKind::Ejection(1), EventKind::Ejection(2)] {
2427 assert!(
2428 result.bodies[0].event(kind).is_some(),
2429 "order {order}: {kind:?}"
2430 );
2431 assert!(
2432 result.bodies[2].event(kind).is_none(),
2433 "order {order}: {kind:?}"
2434 );
2435 }
2436 }
2437 }
2438
2439 fn refused(ejections: Vec<Ejection>) -> SimError {
2441 let devices = (0..=ejections.len())
2442 .map(|body| canopy(body, 0.5, Trigger::Apogee))
2443 .collect();
2444 Simulation::new(
2445 &with_payload(),
2446 "i175",
2447 analytic_environment(UniformAir::sea_level(), G),
2448 Rail::vertical(3.0),
2449 FlightSettings::default(),
2450 )
2451 .unwrap()
2452 .with_recovery(devices)
2453 .unwrap()
2454 .with_ejections(ejections)
2455 .unwrap_err()
2456 }
2457
2458 fn what(error: SimError) -> (&'static str, String) {
2459 match error {
2460 SimError::Parting { what, component } => (what, component),
2461 SimError::Domain { what, .. } => (what, String::new()),
2462 other => panic!("{other:?}"),
2463 }
2464 }
2465
2466 #[test]
2467 fn partings_the_design_cant_make_are_refused() {
2468 let apogee = Trigger::Apogee;
2469 let cases: Vec<(Vec<Ejection>, &str, &str)> = vec![
2470 (
2471 vec![Ejection::aft_of(apogee, "fairing")],
2472 "an ejection names a component the design doesn't have",
2473 "fairing",
2474 ),
2475 (
2476 vec![Ejection::aft_of(apogee, "payload")],
2477 "a joint is aft of a body component, and this one is inside another (a part \
2478 carried inside is a `Parting::Payload`)",
2479 "payload",
2480 ),
2481 (
2482 vec![Ejection::aft_of(apogee, "sustainer-airframe")],
2483 "a joint aft of the last body component (nothing is aft of it)",
2484 "sustainer-airframe",
2485 ),
2486 (
2487 vec![Ejection::payload(apogee, "sustainer-fins")],
2488 "a payload is carried inside the airframe, and this part is outside it",
2489 "sustainer-fins",
2490 ),
2491 (
2492 vec![Ejection::payload(apogee, "nose")],
2493 "a payload is carried inside the airframe, and this is a body component (a body \
2494 component leaves at a joint, `Parting::AftOf`)",
2495 "nose",
2496 ),
2497 (
2498 vec![
2499 Ejection::aft_of(apogee, "nose"),
2500 Ejection::aft_of(Trigger::Time { time_s: 20.0 }, "nose"),
2501 ],
2502 "two partings at the joint aft of this component (a separation's stage boundary \
2503 is a joint too)",
2504 "nose",
2505 ),
2506 (
2507 vec![
2508 Ejection::payload(apogee, "payload"),
2509 Ejection::payload(apogee, "payload"),
2510 ],
2511 "two ejections of one payload",
2512 "payload",
2513 ),
2514 ];
2515 for (ejections, expected, component) in cases {
2516 assert_eq!(
2517 what(refused(ejections)),
2518 (expected, component.to_owned()),
2519 "{expected}"
2520 );
2521 }
2522
2523 let mut rocket = with_payload();
2525 let airframe = &mut rocket.stages[0].components[1];
2526 let mut inner = airframe
2527 .children
2528 .iter()
2529 .find(|child| child.id == "altimeter")
2530 .cloned()
2531 .unwrap();
2532 inner.id = "inner".to_owned();
2533 inner.position = Some(Position::Top { aft_offset_m: 0.0 });
2534 let outer = airframe
2535 .children
2536 .iter_mut()
2537 .find(|child| child.id == "sustainer-motor-mount")
2538 .unwrap();
2539 outer.children.push(inner);
2540 let error = Simulation::new(
2541 &rocket,
2542 "i175",
2543 analytic_environment(UniformAir::sea_level(), G),
2544 Rail::vertical(3.0),
2545 FlightSettings::default(),
2546 )
2547 .unwrap()
2548 .with_ejections(vec![
2549 Ejection::payload(apogee, "sustainer-motor-mount"),
2550 Ejection::payload(apogee, "inner"),
2551 ])
2552 .unwrap_err();
2553 assert_eq!(
2554 what(error),
2555 ("a payload inside another payload", "inner".to_owned())
2556 );
2557
2558 let mut rocket = with_payload();
2560 let airframe = &mut rocket.stages[0].components[1];
2561 let mut pod_tube = airframe.clone();
2562 pod_tube.id = "pod-tube".to_owned();
2563 pod_tube.auto.clear();
2564 pod_tube.motor_mount = None;
2565 pod_tube.children.clear();
2566 let mut pods = pod_tube.clone();
2567 pods.id = "pods".to_owned();
2568 pods.part = hpr_design::Part::PodSet(hpr_design::PodSet {
2569 count: 1,
2570 radial_offset_m: 0.2,
2571 angle_rad: 0.0,
2572 });
2573 pods.position = Some(Position::Top { aft_offset_m: 0.0 });
2574 pods.children = vec![pod_tube];
2575 airframe.children.push(pods);
2576 let assembly = rocket.assemble("i175").unwrap();
2577 for ejection in [
2578 Ejection::payload(apogee, "pod-tube"),
2579 Ejection::aft_of(apogee, "pod-tube"),
2580 ] {
2581 let error = Pieces::new(&rocket, &assembly, &[], &[ejection])
2582 .err()
2583 .unwrap();
2584 assert_eq!(
2585 what(error),
2586 (
2587 "a pod's body component leaves with the tube its pod set hangs from, at no \
2588 joint of its own and not as a payload",
2589 "pod-tube".to_owned()
2590 )
2591 );
2592 }
2593
2594 let mut rocket = with_payload();
2596 rocket.stages[0].overrides.mass_kg = Some(1.0);
2597 let error = Simulation::new(
2598 &rocket,
2599 "i175",
2600 analytic_environment(UniformAir::sea_level(), G),
2601 Rail::vertical(3.0),
2602 FlightSettings::default(),
2603 )
2604 .unwrap()
2605 .with_ejections(vec![Ejection::payload(apogee, "payload")])
2606 .unwrap_err();
2607 assert_eq!(
2608 what(error),
2609 (
2610 "a parting through a stage whose mass is overridden (the override doesn't say \
2611 which piece its mass is in)",
2612 "sustainer".to_owned()
2613 )
2614 );
2615
2616 let mut rocket = with_payload();
2618 rocket.stages[0].components[1].overrides.mass_kg = Some(1.0);
2619 rocket.stages[0].components[1].overrides_include_children = true;
2620 let error = Simulation::new(
2621 &rocket,
2622 "i175",
2623 analytic_environment(UniformAir::sea_level(), G),
2624 Rail::vertical(3.0),
2625 FlightSettings::default(),
2626 )
2627 .unwrap()
2628 .with_ejections(vec![Ejection::payload(apogee, "payload")])
2629 .unwrap_err();
2630 assert_eq!(
2631 what(error),
2632 (
2633 "a parting inside a component whose overridden mass covers what it holds (the \
2634 override doesn't say which piece that mass is in)",
2635 "sustainer-airframe".to_owned()
2636 )
2637 );
2638 }
2639
2640 #[test]
2641 fn ejections_outside_their_domain_are_refused() {
2642 let error = refused(vec![Ejection::aft_of(
2644 Trigger::Time { time_s: 0.5 },
2645 "nose",
2646 )]);
2647 assert_eq!(
2648 what(error).0,
2649 "time of an ejection (every motor must have burned out by then; this is when one of \
2650 them does)"
2651 );
2652 let error = refused(vec![Ejection::aft_of(
2653 Trigger::Altitude {
2654 height_above_ground_m: -1.0,
2655 },
2656 "nose",
2657 )]);
2658 assert_eq!(
2659 what(error).0,
2660 "height above the launch site at which a piece is ejected, m"
2661 );
2662 let payload_only = |impulse_n_s: f64| {
2666 Simulation::new(
2667 &with_payload(),
2668 "i175",
2669 analytic_environment(UniformAir::sea_level(), G),
2670 Rail::vertical(3.0),
2671 FlightSettings::default(),
2672 )
2673 .unwrap()
2674 .with_recovery(vec![
2675 canopy(0, 0.5, Trigger::Apogee),
2676 canopy(1, 0.5, Trigger::Apogee),
2677 ])
2678 .unwrap()
2679 .with_ejections(vec![
2680 Ejection::payload(Trigger::Apogee, "payload").with_impulse(impulse_n_s),
2681 ])
2682 .unwrap()
2683 .run(&mut ())
2684 };
2685 let (what_, component) = what(payload_only(1.0).unwrap_err());
2686 assert!(
2687 what_.starts_with("a pushed payload leaves forward, and this one is in the nose's")
2688 && component == "payload",
2689 "{what_}"
2690 );
2691 assert!(payload_only(0.0).is_ok());
2692 for impulse_n_s in [-1.0, f64::NAN, f64::INFINITY] {
2693 let error = refused(vec![
2694 Ejection::aft_of(Trigger::Apogee, "nose").with_impulse(impulse_n_s),
2695 ]);
2696 assert!(
2697 matches!(error, SimError::Domain { what, value }
2698 if what == "impulse of an ejection, N·s (zero or more: it pushes the two \
2699 sides apart)"
2700 && (value == impulse_n_s || value.is_nan() && impulse_n_s.is_nan())),
2701 "{error:?}"
2702 );
2703 }
2704
2705 let base = || {
2707 Simulation::new(
2708 &with_payload(),
2709 "i175",
2710 analytic_environment(UniformAir::sea_level(), G),
2711 Rail::vertical(3.0),
2712 FlightSettings::default(),
2713 )
2714 .unwrap()
2715 };
2716 let error = base()
2717 .with_recovery(vec![canopy(0, 0.5, Trigger::Apogee)])
2718 .unwrap()
2719 .with_ejections(vec![Ejection::aft_of(Trigger::Apogee, "nose")])
2720 .unwrap_err();
2721 assert!(
2722 matches!(error, SimError::Domain { what, value }
2723 if what.starts_with("recovery devices on a separated body") && value == 1.0),
2724 "{error:?}"
2725 );
2726 let error = base()
2728 .with_ejections(vec![Ejection::aft_of(Trigger::Apogee, "nose")])
2729 .unwrap()
2730 .with_recovery(vec![
2731 canopy(0, 0.5, Trigger::Apogee),
2732 canopy(1, 0.5, Trigger::Apogee),
2733 canopy(2, 0.5, Trigger::Apogee),
2734 ])
2735 .unwrap()
2736 .run(&mut ())
2737 .unwrap_err();
2738 assert!(
2739 matches!(error, SimError::Domain { what, value }
2740 if what.starts_with("body a device is attached to") && value == 2.0),
2741 "{error:?}"
2742 );
2743 }
2744
2745 #[test]
2746 fn a_flight_without_partings_is_unchanged() {
2747 let sim = Simulation::new(
2750 &with_payload(),
2751 "i175",
2752 analytic_environment(UniformAir::sea_level(), G),
2753 Rail::vertical(3.0),
2754 FlightSettings {
2755 max_time_s: 3600.0,
2756 ..FlightSettings::default()
2757 },
2758 )
2759 .unwrap()
2760 .with_recovery(vec![canopy(0, 0.9, Trigger::Apogee)])
2761 .unwrap();
2762 let plain: FlightResult = sim.run(&mut ()).unwrap();
2763 let result = sim
2764 .with_ejections(Vec::new())
2765 .unwrap()
2766 .run(&mut ())
2767 .unwrap();
2768 assert_eq!(result.termination, Termination::GroundHit);
2769 assert!(result.bodies.is_empty());
2770 assert_eq!(result, plain);
2771 }
2772
2773 #[test]
2774 fn an_override_that_covers_only_its_own_part_still_divides() {
2775 let mut rocket = with_payload();
2778 rocket.stages[0].components[1].overrides.mass_kg = Some(0.5);
2779 let sim = Simulation::new(
2780 &rocket,
2781 "i175",
2782 analytic_environment(UniformAir::sea_level(), G),
2783 Rail::vertical(3.0),
2784 FlightSettings {
2785 max_time_s: 3600.0,
2786 ..FlightSettings::default()
2787 },
2788 )
2789 .unwrap()
2790 .with_recovery(devices())
2791 .unwrap()
2792 .with_ejections(ejections())
2793 .unwrap();
2794 let start = dropped(&sim, 1_500.0, DVec3::new(0.0, 0.0, -0.5), DVec3::ZERO);
2795 let result = sim.run_free(START_S, start, &mut ()).unwrap();
2796 assert!(result.bodies_landed());
2797 let whole_kg = sim.assembly().mass_properties(START_S).mass_kg;
2798 let sum_kg: f64 = result.bodies.iter().map(|body| body.mass_kg).sum();
2799 close(sum_kg, whole_kg, 1e-12, "the pieces' mass");
2800 close(result.bodies[2].mass_kg, PAYLOAD_KG, 1e-12, "the payload");
2801 }
2802
2803 #[test]
2804 fn refusals_in_flight_name_what_fired() {
2805 let mut rocket = design("synthetic-two-stage-75mm-54mm");
2808 rocket.configurations[0]
2809 .motors
2810 .iter_mut()
2811 .find(|motor| motor.mount == "sustainer-motor-mount")
2812 .unwrap()
2813 .ignition = hpr_design::Ignition::Separation { delay_s: 0.0 };
2814 let build = || {
2815 Simulation::new(
2816 &rocket,
2817 "j760-i175",
2818 analytic_environment(UniformAir::sea_level(), G),
2819 Rail::vertical(6.0),
2820 FlightSettings {
2821 max_time_s: 3600.0,
2822 ..FlightSettings::default()
2823 },
2824 )
2825 .unwrap()
2826 };
2827 let booster = build()
2828 .assembly()
2829 .motors
2830 .iter()
2831 .position(|motor| motor.mount == "booster-motor-mount")
2832 .unwrap();
2833 let sustainer = 1 - booster;
2834 let devices = (0..3)
2835 .map(|body| canopy(body, 0.8, Trigger::Time { time_s: 0.0 }))
2836 .collect::<Vec<_>>();
2837 let domain = |error: SimError| match error {
2838 SimError::Domain { what, .. } => what,
2839 other => panic!("{other:?}"),
2840 };
2841
2842 let error = build()
2844 .with_recovery(devices.clone())
2845 .unwrap()
2846 .with_separation(Separation::new(
2847 Trigger::Burnout {
2848 motor: booster,
2849 delay_s: 0.5,
2850 },
2851 0,
2852 ))
2853 .unwrap()
2854 .with_ejections(vec![Ejection::aft_of(Trigger::Apogee, "nose")])
2855 .unwrap()
2856 .run(&mut ())
2857 .unwrap_err();
2858 assert_eq!(
2859 domain(error),
2860 "time of a powered separation in a flight with ejections (the pieces of a sustainer \
2861 aren't tracked)"
2862 );
2863
2864 let error = build()
2866 .with_recovery(devices.clone())
2867 .unwrap()
2868 .with_separation(Separation::new(Trigger::Time { time_s: 3_000.0 }, 0))
2869 .unwrap()
2870 .with_ejections(vec![Ejection::aft_of(Trigger::Apogee, "nose")])
2871 .unwrap()
2872 .run(&mut ())
2873 .unwrap_err();
2874 assert_eq!(
2875 domain(error),
2876 "time of an ejection before the separation that lights a motor (the pieces would \
2877 never light it)"
2878 );
2879
2880 let error = build()
2882 .with_recovery(devices)
2883 .unwrap()
2884 .with_separation(Separation::new(Trigger::Time { time_s: 3_000.0 }, 0))
2885 .unwrap()
2886 .with_ejections(vec![Ejection::aft_of(
2887 Trigger::MotorDelay { motor: sustainer },
2888 "nose",
2889 )])
2890 .unwrap_err();
2891 assert_eq!(
2892 domain(error),
2893 "index of the motor an ejection is timed from (it has no ignition time before the \
2894 flight, so the ejection could never fire)"
2895 );
2896 }
2897
2898 #[test]
2899 fn an_ejection_reads_and_writes_as_json() {
2900 let ejection = Ejection::payload(
2901 Trigger::Altitude {
2902 height_above_ground_m: 300.0,
2903 },
2904 "payload",
2905 );
2906 let text = serde_json::to_string(&ejection).unwrap();
2907 assert_eq!(
2908 text,
2909 r#"{"trigger":{"altitude":{"height_above_ground_m":300.0}},"parting":{"payload":{"component":"payload"}}}"#
2910 );
2911 assert_eq!(serde_json::from_str::<Ejection>(&text).unwrap(), ejection);
2912 let pushed = ejection.clone().with_impulse(0.75);
2914 let text = serde_json::to_string(&pushed).unwrap();
2915 assert!(text.ends_with(r#""impulse_n_s":0.75}"#), "{text}");
2916 assert_eq!(serde_json::from_str::<Ejection>(&text).unwrap(), pushed);
2917 for text in [
2919 r#"{"trigger":"apogee","parting":{"aft_of":{"component":"nose","x":1}}}"#,
2920 r#"{"trigger":"apogee","parting":{"aft_of":{"component":"nose"}},"x":1}"#,
2921 ] {
2922 assert!(serde_json::from_str::<Ejection>(text).is_err(), "{text}");
2923 }
2924 let read: Ejection = serde_json::from_str(
2925 r#"{"trigger":"apogee","parting":{"aft_of":{"component":"nose"}}}"#,
2926 )
2927 .unwrap();
2928 assert_eq!(read, Ejection::aft_of(Trigger::Apogee, "nose"));
2929 }
2930}