1use serde::{Deserialize, Serialize};
15
16use crate::config::{Configuration, LaidOut};
17use crate::error::DesignError;
18use crate::solids::Wall;
19use crate::tree::{LENGTH_TOLERANCE_M, Layout, Part, PlacedComponent, Rocket};
20
21pub const DRAWING_TOLERANCE_M: f64 = 0.005 * 0.0254;
24
25pub const COARSE_GENERAL_TOLERANCE_M: [(f64, f64); 8] = [
31 (0.003, 0.0002),
32 (0.006, 0.0003),
33 (0.030, 0.0005),
34 (0.120, 0.0008),
35 (0.400, 0.0012),
36 (1.000, 0.002),
37 (2.000, 0.003),
38 (4.000, 0.004),
39];
40
41pub fn fit_tolerance_m(room_m: f64) -> f64 {
48 let diameter_m = 2.0 * room_m;
49 if diameter_m.is_nan() || diameter_m < 0.0005 {
51 return 0.0;
52 }
53 COARSE_GENERAL_TOLERANCE_M
54 .iter()
55 .find(|(upto, _)| diameter_m <= *upto)
56 .map_or(0.004, |(_, t)| *t)
57}
58
59pub const NARROWER_THAN_NOMINAL_M: [(f64, f64); 3] = [
69 (0.018, 0.698 * 0.0254 + DRAWING_TOLERANCE_M),
70 (0.024, 0.938 * 0.0254 + DRAWING_TOLERANCE_M),
71 (0.029, 1.125 * 0.0254 + DRAWING_TOLERANCE_M),
72];
73
74pub fn motor_fit_slack_m(diameter_m: f64) -> f64 {
79 NARROWER_THAN_NOMINAL_M
80 .iter()
81 .find(|(nominal, _)| (diameter_m - nominal).abs() <= LENGTH_TOLERANCE_M)
82 .map_or(0.0, |(nominal, case)| nominal - case)
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum Severity {
89 Warning,
91 Error,
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97#[serde(tag = "kind", rename_all = "snake_case")]
98#[non_exhaustive]
99pub enum Finding {
100 MotorWiderThanMount {
103 configuration: String,
105 mount: String,
107 motor_diameter_m: f64,
109 mount_inner_diameter_m: f64,
111 },
112 MotorTightInMount {
116 configuration: String,
118 mount: String,
120 motor_diameter_m: f64,
122 mount_inner_diameter_m: f64,
124 },
125 MotorOutsideMount {
128 configuration: String,
130 mount: String,
132 },
133 MotorPastMountTop {
135 configuration: String,
137 mount: String,
139 excess_m: f64,
141 },
142 AttachmentOffBody {
147 component: String,
149 body: String,
151 },
152 AttachmentPastBodyEnd {
156 component: String,
158 body: String,
160 excess_m: f64,
162 },
163 PartOutsideRocket {
166 component: String,
168 },
169 InternalPartPastParentEnd {
171 component: String,
173 parent: String,
175 excess_m: f64,
177 },
178 InternalPartWiderThanParent {
185 component: String,
187 parent: String,
189 reach_m: f64,
191 room_m: f64,
193 },
194 InternalPartTightInParent {
198 component: String,
200 parent: String,
202 reach_m: f64,
204 room_m: f64,
206 },
207 InternalPartWedgedInParent {
213 component: String,
215 parent: String,
217 reach_m: f64,
219 room_m: f64,
221 widest_room_m: f64,
223 },
224 PackedPartWiderThanParent {
228 component: String,
230 parent: String,
232 reach_m: f64,
234 room_m: f64,
236 #[serde(default)]
242 room_widens_aft: bool,
243 },
244 RingAgainstParentEnd {
249 ring: String,
251 parent: String,
253 reach_m: f64,
255 room_m: f64,
257 },
258 ClusterTubesOverlap {
261 tube: String,
263 apart_m: f64,
265 diameter_m: f64,
267 },
268 RingOverlapsInnerTube {
271 ring: String,
273 tube: String,
275 },
276 #[serde(alias = "centre_outside_rocket")]
281 CenterOutsideRocket {
282 stage: String,
284 station_m: f64,
286 },
287 RadiusStep {
289 fore: String,
291 aft: String,
293 fore_radius_m: f64,
295 aft_radius_m: f64,
297 },
298 NoNoseCone {
300 component: String,
302 },
303}
304
305impl Finding {
306 pub fn severity(&self) -> Severity {
308 match self {
309 Self::MotorWiderThanMount { .. }
310 | Self::MotorOutsideMount { .. }
311 | Self::AttachmentOffBody { .. }
312 | Self::PartOutsideRocket { .. }
313 | Self::InternalPartWiderThanParent { .. }
314 | Self::CenterOutsideRocket { .. } => Severity::Error,
315 Self::MotorTightInMount { .. }
316 | Self::MotorPastMountTop { .. }
317 | Self::AttachmentPastBodyEnd { .. }
318 | Self::InternalPartPastParentEnd { .. }
319 | Self::InternalPartTightInParent { .. }
320 | Self::InternalPartWedgedInParent { .. }
321 | Self::PackedPartWiderThanParent { .. }
322 | Self::RingAgainstParentEnd { .. }
323 | Self::ClusterTubesOverlap { .. }
324 | Self::RingOverlapsInnerTube { .. }
325 | Self::RadiusStep { .. }
326 | Self::NoNoseCone { .. } => Severity::Warning,
327 }
328 }
329
330 pub fn describe(&self, name: &dyn Fn(&str) -> String) -> String {
348 match self {
349 Self::MotorWiderThanMount {
350 configuration,
351 mount,
352 motor_diameter_m,
353 mount_inner_diameter_m,
354 } => {
355 let (motor, bore) = mm_pair(*motor_diameter_m, *mount_inner_diameter_m);
356 format!(
357 "the motor in {} is {motor} wide, wider than the mount's {bore} bore by more \
358 than a real case's slack (configuration {})",
359 name(mount),
360 name(configuration)
361 )
362 }
363 Self::MotorTightInMount {
364 configuration,
365 mount,
366 motor_diameter_m,
367 mount_inner_diameter_m,
368 } => {
369 let (motor, bore) = mm_pair(*motor_diameter_m, *mount_inner_diameter_m);
370 format!(
371 "the motor in {} is nominally {motor} wide in a {bore} bore: it fits, as real \
372 cases run under their nominal size (configuration {})",
373 name(mount),
374 name(configuration)
375 )
376 }
377 Self::MotorOutsideMount {
378 configuration,
379 mount,
380 } => format!(
381 "the motor in {} lies wholly outside the mount along its length, such as an \
382 overhang typed in millimeters as meters (configuration {})",
383 name(mount),
384 name(configuration)
385 ),
386 Self::MotorPastMountTop {
387 configuration,
388 mount,
389 excess_m,
390 } => format!(
391 "the motor in {} reaches {} past the mount's forward end (configuration {})",
392 name(mount),
393 mm(*excess_m),
394 name(configuration)
395 ),
396 Self::AttachmentOffBody { component, body } => format!(
397 "{} doesn't touch {}, the body tube it is attached to",
398 name(component),
399 name(body)
400 ),
401 Self::AttachmentPastBodyEnd {
402 component,
403 body,
404 excess_m,
405 } => format!(
406 "{} runs {} past an end of {}, the body tube it is attached to",
407 name(component),
408 mm(*excess_m),
409 name(body)
410 ),
411 Self::PartOutsideRocket { component } => format!(
412 "{} lies wholly forward of the nose tip or aft of the rocket's end",
413 name(component)
414 ),
415 Self::InternalPartPastParentEnd {
416 component,
417 parent,
418 excess_m,
419 } => format!(
420 "{} runs {} past an end of {}, which holds it",
421 name(component),
422 mm(*excess_m),
423 name(parent)
424 ),
425 Self::InternalPartWiderThanParent {
426 component,
427 parent,
428 reach_m,
429 room_m,
430 } => {
431 let (reach, room) = mm_pair(*reach_m, *room_m);
432 format!(
433 "{} reaches {reach} from the axis of {}, which has {room} of room: too wide \
434 to fit, past the fit tolerance",
435 name(component),
436 name(parent)
437 )
438 }
439 Self::InternalPartTightInParent {
440 component,
441 parent,
442 reach_m,
443 room_m,
444 } => {
445 let (reach, room) = mm_pair(*reach_m, *room_m);
446 format!(
447 "{} reaches {reach} from the axis of {}, which has {room} of room: a fit to \
448 sand, and the mass where it crosses the wall counts twice",
449 name(component),
450 name(parent)
451 )
452 }
453 Self::InternalPartWedgedInParent {
454 component,
455 parent,
456 reach_m,
457 room_m,
458 widest_room_m,
459 } => {
460 let (reach, room) = mm_pair(*reach_m, *room_m);
461 let (_, widest) = mm_pair(*reach_m, *widest_room_m);
462 format!(
463 "{} reaches {reach} from the axis of {}, which narrows to {room} of room \
464 along it ({widest} where widest): it can't slide in that far as drawn, and \
465 flies where it is drawn",
466 name(component),
467 name(parent)
468 )
469 }
470 Self::PackedPartWiderThanParent {
471 component,
472 parent,
473 reach_m,
474 room_m,
475 room_widens_aft,
476 } => {
477 let (reach, room) = mm_pair(*reach_m, *room_m);
478 let mut text = format!(
479 "{} is packed to reach {reach} from the axis of {}, which has {room} of \
480 room: it can't go in as drawn; its mass, length and station fly as drawn, and \
481 its width sets only its own inertia",
482 name(component),
483 name(parent)
484 );
485 if *room_widens_aft {
486 text.push_str(
487 ". The room widens aft of it, so its mass can sit only farther aft: the \
488 center of gravity flies forward of where it can be, and the stability \
489 margin may read high (issue #367, \
490 https://github.com/nrdptel/hpr-sim/issues/367)",
491 );
492 }
493 text
494 }
495 Self::RingAgainstParentEnd {
496 ring,
497 parent,
498 reach_m,
499 room_m,
500 } => {
501 let (reach, room) = mm_pair(*reach_m, *room_m);
502 format!(
503 "{} reaches {reach} from the axis, wider than {}, and caps its end, in \
504 {room} of room around it; the mass where it crosses the wall counts twice",
505 name(ring),
506 name(parent)
507 )
508 }
509 Self::ClusterTubesOverlap {
510 tube,
511 apart_m,
512 diameter_m,
513 } => {
514 let (apart, diameter) = mm_pair(*apart_m, *diameter_m);
515 format!(
516 "the cluster of {} has tubes {apart} apart, closer than their {diameter} \
517 diameter, so they cross: the mass where they cross counts twice and their \
518 motors would not fit",
519 name(tube)
520 )
521 }
522 Self::RingOverlapsInnerTube { ring, tube } => format!(
523 "{} overlaps {} beside it, so the mass where they cross counts twice",
524 name(ring),
525 name(tube)
526 ),
527 Self::CenterOutsideRocket { stage, station_m } => format!(
528 "the center of mass set for {} is {} {} the nose tip, outside the rocket, such \
529 as a center typed in millimeters as meters",
530 name(stage),
531 mm(station_m.abs()),
532 if *station_m < 0.0 {
533 "forward of"
534 } else {
535 "aft of"
536 }
537 ),
538 Self::RadiusStep {
539 fore,
540 aft,
541 fore_radius_m,
542 aft_radius_m,
543 } => {
544 let (fore_radius, aft_radius) = mm_pair(*fore_radius_m, *aft_radius_m);
545 format!(
546 "{} ends {fore_radius} in radius where {} begins {aft_radius}: a step in the \
547 body's outline",
548 name(fore),
549 name(aft)
550 )
551 }
552 Self::NoNoseCone { component } => format!(
553 "the rocket's first body part, {}, is not a nose cone",
554 name(component)
555 ),
556 }
557 }
558}
559
560fn mm(length_m: f64) -> String {
563 let mm = length_m * 1000.0;
564 format!("{mm:.prec$} mm", prec = decimals(mm))
565}
566
567fn decimals(mm: f64) -> usize {
569 match mm.abs() {
570 size if size >= 1.0 || size == 0.0 => 1,
571 size if size >= 0.1 => 2,
572 _ => 3,
573 }
574}
575
576fn mm_pair(a_m: f64, b_m: f64) -> (String, String) {
580 let (a, b) = (a_m * 1000.0, b_m * 1000.0);
581 let mut prec = decimals(a).max(decimals(b));
583 while prec < 4 && format!("{a:.prec$}") == format!("{b:.prec$}") {
584 prec += 1;
585 }
586 (format!("{a:.prec$} mm"), format!("{b:.prec$} mm"))
587}
588
589pub fn check(rocket: &Rocket) -> Result<Vec<Finding>, DesignError> {
596 rocket.check_configuration_ids()?;
597 check_on(rocket, &rocket.layout()?)
598}
599
600impl LaidOut {
601 pub fn check(&self) -> Result<Vec<Finding>, DesignError> {
607 check_on(self.rocket(), self.layout())
608 }
609}
610
611fn check_on(rocket: &Rocket, layout: &Layout) -> Result<Vec<Finding>, DesignError> {
613 let mut findings = check_layout(layout);
614 for configuration in &rocket.configurations {
615 findings.extend(check_configuration(layout, configuration)?);
616 }
617 Ok(findings)
618}
619
620pub fn has_errors(findings: &[Finding]) -> bool {
622 findings.iter().any(|f| f.severity() == Severity::Error)
623}
624
625fn overlap(a0: f64, a1: f64, b0: f64, b1: f64) -> f64 {
627 a1.min(b1) - a0.max(b0)
628}
629
630fn excess(fore: f64, aft: f64, parent_fore: f64, parent_aft: f64) -> Option<f64> {
633 if overlap(fore, aft, parent_fore, parent_aft) <= 0.0 {
634 return None;
635 }
636 Some((parent_fore - fore).max(aft - parent_aft).max(0.0))
637}
638
639pub fn check_layout(layout: &Layout) -> Vec<Finding> {
644 let mut findings = Vec::new();
645 let body: Vec<_> = layout.body().collect();
646 if let Some(first) = body.first()
647 && !matches!(first.part, Part::NoseCone(_))
648 {
649 findings.push(Finding::NoNoseCone {
650 component: first.id.clone(),
651 });
652 }
653 for pair in body.windows(2) {
654 let (fore, aft) = (pair[0], pair[1]);
655 if let (Some(a), Some(b)) = (fore.part.aft_radius_m(), aft.part.fore_radius_m())
656 && (a - b).abs() > LENGTH_TOLERANCE_M
657 {
658 findings.push(Finding::RadiusStep {
659 fore: fore.id.clone(),
660 aft: aft.id.clone(),
661 fore_radius_m: a,
662 aft_radius_m: b,
663 });
664 }
665 }
666 let length = layout.length_m;
669 let apart = |c: &PlacedComponent, fore: f64, aft: f64| {
671 overlap(c.fore_station_m, c.aft_station_m(), fore, aft) < -LENGTH_TOLERANCE_M
672 };
673 let off_rocket: Vec<bool> = layout
674 .components
675 .iter()
676 .map(|c| {
677 if c.parent.is_none()
679 || c.part.is_external()
680 || c.part.is_body()
681 || !apart(c, 0.0, length)
682 {
683 return false;
684 }
685 let mut ancestor = c.parent;
686 for _ in 0..layout.components.len() {
687 let Some(a) = ancestor.and_then(|i| layout.components.get(i)) else {
688 break;
689 };
690 if !apart(c, a.fore_station_m, a.aft_station_m()) {
691 return false;
692 }
693 ancestor = a.parent;
694 }
695 true
696 })
697 .collect();
698 let mut holds_off = off_rocket.clone();
699 for (index, component) in layout.components.iter().enumerate().rev() {
700 if holds_off[index]
701 && let Some(flag) = component.parent.and_then(|p| holds_off.get_mut(p))
702 {
703 *flag = true;
704 }
705 }
706 for (k, stage) in layout.stages.iter().enumerate() {
707 let station = -stage.mass.cg_m.z;
708 let in_stage = || {
709 layout
710 .components
711 .iter()
712 .enumerate()
713 .filter(|(_, c)| c.stage == k)
714 };
715 let holds = in_stage().any(|(i, _)| holds_off[i]);
716 let overridden = stage.center_overridden || in_stage().any(|(_, c)| c.center_overridden);
717 if overridden
718 && !holds
719 && stage.mass.mass_kg > 0.0
720 && !(-LENGTH_TOLERANCE_M..=length + LENGTH_TOLERANCE_M).contains(&station)
721 {
722 findings.push(Finding::CenterOutsideRocket {
723 stage: stage.id.clone(),
724 station_m: station,
725 });
726 }
727 }
728 for (index, component) in layout.components.iter().enumerate() {
729 let Some(parent) = component.parent.and_then(|i| layout.components.get(i)) else {
730 continue;
731 };
732 attached_findings(component, parent, off_rocket[index], layout, &mut findings);
733 }
734 findings
735}
736
737const ROOM_SAMPLES: usize = 31;
740
741#[derive(Debug, Clone, Copy)]
743struct Room {
744 least_m: f64,
746 most_m: f64,
748}
749
750fn room_m(part: &PlacedComponent, fore: f64, aft: f64) -> Option<Room> {
770 let both = |room_m: f64| Room {
771 least_m: room_m,
772 most_m: room_m,
773 };
774 if let Some(bore) = part.part.inner_radius_m() {
775 return Some(both(bore));
776 }
777 let (profile, wall) = match &part.part {
778 Part::NoseCone(nose) => (nose.profile().ok()?, nose.wall),
779 Part::Transition(transition) => (transition.profile().ok()?, transition.wall),
780 _ => return None,
781 };
782 let length = part.length_m;
783 let (x0, x1) = (fore - part.fore_station_m, aft - part.fore_station_m);
784 let inside_m = x1.min(length) - x0.max(0.0);
787 if x1 < -LENGTH_TOLERANCE_M
788 || x0 > length + LENGTH_TOLERANCE_M
789 || (x1 - x0 > LENGTH_TOLERANCE_M && inside_m <= LENGTH_TOLERANCE_M)
790 {
791 return Some(both(profile.max_radius_m()));
792 }
793 let wall_m = match wall {
794 Wall::Filled {} => return Some(both(0.0)),
795 Wall::Shell { thickness_m } => thickness_m,
796 };
797 let (x0, x1) = (x0.clamp(0.0, length), x1.clamp(0.0, length));
798 let (least, most) = (0..=ROOM_SAMPLES + 1)
799 .map(|i| {
800 let x = x0 + (x1 - x0) * (i as f64) / ((ROOM_SAMPLES + 1) as f64);
801 profile.radius_m(x)
802 })
803 .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), r| {
804 (lo.min(r), hi.max(r))
805 });
806 Some(Room {
807 least_m: (least - wall_m).max(0.0),
808 most_m: (most - wall_m).max(0.0),
809 })
810}
811
812fn wrapped_ring_container<'a>(
817 fore: f64,
818 aft: f64,
819 tube: &PlacedComponent,
820 layout: &'a Layout,
821) -> Option<&'a PlacedComponent> {
822 let mut next = tube.parent;
823 while let Some(part) = next.and_then(|i| layout.components.get(i)) {
824 if room_m(part, fore, aft).is_some()
825 && let Some(e) = excess(fore, aft, part.fore_station_m, part.aft_station_m())
826 {
827 return (e <= LENGTH_TOLERANCE_M).then_some(part);
828 }
829 next = part.parent;
830 }
831 None
832}
833
834fn attached_findings(
836 component: &PlacedComponent,
837 parent: &PlacedComponent,
838 off_rocket: bool,
839 layout: &Layout,
840 findings: &mut Vec<Finding>,
841) {
842 if component.part.is_body() {
844 return;
845 }
846 let (fore, aft) = (component.fore_station_m, component.aft_station_m());
847 let holds =
849 |a0: f64, a1: f64, x: f64| a0 - LENGTH_TOLERANCE_M <= x && x <= a1 + LENGTH_TOLERANCE_M;
850 if component.part.is_external() && parent.part.is_body() && parent.length_m == 0.0 {
854 if !holds(fore, aft, parent.fore_station_m) {
855 findings.push(Finding::AttachmentOffBody {
856 component: component.id.clone(),
857 body: parent.id.clone(),
858 });
859 }
860 return;
861 }
862 let span = if component.length_m == 0.0 && matches!(component.part, Part::PodSet(_)) {
863 holds(parent.fore_station_m, parent.aft_station_m(), fore).then_some(0.0)
866 } else {
867 excess(fore, aft, parent.fore_station_m, parent.aft_station_m())
868 };
869 if component.part.is_external() {
870 match span {
871 None => findings.push(Finding::AttachmentOffBody {
872 component: component.id.clone(),
873 body: parent.id.clone(),
874 }),
875 Some(_) if matches!(component.part, Part::PodSet(_)) => {}
878 Some(e) if e > LENGTH_TOLERANCE_M => {
879 findings.push(Finding::AttachmentPastBodyEnd {
880 component: component.id.clone(),
881 body: parent.id.clone(),
882 excess_m: e,
883 });
884 }
885 Some(_) => {}
886 }
887 return;
888 }
889 let e =
891 span.unwrap_or_else(|| (parent.fore_station_m - fore).max(aft - parent.aft_station_m()));
892 if off_rocket {
893 findings.push(Finding::PartOutsideRocket {
894 component: component.id.clone(),
895 });
896 } else if e > LENGTH_TOLERANCE_M {
897 findings.push(Finding::InternalPartPastParentEnd {
898 component: component.id.clone(),
899 parent: parent.id.clone(),
900 excess_m: e,
901 });
902 }
903 let holder = parent.parent.and_then(|i| layout.components.get(i));
904 let concentric = parent.part.axis_offset_m() == component.part.axis_offset_m()
907 && !matches!(&parent.part, Part::InnerTube(tube) if !tube.cluster_m.is_empty());
908 let wraps = concentric
912 && matches!((&component.part, &parent.part), (Part::CenteringRing(ring), Part::InnerTube(tube))
913 if ring.inner_radius_m >= tube.outer_radius_m - LENGTH_TOLERANCE_M);
914 let container = if wraps {
915 wrapped_ring_container(fore, aft, parent, layout).unwrap_or(parent)
916 } else {
917 parent
918 };
919 if let (Some(reach_m), Some(room)) = (
920 component.part.reach_from_m(container.part.axis_offset_m()),
921 room_m(container, fore, aft),
922 ) && reach_m > room.least_m + LENGTH_TOLERANCE_M
923 {
924 let Room { least_m, most_m } = room;
925 let fits = |room_m: f64| reach_m <= room_m + fit_tolerance_m(room_m) + LENGTH_TOLERANCE_M;
926 let at_end = matches!(component.part, Part::CenteringRing(_))
930 && concentric
931 && !wraps
932 && holds(fore, aft, parent.fore_station_m) != holds(fore, aft, parent.aft_station_m());
933 let cap = holder.filter(|_| at_end).and_then(|holder| {
934 let reach_m = component.part.reach_from_m(holder.part.axis_offset_m())?;
935 let room_m = room_m(holder, fore, aft)?.most_m;
936 (reach_m <= room_m + fit_tolerance_m(room_m) + LENGTH_TOLERANCE_M)
937 .then_some((reach_m, room_m))
938 });
939 let packed_center_inside = component
944 .part
945 .packing()
946 .map(|packing| reach_m - packing.radius_m <= least_m + LENGTH_TOLERANCE_M);
947 let rigid = packed_center_inside.is_none();
948 findings.push(if packed_center_inside == Some(true) {
949 let end = container.aft_station_m();
953 let room_widens_aft = room_m(container, aft.min(end), end).is_none_or(|aft_room| {
954 let most_m = aft_room.most_m;
955 most_m.is_nan() || least_m.is_nan() || most_m > least_m + LENGTH_TOLERANCE_M
956 });
957 Finding::PackedPartWiderThanParent {
958 component: component.id.clone(),
959 parent: container.id.clone(),
960 reach_m,
961 room_m: least_m,
962 room_widens_aft,
963 }
964 } else if rigid && fits(least_m) {
965 Finding::InternalPartTightInParent {
966 component: component.id.clone(),
967 parent: container.id.clone(),
968 reach_m,
969 room_m: least_m,
970 }
971 } else if rigid && fits(most_m) {
972 Finding::InternalPartWedgedInParent {
975 component: component.id.clone(),
976 parent: container.id.clone(),
977 reach_m,
978 room_m: least_m,
979 widest_room_m: most_m,
980 }
981 } else if let Some((reach_m, room_m)) = cap {
982 Finding::RingAgainstParentEnd {
983 ring: component.id.clone(),
984 parent: parent.id.clone(),
985 reach_m,
986 room_m,
987 }
988 } else {
989 Finding::InternalPartWiderThanParent {
990 component: component.id.clone(),
991 parent: container.id.clone(),
992 reach_m,
993 room_m: if rigid { most_m } else { least_m },
994 }
995 });
996 }
997 if let Part::InnerTube(inner) = &component.part {
998 let apart_m = inner
999 .cluster_m
1000 .iter()
1001 .enumerate()
1002 .flat_map(|(i, &[x, y])| {
1003 inner.cluster_m[i + 1..]
1004 .iter()
1005 .map(move |&[u, v]| (x - u).hypot(y - v))
1006 })
1007 .fold(f64::INFINITY, f64::min);
1008 let diameter_m = 2.0 * inner.outer_radius_m;
1009 if apart_m < diameter_m - LENGTH_TOLERANCE_M {
1010 findings.push(Finding::ClusterTubesOverlap {
1011 tube: component.id.clone(),
1012 apart_m,
1013 diameter_m,
1014 });
1015 }
1016 }
1017 if let Part::CenteringRing(ring) = &component.part {
1018 let around = (container.id != parent.id)
1020 .then(|| layout.find(&container.id))
1021 .flatten()
1022 .map(|(i, _)| i);
1023 for tube in layout.components.iter().filter(|c| {
1024 (c.parent == component.parent || around.is_some_and(|i| c.parent == Some(i)))
1025 && c.id != component.id
1026 && c.id != parent.id
1027 }) {
1028 let Part::InnerTube(inner) = &tube.part else {
1029 continue;
1030 };
1031 let [x, y] = tube.part.axis_offset_m();
1034 let tubes = if inner.cluster_m.is_empty() {
1035 &[[0.0, 0.0]][..]
1036 } else {
1037 inner.cluster_m.as_slice()
1038 };
1039 let radial = tubes
1040 .iter()
1041 .map(|&[u, v]| {
1042 let d = (x + u).hypot(y + v);
1043 (d + inner.outer_radius_m).min(ring.outer_radius_m)
1044 - (d - inner.outer_radius_m).max(ring.inner_radius_m)
1045 })
1046 .fold(f64::NEG_INFINITY, f64::max);
1047 if overlap(fore, aft, tube.fore_station_m, tube.aft_station_m()) > LENGTH_TOLERANCE_M
1048 && radial > LENGTH_TOLERANCE_M
1049 {
1050 findings.push(Finding::RingOverlapsInnerTube {
1051 ring: component.id.clone(),
1052 tube: tube.id.clone(),
1053 });
1054 }
1055 }
1056 }
1057}
1058
1059pub fn check_configuration(
1066 layout: &Layout,
1067 configuration: &Configuration,
1068) -> Result<Vec<Finding>, DesignError> {
1069 let mut findings = Vec::new();
1070 let placed = layout.place_motors(configuration)?;
1073 for motor in placed.into_iter().filter(|motor| motor.tube == 0) {
1074 let Some((_, mount)) = layout.find(&motor.mount) else {
1075 continue;
1076 };
1077 if let Some(inner) = mount.part.inner_radius_m() {
1078 let inner_diameter = 2.0 * inner;
1079 let motor_diameter_m = motor.mounted.diameter_m;
1080 let slack_m = motor_fit_slack_m(motor_diameter_m);
1081 if motor_diameter_m > inner_diameter + slack_m + LENGTH_TOLERANCE_M {
1082 findings.push(Finding::MotorWiderThanMount {
1083 configuration: configuration.id.clone(),
1084 mount: mount.id.clone(),
1085 motor_diameter_m,
1086 mount_inner_diameter_m: inner_diameter,
1087 });
1088 } else if motor_diameter_m > inner_diameter + LENGTH_TOLERANCE_M {
1089 findings.push(Finding::MotorTightInMount {
1090 configuration: configuration.id.clone(),
1091 mount: mount.id.clone(),
1092 motor_diameter_m,
1093 mount_inner_diameter_m: inner_diameter,
1094 });
1095 }
1096 }
1097 let (fore, nozzle) = (motor.fore_station_m(), motor.nozzle_station_m());
1098 if overlap(fore, nozzle, mount.fore_station_m, mount.aft_station_m()) <= 0.0 {
1099 findings.push(Finding::MotorOutsideMount {
1100 configuration: configuration.id.clone(),
1101 mount: mount.id.clone(),
1102 });
1103 continue;
1104 }
1105 let past = mount.fore_station_m - fore;
1106 if past > LENGTH_TOLERANCE_M {
1107 findings.push(Finding::MotorPastMountTop {
1108 configuration: configuration.id.clone(),
1109 mount: mount.id.clone(),
1110 excess_m: past,
1111 });
1112 }
1113 }
1114 Ok(findings)
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119 use super::*;
1120 use crate::testing::{
1121 attached, body, bottom, inner_tube, mass_component, ring, rocket, stage, three_fin_rocket,
1122 top, tube,
1123 };
1124 use crate::{AutoDimension, MotorMount};
1125 use crate::{Part, Position};
1126
1127 #[test]
1130 fn describe_names_parts_and_sizes_in_millimeters() {
1131 let id = |s: &str| s.to_owned();
1132 let findings = [
1133 Finding::MotorWiderThanMount {
1134 configuration: id("cfg"),
1135 mount: id("mt"),
1136 motor_diameter_m: 0.038,
1137 mount_inner_diameter_m: 0.029,
1138 },
1139 Finding::MotorTightInMount {
1140 configuration: id("cfg"),
1141 mount: id("mt"),
1142 motor_diameter_m: 0.029,
1143 mount_inner_diameter_m: 0.028_956,
1144 },
1145 Finding::MotorOutsideMount {
1146 configuration: id("cfg"),
1147 mount: id("mt"),
1148 },
1149 Finding::MotorPastMountTop {
1150 configuration: id("cfg"),
1151 mount: id("mt"),
1152 excess_m: 0.012,
1153 },
1154 Finding::AttachmentOffBody {
1155 component: id("fin"),
1156 body: id("bt"),
1157 },
1158 Finding::AttachmentPastBodyEnd {
1159 component: id("fin"),
1160 body: id("bt"),
1161 excess_m: 0.0004,
1162 },
1163 Finding::PartOutsideRocket {
1164 component: id("mass"),
1165 },
1166 Finding::InternalPartPastParentEnd {
1167 component: id("cord"),
1168 parent: id("bt"),
1169 excess_m: 0.050_800_000_000_000_01,
1170 },
1171 Finding::InternalPartWiderThanParent {
1172 component: id("ring"),
1173 parent: id("bt"),
1174 reach_m: 0.04,
1175 room_m: 0.035,
1176 },
1177 Finding::InternalPartTightInParent {
1178 component: id("ring"),
1179 parent: id("bt"),
1180 reach_m: 0.035_05,
1181 room_m: 0.035,
1182 },
1183 Finding::PackedPartWiderThanParent {
1184 component: id("payload"),
1185 parent: id("bt"),
1186 reach_m: 0.0125,
1187 room_m: 0.0105,
1188 room_widens_aft: false,
1189 },
1190 Finding::RingAgainstParentEnd {
1191 ring: id("ring"),
1192 parent: id("cp"),
1193 reach_m: 0.04,
1194 room_m: 0.0399,
1195 },
1196 Finding::ClusterTubesOverlap {
1197 tube: id("it"),
1198 apart_m: 0.02,
1199 diameter_m: 0.025,
1200 },
1201 Finding::RingOverlapsInnerTube {
1202 ring: id("ring"),
1203 tube: id("it"),
1204 },
1205 Finding::CenterOutsideRocket {
1206 stage: id("st"),
1207 station_m: -0.25,
1208 },
1209 Finding::RadiusStep {
1210 fore: id("nose"),
1211 aft: id("bt"),
1212 fore_radius_m: 0.0205,
1213 aft_radius_m: 0.0207,
1214 },
1215 Finding::NoNoseCone {
1216 component: id("bt"),
1217 },
1218 Finding::InternalPartWedgedInParent {
1219 component: id("coupler"),
1220 parent: id("nose"),
1221 reach_m: 0.0245,
1222 room_m: 0.0115,
1223 widest_room_m: 0.025,
1224 },
1225 ];
1226 let name = |id: &str| format!("<{id}>");
1227 let sentences: Vec<String> = findings.iter().map(|f| f.describe(&name)).collect();
1228 for (finding, sentence) in findings.iter().zip(&sentences) {
1229 let value = serde_json::to_value(finding).unwrap();
1231 for (key, field) in value.as_object().unwrap() {
1232 if let Some(text) = field.as_str().filter(|_| key != "kind") {
1233 assert!(sentence.contains(&format!("<{text}>")), "{key}: {sentence}");
1234 }
1235 }
1236 assert!(
1237 !sentence.contains('{') && !sentence.contains('_'),
1238 "{sentence}"
1239 );
1240 }
1241 assert_eq!(
1242 sentences[0],
1243 "the motor in <mt> is 38.0 mm wide, wider than the mount's 29.0 mm bore by more \
1244 than a real case's slack (configuration <cfg>)"
1245 );
1246 assert!(sentences[5].contains(" 0.40 mm past "), "{}", sentences[5]);
1247 assert!(sentences[7].contains(" 50.8 mm past "), "{}", sentences[7]);
1248 assert!(sentences[14].contains(" 250.0 mm forward of the nose tip"));
1249 assert_eq!(mm(0.000_05), "0.050 mm");
1250 assert!(
1252 sentences[1].contains(" is nominally 29.00 mm wide in a 28.96 mm bore"),
1253 "{}",
1254 sentences[1]
1255 );
1256 assert!(
1257 sentences[9].contains(" reaches 35.05 mm from the axis of <bt>, which has 35.00 mm")
1258 );
1259 assert!(sentences[10].contains(
1260 "<payload> is packed to reach 12.5 mm from the axis of <bt>, which has 10.5 mm of room"
1261 ));
1262 assert!(
1263 sentences[17].contains(
1264 "<coupler> reaches 24.5 mm from the axis of <nose>, which narrows to 11.5 mm of \
1265 room along it (25.0 mm where widest)"
1266 ),
1267 "{}",
1268 sentences[17]
1269 );
1270 assert!(sentences[11].contains(
1271 " reaches 40.0 mm from the axis, wider than <cp>, and caps its end, in 39.9 mm"
1272 ));
1273 assert!(sentences[15].contains(" ends 20.5 mm in radius where <bt> begins 20.7 mm"));
1274 assert_eq!(
1275 mm_pair(0.038, 0.037_97),
1276 ("38.00 mm".to_owned(), "37.97 mm".to_owned())
1277 );
1278 assert_eq!(
1279 mm_pair(0.040, 0.000_049),
1280 ("40.000 mm".to_owned(), "0.049 mm".to_owned())
1281 );
1282 assert_eq!(
1283 mm_pair(0.025, 0.024_98),
1284 ("25.00 mm".to_owned(), "24.98 mm".to_owned())
1285 );
1286 assert_eq!(
1287 mm_pair(0.020_5, 0.020_500_01),
1288 ("20.5000 mm".to_owned(), "20.5000 mm".to_owned())
1289 );
1290 assert_eq!(mm(0.0005), "0.50 mm");
1291 assert_eq!(mm(0.0), "0.0 mm");
1292 }
1293
1294 fn kinds(findings: &[Finding]) -> Vec<(String, Severity)> {
1295 findings
1296 .iter()
1297 .map(|f| {
1298 let tag = serde_json::to_value(f).unwrap()["kind"]
1299 .as_str()
1300 .unwrap()
1301 .to_owned();
1302 (tag, f.severity())
1303 })
1304 .collect()
1305 }
1306
1307 #[test]
1308 fn sample_rocket_passes_every_check() {
1309 let findings = check(&three_fin_rocket()).unwrap();
1310 assert!(findings.is_empty(), "{findings:?}");
1311 }
1312
1313 #[test]
1316 fn motor_wider_than_mount_is_rejected() {
1317 let mut design = three_fin_rocket();
1318 design.configurations[0].motors[0] = crate::testing::motor("mmt", 0.054, 0.2);
1319 let findings = check(&design).unwrap();
1320 assert_eq!(
1321 findings,
1322 vec![Finding::MotorWiderThanMount {
1323 configuration: "main".to_owned(),
1324 mount: "mmt".to_owned(),
1325 motor_diameter_m: 0.054,
1326 mount_inner_diameter_m: 2.0 * (0.020 - 0.001),
1327 }]
1328 );
1329 assert_eq!(findings[0].severity(), Severity::Error);
1330 assert!(has_errors(&findings));
1331 if let Part::InnerTube(tube) = &mut design.stages[0].components[1].children[0].part {
1333 tube.cluster_m = vec![[0.0, 0.0], [0.04, 0.0], [-0.04, 0.0]];
1334 }
1335 let findings = check(&design).unwrap();
1336 let wider = findings
1337 .iter()
1338 .filter(|f| matches!(f, Finding::MotorWiderThanMount { .. }))
1339 .count();
1340 assert_eq!(wider, 1, "{findings:?}");
1341
1342 let exact = three_fin_rocket();
1344 assert!(check(&exact).unwrap().is_empty());
1345 let mut design = three_fin_rocket();
1347 design.configurations[0].motors[0] = crate::testing::motor("mmt", 0.038 + 1e-6, 0.2);
1348 assert!(has_errors(&check(&design).unwrap()));
1349
1350 let mut design = three_fin_rocket();
1352 design.configurations[0].motors[0] = crate::testing::motor("mmt", 0.038, 0.35);
1353 let findings = check(&design).unwrap();
1354 let [Finding::MotorPastMountTop { excess_m, .. }] = findings[..] else {
1355 panic!("{findings:?}")
1356 };
1357 assert!((excess_m - 0.04).abs() < 1e-12);
1358 assert!(!has_errors(&findings));
1359 }
1360
1361 #[test]
1366 fn a_nominal_motor_in_its_matching_tube_only_warns() {
1367 let fitted = |motor_m: f64, bore_m: f64| {
1368 let mut design = three_fin_rocket();
1369 let Part::InnerTube(tube) = &mut design.stages[0].components[1].children[0].part else {
1370 panic!("the tests' rocket's mount is an inner tube");
1371 };
1372 tube.outer_radius_m = bore_m / 2.0 + tube.thickness_m;
1373 design.configurations[0].motors[0] = crate::testing::motor("mmt", motor_m, 0.2);
1374 check(&design).unwrap()
1375 };
1376 let findings = fitted(0.029, 0.028956);
1377 let [
1378 Finding::MotorTightInMount {
1379 motor_diameter_m,
1380 mount_inner_diameter_m,
1381 ..
1382 },
1383 ] = findings[..]
1384 else {
1385 panic!("{findings:?}")
1386 };
1387 assert_eq!(motor_diameter_m, 0.029);
1388 assert!((mount_inner_diameter_m - 0.028956).abs() < 1e-12);
1389 assert_eq!(findings[0].severity(), Severity::Warning);
1390 assert!(!has_errors(&findings));
1391 assert!((motor_fit_slack_m(0.029) - 0.000_298).abs() < 1e-12);
1393 assert!((motor_fit_slack_m(0.024) - 0.000_047_8).abs() < 1e-12);
1394 assert!((motor_fit_slack_m(0.018) - 0.000_143_8).abs() < 1e-12);
1395 assert_eq!(motor_fit_slack_m(0.038), 0.0);
1396 assert!(!has_errors(&fitted(0.029, 0.028702 + 1e-7)));
1397 for (motor_m, bore_m) in [
1398 (0.029, 0.028702 - 1e-6),
1399 (0.029, 0.028),
1400 (0.024, 0.023952 - 1e-6),
1401 (0.038, 0.038 - 1e-6),
1402 (0.054, 0.054 - 1e-6),
1403 (0.0289, 0.0289 - 1e-6),
1405 ] {
1406 let findings: Vec<_> = fitted(motor_m, bore_m)
1408 .into_iter()
1409 .filter(|f| {
1410 matches!(
1411 f,
1412 Finding::MotorWiderThanMount { .. } | Finding::MotorTightInMount { .. }
1413 )
1414 })
1415 .collect();
1416 assert!(
1417 matches!(&findings[..], [Finding::MotorWiderThanMount { .. }]),
1418 "{motor_m} in {bore_m}: {findings:?}"
1419 );
1420 }
1421 }
1422
1423 #[test]
1428 fn a_pod_set_of_no_length_touches_its_tube_where_it_sits() {
1429 let podded = |children: Vec<crate::Component>, position: Position| {
1430 let mut design = three_fin_rocket();
1431 let mut pods = attached(
1432 "pods",
1433 Part::PodSet(crate::parts::PodSet {
1434 count: 2,
1435 radial_offset_m: 0.06,
1436 angle_rad: 0.0,
1437 }),
1438 position,
1439 );
1440 pods.children = children;
1441 design.stages[0].components[1].children.push(pods);
1442 check(&design).unwrap()
1443 };
1444 let mut phantom = body("phantom", tube(0.0, 0.0, 0.0));
1445 phantom.children = vec![attached(
1446 "wings",
1447 crate::testing::fins(0.05, 0.03),
1448 bottom(0.0),
1449 )];
1450 let base = check(&three_fin_rocket()).unwrap();
1451 assert_eq!(podded(vec![phantom.clone()], top(0.1)), base);
1452 assert_eq!(podded(Vec::new(), top(0.1)), base);
1453 assert_eq!(podded(Vec::new(), bottom(0.0)), base);
1455 let off = podded(vec![phantom], bottom(0.01));
1456 assert!(
1457 off.contains(&Finding::AttachmentOffBody {
1458 component: "pods".to_owned(),
1459 body: "airframe".to_owned(),
1460 }),
1461 "{off:?}"
1462 );
1463 assert_eq!(off.len(), base.len() + 1, "{off:?}");
1464
1465 let mut far = body("phantom", tube(0.0, 0.0, 0.0));
1467 far.children = vec![attached(
1468 "wings",
1469 crate::testing::fins(0.05, 0.03),
1470 top(0.01),
1471 )];
1472 let off = podded(vec![far], top(0.1));
1473 assert_eq!(
1474 off.iter()
1475 .filter(
1476 |f| matches!(f, Finding::AttachmentOffBody { component, body }
1477 if component == "wings" && body == "phantom")
1478 )
1479 .count(),
1480 1,
1481 "{off:?}"
1482 );
1483 assert_eq!(off.len(), base.len() + 1, "{off:?}");
1484 }
1485
1486 #[test]
1489 fn fin_root_must_touch_body() {
1490 let with_fins = |offset: f64| {
1491 let mut design = three_fin_rocket();
1492 design.stages[0].components[1].children[3].position = Some(bottom(offset));
1493 check(&design).unwrap()
1494 };
1495 let off = with_fins(0.15);
1497 assert_eq!(
1498 off,
1499 vec![Finding::AttachmentOffBody {
1500 component: "fins".to_owned(),
1501 body: "airframe".to_owned(),
1502 }]
1503 );
1504 assert!(has_errors(&off));
1505 assert!(has_errors(&with_fins(0.1)));
1507 let past = with_fins(0.04);
1508 let [
1509 Finding::AttachmentPastBodyEnd {
1510 ref component,
1511 excess_m,
1512 ..
1513 },
1514 ] = past[..]
1515 else {
1516 panic!("{past:?}")
1517 };
1518 assert_eq!(component, "fins");
1519 assert!((excess_m - 0.04).abs() < 1e-12);
1520 assert!(!has_errors(&past));
1521 assert!(with_fins(0.0).is_empty());
1522 let mut design = three_fin_rocket();
1524 design.stages[0].components[1].children[3].position = Some(top(-0.2));
1525 assert!(has_errors(&check(&design).unwrap()));
1526 }
1527
1528 #[test]
1529 fn internal_parts_radius_steps_and_a_missing_nose_are_found() {
1530 let mut airframe = body("airframe", tube(0.5, 0.03, 0.001));
1531 airframe.children = vec![
1532 attached("wide-ring", ring(0.005, 0.03, 0.01), top(0.1)),
1534 attached("long-mmt", inner_tube(0.2, 0.01, 0.001), bottom(0.05)),
1536 attached("lost", mass_component(0.1, 0.05, 0.01), top(-0.2)),
1538 attached("tight-ring", ring(0.005, 0.0295, 0.01), top(0.2)),
1540 ];
1541 let mut mount = airframe.children[1].clone();
1542 mount.motor_mount = Some(MotorMount::default());
1543 airframe.children[1] = mount;
1544 let design = rocket(vec![stage(
1545 "s",
1546 vec![airframe, body("tail", tube(0.1, 0.02, 0.001))],
1547 )]);
1548 let findings = check(&design).unwrap();
1549 assert_eq!(
1550 kinds(&findings),
1551 vec![
1552 ("no_nose_cone".to_owned(), Severity::Warning),
1553 ("radius_step".to_owned(), Severity::Warning),
1554 (
1555 "internal_part_wider_than_parent".to_owned(),
1556 Severity::Error
1557 ),
1558 (
1559 "internal_part_past_parent_end".to_owned(),
1560 Severity::Warning
1561 ),
1562 ("part_outside_rocket".to_owned(), Severity::Error),
1564 (
1565 "internal_part_tight_in_parent".to_owned(),
1566 Severity::Warning
1567 ),
1568 ]
1569 );
1570
1571 let mut design = three_fin_rocket();
1573 design.stages[0].components[1].children[1].auto = vec![AutoDimension::OuterRadius];
1574 assert_eq!(
1575 check(&design).unwrap(),
1576 vec![Finding::RingOverlapsInnerTube {
1577 ring: "ring-fore".to_owned(),
1578 tube: "mmt".to_owned(),
1579 }]
1580 );
1581 }
1582
1583 #[test]
1586 fn internal_parts_are_measured_from_their_parents_axis() {
1587 let offset = |part: Part, r: f64| match part {
1588 Part::InnerTube(mut t) => {
1589 t.radial_offset_m = r;
1590 Part::InnerTube(t)
1591 }
1592 Part::MassComponent(mut m) => {
1593 m.packing.radial_offset_m = r;
1594 Part::MassComponent(m)
1595 }
1596 other => other,
1597 };
1598 let mut pod = attached(
1599 "pod",
1600 offset(inner_tube(0.3, 0.02, 0.001), 0.025),
1601 bottom(0.0),
1602 );
1603 pod.children = vec![attached(
1604 "block",
1605 offset(inner_tube(0.01, 0.019, 0.003), 0.025),
1606 top(0.0),
1607 )];
1608 let mut airframe = body("airframe", tube(0.8, 0.05, 0.002));
1609 airframe.children = vec![pod, attached("ring", ring(0.005, 0.048, 0.0), bottom(-0.1))];
1610 let design = rocket(vec![stage(
1611 "s",
1612 vec![body("nose", crate::testing::nose(0.2, 0.05)), airframe],
1613 )]);
1614 assert_eq!(
1615 check(&design).unwrap(),
1616 vec![Finding::RingOverlapsInnerTube {
1617 ring: "ring".to_owned(),
1618 tube: "pod".to_owned(),
1619 }]
1620 );
1621
1622 let mut design = design;
1623 design.stages[0].components[1].children[0].children[0] =
1624 attached("on-axis", mass_component(0.1, 0.05, 0.004), top(0.1));
1625 let findings = check(&design).unwrap();
1626 let wide = findings
1627 .iter()
1628 .find_map(|f| match f {
1629 Finding::InternalPartWiderThanParent {
1630 component,
1631 reach_m,
1632 room_m,
1633 ..
1634 } if component == "on-axis" => Some((*reach_m, *room_m)),
1635 _ => None,
1636 })
1637 .unwrap();
1638 assert!(
1639 (wide.0 - 0.029).abs() < 1e-15 && (wide.1 - 0.019).abs() < 1e-15,
1640 "{wide:?}"
1641 );
1642 }
1643
1644 #[test]
1649 fn a_packed_part_wider_than_its_bore_warns_while_its_center_is_inside() {
1650 let room = 0.0255;
1652 let placed = |part: Part, offset_m: f64| {
1653 let part = match part {
1654 Part::MassComponent(mut m) => {
1655 m.packing.radial_offset_m = offset_m;
1656 Part::MassComponent(m)
1657 }
1658 Part::Parachute(mut p) => {
1659 p.packing.radial_offset_m = offset_m;
1660 Part::Parachute(p)
1661 }
1662 other => other,
1663 };
1664 let mut design = three_fin_rocket();
1665 design.stages[0].components[1]
1666 .children
1667 .push(attached("packed", part, top(0.2)));
1668 check(&design).unwrap()
1669 };
1670 let packed = |reach_m: f64| {
1671 vec![Finding::PackedPartWiderThanParent {
1672 component: "packed".to_owned(),
1673 parent: "airframe".to_owned(),
1674 reach_m,
1675 room_m: room,
1676 room_widens_aft: false,
1679 }]
1680 };
1681 for radius_m in [0.0305, 0.026] {
1683 assert_eq!(
1684 placed(mass_component(0.1, 0.05, radius_m), 0.0),
1685 packed(radius_m)
1686 );
1687 }
1688 let mut chute = three_fin_rocket().stages[0].components[1].children[4]
1689 .part
1690 .clone();
1691 if let Part::Parachute(p) = &mut chute {
1692 p.packing.radius_m = 0.03;
1693 }
1694 assert_eq!(placed(chute, 0.0), packed(0.03));
1695 let reach = room + 0.002;
1697 assert_eq!(
1698 placed(mass_component(0.1, 0.05, 0.002), room),
1699 packed(reach)
1700 );
1701 assert_eq!(
1702 placed(mass_component(0.1, 0.05, 0.002), room + 1e-7),
1703 vec![Finding::InternalPartWiderThanParent {
1704 component: "packed".to_owned(),
1705 parent: "airframe".to_owned(),
1706 reach_m: room + 1e-7 + 0.002,
1707 room_m: room,
1708 }]
1709 );
1710 for (radius_m, offset_m) in [(0.0002, room + 0.0002), (0.0001, room + 0.0005)] {
1713 assert_eq!(
1714 placed(mass_component(0.1, 0.05, radius_m), offset_m),
1715 vec![Finding::InternalPartWiderThanParent {
1716 component: "packed".to_owned(),
1717 parent: "airframe".to_owned(),
1718 reach_m: offset_m + radius_m,
1719 room_m: room,
1720 }]
1721 );
1722 }
1723 let findings = placed(ring(0.005, 0.0305, 0.0), 0.0);
1725 assert!(
1726 matches!(&findings[..], [Finding::InternalPartWiderThanParent { component, .. }]
1727 if component == "packed"),
1728 "{findings:?}"
1729 );
1730 }
1731
1732 #[test]
1738 fn a_part_wider_than_its_parent_warns_only_as_a_fit_or_a_cap() {
1739 let fitted = |radius_m: f64, at: Position| {
1742 let mut design = three_fin_rocket();
1743 let mut coupler = attached("coupler", inner_tube(0.1, 0.0255, 0.001), top(0.1));
1744 coupler.children = vec![attached("bulkhead", ring(0.005, radius_m, 0.0), at)];
1745 design.stages[0].components[1].children.push(coupler);
1746 check(&design).unwrap()
1747 };
1748 let room = 0.0255 - 0.001;
1749 let tight = room + 0.0008;
1750 assert_eq!(
1751 fitted(tight, top(0.02)),
1752 vec![Finding::InternalPartTightInParent {
1753 component: "bulkhead".to_owned(),
1754 parent: "coupler".to_owned(),
1755 reach_m: tight,
1756 room_m: room,
1757 }]
1758 );
1759 assert_eq!(
1760 fitted(tight + 1e-7, top(0.02)),
1761 vec![Finding::InternalPartWiderThanParent {
1762 component: "bulkhead".to_owned(),
1763 parent: "coupler".to_owned(),
1764 reach_m: tight + 1e-7,
1765 room_m: room,
1766 }]
1767 );
1768 let capped = 0.0255 + 0.0008;
1771 for (radius_m, at) in [
1772 (0.0255, top(0.0)),
1773 (0.0255, top(-0.005)),
1774 (0.0255, bottom(0.0)),
1775 (capped, bottom(0.005)),
1776 ] {
1777 let findings = fitted(radius_m, at);
1778 assert!(!has_errors(&findings));
1779 let findings: Vec<_> = findings
1781 .into_iter()
1782 .filter(|f| !matches!(f, Finding::InternalPartPastParentEnd { .. }))
1783 .collect();
1784 assert_eq!(
1785 findings,
1786 vec![Finding::RingAgainstParentEnd {
1787 ring: "bulkhead".to_owned(),
1788 parent: "coupler".to_owned(),
1789 reach_m: radius_m,
1790 room_m: 0.0255,
1791 }],
1792 "{radius_m} at {at:?}"
1793 );
1794 }
1795 for (radius_m, at) in [(0.0255, top(0.02)), (capped + 1e-7, top(0.0))] {
1797 let findings = fitted(radius_m, at);
1798 assert!(
1799 matches!(&findings[..], [Finding::InternalPartWiderThanParent { component, .. }]
1800 if component == "bulkhead"),
1801 "{radius_m} at {at:?}: {findings:?}"
1802 );
1803 }
1804 for (cluster, offset_m, length_m) in [
1807 (vec![[0.018, 0.0], [-0.018, 0.0]], 0.0, 0.005),
1808 (Vec::new(), 0.018, 0.005),
1809 (Vec::new(), 0.0, 0.1),
1810 ] {
1811 let mut design = three_fin_rocket();
1812 let mut tube = inner_tube(0.1, 0.007, 0.001);
1813 if let Part::InnerTube(inner) = &mut tube {
1814 inner.cluster_m = cluster.clone();
1815 inner.radial_offset_m = offset_m;
1816 }
1817 let mut holder = attached("holder", tube, top(0.1));
1818 holder.children = vec![attached("ring", ring(length_m, 0.012, 0.0), top(0.0))];
1819 design.stages[0].components[1].children.push(holder);
1820 let findings = check(&design).unwrap();
1821 assert!(
1822 findings.iter().any(|f| matches!(f,
1823 Finding::InternalPartWiderThanParent { component, .. } if component == "ring")),
1824 "{cluster:?} {offset_m} {length_m}: {findings:?}"
1825 );
1826 }
1827 let mut design = three_fin_rocket();
1829 let mut coupler = attached("coupler", inner_tube(0.1, 0.0255, 0.001), top(0.1));
1830 coupler.children = vec![attached(
1831 "sleeve",
1832 inner_tube(0.01, 0.0255, 0.001),
1833 top(0.0),
1834 )];
1835 design.stages[0].components[1].children.push(coupler);
1836 assert!(matches!(
1837 &check(&design).unwrap()[..],
1838 [Finding::InternalPartWiderThanParent { component, .. }] if component == "sleeve"
1839 ));
1840 }
1841
1842 #[test]
1847 fn a_ring_around_its_motor_tube_needs_room_in_the_airframe() {
1848 let wrapped = |outer_m: f64, inner_m: f64, offset_m: f64, cluster: Vec<[f64; 2]>| {
1851 let mut design = three_fin_rocket();
1852 let mount = &mut design.stages[0].components[1].children[0];
1853 if let Part::InnerTube(tube) = &mut mount.part {
1854 tube.radial_offset_m = offset_m;
1855 tube.cluster_m = cluster;
1856 }
1857 mount.children = vec![attached("wrap", ring(0.003, outer_m, inner_m), top(0.1))];
1858 check(&design).unwrap()
1859 };
1860 let (outer, bore, airframe) = (0.020, 0.019, 0.0255);
1861 assert_eq!(wrapped(airframe, outer, 0.0, Vec::new()), vec![]);
1862 assert_eq!(wrapped(airframe, outer + 0.001, 0.0, Vec::new()), vec![]);
1864 let tight = airframe + 0.0008;
1865 assert_eq!(
1866 wrapped(tight, outer, 0.0, Vec::new()),
1867 vec![Finding::InternalPartTightInParent {
1868 component: "wrap".to_owned(),
1869 parent: "airframe".to_owned(),
1870 reach_m: tight,
1871 room_m: airframe,
1872 }]
1873 );
1874 assert_eq!(
1875 wrapped(tight + 1e-7, outer, 0.0, Vec::new()),
1876 vec![Finding::InternalPartWiderThanParent {
1877 component: "wrap".to_owned(),
1878 parent: "airframe".to_owned(),
1879 reach_m: tight + 1e-7,
1880 room_m: airframe,
1881 }]
1882 );
1883 let in_tube = |findings: Vec<Finding>| {
1885 findings.iter().any(|f| {
1886 matches!(f, Finding::InternalPartWiderThanParent { component, parent, room_m, .. }
1887 if component == "wrap" && parent == "mmt" && *room_m == bore)
1888 })
1889 };
1890 assert!(in_tube(wrapped(airframe, outer - 1e-6, 0.0, Vec::new())));
1891 assert!(in_tube(wrapped(airframe, outer, 0.002, Vec::new())));
1892 assert!(in_tube(wrapped(
1893 airframe,
1894 outer,
1895 0.0,
1896 vec![[0.0, 0.0], [0.05, 0.0]]
1897 )));
1898 assert_eq!(wrapped(airframe, outer - 5e-10, 0.0, Vec::new()), vec![]);
1900
1901 let overhung = |at: Position| {
1905 let mut design = three_fin_rocket();
1906 let mount = &mut design.stages[0].components[1].children[0];
1907 mount.position = Some(bottom(0.05));
1908 mount.children = vec![attached("wrap", ring(0.003, airframe, outer), at)];
1909 check(&design).unwrap()
1910 };
1911 assert!(in_tube(overhung(bottom(0.0))));
1912 assert!(in_tube(overhung(bottom(-0.048))));
1913 assert_eq!(
1914 overhung(top(0.1))
1915 .into_iter()
1916 .filter(
1917 |f| matches!(f, Finding::InternalPartWiderThanParent { component, .. }
1918 | Finding::InternalPartTightInParent { component, .. } if component == "wrap")
1919 )
1920 .count(),
1921 0
1922 );
1923
1924 let mut design = three_fin_rocket();
1926 let airframe_part = &mut design.stages[0].components[1];
1927 airframe_part.children[0].children =
1928 vec![attached("wrap", ring(0.003, airframe, outer), top(0.1))];
1929 let mut side = inner_tube(0.04, 0.004, 0.0005);
1930 if let Part::InnerTube(tube) = &mut side {
1931 tube.radial_offset_m = 0.021;
1932 }
1933 airframe_part
1934 .children
1935 .push(attached("side", side, top(0.58)));
1936 assert_eq!(
1937 check(&design).unwrap(),
1938 vec![Finding::RingOverlapsInnerTube {
1939 ring: "wrap".to_owned(),
1940 tube: "side".to_owned(),
1941 }]
1942 );
1943 }
1944
1945 #[test]
1948 fn the_fit_tolerance_is_the_coarse_general_tolerance_of_the_bore() {
1949 for (diameter_m, tolerance_m) in [
1950 (0.0004, 0.0),
1951 (0.0005, 0.0002),
1952 (0.002, 0.0002),
1953 (0.006, 0.0003),
1954 (0.0061, 0.0005),
1955 (0.030, 0.0005),
1956 (0.0301, 0.0008),
1957 (0.120, 0.0008),
1958 (0.150, 0.0012),
1959 (5.0, 0.004),
1960 ] {
1961 assert_eq!(
1962 fit_tolerance_m(diameter_m / 2.0),
1963 tolerance_m,
1964 "{diameter_m}"
1965 );
1966 }
1967 assert_eq!(fit_tolerance_m(f64::NAN), 0.0);
1968 }
1969
1970 #[test]
1972 fn motor_outside_its_mount_is_rejected() {
1973 for overhang in [5.0, 0.2, -0.3] {
1974 let mut design = three_fin_rocket();
1975 design.stages[0].components[1].children[0].motor_mount = Some(MotorMount {
1976 overhang_m: overhang,
1977 });
1978 let findings = check(&design).unwrap();
1979 assert_eq!(
1980 findings,
1981 vec![Finding::MotorOutsideMount {
1982 configuration: "main".to_owned(),
1983 mount: "mmt".to_owned(),
1984 }],
1985 "{overhang}"
1986 );
1987 assert!(has_errors(&findings));
1988 }
1989 }
1990
1991 #[test]
1993 fn parts_and_centers_off_the_rocket_are_rejected() {
1994 let mut design = three_fin_rocket();
1995 design.stages[0].components[1].children[4].position = Some(top(2.0));
1996 assert_eq!(
1997 check(&design).unwrap(),
1998 vec![Finding::PartOutsideRocket {
1999 component: "chute".to_owned(),
2000 }]
2001 );
2002 let mut design = three_fin_rocket();
2003 design.stages[0].overrides.cg_aft_m = Some(350.0);
2004 let findings = check(&design).unwrap();
2005 assert!(
2006 matches!(&findings[..], [Finding::CenterOutsideRocket { stage, station_m }]
2007 if stage == "sustainer" && *station_m == 350.0),
2008 "{findings:?}"
2009 );
2010 let mut design = three_fin_rocket();
2012 let mount = &mut design.stages[0].components[1].children[0];
2013 mount.position = Some(bottom(0.012));
2014 mount.children = vec![attached(
2015 "retainer",
2016 mass_component(0.05, 0.01, 0.015),
2017 bottom(0.0),
2018 )];
2019 let findings = check(&design).unwrap();
2020 assert!(
2021 matches!(&findings[..], [Finding::InternalPartPastParentEnd { component, .. }]
2022 if component == "mmt"),
2023 "{findings:?}"
2024 );
2025 let mut flush = design.clone();
2027 flush.stages[0].components[1].children[0].children[0].position = Some(top(0.3));
2028 let layout = flush.layout().unwrap();
2029 let (_, retainer) = layout.find("retainer").unwrap();
2030 assert!(
2031 retainer.fore_station_m >= layout.length_m,
2032 "the test needs it behind"
2033 );
2034 let findings = check(&flush).unwrap();
2035 assert!(
2036 findings
2037 .iter()
2038 .all(|f| !matches!(f, Finding::PartOutsideRocket { .. })),
2039 "{findings:?}"
2040 );
2041 assert!(!has_errors(&findings), "{findings:?}");
2042 let mut clear = design.clone();
2044 clear.stages[0].components[1].children[0].children[0].position = Some(top(0.35));
2045 assert_eq!(
2046 check(&clear).unwrap(),
2047 vec![
2048 Finding::InternalPartPastParentEnd {
2049 component: "mmt".to_owned(),
2050 parent: "airframe".to_owned(),
2051 excess_m: check(&design)
2052 .unwrap()
2053 .iter()
2054 .find_map(|f| match f {
2055 Finding::InternalPartPastParentEnd { excess_m, .. } => Some(*excess_m),
2056 _ => None,
2057 })
2058 .unwrap(),
2059 },
2060 Finding::PartOutsideRocket {
2061 component: "retainer".to_owned(),
2062 },
2063 ]
2064 );
2065 let mut can = body("can", tube(0.3, 0.03, 0.0005));
2068 let mut fins = attached("trailing-fins", crate::testing::fins(0.2, 0.1), bottom(0.0));
2069 if let Part::FinSet(set) = &mut fins.part {
2070 set.planform = crate::FinPlanform::Trapezoidal {
2071 root_chord_m: 0.2,
2072 tip_chord_m: 0.2,
2073 span_m: 0.1,
2074 sweep_m: 0.4,
2075 };
2076 }
2077 can.children = vec![fins];
2078 let design = rocket(vec![stage("s", vec![can])]);
2079 let layout = design.layout().unwrap();
2080 assert!(
2081 -layout.stages[0].mass.cg_m.z > layout.length_m,
2082 "the test needs it off"
2083 );
2084 assert!(!has_errors(&check(&design).unwrap()));
2085 let mut heavier = design.clone();
2087 heavier.stages[0].components[0].children[0]
2088 .overrides
2089 .mass_kg = Some(1.0);
2090 assert!(!has_errors(&check(&heavier).unwrap()));
2091 let mut sideways = design.clone();
2093 sideways.stages[0].components[0].children[0]
2094 .overrides
2095 .cg_xy_m = Some([0.0, 0.0]);
2096 assert!(!has_errors(&check(&sideways).unwrap()));
2097 let mut moved = design.clone();
2099 moved.stages[0].components[0].children[0].overrides.cg_aft_m = Some(50.0);
2100 let findings = check(&moved).unwrap();
2101 assert!(
2102 matches!(&findings[..], [Finding::NoNoseCone { .. }, Finding::CenterOutsideRocket { stage, .. }] if stage == "s"),
2103 "{findings:?}"
2104 );
2105 let mut design = rocket(vec![stage(
2108 "s",
2109 vec![
2110 body("nose", crate::testing::nose(0.1, 0.03)),
2111 body("airframe", tube(0.7, 0.03, 0.001)),
2112 ],
2113 )]);
2114 design.stages[0].components[1].children = vec![attached(
2115 "tail-weight",
2116 mass_component(0.05, 0.0, 0.01),
2117 Position::Absolute { station_m: 0.8 },
2118 )];
2119 assert!(design.layout().unwrap().length_m < 0.8);
2120 assert!(check(&design).unwrap().is_empty());
2121 for (nose, tube_length) in [(0.1, 0.2), (0.15, 0.15)] {
2124 let mut design = rocket(vec![stage(
2125 "s",
2126 vec![
2127 body("nose", crate::testing::nose(nose, 0.03)),
2128 body("airframe", tube(tube_length, 0.03, 0.001)),
2129 ],
2130 )]);
2131 design.stages[0].components[1].children = vec![attached(
2132 "tail-block",
2133 mass_component(0.05, 0.05, 0.01),
2134 Position::Absolute { station_m: 0.3 },
2135 )];
2136 let findings = check(&design).unwrap();
2137 assert!(
2138 matches!(&findings[..], [Finding::InternalPartPastParentEnd { component, .. }]
2139 if component == "tail-block"),
2140 "{nose} + {tube_length}: {findings:?}"
2141 );
2142 }
2143 let mut design = three_fin_rocket();
2146 design.stages[0].components[0].children = vec![attached(
2147 "ballast",
2148 mass_component(0.1, 0.02, 0.2),
2149 bottom(0.0),
2150 )];
2151 assert!(matches!(
2152 &check(&design).unwrap()[..],
2153 [Finding::PackedPartWiderThanParent { component, .. }] if component == "ballast"
2154 ));
2155 let mut layout = three_fin_rocket().layout().unwrap();
2157 layout.components[2].parent = Some(999);
2158 let _ = check_layout(&layout);
2159 }
2160
2161 #[test]
2167 fn a_packed_part_names_367_only_where_the_room_widens_aft_of_it() {
2168 let widens = |fore_radius_m: f64, aft_radius_m: f64, position: Option<Position>| {
2169 let transition = Part::Transition(crate::Transition {
2170 shape: crate::NoseShape::Conical {},
2171 clipped: false,
2172 length_m: 0.2,
2173 fore_radius_m,
2174 aft_radius_m,
2175 wall: crate::Wall::Shell { thickness_m: 0.002 },
2176 fore_shoulder: None,
2177 aft_shoulder: None,
2178 material: crate::Material::bulk("PLA", 1240.0),
2179 });
2180 let mut middle = body("transition", transition);
2181 let position = position.unwrap_or(if fore_radius_m < aft_radius_m {
2184 top(0.01)
2185 } else {
2186 bottom(-0.01)
2187 });
2188 middle.children = vec![attached("mass", mass_component(0.05, 0.03, 0.02), position)];
2189 let design = rocket(vec![stage(
2190 "sustainer",
2191 vec![
2192 body("nose", crate::testing::nose(0.2, fore_radius_m)),
2193 middle,
2194 body("airframe", tube(0.6, aft_radius_m, 0.0015)),
2195 ],
2196 )]);
2197 let findings = check(&design).unwrap();
2198 let packed: Vec<_> = findings
2199 .iter()
2200 .filter_map(|finding| match finding {
2201 Finding::PackedPartWiderThanParent {
2202 component,
2203 room_widens_aft,
2204 ..
2205 } if component == "mass" => Some((
2206 *room_widens_aft,
2207 finding.describe(&|id: &str| id.to_owned()),
2208 )),
2209 _ => None,
2210 })
2211 .collect();
2212 assert_eq!(packed.len(), 1, "{findings:?}");
2213 let (flag, text) = packed[0].clone();
2214 assert_eq!(flag, text.contains("issue #367"), "{text}");
2215 flag
2216 };
2217 assert!(widens(0.015, 0.025, None), "a shoulder widens aft");
2218 assert!(!widens(0.025, 0.015, None), "a boattail narrows aft");
2219 assert!(
2222 !widens(0.025, 0.015, Some(bottom(0.01))),
2223 "past a boattail's end"
2224 );
2225 }
2226
2227 #[test]
2231 fn a_part_in_a_nose_cone_needs_room_where_it_sits() {
2232 let findings = |part: Part, position: Position| {
2233 let mut nose = body("nose", crate::testing::nose(0.2, 0.027));
2234 nose.children = vec![attached("part", part, position)];
2235 let design = rocket(vec![stage(
2236 "sustainer",
2237 vec![nose, body("airframe", tube(0.6, 0.027, 0.0015))],
2238 )]);
2239 check(&design).unwrap()
2240 };
2241 let room = |x_m: f64| (0.135 * x_m - 0.002_f64).max(0.0);
2242 let bulkhead = || ring(0.01, 0.02, 0.0);
2245 let tip = findings(bulkhead(), top(0.0));
2246 assert!(
2247 matches!(&tip[..], [Finding::InternalPartWiderThanParent { component, parent, reach_m, room_m }]
2248 if component == "part" && parent == "nose" && *reach_m == 0.02 && *room_m == 0.0),
2249 "{tip:?}"
2250 );
2251 assert_eq!(findings(bulkhead(), bottom(0.0)), vec![]);
2253 assert_eq!(
2255 findings(mass_component(0.05, 0.01, 0.02), bottom(0.0)),
2256 vec![]
2257 );
2258 let packed = findings(mass_component(0.05, 0.03, 0.02), top(0.0));
2261 assert!(
2262 matches!(&packed[..], [Finding::PackedPartWiderThanParent { component, room_m, room_widens_aft, .. }]
2263 if component == "part" && *room_m == 0.0 && *room_widens_aft),
2264 "{packed:?}"
2265 );
2266 let text = packed[0].describe(&|id: &str| id.to_owned());
2269 assert!(text.contains("issue #367"), "{text}");
2270 assert!(text.contains("margin may read high"), "{text}");
2271 let mut off_axis = mass_component(0.05, 0.03, 0.02);
2273 if let Part::MassComponent(mass) = &mut off_axis {
2274 mass.packing.radial_offset_m = 0.005;
2275 }
2276 let off = findings(off_axis, top(0.0));
2277 assert!(
2278 matches!(&off[..], [Finding::InternalPartWiderThanParent { component, reach_m, room_m, .. }]
2279 if component == "part" && (*reach_m - 0.025).abs() < 1e-15 && *room_m == 0.0),
2280 "{off:?}"
2281 );
2282 let point = findings(mass_component(0.05, 0.0, 0.02), top(0.0));
2285 assert!(
2286 matches!(&point[..], [Finding::PackedPartWiderThanParent { room_m, room_widens_aft, .. }]
2287 if *room_m == 0.0 && *room_widens_aft),
2288 "{point:?}"
2289 );
2290 let mut point_off_axis = mass_component(0.05, 0.0, 0.02);
2291 if let Part::MassComponent(mass) = &mut point_off_axis {
2292 mass.packing.radial_offset_m = 0.005;
2293 }
2294 let point_off = findings(point_off_axis, top(0.0));
2295 assert!(
2296 matches!(&point_off[..], [Finding::InternalPartWiderThanParent { room_m, .. }] if *room_m == 0.0),
2297 "{point_off:?}"
2298 );
2299 let wedged = findings(inner_tube(0.1, 0.0245, 0.001), bottom(0.0));
2302 match &wedged[..] {
2303 [
2304 Finding::InternalPartWedgedInParent {
2305 component,
2306 parent,
2307 reach_m,
2308 room_m,
2309 widest_room_m,
2310 },
2311 ] => {
2312 assert_eq!((component.as_str(), parent.as_str()), ("part", "nose"));
2313 assert_eq!(*reach_m, 0.0245);
2314 assert!((room_m - room(0.1)).abs() < 1e-15, "{room_m}");
2315 assert!((widest_room_m - room(0.2)).abs() < 1e-15, "{widest_room_m}");
2316 }
2317 other => panic!("{other:?}"),
2318 }
2319 let wide = findings(inner_tube(0.1, 0.0265, 0.001), bottom(0.0));
2321 assert!(
2322 matches!(&wide[..], [Finding::InternalPartWiderThanParent { room_m, .. }]
2323 if (room_m - room(0.2)).abs() < 1e-15),
2324 "{wide:?}"
2325 );
2326 let past = findings(ring(0.006, 0.026, 0.0), bottom(0.006));
2329 assert!(
2330 matches!(&past[..], [Finding::InternalPartPastParentEnd { component, .. }]
2331 if component == "part"),
2332 "{past:?}"
2333 );
2334 }
2335
2336 #[test]
2338 fn a_cluster_whose_tubes_cross_is_flagged() {
2339 let at = |r: f64| {
2340 let mut design = three_fin_rocket();
2341 if let Part::InnerTube(tube) = &mut design.stages[0].components[1].children[0].part {
2342 tube.cluster_m = [90.0_f64, 210.0, 330.0]
2343 .iter()
2344 .map(|a| [r * a.to_radians().cos(), r * a.to_radians().sin()])
2345 .collect();
2346 }
2347 check(&design)
2348 .unwrap()
2349 .into_iter()
2350 .filter(|f| matches!(f, Finding::ClusterTubesOverlap { .. }))
2351 .collect::<Vec<_>>()
2352 };
2353 assert!(at(0.04 / 3.0_f64.sqrt()).is_empty());
2354 let [
2355 Finding::ClusterTubesOverlap {
2356 tube,
2357 apart_m,
2358 diameter_m,
2359 },
2360 ] = &at(0.02)[..]
2361 else {
2362 panic!("one finding");
2363 };
2364 assert_eq!(tube, "mmt");
2365 assert!((apart_m - 0.02 * 3.0_f64.sqrt()).abs() < 1e-15, "{apart_m}");
2366 assert_eq!(*diameter_m, 0.04);
2367 assert_eq!(at(0.02)[0].severity(), Severity::Warning);
2368 }
2369
2370 #[test]
2373 fn a_finding_with_the_old_uk_kind_reads_the_same() {
2374 let finding = Finding::CenterOutsideRocket {
2375 stage: "sustainer".to_owned(),
2376 station_m: 2.5,
2377 };
2378 let text = serde_json::to_string(&finding).unwrap();
2379 assert!(
2380 text.contains("\"kind\":\"center_outside_rocket\""),
2381 "{text}"
2382 );
2383 let old = text.replace("center_outside_rocket", "centre_outside_rocket");
2384 assert_eq!(serde_json::from_str::<Finding>(&old).unwrap(), finding);
2385 }
2386}