1use std::collections::{BTreeMap, BTreeSet};
37
38use hpr_design::{Configuration, MountedMotor, Rocket};
39use hpr_motor::catalog::bundled_curve_text;
40use hpr_motor::{Catalog, Delay, MotorError, SolidMotor, rse};
41use serde::{Deserialize, Serialize};
42
43use super::recovery::StageSeparation;
44use super::staging::{self, Staging};
45
46use super::component::subcomponents;
47use super::container::Attachment;
48use super::document::Element;
49use super::value::Values;
50use super::warning::{Warning, WarningKind};
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
54#[serde(tag = "source", rename_all = "snake_case")]
55#[non_exhaustive]
56#[serde(deny_unknown_fields)]
57pub enum Curve {
58 Embedded {
60 entry: String,
62 motor: Box<SolidMotor>,
64 },
65 Supplied {
67 digest: String,
69 from: String,
71 motor: Box<SolidMotor>,
73 },
74 Catalog {
76 motor_id: String,
78 simfile_id: String,
80 motor: Box<SolidMotor>,
82 },
83 Unresolved {
85 why: NoCurve,
87 reason: String,
89 },
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
94#[serde(rename_all = "snake_case")]
95#[non_exhaustive]
96pub enum NoCurve {
97 Hybrid,
99 NoDesignation,
101 NotFound,
103 Ambiguous,
105 Unusable,
108}
109
110#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
129pub struct SuppliedCurves {
130 source: String,
131 by_digest: BTreeMap<String, SuppliedCurve>,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
137pub struct CaseSize {
138 pub diameter_m: f64,
140 pub length_m: f64,
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
146struct SuppliedCurve {
147 case: CaseSize,
148 motor: SolidMotor,
149}
150
151impl SuppliedCurves {
152 pub fn new(source: impl Into<String>) -> Self {
155 Self {
156 source: source.into(),
157 by_digest: BTreeMap::new(),
158 }
159 }
160
161 pub fn insert(
168 &mut self,
169 digest: impl Into<String>,
170 case: CaseSize,
171 motor: SolidMotor,
172 ) -> Result<Option<SolidMotor>, MotorError> {
173 for (value, what) in [
174 (case.diameter_m, "supplied case diameter (m)"),
175 (case.length_m, "supplied case length (m)"),
176 ] {
177 if !(value.is_finite() && value > 0.0) {
178 return Err(MotorError::Domain { what, value });
179 }
180 }
181 Ok(self
182 .by_digest
183 .insert(digest.into(), SuppliedCurve { case, motor })
184 .map(|replaced| replaced.motor))
185 }
186
187 pub fn source(&self) -> &str {
190 if self.source.is_empty() {
191 "the supplied curves"
192 } else {
193 &self.source
194 }
195 }
196
197 pub fn get(&self, digest: &str) -> Option<&SolidMotor> {
199 self.by_digest.get(digest).map(|curve| &curve.motor)
200 }
201
202 pub fn len(&self) -> usize {
204 self.by_digest.len()
205 }
206
207 pub fn is_empty(&self) -> bool {
209 self.by_digest.is_empty()
210 }
211}
212
213impl Curve {
214 pub fn motor(&self) -> Option<&SolidMotor> {
216 match self {
217 Self::Embedded { motor, .. }
218 | Self::Supplied { motor, .. }
219 | Self::Catalog { motor, .. } => Some(motor),
220 Self::Unresolved { .. } => None,
221 }
222 }
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
232#[serde(rename_all = "snake_case")]
233#[non_exhaustive]
234pub enum IgnitionEvent {
235 Automatic,
238 Launch,
240 EjectionCharge,
242 Burnout,
244 Never,
246 Other(String),
248}
249
250impl IgnitionEvent {
251 pub fn parse(text: &str) -> Self {
253 match text.trim() {
254 "automatic" => Self::Automatic,
255 "launch" => Self::Launch,
256 "ejectioncharge" => Self::EjectionCharge,
257 "burnout" => Self::Burnout,
258 "never" => Self::Never,
259 other => Self::Other(other.to_owned()),
260 }
261 }
262
263 pub fn as_str(&self) -> &str {
265 match self {
266 Self::Automatic => "automatic",
267 Self::Launch => "launch",
268 Self::EjectionCharge => "ejectioncharge",
269 Self::Burnout => "burnout",
270 Self::Never => "never",
271 Self::Other(text) => text,
272 }
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
278#[serde(deny_unknown_fields)]
279#[schemars(rename = "OrkIgnition")]
281pub struct Ignition {
282 pub event: IgnitionEvent,
284 pub delay_s: f64,
286}
287
288impl Default for Ignition {
289 fn default() -> Self {
291 Self {
292 event: IgnitionEvent::Automatic,
293 delay_s: 0.0,
294 }
295 }
296}
297
298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
301#[non_exhaustive]
302#[serde(deny_unknown_fields)]
303pub struct OrkMotor {
304 pub mount: String,
306 pub stage: usize,
308 pub kind: Option<String>,
310 pub manufacturer: String,
312 pub designation: String,
314 pub digest: Option<String>,
316 pub diameter_m: Option<f64>,
318 pub length_m: Option<f64>,
320 pub delay: Option<Delay>,
324 pub ignition: Ignition,
327 pub curve: Curve,
329}
330
331#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
333#[non_exhaustive]
334#[serde(deny_unknown_fields)]
335pub struct UnreadMotor {
336 pub at: String,
338 pub designation: String,
340 pub inside: String,
343 pub reason: String,
345}
346
347#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
349#[non_exhaustive]
350#[serde(deny_unknown_fields)]
351pub struct MotorConfiguration {
352 pub id: String,
354 pub name: String,
356 pub default: bool,
358 pub declared: bool,
360 pub inactive_stages: Vec<Option<u32>>,
363 pub motors: Vec<OrkMotor>,
365 pub unread: Vec<UnreadMotor>,
367 pub left_out: Option<LeftOut>,
369 pub staging: Option<Staging>,
374 #[serde(default, skip_serializing_if = "Vec::is_empty")]
378 pub later_stagings: Vec<Staging>,
379}
380
381impl MotorConfiguration {
382 pub fn stagings(&self) -> impl Iterator<Item = &Staging> {
385 self.staging.iter().chain(&self.later_stagings)
386 }
387}
388
389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
391#[non_exhaustive]
392#[serde(deny_unknown_fields)]
393pub struct LeftOut {
394 pub why: NotFlown,
396 pub message: String,
398}
399
400#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
404#[serde(rename_all = "snake_case")]
405#[non_exhaustive]
406pub enum NotFlown {
407 UnreadMotor,
409 NoMotor,
411 InactiveStage,
413 NoCurve,
415 NoSize,
417 IgnitionNotFlown,
422 AirframeNotAsWritten,
429 SeparationNotFlown,
435}
436
437#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
439#[non_exhaustive]
440#[serde(deny_unknown_fields)]
441pub struct Motors {
442 pub configurations: Vec<MotorConfiguration>,
444}
445
446impl Motors {
447 pub fn default_configuration(&self) -> Option<&MotorConfiguration> {
449 self.configurations.iter().find(|c| c.default)
450 }
451}
452
453#[derive(Debug, Clone, PartialEq)]
455struct MotorRead {
456 kind: Option<String>,
457 manufacturer: String,
458 designation: String,
459 digest: Option<String>,
460 diameter_m: Option<f64>,
461 length_m: Option<f64>,
462 delay: Option<Delay>,
463}
464
465#[derive(Debug, Clone, PartialEq)]
468pub(super) struct MountRead {
469 pub at: String,
471 pub overhang_m: f64,
473 default_ignition: Ignition,
475 ignitions: BTreeMap<String, (Option<IgnitionEvent>, Option<f64>)>,
477 motors: Vec<(String, MotorRead)>,
479 doubled: Vec<(String, UnreadMotor)>,
481 cluster: Option<String>,
483}
484
485pub(super) fn mount(element: &Element, at: &str, warnings: &mut Vec<Warning>) -> Option<MountRead> {
487 super::reads::note(element, "motormount");
488 super::reads::note(element, "clusterconfiguration");
489 let mount = element.child("motormount")?;
490 let here = format!("{at}/motormount");
491 let mut values = Values::new(mount, &here, warnings);
492 let overhang_m = values.number(&["overhang"]).unwrap_or_default();
493 let default_ignition = Ignition {
494 event: values
495 .word(&["ignitionevent"])
496 .map_or(IgnitionEvent::Automatic, |text| IgnitionEvent::parse(&text)),
497 delay_s: values.number(&["ignitiondelay"]).unwrap_or_default(),
498 };
499 let mut ignitions = BTreeMap::new();
500 let mut motors = Vec::new();
501 let mut doubled = Vec::new();
502 super::reads::note(mount, "motor");
503 super::reads::note(mount, "ignitionconfiguration");
504 for child in mount.elements() {
505 if !matches!(child.name.as_str(), "motor" | "ignitionconfiguration") {
506 continue;
507 }
508 let at = format!("{here}/{}", child.name);
509 let Some(config) = configid(child) else {
510 warnings.push(Warning::new(
511 at,
512 WarningKind::Dropped,
513 format!(
514 "a `{}` with no `configid` belongs to no configuration; it was ignored",
515 child.name
516 ),
517 ));
518 continue;
519 };
520 if child.name == "motor" && motors.iter().any(|(c, _)| *c == config) {
521 warnings.push(Warning::new(
524 at.clone(),
525 WarningKind::Dropped,
526 format!(
527 "a second motor for configuration `{config}` in one mount, which holds one; \
528 the configuration is not flown"
529 ),
530 ));
531 doubled.push((
532 config,
533 UnreadMotor {
534 at,
535 designation: child
536 .child("designation")
537 .map(|d| d.text().trim().to_owned())
538 .unwrap_or_default(),
539 inside: element.name.clone(),
540 reason: "a second motor for this configuration in the same mount".to_owned(),
541 },
542 ));
543 continue;
544 }
545 match child.name.as_str() {
546 "ignitionconfiguration" => {
547 let mut values = Values::new(child, &at, warnings);
548 let event = values
549 .word(&["ignitionevent"])
550 .map(|text| IgnitionEvent::parse(&text));
551 let delay_s = values.number(&["ignitiondelay"]);
552 ignitions.insert(config, (event, delay_s));
553 }
554 "motor" => motors.push((config, motor(child, &at, warnings))),
555 _ => {}
556 }
557 }
558 let cluster = element
559 .child("clusterconfiguration")
560 .map(|c| c.text().trim().to_owned())
561 .filter(|c| super::attached::cluster_pattern(c).is_some_and(|tubes| tubes.len() > 1));
564 Some(MountRead {
565 at: at.to_owned(),
566 overhang_m,
567 default_ignition,
568 ignitions,
569 motors,
570 doubled,
571 cluster,
572 })
573}
574
575fn configid(element: &Element) -> Option<String> {
577 element
578 .attribute("configid")
579 .map(str::trim)
580 .filter(|id| !id.is_empty())
581 .map(str::to_owned)
582}
583
584fn motor(element: &Element, at: &str, warnings: &mut Vec<Warning>) -> MotorRead {
586 let mut values = Values::new(element, at, warnings);
587 let delay = match values.word(&["delay"]) {
588 None => None,
589 Some(text) => delay(&text, &mut values),
590 };
591 MotorRead {
592 kind: values.word(&["type"]),
593 manufacturer: values.word(&["manufacturer"]).unwrap_or_default(),
594 designation: values.word(&["designation"]).unwrap_or_default(),
595 digest: values.word(&["digest"]).filter(|digest| !digest.is_empty()),
596 diameter_m: values.number(&["diameter"]),
597 length_m: values.number(&["length"]),
598 delay,
599 }
600}
601
602fn delay(text: &str, values: &mut Values<'_>) -> Option<Delay> {
604 if text.eq_ignore_ascii_case("none") {
605 return Some(Delay::Plugged);
606 }
607 match text.parse::<f64>() {
608 Ok(seconds) if seconds.is_finite() && seconds >= 0.0 => Some(Delay::Seconds(seconds)),
609 _ => {
610 values.warn_at(
611 WarningKind::Dropped,
612 format!(
613 "`delay` says `{text}`, which is neither `none` nor a delay; it was ignored"
614 ),
615 );
616 values.forget(&["delay"]);
617 None
618 }
619 }
620}
621
622pub(super) fn read(
629 rocket_element: &Element,
630 rocket: &mut Rocket,
631 airframe: Airframe<'_>,
632 mounts: &[(String, MountRead)],
633 attachments: &[Attachment],
634 supplied: &SuppliedCurves,
635 warnings: &mut Vec<Warning>,
636) -> Motors {
637 let at = "openrocket/rocket";
638 let mut configurations: Vec<MotorConfiguration> = Vec::new();
639 super::reads::note(rocket_element, "motorconfiguration");
640 for element in rocket_element.children_named("motorconfiguration") {
641 let Some(id) = configid(element) else {
642 warnings.push(Warning::new(
643 format!("{at}/motorconfiguration"),
644 WarningKind::Dropped,
645 "a motor configuration with no `configid` can hold no motor; it was ignored",
646 ));
647 continue;
648 };
649 if configurations.iter().any(|c| c.id == id) {
650 warnings.push(Warning::new(
651 format!("{at}/motorconfiguration"),
652 WarningKind::Dropped,
653 format!("motor configuration `{id}` is declared twice; the first was kept"),
654 ));
655 continue;
656 }
657 super::reads::note(element, "stage");
658 super::reads::note(element, "name");
659 let inactive_stages = element
660 .children_named("stage")
661 .filter(|stage| stage.attribute("active") == Some("false"))
662 .map(|stage| {
663 stage
664 .attribute("number")
665 .and_then(|n| n.trim().parse().ok())
666 })
667 .collect();
668 configurations.push(MotorConfiguration {
669 id,
670 name: element
671 .child("name")
672 .map(|name| name.text().trim().to_owned())
673 .unwrap_or_default(),
674 default: element.attribute("default") == Some("true"),
675 declared: true,
676 inactive_stages,
677 motors: Vec::new(),
678 unread: Vec::new(),
679 left_out: None,
680 staging: None,
681 later_stagings: Vec::new(),
682 });
683 }
684
685 let catalog = match Catalog::bundled() {
686 Ok(catalog) => Some(catalog),
687 Err(error) => {
688 warnings.push(Warning::new(
689 at,
690 WarningKind::Unusual,
691 format!("the bundled motor catalog could not be read ({error}); no motor was looked up in it"),
692 ));
693 None
694 }
695 };
696 let mut placed: BTreeSet<&str> = BTreeSet::new();
697 for (mount_id, mount) in mounts {
698 let Some(stage) = stage_of(rocket, mount_id) else {
701 continue;
702 };
703 placed.insert(mount.at.as_str());
704 for (config, motor) in &mount.doubled {
705 let index = configuration(&mut configurations, config, at, warnings);
706 configurations[index].unread.push(motor.clone());
707 }
708 for (config, read) in &mount.motors {
709 let index = configuration(&mut configurations, config, at, warnings);
710 let ignition = match mount.ignitions.get(config) {
711 Some((event, delay_s)) => Ignition {
712 event: event
713 .clone()
714 .unwrap_or_else(|| mount.default_ignition.event.clone()),
715 delay_s: delay_s.unwrap_or(mount.default_ignition.delay_s),
716 },
717 None => mount.default_ignition.clone(),
718 };
719 let curve = curve(
720 read,
721 attachments,
722 supplied,
723 catalog.as_ref(),
724 &mount.at,
725 warnings,
726 );
727 configurations[index].motors.push(OrkMotor {
728 mount: mount_id.clone(),
729 stage,
730 kind: read.kind.clone(),
731 manufacturer: read.manufacturer.clone(),
732 designation: read.designation.clone(),
733 digest: read.digest.clone(),
734 diameter_m: read.diameter_m,
735 length_m: read.length_m,
736 delay: read.delay,
737 ignition,
738 curve,
739 });
740 }
741 }
742
743 let mut unread = Vec::new();
744 for (index, stage) in subcomponents(rocket_element).enumerate() {
745 let path = format!("{at}/{}[{index}]", stage.name);
746 unread_motors(stage, &path, None, &placed, &mut unread, warnings);
747 }
748 for (config, motor) in unread {
749 let index = configuration(&mut configurations, &config, at, warnings);
750 configurations[index].unread.push(motor);
751 }
752
753 let hung_on: Vec<Option<usize>> = rocket
755 .stages
756 .iter()
757 .map(|stage| {
758 let parallel = stage.parallel.as_ref()?;
759 stage_of(rocket, ¶llel.on)
760 })
761 .collect();
762 for configuration in &mut configurations {
763 match flyable(configuration, &hung_on, airframe) {
764 Ok((lit, stagings)) => {
765 rocket.configurations.push(flown(configuration, lit));
766 let mut stagings = stagings.into_iter();
767 configuration.staging = stagings.next();
768 configuration.later_stagings = stagings.collect();
769 }
770 Err(out) => configuration.left_out = Some(out),
771 }
772 }
773 Motors { configurations }
774}
775
776fn configuration(
778 configurations: &mut Vec<MotorConfiguration>,
779 id: &str,
780 at: &str,
781 warnings: &mut Vec<Warning>,
782) -> usize {
783 if let Some(index) = configurations.iter().position(|c| c.id == id) {
784 return index;
785 }
786 warnings.push(Warning::new(
787 at,
788 WarningKind::Unusual,
789 format!(
790 "a mount puts a motor in configuration `{id}`, which the rocket does not declare; it \
791 was read as a configuration of its own"
792 ),
793 ));
794 configurations.push(MotorConfiguration {
795 id: id.to_owned(),
796 name: String::new(),
797 default: false,
798 declared: false,
799 inactive_stages: Vec::new(),
800 motors: Vec::new(),
801 unread: Vec::new(),
802 left_out: None,
803 staging: None,
804 later_stagings: Vec::new(),
805 });
806 configurations.len() - 1
807}
808
809fn unread_motors(
812 element: &Element,
813 at: &str,
814 inside: Option<&str>,
815 read: &BTreeSet<&str>,
816 found: &mut Vec<(String, UnreadMotor)>,
817 warnings: &mut Vec<Warning>,
818) {
819 let inside = inside.or(match element.name.as_str() {
820 tag @ ("podset" | "parallelstage") => Some(tag),
821 _ => None,
822 });
823 if let Some(mount) = element.child("motormount")
824 && !read.contains(at)
825 {
826 let reason = match inside {
827 Some("podset") => {
828 "its mount is inside a pod set, in a part hpr did not read".to_owned()
829 }
830 Some(_) => {
831 "its mount is inside a parallel stage, in a part hpr did not read".to_owned()
832 }
833 None => format!("its mount, a `{}`, was not read", element.name),
834 };
835 let part = inside.unwrap_or(element.name.as_str());
836 for motor in mount.children_named("motor") {
837 let Some(config) = configid(motor) else {
839 warnings.push(Warning::new(
840 format!("{at}/motormount/motor"),
841 WarningKind::Dropped,
842 "a `motor` with no `configid` belongs to no configuration; it was ignored",
843 ));
844 continue;
845 };
846 found.push((
847 config,
848 UnreadMotor {
849 at: at.to_owned(),
850 designation: motor
851 .child("designation")
852 .map(|d| d.text().trim().to_owned())
853 .unwrap_or_default(),
854 inside: part.to_owned(),
855 reason: reason.clone(),
856 },
857 ));
858 }
859 }
860 for (index, child) in subcomponents(element).enumerate() {
861 let path = format!("{at}/{}[{index}]", child.name);
862 unread_motors(child, &path, inside, read, found, warnings);
863 }
864}
865
866pub(super) fn stage_of(rocket: &Rocket, id: &str) -> Option<usize> {
868 fn holds(components: &[hpr_design::Component], id: &str) -> bool {
869 components
870 .iter()
871 .any(|c| c.id == id || holds(&c.children, id))
872 }
873 rocket
874 .stages
875 .iter()
876 .position(|stage| holds(&stage.components, id))
877}
878
879fn key(text: &str) -> String {
881 text.chars()
882 .filter(char::is_ascii_alphanumeric)
883 .map(|c| c.to_ascii_lowercase())
884 .collect()
885}
886
887fn curve(
890 motor: &MotorRead,
891 attachments: &[Attachment],
892 supplied: &SuppliedCurves,
893 catalog: Option<&Catalog>,
894 at: &str,
895 warnings: &mut Vec<Warning>,
896) -> Curve {
897 let unresolved = |why: NoCurve, reason: String| Curve::Unresolved { why, reason };
898 if motor.kind.as_deref() == Some("hybrid") {
899 return unresolved(
900 NoCurve::Hybrid,
901 "a hybrid motor; hpr flies commercial solid motors only".to_owned(),
902 );
903 }
904 if motor.designation.is_empty() {
905 return unresolved(
906 NoCurve::NoDesignation,
907 "the motor has no designation".to_owned(),
908 );
909 }
910 let mut embedded_failed = None;
911 if let Some(digest) = &motor.digest {
912 let entry = format!("thrustcurves/{digest}.rse");
913 if let Some(attachment) = attachments.iter().find(|a| a.name == entry) {
914 match embedded(attachment, motor, at, warnings) {
915 Ok(solid) => {
916 return Curve::Embedded {
917 entry,
918 motor: Box::new(solid),
919 };
920 }
921 Err(Refused::Hybrid) => {
923 return unresolved(
924 NoCurve::Hybrid,
925 format!(
926 "its embedded curve {entry} is a hybrid's; hpr flies commercial solid motors only"
927 ),
928 );
929 }
930 Err(Refused::Unusable(reason)) => {
931 warnings.push(Warning::new(
932 entry.clone(),
933 WarningKind::Dropped,
934 format!(
935 "the embedded curve for {} was not used: {reason}",
936 motor.designation
937 ),
938 ));
939 embedded_failed = Some(format!("its embedded curve {entry} could not be used"));
940 }
941 }
942 }
943 }
944 if let Some(digest) = &motor.digest
945 && let Some(found) = supplied.by_digest.get(digest)
946 {
947 sizes_agree(
948 motor,
949 found.case.diameter_m * 1e3,
950 found.case.length_m * 1e3,
951 supplied.source(),
952 at,
953 warnings,
954 );
955 return Curve::Supplied {
956 digest: digest.clone(),
957 from: supplied.source().to_owned(),
958 motor: Box::new(found.motor.clone()),
959 };
960 }
961 let not_supplied = if supplied.is_empty() {
963 String::new()
964 } else if motor.digest.is_some() {
965 format!(", {} has no curve for its digest", supplied.source())
966 } else {
967 ", it records no digest to look up in the supplied curves".to_owned()
968 };
969 let Some(catalog) = catalog else {
970 return match embedded_failed {
971 Some(reason) => unresolved(NoCurve::Unusable, format!("{reason}{not_supplied}")),
972 None => unresolved(
973 NoCurve::NotFound,
974 format!("no embedded curve{not_supplied}"),
975 ),
976 };
977 };
978 let maker = key(&motor.manufacturer);
979 let matches: Vec<_> = catalog
980 .find(&motor.designation)
981 .filter(|m| key(&m.manufacturer) == maker || key(&m.manufacturer_abbrev) == maker)
982 .collect();
983 let not_found = |kind: NoCurve, why: &str| match &embedded_failed {
985 Some(lead) => unresolved(
986 NoCurve::Unusable,
987 format!("{lead}{not_supplied}, and {why}"),
988 ),
989 None => unresolved(kind, format!("no embedded curve{not_supplied}, and {why}")),
990 };
991 match matches.as_slice() {
992 [] => not_found(
993 NoCurve::NotFound,
994 "no motor of that manufacturer and designation in the bundled catalog",
995 ),
996 [found] => {
997 let Some((curve, text)) = found
998 .curves
999 .iter()
1000 .find_map(|curve| Some((curve, bundled_curve_text(&curve.file)?)))
1001 else {
1002 return not_found(
1003 NoCurve::NotFound,
1004 "the bundled catalog lists it without a curve",
1005 );
1006 };
1007 sizes_agree(
1008 motor,
1009 found.diameter_mm,
1010 found.length_mm,
1011 "the bundled catalog",
1012 at,
1013 warnings,
1014 );
1015 match found.motor(curve, text) {
1016 Ok(solid) => Curve::Catalog {
1017 motor_id: found.motor_id.clone(),
1018 simfile_id: curve.simfile_id.clone(),
1019 motor: Box::new(solid),
1020 },
1021 Err(error) => not_found(
1022 NoCurve::Unusable,
1023 &format!("the bundled catalog's curve failed: {error}"),
1024 ),
1025 }
1026 }
1027 several => not_found(
1028 NoCurve::Ambiguous,
1029 &format!(
1030 "{} motors of that manufacturer and designation are in the bundled catalog",
1031 several.len()
1032 ),
1033 ),
1034 }
1035}
1036
1037fn sizes_agree(
1041 motor: &MotorRead,
1042 diameter_mm: f64,
1043 length_mm: f64,
1044 source: &str,
1045 at: &str,
1046 warnings: &mut Vec<Warning>,
1047) {
1048 let apart = |ork_m: Option<f64>, mm: f64| ork_m.is_some_and(|m| (m * 1e3 - mm).abs() > 1.0);
1049 if apart(motor.diameter_m, diameter_mm) || apart(motor.length_m, length_mm) {
1050 warnings.push(Warning::new(
1051 at,
1052 WarningKind::Unusual,
1053 format!(
1054 "{} is {} by {} mm in the design and {diameter_mm} by {length_mm} mm in {source}; \
1055 the design's size places it, and the curve's gives its mass",
1056 motor.designation,
1057 motor.diameter_m.map_or(f64::NAN, |m| m * 1e3),
1058 motor.length_m.map_or(f64::NAN, |m| m * 1e3),
1059 ),
1060 ));
1061 }
1062}
1063
1064enum Refused {
1066 Hybrid,
1068 Unusable(String),
1070}
1071
1072fn embedded(
1075 attachment: &Attachment,
1076 motor: &MotorRead,
1077 at: &str,
1078 warnings: &mut Vec<Warning>,
1079) -> Result<SolidMotor, Refused> {
1080 let unusable = |reason: String| Refused::Unusable(reason);
1081 let text = std::str::from_utf8(&attachment.bytes)
1082 .map_err(|_| unusable("it is not UTF-8 text".to_owned()))?;
1083 let parsed = rse::parse(text).map_err(|error| unusable(error.to_string()))?;
1084 for warning in &parsed.warnings {
1085 warnings.push(Warning::new(
1086 attachment.name.clone(),
1087 WarningKind::Unusual,
1088 format!("line {}: {}", warning.line, warning.message),
1089 ));
1090 }
1091 let [engine] = parsed.value.engines.as_slice() else {
1092 return Err(unusable(format!(
1093 "it holds {} engines, not one",
1094 parsed.value.engines.len()
1095 )));
1096 };
1097 if engine
1098 .motor_type
1099 .as_deref()
1100 .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("hybrid"))
1101 {
1102 return Err(Refused::Hybrid);
1103 }
1104 if key(&engine.code) != key(&motor.designation) {
1105 warnings.push(Warning::new(
1106 at,
1107 WarningKind::Unusual,
1108 format!(
1109 "the embedded curve its digest names is for `{}`, and the motor says `{}`; the \
1110 curve was used",
1111 engine.code, motor.designation
1112 ),
1113 ));
1114 }
1115 sizes_agree(
1116 motor,
1117 engine.diameter_mm,
1118 engine.length_mm,
1119 "its embedded curve",
1120 at,
1121 warnings,
1122 );
1123 let thrust = engine
1124 .thrust_curve()
1125 .map_err(|error| unusable(error.to_string()))?;
1126 SolidMotor::from_envelope(
1127 thrust,
1128 engine.diameter_mm * 1e-3,
1129 engine.length_mm * 1e-3,
1130 engine.propellant_mass_g * 1e-3,
1131 engine.initial_mass_g * 1e-3,
1132 )
1133 .map_err(|error| unusable(error.to_string()))
1134}
1135
1136#[derive(Debug, Clone, Copy)]
1138pub(super) struct Airframe<'a> {
1139 pub(super) incomplete: Option<&'a str>,
1141 pub(super) separations: &'a [StageSeparation],
1143}
1144
1145fn flyable(
1148 configuration: &MotorConfiguration,
1149 hung_on: &[Option<usize>],
1150 airframe: Airframe<'_>,
1151) -> Result<(Vec<hpr_design::Ignition>, Vec<Staging>), LeftOut> {
1152 if let Some(out) = left_out(configuration) {
1153 return Err(out);
1154 }
1155 let motors = &configuration.motors;
1156 let lit = motors
1157 .iter()
1158 .zip(staging::ignitions(motors, hung_on))
1159 .map(|(motor, lit)| {
1160 lit.map_err(|why| LeftOut {
1161 why: NotFlown::IgnitionNotFlown,
1162 message: format!(
1163 "{}, in stage {}, can't be lit as written: {why}",
1164 motor.designation, motor.stage
1165 ),
1166 })
1167 })
1168 .collect::<Result<Vec<_>, _>>()?;
1169 let lit = staging::never_when_waiting_on_never(motors, lit).map_err(|why| LeftOut {
1172 why: NotFlown::IgnitionNotFlown,
1173 message: why,
1174 })?;
1175 if let Some(what) = airframe.incomplete {
1177 return Err(LeftOut {
1178 why: NotFlown::AirframeNotAsWritten,
1179 message: format!("the airframe was not read exactly as written: {what}"),
1180 });
1181 }
1182 let staging = staging::staging(
1183 &configuration.id,
1184 motors,
1185 &lit,
1186 airframe.separations,
1187 hung_on,
1188 )
1189 .map_err(|why| LeftOut {
1190 why: NotFlown::SeparationNotFlown,
1191 message: format!("its stages can't be flown apart as written: {why}"),
1192 })?;
1193 Ok((lit, staging))
1194}
1195
1196fn left_out(configuration: &MotorConfiguration) -> Option<LeftOut> {
1199 let out = |why: NotFlown, message: String| Some(LeftOut { why, message });
1200 if let Some(unread) = configuration.unread.first() {
1201 return out(
1202 NotFlown::UnreadMotor,
1203 format!(
1204 "{} of its motors is in a part hpr does not read ({}: {})",
1205 configuration.unread.len(),
1206 unread.designation,
1207 unread.reason
1208 ),
1209 );
1210 }
1211 if configuration.motors.is_empty() {
1212 return out(NotFlown::NoMotor, "it holds no motor".to_owned());
1213 }
1214 if !configuration.inactive_stages.is_empty() {
1215 return out(
1216 NotFlown::InactiveStage,
1217 format!(
1218 "it flies without stage {:?}, and hpr flies every stage",
1219 configuration.inactive_stages
1220 ),
1221 );
1222 }
1223 let motors = &configuration.motors;
1224 if let Some((motor, reason)) = motors.iter().find_map(|m| match &m.curve {
1225 Curve::Unresolved { reason, .. } => Some((m, reason)),
1226 _ => None,
1227 }) {
1228 return out(
1229 NotFlown::NoCurve,
1230 format!("no thrust curve for {}: {reason}", motor.designation),
1231 );
1232 }
1233 if let Some(motor) = motors
1234 .iter()
1235 .find(|m| !m.diameter_m.is_some_and(|d| d > 0.0) || !m.length_m.is_some_and(|l| l > 0.0))
1236 {
1237 return out(
1238 NotFlown::NoSize,
1239 format!("{} has no case diameter and length", motor.designation),
1240 );
1241 }
1242 None
1243}
1244
1245fn flown(configuration: &MotorConfiguration, lit: Vec<hpr_design::Ignition>) -> Configuration {
1248 Configuration {
1249 id: configuration.id.clone(),
1250 name: configuration.name.clone(),
1251 motors: configuration
1252 .motors
1253 .iter()
1254 .zip(lit)
1255 .filter_map(|(motor, ignition)| {
1256 Some(MountedMotor {
1257 mount: motor.mount.clone(),
1258 designation: motor.designation.clone(),
1259 diameter_m: motor.diameter_m?,
1260 length_m: motor.length_m?,
1261 motor: motor.curve.motor()?.clone(),
1262 delay: motor.delay,
1263 ignition,
1264 failed_tubes: Vec::new(),
1265 })
1266 })
1267 .collect(),
1268 }
1269}