1use std::collections::BTreeSet;
21use std::sync::Arc;
22
23use hpr_core::DVec3;
24use hpr_motor::{Delay, MassElement, SolidMotor};
25use serde::{Deserialize, Serialize};
26
27use crate::error::DesignError;
28use crate::mass::MassProperties;
29use crate::shapes::check_dimension;
30use crate::tree::{Layout, Rocket, StageMasses};
31
32#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
34#[serde(deny_unknown_fields)]
35pub struct MotorMount {
36 #[serde(default)]
38 pub overhang_m: f64,
39}
40
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
44#[serde(deny_unknown_fields)]
45pub struct Configuration {
46 pub id: String,
48 #[serde(default)]
50 pub name: String,
51 pub motors: Vec<MountedMotor>,
53}
54
55#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, schemars::JsonSchema)]
61#[serde(rename_all = "snake_case", deny_unknown_fields)]
62#[non_exhaustive]
63pub enum Ignition {
64 #[default]
66 Launch,
67 Time {
69 time_s: f64,
71 },
72 Burnout {
75 mount: String,
77 delay_s: f64,
79 },
80 Separation {
84 delay_s: f64,
86 },
87 Never,
91}
92
93impl Ignition {
94 #[must_use]
96 pub fn is_launch(&self) -> bool {
97 *self == Self::Launch
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
103#[serde(deny_unknown_fields)]
104pub struct MountedMotor {
105 pub mount: String,
107 #[serde(default)]
109 pub designation: String,
110 pub diameter_m: f64,
112 pub length_m: f64,
114 pub motor: SolidMotor,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub delay: Option<Delay>,
119 #[serde(default, skip_serializing_if = "Ignition::is_launch")]
121 pub ignition: Ignition,
122 #[serde(default, skip_serializing_if = "Vec::is_empty")]
131 pub failed_tubes: Vec<usize>,
132}
133
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137pub struct PlacedMotor {
138 pub mount: String,
140 pub stage: usize,
142 pub nozzle_m: DVec3,
144 pub mounted: MountedMotor,
146 #[serde(default)]
148 pub tube: usize,
149 #[serde(default)]
152 pub fails: bool,
153}
154
155impl PlacedMotor {
156 pub fn nozzle_station_m(&self) -> f64 {
158 -self.nozzle_m.z
159 }
160
161 pub fn fore_station_m(&self) -> f64 {
163 self.nozzle_station_m() - self.mounted.length_m
164 }
165
166 pub fn place(&self, element: &MassElement) -> MassProperties {
168 MassProperties::from_motor_element(element, self.nozzle_m.z).translated(DVec3::new(
169 self.nozzle_m.x,
170 self.nozzle_m.y,
171 0.0,
172 ))
173 }
174
175 pub fn mass_properties(&self, t_s: f64) -> MassProperties {
177 self.place(&self.mounted.motor.state(t_s).total)
178 }
179
180 pub fn mass_properties_lit(&self, t_s: f64, ignition_s: Option<f64>) -> MassProperties {
183 let since_s = ignition_s.map_or(0.0, |ignition_s| (t_s - ignition_s).max(0.0));
184 self.mass_properties(since_s)
185 }
186
187 pub fn dry_mass_properties(&self) -> MassProperties {
189 self.place(&self.mounted.motor.dry())
190 }
191}
192
193#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195pub struct Assembly {
196 pub configuration: String,
198 pub layout: Layout,
200 pub motors: Vec<PlacedMotor>,
202}
203
204impl Assembly {
205 pub fn mass_properties(&self, t_s: f64) -> MassProperties {
210 self.motors
211 .iter()
212 .fold(self.layout.structure, |sum, motor| {
213 let ignition = (!motor.fails).then_some(0.0);
214 MassProperties::combine([&sum, &motor.mass_properties_lit(t_s, ignition)])
215 })
216 }
217
218 pub fn mass_properties_lit(&self, t_s: f64, ignition_s: &[Option<f64>]) -> MassProperties {
223 self.mass_properties_lit_on(self.layout.structure, t_s, ignition_s)
224 }
225
226 pub fn mass_properties_lit_on(
229 &self,
230 structure: MassProperties,
231 t_s: f64,
232 ignition_s: &[Option<f64>],
233 ) -> MassProperties {
234 self.motors
235 .iter()
236 .enumerate()
237 .fold(structure, |sum, (index, motor)| {
238 let ignition = ignition_s.get(index).copied().unwrap_or(Some(0.0));
239 MassProperties::combine([&sum, &motor.mass_properties_lit(t_s, ignition)])
240 })
241 }
242
243 #[must_use]
252 pub fn ignition_times_s(&self, separated_s: impl Fn(usize) -> Option<f64>) -> Vec<Option<f64>> {
253 ignition_times_s(&self.motors, separated_s, true)
254 }
255
256 pub fn dry_mass_properties(&self) -> MassProperties {
259 self.motors
260 .iter()
261 .fold(self.layout.structure, |sum, motor| {
262 let motor = if motor.fails {
263 motor.mass_properties_lit(0.0, None)
264 } else {
265 motor.dry_mass_properties()
266 };
267 MassProperties::combine([&sum, &motor])
268 })
269 }
270}
271
272fn ignition_times_s(
275 motors: &[PlacedMotor],
276 separated_s: impl Fn(usize) -> Option<f64>,
277 failures: bool,
278) -> Vec<Option<f64>> {
279 let fails = |motor: &PlacedMotor| failures && motor.fails;
280 let mut times: Vec<Option<f64>> = motors
281 .iter()
282 .map(|motor| match &motor.mounted.ignition {
283 _ if fails(motor) => None,
284 Ignition::Launch => Some(0.0),
285 Ignition::Time { time_s } => Some(*time_s),
286 Ignition::Separation { delay_s } => separated_s(motor.stage).map(|t| t + delay_s),
287 Ignition::Burnout { .. } => None,
288 Ignition::Never => (!failures).then_some(0.0),
290 })
291 .collect();
292 for _ in 0..motors.len() {
295 let mut changed = false;
296 for (index, motor) in motors.iter().enumerate() {
297 let Ignition::Burnout { mount, delay_s } = &motor.mounted.ignition else {
298 continue;
299 };
300 if times[index].is_some() || fails(motor) {
301 continue;
302 }
303 let lit = motors
305 .iter()
306 .zip(×)
307 .filter(|(other, _)| other.mount == *mount)
308 .find_map(|(other, time)| time.map(|t| t + other.mounted.motor.burnout_time_s()));
309 if let Some(burnout_s) = lit {
310 times[index] = Some(burnout_s + delay_s);
311 changed = true;
312 }
313 }
314 if !changed {
315 break;
316 }
317 }
318 times
319}
320
321impl Layout {
322 pub fn place_motors(
337 &self,
338 configuration: &Configuration,
339 ) -> Result<Vec<PlacedMotor>, DesignError> {
340 let mut mounts = BTreeSet::new();
341 let mut motors = Vec::with_capacity(configuration.motors.len());
342 for mounted in &configuration.motors {
343 let (_, mount) = self
344 .find(&mounted.mount)
345 .ok_or_else(|| DesignError::UnknownId {
346 what: "motor mount",
347 id: mounted.mount.clone(),
348 })?;
349 let in_mount = |error| DesignError::InComponent {
350 id: mount.id.clone(),
351 source: Box::new(error),
352 };
353 check_dimension("motor diameter (m)", mounted.diameter_m, false).map_err(in_mount)?;
354 check_dimension("motor length (m)", mounted.length_m, false).map_err(in_mount)?;
355 let Some(spec) = mount.motor_mount else {
356 return Err(DesignError::Tree {
357 id: mount.id.clone(),
358 message: format!(
359 "configuration {} puts a motor here, but it is not a motor mount",
360 configuration.id
361 ),
362 });
363 };
364 if !mounts.insert(mount.id.as_str()) {
365 return Err(DesignError::Tree {
366 id: mount.id.clone(),
367 message: format!(
368 "configuration {} puts two motors in this mount",
369 configuration.id
370 ),
371 });
372 }
373 if !spec.overhang_m.is_finite() {
374 return Err(in_mount(DesignError::Domain {
375 what: "motor overhang (m)",
376 value: spec.overhang_m,
377 }));
378 }
379 match &mounted.ignition {
380 Ignition::Launch => {}
381 Ignition::Time { time_s: value } => {
382 check_ignition("ignition time after launch (s)", *value).map_err(in_mount)?;
383 }
384 Ignition::Burnout { delay_s: value, .. } => {
385 check_ignition("ignition delay (s)", *value).map_err(in_mount)?;
386 }
387 Ignition::Separation { delay_s: value } => {
388 check_ignition("ignition delay (s)", *value).map_err(in_mount)?;
389 if mount.stage + 1 >= self.stages.len() {
390 return Err(DesignError::Tree {
391 id: mount.id.clone(),
392 message: format!(
393 "configuration {} lights this motor at its stage's separation, \
394 but no stage is aft of it to separate",
395 configuration.id
396 ),
397 });
398 }
399 }
400 Ignition::Never => {}
401 }
402 let tubes = mount.contents_copies().map_err(in_mount)?;
403 for (k, &tube) in mounted.failed_tubes.iter().enumerate() {
404 if tube >= tubes.len() || mounted.failed_tubes[..k].contains(&tube) {
405 return Err(in_mount(DesignError::Domain {
406 what: "failed tube (index into the mount's tubes, each named once)",
407 value: tube as f64,
408 }));
409 }
410 }
411 let axis = mount.part.axis_offset_m();
412 let z = -(mount.aft_station_m() + spec.overhang_m);
413 for (tube, place) in tubes.into_iter().enumerate() {
414 let [x, y] = place.point(axis);
415 motors.push(PlacedMotor {
416 mount: mount.id.clone(),
417 stage: mount.stage,
418 nozzle_m: DVec3::new(x, y, z),
419 mounted: mounted.clone(),
420 tube,
421 fails: mounted.ignition == Ignition::Never
422 || mounted.failed_tubes.contains(&tube),
423 });
424 }
425 }
426 for motor in &motors {
427 if let Ignition::Burnout { mount, .. } = &motor.mounted.ignition
428 && !motors.iter().any(|other| other.mount == *mount)
429 {
430 return Err(DesignError::Tree {
431 id: motor.mount.clone(),
432 message: format!(
433 "configuration {} lights this motor at the burnout of mount {mount}, \
434 which holds no motor in it",
435 configuration.id
436 ),
437 });
438 }
439 }
440 let lit = ignition_times_s(&motors, |_| Some(0.0), false);
443 if let Some(index) = lit.iter().position(Option::is_none) {
444 return Err(DesignError::Tree {
445 id: motors[index].mount.clone(),
446 message: format!(
447 "configuration {} lights this motor by a chain of burnouts that comes back \
448 to it",
449 configuration.id
450 ),
451 });
452 }
453 Ok(motors)
454 }
455}
456
457fn check_ignition(what: &'static str, value: f64) -> Result<(), DesignError> {
459 if value.is_finite() && value >= 0.0 {
460 Ok(())
461 } else {
462 Err(DesignError::Domain { what, value })
463 }
464}
465
466impl Rocket {
467 pub fn configuration(&self, id: &str) -> Option<&Configuration> {
469 self.configurations.iter().find(|c| c.id == id)
470 }
471
472 pub fn check_configuration_ids(&self) -> Result<(), DesignError> {
478 let mut ids = BTreeSet::new();
479 for configuration in &self.configurations {
480 if configuration.id.is_empty() || !ids.insert(configuration.id.as_str()) {
481 return Err(DesignError::DuplicateId(configuration.id.clone()));
482 }
483 }
484 Ok(())
485 }
486
487 pub fn assemble(&self, configuration_id: &str) -> Result<Assembly, DesignError> {
495 self.check_configuration_ids()?;
496 let configuration = self.configuration_or_error(configuration_id)?;
497 let layout = self.layout()?;
498 let motors = layout.place_motors(configuration)?;
499 Ok(Assembly {
500 configuration: configuration.id.clone(),
501 layout,
502 motors,
503 })
504 }
505
506 pub fn lay_out(&self) -> Result<LaidOut, DesignError> {
514 self.clone().into_laid_out()
515 }
516
517 fn into_laid_out(self) -> Result<LaidOut, DesignError> {
519 self.check_configuration_ids()?;
520 let (components, masses) = self.placed_components()?;
521 Ok(LaidOut {
522 layout: self.staged_layout(components, &masses)?,
523 rocket: self,
524 masses: Arc::new(masses),
525 })
526 }
527
528 fn configuration_or_error(
530 &self,
531 configuration_id: &str,
532 ) -> Result<&Configuration, DesignError> {
533 self.configuration(configuration_id)
534 .ok_or_else(|| DesignError::UnknownId {
535 what: "configuration",
536 id: configuration_id.to_owned(),
537 })
538 }
539}
540
541#[derive(Debug, Clone)]
545pub struct LaidOut {
546 rocket: Rocket,
547 layout: Layout,
548 masses: Arc<StageMasses>,
551}
552
553impl LaidOut {
554 pub fn rocket(&self) -> &Rocket {
556 &self.rocket
557 }
558
559 pub fn layout(&self) -> &Layout {
561 &self.layout
562 }
563
564 pub fn into_assembly(self, configuration_id: &str) -> Result<(Rocket, Assembly), DesignError> {
572 let configuration = self.rocket.configuration_or_error(configuration_id)?;
573 let motors = self.layout.place_motors(configuration)?;
574 let assembly = Assembly {
575 configuration: configuration.id.clone(),
576 layout: self.layout,
577 motors,
578 };
579 Ok((self.rocket, assembly))
580 }
581
582 pub fn relay(&self, rocket: Rocket) -> Result<LaidOut, DesignError> {
594 if !self.same_but_overrides_and_motors(&rocket) {
595 return rocket.into_laid_out();
596 }
597 rocket.check_configuration_ids()?;
598 let layout = rocket.staged_layout(self.layout.components.clone(), &self.masses)?;
599 Ok(LaidOut {
600 rocket,
601 layout,
602 masses: Arc::clone(&self.masses),
603 })
604 }
605
606 fn same_but_overrides_and_motors(&self, rocket: &Rocket) -> bool {
609 let Rocket {
610 name,
611 stages,
612 reference_diameter,
613 configurations: _,
614 } = rocket;
615 *name == self.rocket.name
616 && *reference_diameter == self.rocket.reference_diameter
617 && stages.len() == self.rocket.stages.len()
618 && stages.iter().zip(&self.rocket.stages).all(|(stage, ours)| {
619 let crate::Stage {
620 id,
621 name,
622 components,
623 overrides: _,
624 drag_override: _,
625 parallel,
626 } = stage;
627 *id == ours.id
628 && *name == ours.name
629 && *components == ours.components
630 && *parallel == ours.parallel
631 })
632 }
633}
634
635#[cfg(test)]
636mod tests {
637 use hpr_core::DMat3;
638
639 use super::*;
640 use crate::testing::{motor, three_fin_rocket};
641
642 #[test]
645 fn a_design_laid_out_once_checks_and_assembles_as_twice() {
646 let design = three_fin_rocket();
647 let laid_out = design.lay_out().unwrap();
648 assert_eq!(laid_out.layout(), &design.layout().unwrap());
649 assert_eq!(
650 laid_out.check().unwrap(),
651 crate::checks::check(&design).unwrap()
652 );
653 let id = design.configurations[0].id.clone();
654 assert_eq!(
655 laid_out.clone().into_assembly(&id).unwrap(),
656 (design.clone(), design.assemble(&id).unwrap())
657 );
658 assert!(matches!(
659 laid_out.into_assembly("no such configuration"),
660 Err(DesignError::UnknownId { what: "configuration", ref id })
661 if id == "no such configuration"
662 ));
663 }
664
665 #[test]
668 fn a_relaid_design_is_laid_out_as_alone() {
669 let nominal = three_fin_rocket();
670 let laid_out = nominal.lay_out().unwrap();
671 let id = nominal.configurations[0].id.clone();
672 let same = |relaid: &LaidOut, design: &Rocket| {
673 let alone = design.lay_out().unwrap();
674 assert_eq!(relaid.rocket(), design);
675 assert_eq!(relaid.layout(), alone.layout());
676 assert_eq!(relaid.check().unwrap(), alone.check().unwrap());
677 assert_eq!(
678 relaid.clone().into_assembly(&id).unwrap(),
679 alone.into_assembly(&id).unwrap()
680 );
681 };
682 let mut overridden = nominal.clone();
683 let stage = &mut overridden.stages[0].overrides;
684 stage.mass_kg = Some(1.234_567);
685 stage.cg_aft_m = Some(0.456_789);
686 stage.cg_xy_m = Some([0.001, -0.002]);
687 let mounted = &mut overridden.configurations[0].motors[0];
688 mounted.motor = motor(&mounted.mount, mounted.diameter_m, 0.25).motor;
689 assert!(laid_out.same_but_overrides_and_motors(&overridden));
690 let relaid = laid_out.relay(overridden.clone()).unwrap();
691 same(&relaid, &overridden);
692 assert_ne!(relaid.layout(), laid_out.layout());
693 same(&relaid.relay(nominal.clone()).unwrap(), &nominal);
695 let mut longer = nominal.clone();
697 let crate::Part::BodyTube(tube) = &mut longer.stages[0].components[1].part else {
698 panic!("the test rocket's second component is its airframe");
699 };
700 tube.length_m += 0.1;
701 assert!(!laid_out.same_but_overrides_and_motors(&longer));
702 let relaid = laid_out.relay(longer.clone()).unwrap();
703 same(&relaid, &longer);
704 let mut renamed = nominal.clone();
706 renamed.stages[0].id = "renamed".to_owned();
707 assert!(!laid_out.same_but_overrides_and_motors(&renamed));
708 same(&laid_out.relay(renamed.clone()).unwrap(), &renamed);
709 let mut staged = nominal.clone();
710 let mut booster = staged.stages[0].clone();
711 booster.id = "booster".to_owned();
712 for (index, component) in booster.components.iter_mut().enumerate() {
713 component.id = format!("booster-{index}");
714 component.children.clear();
715 }
716 booster
717 .components
718 .retain(|c| matches!(c.part, crate::Part::BodyTube(_)));
719 staged.stages.push(booster);
720 assert!(!laid_out.same_but_overrides_and_motors(&staged));
721 let relaid = laid_out.relay(staged.clone()).unwrap();
722 same(&relaid, &staged);
723 assert_eq!(relaid.layout().stages.len(), 2);
724 let mut heavier = staged.clone();
726 heavier.stages[1].overrides.mass_kg = Some(0.75);
727 heavier.stages[1].overrides.cg_aft_m = Some(0.3);
728 assert!(relaid.same_but_overrides_and_motors(&heavier));
729 let twice = relaid.relay(heavier.clone()).unwrap();
730 same(&twice, &heavier);
731 assert_ne!(twice.layout().stages[1], relaid.layout().stages[1]);
732 assert_eq!(twice.layout().stages[0], relaid.layout().stages[0]);
733 assert_ne!(relaid.layout().length_m, laid_out.layout().length_m);
734 let mut bad = nominal;
736 bad.stages[0].overrides.mass_kg = Some(-1.0);
737 assert_eq!(
738 laid_out.relay(bad.clone()).unwrap_err().to_string(),
739 bad.lay_out().unwrap_err().to_string()
740 );
741 }
742
743 #[test]
747 fn motor_sits_at_mount_aft_end_plus_overhang() {
748 let design = three_fin_rocket();
749 let assembly = design.assemble("main").unwrap();
750 let [placed] = &assembly.motors[..] else {
751 panic!("one motor")
752 };
753 assert!((placed.nozzle_station_m() - 1.01).abs() < 1e-15);
754 assert!((placed.fore_station_m() - 0.81).abs() < 1e-15);
755 assert_eq!(placed.stage, 0);
756 assert_eq!((placed.nozzle_m.x, placed.nozzle_m.y), (0.0, 0.0));
757
758 let structure = assembly.layout.structure;
759 let (m_s, z_s) = (structure.mass_kg, structure.cg_m.z);
760 for (t, motor_kg) in [(0.0, 1.0), (0.5, 0.75), (2.0, 0.5)] {
761 let whole = assembly.mass_properties(t);
762 let want_mass = m_s + motor_kg;
763 assert!((whole.mass_kg - want_mass).abs() < 1e-12, "{t}");
764 let want_z = (m_s * z_s + motor_kg * -0.91) / want_mass;
765 assert!((whole.cg_m.z - want_z).abs() < 1e-12, "{t}");
766 let state = placed.mounted.motor.state(t).total;
769 let (dx, dy, dz) = (-whole.cg_m.x, -whole.cg_m.y, -0.91 - whole.cg_m.z);
770 let about = structure.inertia_about(whole.cg_m);
771 let transverse =
772 about.x_axis.x + state.transverse_inertia_kg_m2 + motor_kg * (dy * dy + dz * dz);
773 assert!(
774 (whole.inertia_kg_m2.x_axis.x - transverse).abs() < 1e-12 * transverse,
775 "{t}"
776 );
777 let axial = about.z_axis.z + state.axial_inertia_kg_m2 + motor_kg * (dx * dx + dy * dy);
778 assert!(
779 (whole.inertia_kg_m2.z_axis.z - axial).abs() < 1e-12 * axial,
780 "{t}"
781 );
782 assert!(whole.cg_m.x.abs() > 1e-6, "the lug is off the axis");
783 }
784 let dry = assembly.dry_mass_properties();
785 assert!((dry.mass_kg - (m_s + 0.5)).abs() < 1e-12);
786 assert_eq!(dry, assembly.mass_properties(1.0));
787 }
788
789 #[test]
792 fn motor_in_an_offset_mount_is_off_axis() {
793 let mut design = three_fin_rocket();
794 let mount = &mut design.stages[0].components[1].children[0];
795 if let crate::Part::InnerTube(tube) = &mut mount.part {
796 tube.outer_radius_m = 0.0105;
797 tube.radial_offset_m = 0.01;
798 tube.angle_rad = std::f64::consts::FRAC_PI_2;
799 }
800 design.stages[0].components[1].children.drain(1..3);
802 design.configurations[0].motors = vec![motor("mmt", 0.018, 0.1)];
803 let assembly = design.assemble("main").unwrap();
804 let placed = &assembly.motors[0];
805 assert!(placed.nozzle_m.x.abs() < 1e-18);
806 assert!((placed.nozzle_m.y - 0.01).abs() < 1e-18);
807 let m = placed.mass_properties(0.0);
808 assert!((m.cg_m.y - 0.01).abs() < 1e-18);
809 let about_origin = m.inertia_about(DVec3::ZERO);
810 let own = MassProperties {
811 cg_m: DVec3::ZERO,
812 ..m
813 };
814 let offset = about_origin - own.inertia_kg_m2;
815 let want_yz = -m.mass_kg * m.cg_m.y * m.cg_m.z;
816 assert!((offset.z_axis.y - want_yz).abs() < 1e-15, "{offset:?}");
817 assert!(offset.y_axis.x.abs() < 1e-18);
819 assert!(assembly.mass_properties(0.0).inertia_kg_m2.z_axis.y.abs() > 1e-6);
820 assert_ne!(m.inertia_kg_m2, DMat3::ZERO);
821 }
822
823 fn clustered(tubes: Vec<[f64; 2]>) -> Rocket {
826 let mut design = three_fin_rocket();
827 let mount = &mut design.stages[0].components[1].children[0];
828 if let crate::Part::InnerTube(tube) = &mut mount.part {
829 tube.outer_radius_m = 0.0095;
830 tube.cluster_m = tubes;
831 }
832 design.stages[0].components[1].children.drain(1..3);
833 design.configurations[0].motors = vec![motor("mmt", 0.018, 0.1)];
834 design
835 }
836
837 fn ring_of_three() -> Vec<[f64; 2]> {
839 [90.0_f64, 210.0, 330.0]
840 .iter()
841 .map(|a| [0.02 * a.to_radians().cos(), 0.02 * a.to_radians().sin()])
842 .collect()
843 }
844
845 #[test]
850 fn a_cluster_mount_takes_its_motor_in_every_tube() {
851 let assembly = clustered(ring_of_three()).assemble("main").unwrap();
852 assert_eq!(assembly.motors.len(), 3);
853 for (k, (placed, [x, y])) in assembly.motors.iter().zip(ring_of_three()).enumerate() {
854 assert_eq!((placed.tube, placed.fails), (k, false));
855 assert_eq!(placed.mount, "mmt");
856 assert_eq!((placed.nozzle_m.x, placed.nozzle_m.y), (x, y));
857 assert!((placed.nozzle_station_m() - 1.01).abs() < 1e-15);
858 }
859 let structure = assembly.layout.structure;
860 for (t, motor_kg) in [(0.0, 1.0), (0.5, 0.75), (2.0, 0.5)] {
861 let whole = assembly.mass_properties(t);
862 assert!((whole.mass_kg - (structure.mass_kg + 3.0 * motor_kg)).abs() < 1e-12);
863 let axis = DVec3::new(0.0, 0.0, whole.cg_m.z);
864 let own = assembly.motors[0].mounted.motor.state(t).total;
865 let want = structure.inertia_about(axis).z_axis.z
866 + 3.0 * (own.axial_inertia_kg_m2 + motor_kg * 0.02 * 0.02);
867 let got = whole.inertia_about(axis).z_axis.z;
868 assert!((got - want).abs() < 1e-12 * want, "{t}: {got} vs {want}");
869 }
870 }
871
872 #[test]
875 fn a_failed_tube_never_lights() {
876 let mut design = clustered(ring_of_three());
877 design.configurations[0].motors[0].failed_tubes = vec![0];
878 let assembly = design.assemble("main").unwrap();
879 let fails: Vec<bool> = assembly.motors.iter().map(|m| m.fails).collect();
880 assert_eq!(fails, [true, false, false]);
881 let lit = assembly.ignition_times_s(|_| None);
882 assert_eq!(lit, [None, Some(0.0), Some(0.0)]);
883 let spent = assembly.mass_properties_lit(2.0, &lit);
884 let structure = assembly.layout.structure.mass_kg;
885 assert!((spent.mass_kg - (structure + 1.0 + 0.5 + 0.5)).abs() < 1e-12);
886 let all_burning = assembly.mass_properties_lit(2.0, &[Some(0.0); 3]);
888 assert!(spent.cg_m.y > all_burning.cg_m.y + 1e-4);
889 assert_eq!(assembly.mass_properties(2.0), spent);
890
891 for (failed, value) in [(vec![3], 3.0), (vec![1, 1], 1.0)] {
892 let mut design = clustered(ring_of_three());
893 design.configurations[0].motors[0].failed_tubes = failed;
894 let Err(DesignError::InComponent { id, source }) = design.assemble("main") else {
895 panic!("refused");
896 };
897 assert_eq!(id, "mmt");
898 assert!(
899 matches!(*source, DesignError::Domain { what, value: v }
900 if what.starts_with("failed tube") && v == value),
901 "{source:?}"
902 );
903 }
904 let mut design = three_fin_rocket();
906 design.configurations[0].motors[0].failed_tubes = vec![0];
907 let assembly = design.assemble("main").unwrap();
908 assert_eq!(assembly.ignition_times_s(|_| None), [None]);
909 let loaded = MassProperties::combine([
911 &assembly.layout.structure,
912 &assembly.motors[0].mass_properties(0.0),
913 ]);
914 assert_eq!(assembly.dry_mass_properties(), loaded);
915 assert_eq!(assembly.mass_properties(10.0), loaded);
916 }
917
918 #[test]
919 fn bad_configurations_are_refused() {
920 let design = three_fin_rocket();
921 assert!(matches!(
922 design.assemble("missing"),
923 Err(DesignError::UnknownId {
924 what: "configuration",
925 ..
926 })
927 ));
928
929 let mut d = design.clone();
930 d.configurations[0].motors[0].mount = "nowhere".to_owned();
931 assert!(matches!(
932 d.assemble("main"),
933 Err(DesignError::UnknownId {
934 what: "motor mount",
935 ..
936 })
937 ));
938
939 let mut d = design.clone();
940 d.configurations[0].motors[0].mount = "airframe".to_owned();
941 assert!(
942 matches!(d.assemble("main"), Err(DesignError::Tree { ref id, .. }) if id == "airframe")
943 );
944
945 let mut d = design.clone();
946 let second = d.configurations[0].motors[0].clone();
947 d.configurations[0].motors.push(second);
948 assert!(matches!(d.assemble("main"), Err(DesignError::Tree { ref id, .. }) if id == "mmt"));
949
950 let mut d = design.clone();
951 let copy = d.configurations[0].clone();
952 d.configurations.push(copy);
953 assert_eq!(
954 d.assemble("main"),
955 Err(DesignError::DuplicateId("main".to_owned()))
956 );
957
958 let mut d = design.clone();
959 d.configurations[0].motors[0].diameter_m = 0.0;
960 assert!(matches!(
961 d.assemble("main"),
962 Err(DesignError::InComponent { ref id, ref source })
963 if id == "mmt" && matches!(**source, DesignError::Domain { .. })
964 ));
965
966 let mut d = design.clone();
967 d.stages[0].components[1].children[0].motor_mount = Some(MotorMount {
968 overhang_m: f64::NAN,
969 });
970 assert!(matches!(
971 d.assemble("main"),
972 Err(DesignError::InComponent { .. })
973 ));
974
975 let layout = design.layout().unwrap();
977 let mut candidate = design.configurations[0].clone();
978 candidate.id = "candidate".to_owned();
979 assert_eq!(layout.place_motors(&candidate).unwrap().len(), 1);
980 candidate.motors[0].mount = "chute".to_owned();
981 assert!(matches!(
982 layout.place_motors(&candidate),
983 Err(DesignError::Tree { ref id, .. }) if id == "chute"
984 ));
985 }
986 const ROCKETPY_DESIGNS: [(&str, &str); 10] = [
988 (
989 "calisto-getting-started-motor-at-minus-1.255",
990 include_str!(
991 "../../../validation/designs/rocketpy-calisto-getting-started-motor-at-minus-1.255.json"
992 ),
993 ),
994 (
995 "calisto-tests-motor-at-minus-1.373",
996 include_str!(
997 "../../../validation/designs/rocketpy-calisto-tests-motor-at-minus-1.373.json"
998 ),
999 ),
1000 (
1001 "bella-lui",
1002 include_str!("../../../validation/designs/rocketpy-bella-lui.json"),
1003 ),
1004 (
1005 "ndrt-2020-nose-to-tail",
1006 include_str!("../../../validation/designs/rocketpy-ndrt-2020-nose-to-tail.json"),
1007 ),
1008 (
1009 "valetudo",
1010 include_str!("../../../validation/designs/rocketpy-valetudo.json"),
1011 ),
1012 (
1013 "juno-iii",
1014 include_str!("../../../validation/designs/rocketpy-juno-iii.json"),
1015 ),
1016 (
1017 "cavour",
1018 include_str!("../../../validation/designs/rocketpy-cavour.json"),
1019 ),
1020 (
1021 "genesis",
1022 include_str!("../../../validation/designs/rocketpy-genesis.json"),
1023 ),
1024 (
1025 "lince",
1026 include_str!("../../../validation/designs/rocketpy-lince.json"),
1027 ),
1028 (
1029 "prometheus-2022-generic-motor",
1030 include_str!("../../../validation/designs/rocketpy-prometheus-2022-generic-motor.json"),
1031 ),
1032 ];
1033
1034 #[derive(Debug, serde::Deserialize)]
1035 struct Oracle {
1036 oracle: String,
1037 cases: Vec<OracleCase>,
1038 }
1039
1040 #[derive(Debug, serde::Deserialize)]
1041 struct OracleCase {
1042 name: String,
1043 rocket: OracleRocket,
1044 motor: serde_json::Value,
1045 geometry: serde_json::Value,
1046 scalars: OracleScalars,
1047 series: OracleSeries,
1048 knot_series: Option<OracleSeries>,
1049 }
1050
1051 #[derive(Debug, serde::Deserialize)]
1052 struct OracleRocket {
1053 mass: f64,
1054 inertia: Vec<f64>,
1055 center_of_mass_without_motor: f64,
1056 coordinate_system_orientation: String,
1057 motor_position: f64,
1058 }
1059
1060 #[derive(Debug, serde::Deserialize)]
1061 #[allow(non_snake_case, reason = "RocketPy's attribute names")]
1062 struct OracleScalars {
1063 dry_mass_kg: f64,
1064 center_of_dry_mass_position_m: f64,
1065 dry_I_11_kg_m2: f64,
1066 dry_I_33_kg_m2: f64,
1067 nozzle_position_m: f64,
1068 propellant_initial_mass_kg: f64,
1069 }
1070
1071 #[derive(Debug, serde::Deserialize)]
1072 #[allow(non_snake_case, reason = "RocketPy's attribute names")]
1073 struct OracleSeries {
1074 time_s: Vec<f64>,
1075 propellant_mass: Vec<f64>,
1076 total_mass: Vec<f64>,
1077 center_of_mass: Vec<f64>,
1078 I_11: Vec<f64>,
1079 I_33: Vec<f64>,
1080 I_11_about_cg: Vec<f64>,
1081 }
1082
1083 #[derive(Default)]
1085 struct Worst(Vec<(&'static str, f64, String)>);
1086
1087 impl Worst {
1088 fn see(&mut self, what: &'static str, error: f64, at: impl Fn() -> String) {
1089 assert!(error.is_finite(), "{what}: {}", at());
1090 match self.0.iter_mut().find(|w| w.0 == what) {
1091 Some(w) if error > w.1 => (w.1, w.2) = (error, at()),
1092 Some(_) => {}
1093 None => self.0.push((what, error, at())),
1094 }
1095 }
1096 }
1097
1098 fn relative(got: f64, want: f64) -> f64 {
1099 ((got - want) / want).abs()
1100 }
1101
1102 fn number(v: &serde_json::Value, key: &str) -> f64 {
1103 v[key]
1104 .as_f64()
1105 .unwrap_or_else(|| panic!("fixture motor `{key}`"))
1106 }
1107
1108 #[test]
1124 fn matches_rocketpy_example_rockets() {
1125 let oracle: Oracle = serde_json::from_str(include_str!(
1126 "../../../validation/fixtures/design/rocketpy-rocket-mass.json"
1127 ))
1128 .unwrap();
1129 assert_eq!(oracle.oracle, "rocketpy 1.13.0");
1130 assert_eq!(oracle.cases.len(), ROCKETPY_DESIGNS.len());
1131 let catalog = hpr_motor::Catalog::bundled().unwrap();
1132 let mut worst = Worst::default();
1133 for case in &oracle.cases {
1134 let (_, text) = ROCKETPY_DESIGNS
1135 .iter()
1136 .find(|(name, _)| *name == case.name)
1137 .unwrap_or_else(|| panic!("no design for {}", case.name));
1138 let design: Rocket = serde_json::from_str(text).unwrap();
1139 let assembly = design.assemble("example").unwrap();
1140 let name = case.name.as_str();
1141
1142 let tip = case.geometry["nose"]["position"].as_f64().unwrap();
1144 let rocket_sign = match case.rocket.coordinate_system_orientation.as_str() {
1145 "tail_to_nose" => 1.0,
1146 "nose_to_tail" => -1.0,
1147 other => panic!("{other}"),
1148 };
1149 let station = |z: f64| rocket_sign * (tip - z);
1150
1151 let overrides = design.stages[0].overrides;
1153 assert_eq!(overrides.mass_kg, Some(case.rocket.mass), "{name}");
1154 assert_eq!(overrides.cg_xy_m, Some([0.0, 0.0]), "{name}: on the axis");
1155 let cg_aft = overrides.cg_aft_m.unwrap();
1156 assert!(
1157 (cg_aft - station(case.rocket.center_of_mass_without_motor)).abs() < 1e-12,
1158 "{name}"
1159 );
1160 let [i11, i22, i33] = case.rocket.inertia[..] else {
1161 panic!("{name}: inertia")
1162 };
1163 assert_eq!(i11, i22);
1164 assert_eq!(
1165 overrides.inertia,
1166 Some(crate::InertiaOverride::axisymmetric(i33, i11)),
1167 "{name}"
1168 );
1169
1170 let m = &case.motor;
1172 let motor_sign = match m["coordinate_system_orientation"].as_str().unwrap() {
1173 "nozzle_to_combustion_chamber" => 1.0,
1174 "combustion_chamber_to_nozzle" => -1.0,
1175 other => panic!("{other}"),
1176 };
1177 let nozzle = number(m, "nozzle_position");
1178 let to_motor_axis = |z: f64| motor_sign * (z - nozzle);
1179 let [placed] = &assembly.motors[..] else {
1180 panic!("{name}: one motor")
1181 };
1182 let rocketpy_nozzle = case.rocket.motor_position + rocket_sign * motor_sign * nozzle;
1183 assert!(
1184 (rocketpy_nozzle - case.scalars.nozzle_position_m).abs() < 1e-12,
1185 "{name}"
1186 );
1187 assert!(
1188 (placed.nozzle_station_m() - station(rocketpy_nozzle)).abs() < 1e-12,
1189 "{name}: nozzle at {} vs {}",
1190 placed.nozzle_station_m(),
1191 station(rocketpy_nozzle)
1192 );
1193 let motor = &placed.mounted.motor;
1194 let file = m["thrust_file"].as_str().unwrap();
1195 let (entry, curve) = catalog
1196 .motors
1197 .iter()
1198 .find_map(|e| e.curves.iter().find(|c| c.file == file).map(|c| (e, c)))
1199 .unwrap();
1200 assert_eq!(curve.sha256, m["thrust_file_sha256"].as_str().unwrap());
1201 let bundled = entry
1202 .thrust_curve(
1203 curve,
1204 hpr_motor::catalog::bundled_curve_text(&curve.file).unwrap(),
1205 )
1206 .unwrap();
1207 assert_eq!(motor.curve(), &bundled, "{name}: the bundled curve");
1208 let dry = motor.dry();
1209 let example_dry = number(m, "dry_mass");
1212 let expected_dry = if example_dry == 0.0 {
1213 1e-15
1214 } else {
1215 example_dry
1216 };
1217 assert_eq!(dry.mass_kg, expected_dry, "{name}");
1218 let dry_inertia: Vec<f64> = m["dry_inertia"]
1219 .as_array()
1220 .unwrap()
1221 .iter()
1222 .map(|v| v.as_f64().unwrap())
1223 .collect();
1224 assert_eq!(dry.transverse_inertia_kg_m2, dry_inertia[0], "{name}");
1225 assert_eq!(dry.axial_inertia_kg_m2, dry_inertia[2], "{name}");
1226 match (m["motor_kind"].as_str().unwrap(), motor.propellant()) {
1227 ("solid", hpr_motor::Propellant::Grains(g)) => {
1228 assert_eq!(
1229 *g,
1230 hpr_motor::BatesGrains {
1231 count: u32::try_from(m["grain_number"].as_u64().unwrap()).unwrap(),
1232 density_kg_m3: number(m, "grain_density"),
1233 outer_radius_m: number(m, "grain_outer_radius"),
1234 initial_inner_radius_m: number(m, "grain_initial_inner_radius"),
1235 initial_height_m: number(m, "grain_initial_height"),
1236 separation_m: number(m, "grain_separation"),
1237 center_m: to_motor_axis(number(m, "grains_center_of_mass_position")),
1238 inhibited_ends: m["only_radial_burn"].as_bool().unwrap(),
1239 },
1240 "{name}"
1241 );
1242 assert!(
1243 (dry.cg_m - to_motor_axis(number(m, "center_of_dry_mass_position"))).abs()
1244 < 1e-15,
1245 "{name}"
1246 );
1247 }
1248 ("generic", hpr_motor::Propellant::Column(c)) => {
1249 assert!(m["center_of_dry_mass_position"].is_null());
1251 let chamber = to_motor_axis(number(m, "chamber_position"));
1252 assert_eq!(
1253 *c,
1254 hpr_motor::PropellantColumn {
1255 mass_kg: number(m, "propellant_initial_mass"),
1256 center_m: chamber,
1257 outer_radius_m: number(m, "chamber_radius"),
1258 inner_radius_m: 0.0,
1259 length_m: number(m, "chamber_height"),
1260 },
1261 "{name}"
1262 );
1263 assert_eq!(dry.cg_m, chamber, "{name}");
1264 }
1265 (kind, other) => panic!("{name}: {kind} vs {other:?}"),
1266 }
1267 let m_p0 = motor.propellant_initial_mass_kg();
1268 worst.see(
1269 "initial propellant mass",
1270 relative(m_p0, case.scalars.propellant_initial_mass_kg),
1271 || name.to_owned(),
1272 );
1273
1274 let length = assembly.layout.length_m;
1276 let dry_rocket = assembly.dry_mass_properties();
1277 worst.see(
1278 "dry mass",
1279 relative(dry_rocket.mass_kg, case.scalars.dry_mass_kg),
1280 || name.to_owned(),
1281 );
1282 worst.see(
1283 "dry center (of length)",
1284 (-dry_rocket.cg_m.z - station(case.scalars.center_of_dry_mass_position_m)).abs()
1285 / length,
1286 || name.to_owned(),
1287 );
1288 worst.see(
1289 "dry I_11",
1290 relative(
1291 dry_rocket.inertia_kg_m2.x_axis.x,
1292 case.scalars.dry_I_11_kg_m2,
1293 ),
1294 || name.to_owned(),
1295 );
1296 worst.see(
1297 "dry I_33",
1298 relative(
1299 dry_rocket.inertia_kg_m2.z_axis.z,
1300 case.scalars.dry_I_33_kg_m2,
1301 ),
1302 || name.to_owned(),
1303 );
1304
1305 let grids = [
1307 ("", Some(&case.series)),
1308 (" at knots", case.knot_series.as_ref()),
1309 ];
1310 for (grid, s) in grids.into_iter().filter_map(|(g, s)| Some((g, s?))) {
1311 let label = |what: &'static str| -> &'static str {
1312 match (what, grid.is_empty()) {
1313 (_, true) => what,
1314 ("total mass", false) => "total mass at knots",
1315 ("center of mass (of length)", false) => "center of mass at knots",
1316 ("I_11 about the dry center", false) => {
1317 "I_11 about the dry center at knots"
1318 }
1319 ("I_22 about the dry center", false) => {
1320 "I_22 about the dry center at knots"
1321 }
1322 ("I_11 about the center of mass", false) => {
1323 "I_11 about the center of mass at knots"
1324 }
1325 ("I_33", false) => "I_33 at knots",
1326 ("products of inertia (of I_11)", false) => "products of inertia at knots",
1327 ("propellant mass, grains (of initial)", false) => {
1328 "propellant mass, grains, at knots"
1329 }
1330 (other, false) => panic!("no knot label for {other}"),
1331 }
1332 };
1333 for (i, &t) in s.time_s.iter().enumerate() {
1334 let at = || format!("{name} at t = {t} s");
1335 let whole = assembly.mass_properties(t);
1336 worst.see(
1337 label("total mass"),
1338 relative(whole.mass_kg, s.total_mass[i]),
1339 at,
1340 );
1341 worst.see(
1342 label("center of mass (of length)"),
1343 (-whole.cg_m.z - station(s.center_of_mass[i])).abs() / length,
1344 at,
1345 );
1346 let about_dry = whole.inertia_about(dry_rocket.cg_m);
1347 worst.see(
1348 label("I_11 about the dry center"),
1349 relative(about_dry.x_axis.x, s.I_11[i]),
1350 at,
1351 );
1352 worst.see(
1353 label("I_22 about the dry center"),
1354 relative(about_dry.y_axis.y, s.I_11[i]),
1355 at,
1356 );
1357 worst.see(
1358 label("I_11 about the center of mass"),
1359 relative(whole.inertia_kg_m2.x_axis.x, s.I_11_about_cg[i]),
1360 at,
1361 );
1362 worst.see(
1363 label("I_33"),
1364 relative(whole.inertia_kg_m2.z_axis.z, s.I_33[i]),
1365 at,
1366 );
1367 let products = [
1368 whole.inertia_kg_m2.y_axis.x,
1369 whole.inertia_kg_m2.z_axis.x,
1370 whole.inertia_kg_m2.z_axis.y,
1371 ];
1372 worst.see(
1373 label("products of inertia (of I_11)"),
1374 products.iter().fold(0.0f64, |a, v| a.max(v.abs())) / s.I_11[i],
1375 at,
1376 );
1377 let what = match motor.propellant() {
1378 hpr_motor::Propellant::Column(_) => "propellant mass, column (of initial)",
1379 _ => "propellant mass, grains (of initial)",
1380 };
1381 worst.see(
1382 label(what),
1383 (motor.propellant_mass_kg(t) - s.propellant_mass[i]).abs() / m_p0,
1384 at,
1385 );
1386 }
1387 }
1388 }
1389 let tolerance = |what: &str| match what {
1405 "initial propellant mass"
1406 | "dry mass"
1407 | "dry center (of length)"
1408 | "dry I_11"
1409 | "dry I_33"
1410 | "propellant mass, column (of initial)" => 1e-12,
1411 "products of inertia (of I_11)" | "products of inertia at knots" => 1e-15,
1412 "total mass at knots"
1413 | "center of mass at knots"
1414 | "I_11 about the dry center at knots"
1415 | "I_22 about the dry center at knots"
1416 | "I_11 about the center of mass at knots"
1417 | "I_33 at knots"
1418 | "propellant mass, grains, at knots" => 1e-8,
1419 "total mass" => 5e-5,
1420 "center of mass (of length)" => 2e-5,
1421 "I_11 about the dry center"
1422 | "I_22 about the dry center"
1423 | "I_11 about the center of mass"
1424 | "I_33" => 1e-4,
1425 "propellant mass, grains (of initial)" => 2.5e-4,
1426 other => panic!("no tolerance for {other}"),
1427 };
1428 for (what, error, at) in &worst.0 {
1429 println!("{what}: {error:.3e} ({at})");
1430 }
1431 assert_eq!(worst.0.len(), 22, "every quantity was compared");
1432 for (what, error, at) in &worst.0 {
1433 assert!(*error <= tolerance(what), "{what}: {error:e} at {at}");
1434 }
1435 }
1436
1437 fn two_stage(ignition: Ignition) -> Rocket {
1440 let mut rocket: Rocket = serde_json::from_str(include_str!(
1441 "../../../validation/designs/synthetic-two-stage-75mm-54mm.json"
1442 ))
1443 .unwrap();
1444 rocket.configurations[0].motors[1].ignition = ignition;
1445 rocket
1446 }
1447
1448 #[test]
1452 fn a_burnout_of_a_cluster_is_its_first_motor_that_lights() {
1453 let mut rocket = two_stage(Ignition::Burnout {
1454 mount: "booster-motor-mount".to_owned(),
1455 delay_s: 1.0,
1456 });
1457 let booster = rocket.stages[1].components[1]
1458 .children
1459 .iter_mut()
1460 .find(|c| c.id == "booster-motor-mount")
1461 .expect("the booster's mount");
1462 if let crate::Part::InnerTube(tube) = &mut booster.part {
1463 tube.cluster_m = vec![[-0.01, 0.0], [0.01, 0.0]];
1464 }
1465 let booster_burnout_s =
1466 |assembly: &Assembly| assembly.motors[0].mounted.motor.burnout_time_s();
1467 rocket.configurations[0].motors[0].failed_tubes = vec![0];
1468 let assembly = rocket.assemble("j760-i175").unwrap();
1469 let burnout_s = booster_burnout_s(&assembly);
1470 assert_eq!(
1471 assembly.ignition_times_s(|_| None),
1472 [None, Some(0.0), Some(burnout_s + 1.0)]
1473 );
1474 rocket.configurations[0].motors[0].failed_tubes = vec![1, 0];
1475 let assembly = rocket.assemble("j760-i175").unwrap();
1476 assert_eq!(assembly.ignition_times_s(|_| None), [None, None, None]);
1477 }
1478
1479 #[test]
1482 fn a_motor_set_never_to_light_stays_loaded() {
1483 let assembly = two_stage(Ignition::Never).assemble("j760-i175").unwrap();
1484 assert_eq!(
1485 assembly.motors.iter().map(|m| m.fails).collect::<Vec<_>>(),
1486 [false, true]
1487 );
1488 assert_eq!(assembly.ignition_times_s(|_| Some(0.0)), [Some(0.0), None]);
1489 let loaded = assembly.motors[1].mass_properties(0.0);
1490 let structure = assembly.layout.structure;
1491 let booster = |t: f64| assembly.motors[0].mass_properties(t);
1492 let lit = assembly.ignition_times_s(|_| None);
1493 for t in [0.0, 3.0, 100.0] {
1494 let expected = MassProperties::combine([
1496 &MassProperties::combine([&structure, &booster(t)]),
1497 &loaded,
1498 ]);
1499 assert_eq!(assembly.mass_properties_lit(t, &lit), expected);
1500 assert_eq!(assembly.mass_properties(t), expected);
1501 }
1502 let dry = MassProperties::combine([&structure, &assembly.motors[0].dry_mass_properties()]);
1503 assert_eq!(
1504 assembly.dry_mass_properties(),
1505 MassProperties::combine([&dry, &loaded])
1506 );
1507 let mut rocket = two_stage(Ignition::Burnout {
1508 mount: "booster-motor-mount".to_owned(),
1509 delay_s: 0.0,
1510 });
1511 rocket.configurations[0].motors[0].ignition = Ignition::Never;
1512 let assembly = rocket.assemble("j760-i175").unwrap();
1513 assert_eq!(assembly.ignition_times_s(|_| Some(0.0)), [None, None]);
1514 let json = serde_json::to_string(&two_stage(Ignition::Never)).unwrap();
1515 assert!(json.contains(r#""ignition":"never""#), "{json}");
1516 assert_eq!(
1517 serde_json::from_str::<Rocket>(&json).unwrap(),
1518 two_stage(Ignition::Never)
1519 );
1520 }
1521
1522 #[test]
1523 fn ignition_times_follow_their_events() {
1524 let j760 = |assembly: &Assembly| assembly.motors[0].mounted.motor.burnout_time_s();
1525 let assembly = two_stage(Ignition::Launch).assemble("j760-i175").unwrap();
1527 assert_eq!(assembly.ignition_times_s(|_| None), [Some(0.0), Some(0.0)]);
1528 let assembly = two_stage(Ignition::Time { time_s: 3.5 })
1529 .assemble("j760-i175")
1530 .unwrap();
1531 assert_eq!(assembly.ignition_times_s(|_| None), [Some(0.0), Some(3.5)]);
1532 let assembly = two_stage(Ignition::Burnout {
1534 mount: "booster-motor-mount".to_owned(),
1535 delay_s: 1.25,
1536 })
1537 .assemble("j760-i175")
1538 .unwrap();
1539 let burnout_s = j760(&assembly);
1540 assert!(burnout_s > 1.0, "{burnout_s}");
1541 assert_eq!(
1542 assembly.ignition_times_s(|_| None),
1543 [Some(0.0), Some(burnout_s + 1.25)]
1544 );
1545 let assembly = two_stage(Ignition::Separation { delay_s: 0.5 })
1547 .assemble("j760-i175")
1548 .unwrap();
1549 assert_eq!(assembly.ignition_times_s(|_| None), [Some(0.0), None]);
1550 assert_eq!(
1551 assembly.ignition_times_s(|stage| (stage == 0).then_some(4.0)),
1552 [Some(0.0), Some(4.5)]
1553 );
1554 assert_eq!(
1555 assembly.ignition_times_s(|stage| (stage == 1).then_some(4.0)),
1556 [Some(0.0), None]
1557 );
1558 let lit = [Some(0.0), Some(4.5)];
1560 let loaded = assembly.motors[1].mass_properties(0.0).mass_kg;
1561 let spent = assembly.motors[1].dry_mass_properties().mass_kg;
1562 let at = |t: f64| assembly.mass_properties_lit(t, &lit).mass_kg;
1563 let structure = assembly.layout.structure.mass_kg;
1564 let booster = |t: f64| assembly.motors[0].mass_properties(t).mass_kg;
1565 assert_eq!(at(4.0), structure + booster(4.0) + loaded);
1566 let sustainer = assembly.motors[1].mass_properties(1.0).mass_kg;
1567 assert!(sustainer < loaded && sustainer > spent);
1568 assert!((at(5.5) - (structure + booster(5.5) + sustainer)).abs() < 1e-12);
1569 assert!((at(100.0) - assembly.dry_mass_properties().mass_kg).abs() < 1e-12);
1570 }
1571
1572 #[test]
1573 fn bad_ignitions_are_refused() {
1574 let refused = |ignition: Ignition| two_stage(ignition).assemble("j760-i175").is_err();
1575 assert!(refused(Ignition::Time { time_s: -1.0 }));
1576 assert!(refused(Ignition::Time { time_s: f64::NAN }));
1577 assert!(refused(Ignition::Separation {
1578 delay_s: f64::INFINITY
1579 }));
1580 let mut rocket = two_stage(Ignition::Launch);
1582 rocket.configurations[0].motors[0].ignition = Ignition::Separation { delay_s: 0.0 };
1583 let error = rocket.assemble("j760-i175").unwrap_err();
1584 assert!(matches!(error, DesignError::Tree { .. }), "{error:?}");
1585 assert!(refused(Ignition::Burnout {
1586 mount: "booster-motor-mount".to_owned(),
1587 delay_s: -0.1,
1588 }));
1589 assert!(refused(Ignition::Burnout {
1591 mount: "nose".to_owned(),
1592 delay_s: 0.0,
1593 }));
1594 assert!(refused(Ignition::Burnout {
1596 mount: "sustainer-motor-mount".to_owned(),
1597 delay_s: 0.0,
1598 }));
1599 let mut rocket = two_stage(Ignition::Burnout {
1600 mount: "booster-motor-mount".to_owned(),
1601 delay_s: 0.0,
1602 });
1603 rocket.configurations[0].motors[0].ignition = Ignition::Burnout {
1604 mount: "sustainer-motor-mount".to_owned(),
1605 delay_s: 0.0,
1606 };
1607 let error = rocket.assemble("j760-i175").unwrap_err();
1608 assert!(matches!(error, DesignError::Tree { .. }), "{error:?}");
1609 let json = serde_json::to_string(&two_stage(Ignition::Launch)).unwrap();
1611 assert!(!json.contains("ignition"));
1612 let timed = serde_json::to_string(&two_stage(Ignition::Time { time_s: 2.0 })).unwrap();
1613 assert!(
1614 timed.contains(r#""ignition":{"time":{"time_s":2.0}}"#),
1615 "{timed}"
1616 );
1617 }
1618}