1use hpr_design::fins::FinSet;
17
18#[must_use]
42pub fn openrocket_fin_set_roll_kg_m2(
43 fins: &FinSet,
44 body_radius_m: f64,
45 mass_kg: f64,
46) -> Option<f64> {
47 let area_m2 = fins.planform.geometry().ok()?.area_m2;
48 rod_roll_kg_m2(
49 fins.count,
50 area_m2,
51 fins.planform.span_m(),
52 fins.planform.root_chord_m(),
53 body_radius_m,
54 mass_kg,
55 )
56}
57
58fn rod_roll_kg_m2(
61 count: u32,
62 area_m2: f64,
63 span_m: f64,
64 root_m: f64,
65 body_radius_m: f64,
66 mass_kg: f64,
67) -> Option<f64> {
68 let usable = |x: f64| x.is_finite() && x >= 0.0;
69 if count == 0
70 || !(usable(area_m2) && usable(span_m) && usable(body_radius_m) && usable(mass_kg))
71 || !(root_m.is_finite() && root_m > 0.0)
72 {
73 return None;
74 }
75 let reach2 = area_m2 * span_m / root_m;
76 if count == 1 {
77 return Some(mass_kg * reach2 / 12.0);
78 }
79 let r = body_radius_m;
80 Some(mass_kg * (r * r + r * reach2.sqrt() + reach2 / 3.0))
81}
82
83#[cfg(test)]
84mod tests {
85 use hpr_design::tree::{Layout, Part};
86 use hpr_io::ork::{self, StoredReferenceExclusion, StoredSimulation};
87 use serde_json::Value;
88
89 fn record() -> Value {
90 let text = include_str!("../../../validation/fixtures/ork/openrocket-conventions.json");
91 serde_json::from_str(text).expect("the committed record is JSON")
92 }
93
94 fn probe<'a>(record: &'a Value, question: &str) -> &'a Value {
95 let probe = &record["probes"][question];
96 assert!(probe.is_object(), "no probe asks {question:?}");
97 probe
98 }
99
100 fn hpr(probe: &Value) -> (Layout, Vec<String>) {
102 let document = probe["document"].as_str().expect("the probe's document");
103 let read = ork::read(document.as_bytes()).expect("a probe reads");
104 let spine = ork::rocket(&read.value.document);
105 let layout = spine.value.layout().expect("a probe lays out");
106 let warnings = spine.warnings.iter().map(|w| w.message.clone()).collect();
107 (layout, warnings)
108 }
109
110 fn both(probe: &Value) -> ([f64; 4], [f64; 4], Vec<String>) {
115 let (layout, warnings) = hpr(probe);
116 let s = &layout.structure;
117 let i = s.inertia_kg_m2;
118 let ours = [
119 s.mass_kg,
120 -s.cg_m.z,
122 i.z_axis.z,
123 (i.x_axis.x + i.y_axis.y) / 2.0,
124 ];
125 let or = &probe["structure"];
126 let number = |key: &str| or[key].as_f64().expect("a number");
127 let theirs = [
128 number("mass_kg"),
129 number("cm_x_m"),
130 number("ixx"),
131 (number("iyy") + number("izz")) / 2.0,
132 ];
133 (ours, theirs, warnings)
134 }
135
136 fn relative(ours: f64, theirs: f64) -> f64 {
137 (ours - theirs) / theirs
138 }
139
140 fn stored(status: &str, results_xml: Option<&str>) -> StoredSimulation {
141 let xml = format!(
142 r#"<openrocket version="1.10"><rocket><name>R</name></rocket><simulations>
143 <simulation status="{status}"><name>stored</name><simulator>RK4Simulator</simulator><calculator>BarrowmanCalculator</calculator>{results}</simulation>
144 </simulations></openrocket>"#,
145 results = results_xml.unwrap_or_default(),
146 );
147 ork::design(
148 &ork::read(xml.as_bytes())
149 .expect("stored fixture reads")
150 .value,
151 )
152 .value
153 .simulations
154 .into_iter()
155 .next()
156 .expect("stored simulation")
157 }
158
159 fn plausible_results_xml() -> &'static str {
160 r#"<flightdata maxaltitude="100" maxvelocity="80" maxacceleration="120" maxmach="0.23" timetoapogee="5" flighttime="20"/>"#
161 }
162
163 fn stored_with_provenance(
164 simulator: Option<&str>,
165 calculator: Option<&str>,
166 ) -> StoredSimulation {
167 let simulator = simulator.map_or_else(String::new, |value| {
168 format!("<simulator>{value}</simulator>")
169 });
170 let calculator = calculator.map_or_else(String::new, |value| {
171 format!("<calculator>{value}</calculator>")
172 });
173 let xml = format!(
174 r#"<openrocket version="1.10"><rocket><name>R</name></rocket><simulations>
175 <simulation status="uptodate"><name>stored</name>{simulator}{calculator}{results}</simulation>
176 </simulations></openrocket>"#,
177 results = plausible_results_xml(),
178 );
179 ork::design(
180 &ork::read(xml.as_bytes())
181 .expect("stored fixture reads")
182 .value,
183 )
184 .value
185 .simulations
186 .into_iter()
187 .next()
188 .expect("stored simulation")
189 }
190
191 fn stored_with_results(results_xml: &str) -> StoredSimulation {
192 stored("uptodate", Some(results_xml))
193 }
194
195 fn results_with_branches(apogee: &str, branches: &str) -> String {
196 format!(
197 r#"<flightdata maxaltitude="{apogee}" maxvelocity="80" maxacceleration="120" maxmach="0.23" timetoapogee="5" flighttime="20">{branches}</flightdata>"#
198 )
199 }
200
201 #[test]
204 fn stale_and_implausible_stored_results_are_excluded_from_gates_and_census() {
205 let cases = [
206 (stored("uptodate", Some(plausible_results_xml())), None),
207 (
208 stored(
209 "uptodate",
210 Some(
211 r#"<flightdata maxaltitude="100" maxvelocity="80" maxacceleration="120" maxmach="0.23" timetoapogee="5" flighttime="20"><databranch name="Sustainer" types="Time,Altitude"><datapoint>1,1</datapoint><datapoint>2,1</datapoint></databranch></flightdata>"#,
212 ),
213 ),
214 Some(StoredReferenceExclusion::InconsistentResults),
215 ),
216 (
217 stored(
218 "uptodate",
219 Some(
220 r#"<flightdata maxaltitude="100" maxvelocity="80" maxacceleration="120" maxmach="0.23" timetoapogee="5" flighttime="20"><databranch name="Sustainer" types="Time,Altitude"><datapoint>1,100</datapoint><datapoint>2,100.05</datapoint></databranch></flightdata>"#,
221 ),
222 ),
223 None,
224 ),
225 (
226 stored(
227 "uptodate",
228 Some(
229 r#"<flightdata maxaltitude="100" maxvelocity="80" maxacceleration="120" maxmach="0.23" timetoapogee="5" flighttime="20"><databranch name="Sustainer" types="Time,Altitude"><datapoint>1,100</datapoint><datapoint>2,100.2</datapoint></databranch></flightdata>"#,
230 ),
231 ),
232 Some(StoredReferenceExclusion::InconsistentResults),
233 ),
234 (
235 stored("outdated", Some(plausible_results_xml())),
236 Some(StoredReferenceExclusion::Outdated),
237 ),
238 (
239 stored("notsimulated", Some(plausible_results_xml())),
240 Some(StoredReferenceExclusion::NotSimulated),
241 ),
242 (
243 stored("uptodate", None),
244 Some(StoredReferenceExclusion::MissingResults),
245 ),
246 (
247 stored(
248 "uptodate",
249 Some(
250 r#"<flightdata maxaltitude="-1" maxvelocity="80" maxacceleration="120" maxmach="0.23" timetoapogee="5" flighttime="20"/>"#,
251 ),
252 ),
253 Some(StoredReferenceExclusion::ImpossibleSummary),
254 ),
255 (
256 stored(
257 "uptodate",
258 Some(
259 r#"<flightdata maxaltitude="NaN" maxvelocity="80" maxacceleration="120" maxmach="0.23" timetoapogee="5" flighttime="20"/>"#,
260 ),
261 ),
262 Some(StoredReferenceExclusion::MissingSummary),
263 ),
264 (
265 stored(
266 "uptodate",
267 Some(
268 r#"<flightdata maxaltitude="100" maxvelocity="80" maxacceleration="120" maxmach="0.23" timetoapogee="5" flighttime="20"><databranch name="Sustainer" types="Time,Altitude"><datapoint>2,10</datapoint><datapoint>1,20</datapoint></databranch></flightdata>"#,
269 ),
270 ),
271 Some(StoredReferenceExclusion::InconsistentResults),
272 ),
273 (
274 stored(
275 "uptodate",
276 Some(
277 r#"<flightdata maxaltitude="100" maxvelocity="80" maxacceleration="120" maxmach="0.23" timetoapogee="5" flighttime="20"><databranch name="Sustainer" types="Time,Altitude"><datapoint>1,-1</datapoint><datapoint>2,20</datapoint></databranch></flightdata>"#,
278 ),
279 ),
280 Some(StoredReferenceExclusion::ImpossibleSummary),
281 ),
282 ];
283 let classified = cases
284 .iter()
285 .filter(|(simulation, exclusion)| simulation.reference_exclusion() == *exclusion)
286 .count();
287 assert_eq!(classified, cases.len());
288 assert_eq!(
289 cases
290 .iter()
291 .filter(|(_, exclusion)| exclusion.is_none())
292 .count(),
293 2
294 );
295 assert_eq!(
296 StoredReferenceExclusion::Outdated.reason(),
297 "status-outdated"
298 );
299 assert_eq!(
300 StoredReferenceExclusion::MissingResults.reason(),
301 "missing-results"
302 );
303 assert_eq!(
304 stored_with_provenance(None, Some("BarrowmanCalculator")).reference_exclusion(),
305 Some(StoredReferenceExclusion::MissingSimulator)
306 );
307 assert_eq!(
308 stored_with_provenance(Some("OtherSimulator"), Some("BarrowmanCalculator"))
309 .reference_exclusion(),
310 Some(StoredReferenceExclusion::UnsupportedSimulator)
311 );
312 assert_eq!(
313 stored_with_provenance(Some("RK4Simulator"), None).reference_exclusion(),
314 Some(StoredReferenceExclusion::MissingCalculator)
315 );
316 assert_eq!(
317 stored_with_provenance(Some("RK4Simulator"), Some("OtherCalculator"))
318 .reference_exclusion(),
319 Some(StoredReferenceExclusion::UnsupportedCalculator)
320 );
321 }
322
323 #[test]
326 fn stored_apogee_uses_the_first_apogee_branch() {
327 let branches = r#"
328 <databranch name="Sustainer" types="Time,Altitude">
329 <event time="5" type="apogee"/><datapoint>0,0</datapoint><datapoint>5,100</datapoint>
330 </databranch>
331 <databranch name="Booster" types="Time,Altitude">
332 <event time="4" type="apogee"/><datapoint>0,0</datapoint><datapoint>4,99.9</datapoint>
333 </databranch>"#;
334 let simulation = stored_with_results(&results_with_branches("100", branches));
335 assert_eq!(simulation.reference_exclusion(), None);
336 }
337
338 #[test]
341 fn stored_apogee_branch_requires_unambiguous_branch_evidence() {
342 let single = r#"<databranch name="Sustainer" types="Time,Altitude">
343 <datapoint>0,0</datapoint><datapoint>5,100</datapoint>
344 </databranch>"#;
345 assert_eq!(
346 stored_with_results(&results_with_branches("100", single)).reference_exclusion(),
347 None
348 );
349 let multiple = format!(
350 r#"{single}<databranch name="Booster" types="Time,Altitude"><datapoint>0,0</datapoint><datapoint>5,99</datapoint></databranch>"#
351 );
352 assert_eq!(
353 stored_with_results(&results_with_branches("100", &multiple)).reference_exclusion(),
354 Some(StoredReferenceExclusion::UninspectableSeries)
355 );
356 }
357
358 #[test]
361 fn stored_apogee_comparison_uses_the_two_sided_allowance() {
362 let check = |apogee: &str, series: &str| {
363 let branch = format!(
364 r#"<databranch name="Sustainer" types="Time,Altitude"><datapoint>0,0</datapoint><datapoint>5,{series}</datapoint></databranch>"#
365 );
366 stored_with_results(&results_with_branches(apogee, &branch)).reference_exclusion()
367 };
368 assert_eq!(check("100", "100.1"), None);
369 assert_eq!(
370 check("100", "100.1000001"),
371 Some(StoredReferenceExclusion::InconsistentResults)
372 );
373 assert_eq!(check("1", "1.001"), None);
374 assert_eq!(
375 check("1", "1.0010001"),
376 Some(StoredReferenceExclusion::InconsistentResults)
377 );
378 assert_eq!(
379 check("0.5", "0.501"),
380 Some(StoredReferenceExclusion::InconsistentResults)
381 );
382 assert_eq!(check("0.5", "0.5009999"), None);
383 assert_eq!(check("100", "99.9"), None);
384 assert_eq!(
385 check("100", "99.8999999"),
386 Some(StoredReferenceExclusion::InconsistentResults)
387 );
388 }
389
390 #[test]
391 fn stored_times_agree_with_flight_and_apogee_summaries() {
392 let branch = |last_time: &str, apogee_time: &str| {
393 format!(
394 r#"<databranch name="Sustainer" types="Time,Altitude"><event time="{apogee_time}" type="apogee"/><datapoint>0,0</datapoint><datapoint>{last_time},100</datapoint></databranch>"#
395 )
396 };
397 let run = |last_time: &str, apogee_time: &str| {
398 stored_with_results(&results_with_branches(
399 "100",
400 &branch(last_time, apogee_time),
401 ))
402 .reference_exclusion()
403 };
404 assert_eq!(run("20.0009", "5"), None);
405 assert_eq!(run("20", "5.0009"), None);
406 assert_eq!(
407 run("20.0011", "5"),
408 Some(StoredReferenceExclusion::InconsistentResults)
409 );
410 assert_eq!(
411 run("20", "5.0011"),
412 Some(StoredReferenceExclusion::InconsistentResults)
413 );
414 }
415
416 #[test]
417 fn non_finite_event_times_exclude_stored_results() {
418 let mut simulation = stored_with_results(&results_with_branches(
419 "100",
420 r#"<databranch name="Sustainer" types="Time,Altitude"><event time="5" type="launch"/><datapoint>0,0</datapoint><datapoint>5,100</datapoint></databranch>"#,
421 ));
422 simulation.results.as_mut().unwrap().branches[0].events[0].time_s = f64::NAN;
423 assert_eq!(
424 simulation.reference_exclusion(),
425 Some(StoredReferenceExclusion::ImpossibleSummary)
426 );
427 }
428
429 #[test]
430 fn fatal_events_and_events_outside_the_flight_exclude_stored_results() {
431 let with_event = |time: &str, kind: &str| {
432 let branch = format!(
433 r#"<databranch name="Sustainer" types="Time,Altitude"><event time="{time}" type="{kind}"/><datapoint>0,0</datapoint><datapoint>5,100</datapoint></databranch>"#
434 );
435 stored_with_results(&results_with_branches("100", &branch)).reference_exclusion()
436 };
437 assert_eq!(
438 with_event("6", "SIM_ABORT"),
439 Some(StoredReferenceExclusion::FatalEvent)
440 );
441 assert_eq!(
442 with_event("6", "Simulation-Abort"),
443 Some(StoredReferenceExclusion::FatalEvent)
444 );
445 assert_eq!(
446 with_event("-0.1", "launch"),
447 Some(StoredReferenceExclusion::ImpossibleSummary)
448 );
449 assert_eq!(
450 with_event("21", "simulationend"),
451 Some(StoredReferenceExclusion::InconsistentResults)
452 );
453 }
454
455 const PRECEDENCE: [(&str, f64); 12] = [
459 (
460 "a centre of gravity override on a nose with a shoulder",
461 0.0,
462 ),
463 (
464 "a centre of gravity override on a transition with a fore shoulder",
465 0.0,
466 ),
467 ("a mass override on a nose with a shoulder", 0.0),
468 ("a mass override on a tube, not the part inside", 0.0),
469 ("a mass override on the part inside only", 0.0),
470 (
471 "a centre of gravity override on a tube, not the part inside",
472 0.0,
473 ),
474 (
475 "a centre of gravity override on a tube and the part inside",
476 0.0,
477 ),
478 ("a mass override on the stage", 0.0),
479 ("both overrides on the stage", 0.0),
480 ("a stage override over a part's own", 0.0),
481 ("a mass override on a tube and the part inside", -0.003686),
482 (
483 "a mass override on a tube and the part inside, which has its own",
484 -0.019690,
485 ),
486 ];
487
488 const DISAGREEING: [(&str, f64); 2] = [
490 (
491 "both overrides on a tube, the centre covering the part inside and the mass not",
492 0.004672,
493 ),
494 (
495 "both overrides on a tube, the mass covering the part inside and the centre not",
496 0.0,
497 ),
498 ];
499
500 #[test]
519 fn override_precedence_matches_oracle() {
520 let record = record();
521 for (question, apart_m) in PRECEDENCE {
522 let (ours, theirs, warnings) = both(probe(&record, question));
523 assert!(
524 relative(ours[0], theirs[0]).abs() < 1e-5,
525 "{question}: mass {ours:?} {theirs:?}"
526 );
527 assert!(
528 (ours[1] - theirs[1] - apart_m).abs() < 1e-6,
529 "{question}: center {} m apart, not {apart_m}",
530 ours[1] - theirs[1]
531 );
532 assert!(warnings.is_empty(), "{question}: {warnings:?}");
533 }
534 for (question, apart_m) in DISAGREEING {
537 let (ours, theirs, warnings) = both(probe(&record, question));
538 assert!(relative(ours[0], theirs[0]).abs() < 1e-9, "{question}");
539 assert!(
540 (ours[1] - theirs[1] - apart_m).abs() < 1e-6,
541 "{question}: center {} m apart, not {apart_m}",
542 ours[1] - theirs[1]
543 );
544 assert_eq!(warnings.len(), 1, "{question}: {warnings:?}");
545 assert!(
546 warnings[0].contains("the mass flag was taken"),
547 "{warnings:?}"
548 );
549 }
550 }
551
552 const OLD_FLAG: [(&str, f64); 11] = [
555 (
556 "the old flag on both of a stage's overrides, schema 1.4",
557 0.0,
558 ),
559 (
560 "the old flag on a tube's mass override, schema 1.4",
561 -0.003686,
562 ),
563 (
564 "the old flag on a tube's mass override, schema 1.8",
565 -0.003686,
566 ),
567 (
568 "the old flag, false, on a tube's mass override, schema 1.8",
569 0.0,
570 ),
571 (
572 "the old flag on a tube's mass override, schema 1.10",
573 -0.003686,
574 ),
575 ("the old flag after a mass flag that says false", -0.003686),
576 ("the old flag before a mass flag that says false", 0.0),
577 ("the old flag before a drag flag that says false", -0.003686),
578 ("the old flag after a drag flag that says false", -0.003686),
579 (
580 "the old flag, false, after a centre flag that says true",
581 0.0,
582 ),
583 (
584 "the old flag, false, before a centre flag that says true",
585 0.0,
586 ),
587 ];
588
589 fn with_id<'a>(element: &'a ork::Element, id: &str) -> Option<&'a ork::Element> {
591 if element
592 .child("id")
593 .is_some_and(|own| own.text().trim() == id)
594 {
595 return Some(element);
596 }
597 element.elements().find_map(|child| with_id(child, id))
598 }
599
600 #[test]
607 fn the_old_subcomponent_flag_reads_as_openrocket_reads_it() {
608 let record = record();
609 for (question, apart_m) in OLD_FLAG {
610 let probe = probe(&record, question);
611 let (ours, theirs, warnings) = both(probe);
612 assert!(
613 relative(ours[0], theirs[0]).abs() < 1e-9,
614 "{question}: mass {ours:?} {theirs:?}"
615 );
616 assert!(
617 (ours[1] - theirs[1] - apart_m).abs() < 1e-6,
618 "{question}: center {} m apart, not {apart_m}",
619 ours[1] - theirs[1]
620 );
621 assert!(warnings.is_empty(), "{question}: {warnings:?}");
622
623 let document = probe["document"].as_str().expect("the probe's document");
624 let parsed = ork::read(document.as_bytes()).expect("a probe reads");
625 let flags = probe["override_flags"].as_object().expect("the flags");
626 assert!(flags.len() >= 3, "{question}: {flags:?}");
627 for (id, recorded) in flags {
628 let element = with_id(&parsed.value.document.root, id).expect("the part");
629 let mut part_warnings = Vec::new();
630 let overrides = ork::Values::new(element, "probe", &mut part_warnings).overrides();
631 let flags_ours = [
632 overrides.subcomponents_mass,
633 overrides.subcomponents_cg,
634 overrides.subcomponents_cd,
635 ]
636 .map(Option::unwrap_or_default);
637 let flags_theirs =
638 ["mass", "cg", "cd"].map(|k| recorded[k].as_bool().expect("a flag"));
639 assert_eq!(flags_ours, flags_theirs, "{question}: {id}");
640 assert!(part_warnings.is_empty(), "{question}: {part_warnings:?}");
641 }
642 }
643 }
644
645 const INERTIA: [(&str, [f64; 2]); 11] = [
647 ("a mass override on a nose with a shoulder", [0.0, 0.0]),
648 ("a mass override on a tube, not the part inside", [0.0, 0.0]),
649 ("a mass override on the part inside only", [0.0, 0.0]),
650 (
651 "a mass override on a tube and the part inside",
652 [-0.0693, -0.0667],
653 ),
654 (
655 "a mass override on a tube and the part inside, which has its own",
656 [-0.3713, -0.3724],
657 ),
658 (
659 "a centre of gravity override on a tube and the part inside",
660 [0.0, -0.0265],
661 ),
662 (
663 "both overrides on a tube, the centre covering the part inside and the mass not",
664 [0.0, -0.0010],
665 ),
666 (
667 "both overrides on a tube, the mass covering the part inside and the centre not",
668 [-0.0693, -0.0818],
669 ),
670 ("a mass override on the stage", [4.0050, 4.0050]),
671 ("both overrides on the stage", [4.0050, 3.4526]),
672 ("a stage override over a part's own", [1.4756, 1.4756]),
673 ];
674
675 #[test]
684 fn inertia_under_an_override_departs_as_written() {
685 let record = record();
686 for (question, pinned) in INERTIA {
687 let (ours, theirs, _) = both(probe(&record, question));
688 let found = [2, 3].map(|k| (relative(ours[k], theirs[k]) * 1e4).round() / 1e4);
689 assert_eq!(found, pinned, "{question}");
690 }
691 }
692
693 const READINGS: [&str; 18] = [
695 "a nose with no shoulder",
696 "a nose with a walled shoulder",
697 "a nose whose shoulder has no wall",
698 "a nose whose shoulder has no wall, capped",
699 "a filled nose whose shoulder has no wall",
700 "a filled nose with a walled shoulder",
701 "a transition whose shoulders have no wall",
702 "a nose of no wall",
703 "a transition of no wall",
704 "a tube of no wall",
705 "a tube holding an inner tube, a coupler and a lug of no wall",
706 "a mass override on a nose of no wall",
707 "a nose, a shoulder and a tube that write no thickness at all",
708 "a narrower nose and tube that write no thickness at all",
709 "a transition that writes no thickness at all",
710 "a nose and a tube that name no material, with one part of each kind inside",
711 "a transition, and more kinds inside a tube, that name no material",
712 "a tube holding an inner tube, a coupler and a lug that write no thickness",
714 ];
715
716 const PART_GAPS: [(&str, f64, f64); 1] = [("EllipticalFinSet", 0.001830, 0.0)];
724
725 #[test]
741 fn walls_shoulders_and_unnamed_materials_read_as_openrocket_does() {
742 let record = record();
743 for question in READINGS {
744 let probe = probe(&record, question);
745 let unwritten = question.ends_with("that write no thickness");
746 let (ours, theirs, warnings) = both(probe);
747 if unwritten {
748 assert_eq!(warnings.len(), 3, "{question}: {warnings:?}");
749 } else {
750 assert!(warnings.is_empty(), "{question}: {warnings:?}");
751 }
752 if theirs[0] == 0.0 {
753 assert_eq!(ours[0], 0.0, "{question}");
755 assert!(ours[1].is_finite(), "{question}");
756 continue;
757 }
758 let (layout, _) = hpr(probe);
759 let mut gap_kg = 0.0;
761 let mut gap_moment = 0.0;
762 let mut plain = true;
763 for part in probe["parts"].as_array().expect("parts") {
764 let class = part["class"].as_str().expect("a class");
765 if matches!(class, "Rocket" | "AxialStage") {
766 continue; }
768 let id = part["id"].as_str().expect("an id");
769 let (_, placed) = layout
770 .find(id)
771 .unwrap_or_else(|| panic!("{question}: no part {id} in hpr"));
772 let mass = part["mass_kg"].as_f64().expect("a mass");
773 let station = part["cm_x_m"].as_f64().expect("a station");
774 plain &= !matches!(
775 class,
776 "TrapezoidFinSet"
777 | "EllipticalFinSet"
778 | "FreeformFinSet"
779 | "RailButton"
780 | "Parachute"
781 | "Streamer"
782 | "ShockCord"
783 );
784 let (mass_gap, station_gap) =
785 if unwritten && matches!(class, "InnerTube" | "LaunchLug") {
786 (-1.0, 0.0)
788 } else {
789 PART_GAPS
790 .iter()
791 .find(|(kind, _, _)| *kind == class)
792 .map_or((0.0, 0.0), |(_, m, z)| (*m, *z))
793 };
794 if mass == 0.0 {
795 assert_eq!(placed.own.mass_kg, 0.0, "{question}: {id}");
796 continue;
797 }
798 let found = relative(placed.own.mass_kg, mass);
799 assert!(
800 (found - mass_gap).abs() < 1e-5,
801 "{question}: {id} is {found} from OpenRocket's mass, not {mass_gap}"
802 );
803 if placed.own.mass_kg > 0.0 {
804 let apart_m = -placed.own.cg_m.z - station;
805 assert!(
806 (apart_m - station_gap).abs() < 1e-6,
807 "{question}: {id} is {apart_m} m from OpenRocket's station"
808 );
809 }
810 gap_kg += placed.own.mass_kg - mass;
811 gap_moment += placed.own.mass_kg * (-placed.own.cg_m.z) - mass * station;
812 }
813 assert!(
814 (ours[0] - theirs[0] - gap_kg).abs() < 1e-5 * theirs[0],
815 "{question}: mass {ours:?} {theirs:?}"
816 );
817 let expected_m = (theirs[0] * theirs[1] + gap_moment) / (theirs[0] + gap_kg);
818 assert!(
819 (ours[1] - expected_m).abs() < 1e-6,
820 "{question}: center {ours:?} {theirs:?}"
821 );
822 if plain && !unwritten {
823 for k in [2, 3] {
824 let (a, b) = (ours[k], theirs[k]);
825 assert!(
826 (a - b).abs() <= 1e-5 * b.abs().max(1e-12),
827 "{question}: inertia {ours:?} {theirs:?}"
828 );
829 }
830 }
831 }
832 }
833
834 const ALONE: [(&str, [f64; 4]); 50] = [
875 ("a tube and a bulkhead", [0.0, 0.0, 0.0, 0.0]),
876 (
877 "a tube and a canted fin set",
878 [-4.19e-5, -4.54e-6, -2.66e-5, 7.58e-5],
879 ),
880 ("a tube and a centering ring", [0.0, 0.0, 0.0, 0.0]),
881 (
882 "a tube and a fin set of airfoil section",
883 [-0.0165, 0.000935, 0.0, -0.00488],
884 ),
885 (
886 "a tube and a fin set of rounded section",
887 [0.00014, -1.24e-5, 0.0, 7.37e-5],
888 ),
889 (
890 "a tube and a fin set of square section",
891 [0.0, 0.0, 0.0, 4.57e-5],
892 ),
893 ("a tube and a fin set with a tab", [0.0, 0.0, 0.0, -0.00109]),
894 (
895 "a tube and a fin set with fillets",
896 [0.0, 0.0, 0.0, -0.00035],
897 ),
898 (
899 "a tube and a fin set with wider fillets",
900 [0.0, 0.0, 0.0, -0.00129],
901 ),
902 (
903 "a tube and a fin set with fillets of 30 mm",
904 [0.0, 0.0, 0.0, -0.00638],
905 ),
906 (
907 "a tube and a fin set with fillets of their own material",
908 [0.0, 0.0, 0.0, -0.000749],
909 ),
910 (
911 "a tube and a fin set with fillets that name no material",
912 [0.0, 0.0, 0.0, -0.000223],
913 ),
914 (
915 "a tube and a single fin with fillets",
916 [0.0, 0.0, 7.78e-6, 0.00425],
917 ),
918 (
919 "a tube and four fins of rounded section with fillets",
920 [0.000179, -1.52e-5, 0.0, -0.000428],
921 ),
922 (
923 "a tube and a freeform fin set with fillets",
924 [0.0, 0.0, 0.0, -7.74e-5],
925 ),
926 (
927 "a wider tube and a fin set with fillets",
928 [0.0, 0.0, 0.0, -0.00123],
929 ),
930 ("a tube and a freeform fin set", [0.0, 0.0, 0.0, 4.57e-5]),
931 ("a tube and a launch lug", [0.0, 0.0, 0.0, 0.000313]),
932 ("a tube and a mass component", [0.0, 0.0, 0.0, 0.0]),
933 ("a tube and a parachute", [0.0, 0.0, 0.0, 0.0]),
934 (
935 "a tube and a parachute that writes no packed size",
936 [0.0, 0.0, 0.0, 0.0],
937 ),
938 (
939 "a tube and a parachute that writes only a packed length",
940 [0.0, 0.0, 0.0, 0.0],
941 ),
942 (
943 "a tube and a parachute that writes only a packed radius",
944 [0.0, 0.0, 0.0, 0.0],
945 ),
946 (
947 "a tube and a streamer that writes no packed size",
948 [0.0, 0.0, 0.0, 0.0],
949 ),
950 (
951 "a tube and a shock cord that writes no packed size",
952 [0.0, 0.0, 0.0, 0.0],
953 ),
954 (
955 "a tube and a mass component that writes no packed size",
956 [0.0, 0.0, 0.0, 0.0],
957 ),
958 (
959 "a tube and a mass component of no mass, under a mass override",
960 [0.0, 0.0, 0.0, 0.0],
961 ),
962 (
963 "a tube and a shock cord of no length, under a mass override",
964 [0.0, 0.0, 0.0, 0.0],
965 ),
966 (
967 "a wider tube and a parachute that writes no packed size",
968 [0.0, 0.0, 0.0, 0.0],
969 ),
970 (
971 "a narrow tube and a parachute that writes no packed size",
972 [0.0, 0.0, 0.0, 0.0],
973 ),
974 (
975 "a tube and a parachute with a mass override",
976 [0.0, 0.0, 0.0, 0.0],
977 ),
978 ("a tube and a rail button", [0.0, 0.0, 7.16e-6, 9.23e-5]),
979 (
980 "a tube and a rail button from the bottom",
981 [0.0, 0.0, 7.16e-6, 9.23e-5],
982 ),
983 (
984 "a tube and a rail button from the middle",
985 [0.0, 0.0, 7.16e-6, 9.24e-5],
986 ),
987 (
988 "a tube and a row of two rail buttons from the bottom",
989 [0.0, 0.0, 1.43e-5, 0.000497],
990 ),
991 (
992 "a tube and a row of two rail buttons from the middle",
993 [0.0, 0.0, 1.43e-5, 0.000499],
994 ),
995 (
996 "a tube and a row of two rail buttons from the top",
997 [0.0, 0.0, 1.43e-5, 0.000499],
998 ),
999 ("a tube and a shock cord", [0.0, 0.0, 0.0, 0.0]),
1000 ("a tube and a streamer", [0.0, 0.0, 0.0, 0.0]),
1001 (
1002 "a tube and a thicker fin set of airfoil section",
1003 [-0.0305, 0.00155, 0.0, -0.00842],
1004 ),
1005 (
1006 "a tube and an elliptical fin set",
1007 [0.000188, -1.69e-5, 0.000122, -0.000328],
1008 ),
1009 ("a tube and an inner tube", [0.0, 0.0, 0.0, 0.0]),
1010 (
1011 "a tube and a clustered inner tube",
1012 [0.0, 0.0, 0.0511, 0.00273],
1013 ),
1014 ("a tube and rectangular fins", [0.0, 0.0, 0.0, 2.3e-6]),
1015 (
1016 "a tube and rectangular fins of twice the chord",
1017 [0.0, 0.0, 0.0, 4.49e-6],
1018 ),
1019 (
1020 "a tube and rectangular fins of twice the span",
1021 [0.0, 0.0, 0.0, 4.2e-6],
1022 ),
1023 ("a tube and a single fin", [0.0, 0.0, 0.0, 0.00406]),
1024 (
1025 "a tube and a parachute of no canopy, under a mass override",
1026 [0.0, 0.0, 0.0, 0.0],
1027 ),
1028 ("a tube and triangular fins", [0.0, 0.0, 0.0, 0.00028]),
1029 ("a wider tube and rectangular fins", [0.0, 0.0, 0.0, 1e-6]),
1030 ];
1031
1032 #[test]
1033 fn each_part_alone_is_openrocket_s_or_pinned() {
1034 let record = record();
1035 for (question, pinned) in ALONE {
1036 let probe = probe(&record, question);
1037 let (ours, theirs, warnings) = both(probe);
1038 assert!(warnings.is_empty(), "{question}: {warnings:?}");
1039 let (layout, _) = hpr(probe);
1040 let mut roll = ours[2];
1041 for part in probe["parts"].as_array().expect("parts") {
1042 let Some((_, placed)) = part["id"].as_str().and_then(|id| layout.find(id)) else {
1043 continue;
1044 };
1045 if let Part::FinSet(fins) = &placed.part {
1046 let mass_kg = part["mass_kg"].as_f64().expect("a mass");
1047 let radius_m = placed.body_radius_m.expect("fins sit on a tube");
1048 let rule = super::openrocket_fin_set_roll_kg_m2(fins, radius_m, mass_kg)
1049 .expect("three fins");
1050 roll += rule - placed.own.inertia_kg_m2.z_axis.z;
1051 }
1052 }
1053 let found = [
1054 relative(ours[0], theirs[0]),
1055 ours[1] - theirs[1],
1056 relative(roll, theirs[2]),
1057 relative(ours[3], theirs[3]),
1058 ];
1059 for (k, (found, pinned)) in found.iter().zip(pinned).enumerate() {
1060 let bound = if pinned == 0.0 {
1061 1e-12
1062 } else {
1063 5e-3 * pinned.abs()
1064 };
1065 assert!(
1066 (found - pinned).abs() <= bound,
1067 "{question}: quantity {k} is {found:e}, not {pinned:e}"
1068 );
1069 }
1070 }
1071 }
1072
1073 #[test]
1080 fn hpr_s_own_fin_roll_departs_as_pinned() {
1081 let record = record();
1082 let departures = [
1083 ("a tube and rectangular fins", 0.000_129),
1084 ("a tube and a fin set of square section", 0.0241),
1085 ("a tube and triangular fins", -0.0214),
1086 ("a tube and a fin set with a tab", -0.0512),
1087 ];
1088 for (question, pinned) in departures {
1089 let probe = probe(&record, question);
1090 let (ours, theirs, _) = both(probe);
1091 let (layout, _) = hpr(probe);
1092 let fins = layout
1093 .components
1094 .iter()
1095 .find(|placed| matches!(placed.part, Part::FinSet(_)))
1096 .expect("a fin set");
1097 let hpr_fins = fins.own.inertia_kg_m2.z_axis.z;
1098 let their_fins = theirs[2] - (ours[2] - hpr_fins);
1099 let found = relative(hpr_fins, their_fins);
1100 assert!(
1101 (found - pinned).abs() <= 5e-3 * pinned.abs(),
1102 "{question}: {found:e}, not {pinned:e}"
1103 );
1104 }
1105 }
1106
1107 #[test]
1115 fn a_fin_section_is_weighed_as_pinned() {
1116 let record = record();
1117 let fins = |question: &str| -> (f64, f64) {
1118 let probe = probe(&record, question);
1119 let (layout, _) = hpr(probe);
1120 let theirs = probe["parts"]
1121 .as_array()
1122 .expect("parts")
1123 .iter()
1124 .find(|part| part["class"] == "TrapezoidFinSet")
1125 .expect("a fin set");
1126 let id = theirs["id"].as_str().expect("an id");
1127 let (_, ours) = layout.find(id).expect("hpr's fin set");
1128 (
1129 ours.own.mass_kg,
1130 theirs["mass_kg"].as_f64().expect("a mass"),
1131 )
1132 };
1133 let square = fins("a tube and a fin set of square section");
1134 assert!((square.0 - square.1).abs() < 1e-15, "{square:?}");
1135 let sections = [
1136 (
1137 "a tube and a fin set of rounded section",
1138 1.0,
1139 0.99,
1140 0.991_416,
1141 ),
1142 (
1143 "a tube and a fin set of airfoil section",
1144 1.0,
1145 0.85,
1146 0.685_083,
1147 ),
1148 (
1149 "a tube and a thicker fin set of airfoil section",
1150 2.0,
1151 0.85,
1152 0.685_083,
1153 ),
1154 ];
1155 for (question, thickness, openrocket, ours) in sections {
1156 let (hpr, theirs) = fins(question);
1157 let slab = thickness * square.1;
1158 assert!(
1159 (theirs / slab - openrocket).abs() < 1e-12,
1160 "{question}: {}",
1161 theirs / slab
1162 );
1163 assert!(
1164 (hpr / slab - ours).abs() < 1e-6,
1165 "{question}: {}",
1166 hpr / slab
1167 );
1168 }
1169 }
1170
1171 #[test]
1177 fn the_loft_boattail_s_roll_is_the_rule_on_openrocket_s_ellipse() {
1178 let text = include_str!("../../../validation/fixtures/ork/openrocket-mass-loft-demo.json");
1179 let record: Value = serde_json::from_str(text).expect("the committed record is JSON");
1180 let file = "validation/fixtures/ork/loft-demo/demo-boattail.ork";
1181 let design = record["designs"]
1182 .as_array()
1183 .expect("designs")
1184 .iter()
1185 .find(|design| design["file"] == file)
1186 .expect("the boattail's record");
1187 let theirs = design["structure"]["ixx"].as_f64().expect("a roll inertia");
1188 let bytes = include_bytes!("../../../validation/fixtures/ork/loft-demo/demo-boattail.ork");
1189 let read = ork::read(bytes.as_slice()).expect("the design reads");
1190 let layout = ork::design(&read.value)
1191 .value
1192 .rocket
1193 .layout()
1194 .expect("it lays out");
1195 let polygon = (std::f64::consts::PI / 30.0).sin() / (std::f64::consts::PI / 30.0);
1196 let mut ours = layout.structure.inertia_kg_m2.z_axis.z;
1197 let mut fins = 0;
1198 for placed in &layout.components {
1199 let Part::FinSet(set) = &placed.part else {
1200 continue;
1201 };
1202 assert!(matches!(
1203 set.planform,
1204 hpr_design::fins::FinPlanform::Elliptical { .. }
1205 ));
1206 let rule = super::rod_roll_kg_m2(
1208 set.count,
1209 set.planform.geometry().expect("an outline").area_m2 * polygon,
1210 set.planform.span_m(),
1211 set.planform.root_chord_m(),
1212 placed.body_radius_m.expect("fins sit on a tube"),
1213 placed.own.mass_kg * polygon,
1214 )
1215 .expect("three fins");
1216 ours += rule - placed.own.inertia_kg_m2.z_axis.z;
1217 fins += 1;
1218 }
1219 assert_eq!(fins, 1);
1220 let apart = relative(ours, theirs);
1221 assert!(apart.abs() < 2e-6, "{apart:e}");
1222 }
1223
1224 fn pods() -> Value {
1225 let text = include_str!("../../../validation/fixtures/ork/openrocket-pods.json");
1226 serde_json::from_str(text).expect("the committed record is JSON")
1227 }
1228
1229 #[test]
1240 fn every_pod_is_where_openrocket_puts_it() {
1241 let record = pods();
1242 let probes = record["probes"].as_object().expect("probes");
1243 assert_eq!(probes.len(), 18);
1244 for (question, probe) in probes {
1245 let (layout, warnings) = hpr(probe);
1246 if question.ends_with("its mass overridden to 0.1 kg") {
1247 assert_eq!(warnings.len(), 1, "{question}: {warnings:?}");
1248 assert!(warnings[0].contains("at the rocket's tip"), "{warnings:?}");
1249 } else {
1250 assert!(warnings.is_empty(), "{question}: {warnings:?}");
1251 }
1252 let mut inside = 0;
1253 for (id, theirs) in probe["components"].as_object().expect("components") {
1254 let Some((_, placed)) = layout.find(id) else {
1255 continue;
1256 };
1257 if !in_a_pod(&layout, id) {
1258 continue;
1259 }
1260 inside += 1;
1261 let theirs: Vec<[f64; 3]> = theirs["locations_m"]
1262 .as_array()
1263 .expect("locations")
1264 .iter()
1265 .map(|l| {
1266 let at = |k: usize| l[k].as_f64().expect("a number");
1267 [at(1), at(2), at(0)]
1268 })
1269 .collect();
1270 let ours = where_hpr_puts(placed);
1271 assert_eq!(ours.len(), theirs.len(), "{question}: {id}");
1272 let near = |a: &[f64; 3], b: &[f64; 3]| {
1273 (a[0] - b[0]).hypot(a[1] - b[1]) <= 1e-15 && (a[2] - b[2]).abs() <= 1e-15
1274 };
1275 if matches!(placed.part, Part::FinSet(_)) {
1276 let mut left = ours.clone();
1277 for place in &theirs {
1278 let k = left.iter().position(|o| near(o, place));
1279 let k = k.unwrap_or_else(|| panic!("{question}: {id} {place:?} {ours:?}"));
1280 left.swap_remove(k);
1281 }
1282 } else {
1283 for (o, t) in ours.iter().zip(&theirs) {
1284 assert!(near(o, t), "{question}: {id} {o:?} vs {t:?}");
1285 }
1286 }
1287 }
1288 let least = if question.starts_with("an empty") {
1289 0
1290 } else {
1291 2
1292 };
1293 assert!(inside >= least, "{question}: {inside} parts in the pod");
1294 }
1295 }
1296
1297 fn in_a_pod(layout: &Layout, id: &str) -> bool {
1299 let mut at = layout.find(id).expect("the part").1.parent;
1300 while let Some(index) = at {
1301 let parent = &layout.components[index];
1302 if matches!(parent.part, Part::PodSet(_)) {
1303 return true;
1304 }
1305 at = parent.parent;
1306 }
1307 false
1308 }
1309
1310 fn where_hpr_puts(placed: &hpr_design::tree::PlacedComponent) -> Vec<[f64; 3]> {
1316 let fore = placed.fore_station_m;
1317 let out = |offset: [f64; 2], radius: f64, angle: f64| {
1318 [
1319 offset[0] + radius * angle.cos(),
1320 offset[1] + radius * angle.sin(),
1321 fore,
1322 ]
1323 };
1324 let body = placed.body_radius_m.unwrap_or_default();
1325 let mut places = Vec::new();
1326 for copy in &placed.copies {
1327 match &placed.part {
1328 Part::FinSet(set) => {
1329 let step = std::f64::consts::TAU / f64::from(set.count);
1330 for k in 0..set.count {
1331 let angle = copy.roll_rad + set.base_angle_rad + step * f64::from(k);
1332 places.push(out(copy.offset_m, body, angle));
1333 }
1334 }
1335 Part::LaunchLug(lug) => {
1336 let angle = copy.roll_rad + lug.angle_rad;
1337 places.push(out(copy.offset_m, body + lug.outer_radius_m, angle));
1338 }
1339 _ => places.push(out(copy.offset_m, 0.0, 0.0)),
1340 }
1341 }
1342 places
1343 }
1344
1345 #[test]
1375 fn pods_weigh_as_openrocket_s() {
1376 const PART: (f64, f64) = (2e-15, 2e-15);
1378 const NOSE: (f64, f64) = (1e-7, 5e-9);
1379 const FIN_PITCH: [(&str, f64); 4] = [
1381 (
1382 "one pod of no length 0.01 in radius, relative 0.02, two fins at 90",
1383 3.35e-5,
1384 ),
1385 (
1386 "one pod of no length, relative 0.03, bottom 0.0, two fins at 90",
1387 9.35e-6,
1388 ),
1389 (
1390 "two pods of no length 0.01 in radius, relative 0.02, at 30, three fins at 20",
1391 2.58e-7,
1392 ),
1393 (
1394 "two pods of no length, relative 0.02, at 45, three fins",
1395 -9.05e-7,
1396 ),
1397 ];
1398 let pinned = |got: f64, want: f64| (got - want).abs() <= 0.005 * want.abs();
1399 let record = pods();
1400 let probes = record["probes"].as_object().expect("probes");
1401 let empty = &probes["an empty pod set, relative 0.004"];
1402 let (ours, theirs, _) = both(empty);
1403 let bare = [relative(ours[0], theirs[0]), relative(ours[1], theirs[1])];
1404 assert!(pinned(bare[0], 1.17e-7), "{bare:?}");
1405 assert!(pinned(bare[1], -1.125e-7), "{bare:?}");
1406 let (layout, _) = hpr(empty);
1407 let nose = &empty["parts"][0];
1408 assert_eq!(nose["class"], "NoseCone");
1409 let (_, placed) = layout.find(conventions_id(1)).expect("the nose");
1410 let apart = relative(
1411 placed.own.mass_kg,
1412 nose["mass_kg"].as_f64().expect("a mass"),
1413 );
1414 assert!(pinned(apart, 5.10e-7), "{apart:e}");
1415 let apart = relative(
1416 -placed.own.cg_m.z,
1417 nose["cm_x_m"].as_f64().expect("a station"),
1418 );
1419 assert!(pinned(apart, -4.64e-7), "{apart:e}");
1420 let tube = &empty["parts"][1];
1422 assert_eq!(tube["class"], "BodyTube");
1423 let (_, placed) = layout.find(conventions_id(2)).expect("the tube");
1424 let apart = relative(
1425 placed.own.mass_kg,
1426 tube["mass_kg"].as_f64().expect("a mass"),
1427 );
1428 assert!(apart.abs() < PART.0, "{apart:e}");
1429
1430 for (question, probe) in probes {
1431 let (ours, theirs, _) = both(probe);
1432 let (layout, _) = hpr(probe);
1433 if question.ends_with("its mass overridden to 0.1 kg") {
1434 let (bare_ours, bare_theirs, _) = both(empty);
1435 assert_eq!(ours, bare_ours, "{question}");
1436 let mass = theirs[0] - bare_theirs[0];
1437 assert!((mass - 0.1).abs() < 1e-15, "{question}: {mass:e}");
1438 let moment = theirs[0] * theirs[1] - bare_theirs[0] * bare_theirs[1];
1439 assert!(moment.abs() < 1e-15, "{question}: {moment:e}");
1440 assert_eq!(theirs[2], bare_theirs[2], "{question}");
1441 continue;
1442 }
1443 let airframe = [conventions_id(1), conventions_id(2)];
1444 let mut weighed = 0;
1445 let mut in_pods = 0;
1446 for part in probe["parts"].as_array().expect("parts") {
1447 let id = part["id"].as_str().expect("an id");
1448 let class = part["class"].as_str().expect("a class");
1449 if matches!(class, "Rocket" | "AxialStage") || airframe.contains(&id) {
1450 continue;
1451 }
1452 in_pods += 1;
1453 let (_, placed) = layout
1454 .find(id)
1455 .unwrap_or_else(|| panic!("{question}: no part {id} in hpr"));
1456 assert!(
1457 in_a_pod(&layout, id) || matches!(placed.part, Part::PodSet(_)),
1458 "{question}: {id}"
1459 );
1460 weighed += 1;
1461 let mass = part["mass_kg"].as_f64().expect("a mass");
1462 let own = placed.own;
1463 if mass == 0.0 {
1464 assert_eq!(own.mass_kg, 0.0, "{question}: {id}");
1465 continue;
1466 }
1467 let (mass_bound, center_bound) = if matches!(placed.part, Part::NoseCone(_)) {
1468 NOSE
1469 } else {
1470 PART
1471 };
1472 let apart = relative(own.mass_kg, mass);
1473 assert!(apart.abs() < mass_bound, "{question}: {id} mass {apart:e}");
1474 let station = part["cm_x_m"].as_f64().expect("a station");
1475 let apart = relative(-own.cg_m.z, station);
1476 assert!(
1477 apart.abs() < center_bound,
1478 "{question}: {id} center {apart:e}"
1479 );
1480 }
1481 assert_eq!(weighed, in_pods, "{question}");
1482 let holds_nothing = question.starts_with("an empty");
1483 let least = if holds_nothing { 1 } else { 3 };
1484 assert!(in_pods >= least, "{question}: {in_pods}");
1485
1486 let of_length = !(holds_nothing || question.contains("of no length"));
1487 let center_bound = if of_length { 1.1e-7 } else { 1.13e-7 };
1488 for (k, bound) in [(0, 1.2e-7), (1, center_bound)] {
1489 let apart = relative(ours[k], theirs[k]);
1490 assert!(apart.abs() < bound, "{question}: quantity {k}, {apart:e}");
1491 assert!(
1492 apart.abs() <= bare[k].abs(),
1493 "{question}: quantity {k}, {apart:e}"
1494 );
1495 }
1496
1497 let mut roll = ours[2];
1498 for placed in &layout.components {
1499 let Part::FinSet(set) = &placed.part else {
1500 continue;
1501 };
1502 let radius = placed.body_radius_m.expect("fins sit on a tube");
1503 let one = placed
1504 .part
1505 .mass_properties(Some(radius))
1506 .expect("a fin set");
1507 let copies = f64::from(u32::try_from(placed.copies.len()).expect("a few pods"));
1508 let rule = super::openrocket_fin_set_roll_kg_m2(set, radius, one.mass_kg)
1509 .expect("two or more fins");
1510 roll += copies * (rule - one.inertia_kg_m2.z_axis.z);
1511 }
1512 let apart = relative(roll, theirs[2]);
1513 assert!(apart.abs() < 2e-9, "{question}: roll {apart:e}");
1514
1515 let i = layout.structure.inertia_kg_m2;
1516 let pitch = probe["structure"]["iyy"].as_f64().expect("a number");
1517 assert_eq!(
1518 probe["structure"]["izz"].as_f64(),
1519 Some(pitch),
1520 "{question}"
1521 );
1522 let about_x = relative(i.x_axis.x, pitch);
1523 if let Some((_, want)) = FIN_PITCH.iter().find(|(q, _)| q == question) {
1524 assert!(pinned(about_x, *want), "{question}: {about_x:e}");
1525 continue;
1526 }
1527 assert!(
1528 !layout
1529 .components
1530 .iter()
1531 .any(|c| matches!(c.part, Part::FinSet(_))),
1532 "{question}: fins not pinned"
1533 );
1534 assert!(about_x.abs() < 6.1e-7, "{question}: {about_x:e}");
1535 let about_y = relative(i.y_axis.y, pitch);
1536 if question.starts_with("three") {
1537 assert!(about_y.abs() < 6.1e-7, "{question}: {about_y:e}");
1538 } else if of_length {
1539 assert!((0.002..0.011).contains(&about_y), "{question}: {about_y:e}");
1540 }
1541 }
1542 }
1543
1544 fn conventions_id(n: u32) -> &'static str {
1546 match n {
1547 1 => "00000000-0000-4000-8000-000000000001",
1548 2 => "00000000-0000-4000-8000-000000000002",
1549 _ => unreachable!("only the probes' nose and tube are asked for"),
1550 }
1551 }
1552
1553 fn clusters() -> Value {
1554 let text = include_str!("../../../validation/fixtures/ork/openrocket-clusters.json");
1555 serde_json::from_str(text).expect("the committed record is JSON")
1556 }
1557
1558 #[test]
1563 fn every_tube_of_a_cluster_is_where_openrocket_puts_it() {
1564 let record = clusters();
1565 let patterns = record["patterns"].as_object().expect("patterns");
1566 assert_eq!(patterns.len(), 14);
1567 for name in patterns.keys() {
1568 assert!(
1569 record["probes"][format!("{name} at scale 1")].is_object(),
1570 "no probe of {name}"
1571 );
1572 }
1573 for (question, probe) in record["probes"].as_object().expect("probes") {
1574 let (layout, warnings) = hpr(probe);
1575 let unnamed = question == "a pattern OpenRocket has no name for";
1576 assert_eq!(
1577 warnings.len(),
1578 usize::from(unnamed),
1579 "{question}: {warnings:?}"
1580 );
1581 if unnamed {
1582 assert!(
1583 warnings[0].contains("is not one of OpenRocket's cluster patterns"),
1584 "{warnings:?}"
1585 );
1586 }
1587 for (id, tube) in probe["tubes"].as_object().expect("tubes") {
1588 let (_, placed) = layout.find(id).expect("the tube");
1589 let Part::InnerTube(inner) = &placed.part else {
1590 panic!("{question}: {id} is not an inner tube");
1591 };
1592 let [x, y] = placed.part.axis_offset_m();
1593 let ours: Vec<[f64; 2]> = inner
1594 .tubes_m()
1595 .expect("finite")
1596 .iter()
1597 .map(|[u, v]| [x + u, y + v])
1598 .collect();
1599 let theirs: Vec<[f64; 2]> = tube["instance_offsets_m"]
1600 .as_array()
1601 .expect("offsets")
1602 .iter()
1603 .map(|c| [c[1].as_f64().expect("y"), c[2].as_f64().expect("z")])
1604 .collect();
1605 assert_eq!(ours.len(), theirs.len(), "{question}");
1606 assert_eq!(
1607 tube["count"].as_u64(),
1608 Some(ours.len() as u64),
1609 "{question}"
1610 );
1611 for (ours, theirs) in ours.iter().zip(&theirs) {
1612 let apart = (ours[0] - theirs[0]).hypot(ours[1] - theirs[1]);
1613 assert!(apart <= 1e-15, "{question}: {ours:?} vs {theirs:?}");
1614 }
1615 }
1616 }
1617 }
1618
1619 #[test]
1632 fn a_cluster_weighs_as_openrocket_s_but_for_its_tubes_spread() {
1633 const OFF_AXIS: [(&str, [f64; 2]); 3] = [
1634 (
1635 "an unclustered tube 10 mm off the axis at 30",
1636 [0.00303, 0.000164],
1637 ),
1638 ("a 2-row 10 mm off the axis at 30", [0.0, 0.00015]),
1639 (
1640 "a 3-ring 10 mm off the axis at 30, turned 20",
1641 [0.0, 0.000206],
1642 ),
1643 ];
1644 let record = clusters();
1645 for (question, probe) in record["probes"].as_object().expect("probes") {
1646 let (ours, theirs, _) = both(probe);
1647 assert!(
1648 relative(ours[0], theirs[0]).abs() <= 1e-12,
1649 "{question}: mass"
1650 );
1651 assert!((ours[1] - theirs[1]).abs() <= 1e-12, "{question}: center");
1652 let (layout, _) = hpr(probe);
1653 let mut spread = 0.0;
1654 for id in probe["tubes"].as_object().expect("tubes").keys() {
1655 let (_, placed) = layout.find(id).expect("the tube");
1656 let Part::InnerTube(inner) = &placed.part else {
1657 panic!("{question}: {id} is not an inner tube");
1658 };
1659 let tubes = inner.tubes_m().expect("finite");
1660 let each_kg = placed.own.mass_kg / tubes.len() as f64;
1661 spread += tubes
1662 .iter()
1663 .map(|[u, v]| each_kg * (u * u + v * v))
1664 .sum::<f64>();
1665 }
1666 let found = [
1668 relative(ours[2] - spread, theirs[2]),
1669 relative(ours[3] - spread / 2.0, theirs[3]),
1670 ];
1671 let pinned = OFF_AXIS
1672 .iter()
1673 .find(|(q, _)| q == question)
1674 .map_or([0.0, 0.0], |(_, pinned)| *pinned);
1675 for (k, (found, pinned)) in found.iter().zip(pinned).enumerate() {
1676 let bound = if pinned == 0.0 {
1677 1e-12
1678 } else {
1679 5e-3 * pinned.abs()
1680 };
1681 assert!(
1682 (found - pinned).abs() <= bound,
1683 "{question}: inertia {k} less the spread is {found:e}, not {pinned:e}"
1684 );
1685 }
1686 }
1687 }
1688
1689 #[test]
1692 fn the_cluster_record_is_openrocket_s() {
1693 let record = clusters();
1694 assert_eq!(record["openrocket"], "24.12");
1695 assert_eq!(
1696 record["source"],
1697 "validation/oracles/openrocket/clusters.py"
1698 );
1699 assert!(record["probes"].as_object().expect("probes").len() >= 22);
1700 }
1701
1702 const BORES: [&str; 13] = [
1705 "a nose holding a coupler of automatic radius at its bottom",
1706 "an ogive nose holding a coupler of automatic radius at its bottom",
1707 "a nose holding a coupler of automatic radius past its base",
1708 "a nose with a shoulder, holding a coupler of automatic radius past its base",
1709 "a nose holding a long coupler of automatic radius from its middle",
1710 "a nose holding an engine block of automatic radius",
1711 "a nose holding a centering ring and a bulkhead of automatic radius",
1712 "a nose holding a coupler of automatic radius and a wall thicker than its bore",
1713 "a nose holding a coupler of automatic radius with a mass inside",
1714 "a transition holding a coupler of automatic radius",
1715 "a tube holding a coupler of automatic radius and a wall thicker than its bore",
1716 "a tube holding an inner tube of automatic radius",
1717 "a nose holding an inner tube of automatic radius",
1718 ];
1719
1720 const BORE_REFUSED: &str = "a nose holding a coupler of automatic radius at its tip";
1722
1723 #[test]
1741 fn an_automatic_radius_inside_a_nose_reads_as_openrocket_does() {
1742 let record = record();
1743 for question in BORES {
1744 let probe = probe(&record, question);
1745 let (layout, warnings) = hpr(probe);
1746 assert!(warnings.is_empty(), "{question}: {warnings:?}");
1747 for part in probe["parts"].as_array().expect("parts") {
1748 let class = part["class"].as_str().expect("a class");
1749 if matches!(class, "Rocket" | "AxialStage") {
1750 continue;
1751 }
1752 let id = part["id"].as_str().expect("an id");
1753 let (_, placed) = layout
1754 .find(id)
1755 .unwrap_or_else(|| panic!("{question}: no part {id} in hpr"));
1756 let mass = part["mass_kg"].as_f64().expect("a mass");
1757 let station = part["cm_x_m"].as_f64().expect("a station");
1758 let (mass_bound, station_gap, station_bound) = match class {
1759 "NoseCone" | "Transition" => (5e-5, 0.0, 5e-6),
1760 "MassComponent" => (1e-15, 0.020755, 1e-6),
1761 _ => (1e-14, 0.0, 1e-15),
1762 };
1763 let found = relative(placed.own.mass_kg, mass);
1764 assert!(
1765 found.abs() <= mass_bound,
1766 "{question}: {class} {id} is {found:e} from OpenRocket's mass"
1767 );
1768 let apart_m = -placed.own.cg_m.z - station;
1769 assert!(
1770 (apart_m - station_gap).abs() <= station_bound,
1771 "{question}: {class} {id} is {apart_m:e} m from OpenRocket's station"
1772 );
1773 }
1774 }
1775
1776 let probe = probe(&record, BORE_REFUSED);
1777 let coupler = probe["parts"]
1778 .as_array()
1779 .expect("parts")
1780 .iter()
1781 .find(|part| part["class"] == "TubeCoupler")
1782 .expect("the coupler");
1783 assert_eq!(coupler["mass_kg"].as_f64(), Some(0.0));
1784 let document = probe["document"].as_str().expect("the probe's document");
1785 let read = ork::read(document.as_bytes()).expect("a probe reads");
1786 let error = ork::rocket(&read.value.document)
1787 .value
1788 .layout()
1789 .expect_err("a tube of no radius");
1790 assert!(
1791 matches!(
1792 &error,
1793 hpr_design::DesignError::InComponent { source, .. }
1794 if matches!(
1795 **source,
1796 hpr_design::DesignError::Domain { what: "outer radius", value }
1797 if value == 0.0
1798 )
1799 ),
1800 "{error:?}"
1801 );
1802 }
1803
1804 const TUBE_FINS: [&str; 19] = [
1806 "a tube and 1 tube fins of automatic radius",
1807 "a tube and 2 tube fins of automatic radius",
1808 "a tube and 3 tube fins of automatic radius",
1809 "a tube and 4 tube fins of automatic radius",
1810 "a tube and 5 tube fins of automatic radius",
1811 "a tube and 6 tube fins of automatic radius",
1812 "a tube and 8 tube fins of automatic radius",
1813 "a tube and 9 tube fins of automatic radius",
1814 "a tube and 12 tube fins of automatic radius",
1815 "a tube and 20 tube fins of automatic radius",
1816 "a tube and 6 tube fins of a stated radius",
1817 "a tube and 6 tube fins of automatic radius and a wall thicker than it",
1818 "a tube and 6 tube fins of a stated radius, offset from the body",
1819 "a tube of automatic radius and 4 tube fins of automatic radius",
1820 "a tube and 12 tube fins of a stated radius",
1821 "a tube and 100 tube fins of automatic radius",
1822 "a 20 mm tube and 1 tube fins of automatic radius",
1823 "a 20 mm tube and 2 tube fins of automatic radius",
1824 "a 20 mm tube and 5 tube fins of automatic radius",
1825 ];
1826
1827 #[test]
1835 fn a_tube_fin_sets_automatic_radius_reads_as_openrocket_does() {
1836 let record = record();
1837 for question in TUBE_FINS {
1838 let probe = probe(&record, question);
1839 let (layout, warnings) = hpr(probe);
1840 let capped = ["9", "12", "20", "100"]
1843 .iter()
1844 .any(|n| question.contains(&format!(" and {n} tube fins")));
1845 let offset = question.contains("offset");
1846 assert_eq!(
1847 warnings.len(),
1848 usize::from(capped || offset),
1849 "{question}: {warnings:?}"
1850 );
1851 let said = if offset {
1852 "read sitting on it"
1853 } else {
1854 "reads at most 8"
1855 };
1856 assert!(
1857 warnings.iter().all(|w| w.contains(said)),
1858 "{question}: {warnings:?}"
1859 );
1860 for part in probe["parts"].as_array().expect("parts") {
1861 let class = part["class"].as_str().expect("a class");
1862 if matches!(class, "Rocket" | "AxialStage") {
1863 continue;
1864 }
1865 let id = part["id"].as_str().expect("an id");
1866 let (_, placed) = layout
1867 .find(id)
1868 .unwrap_or_else(|| panic!("{question}: no part {id} in hpr"));
1869 let (mass_bound, station_bound) = match class {
1871 "NoseCone" => (5e-5, 5e-6),
1872 _ => (1e-14, 1e-15),
1873 };
1874 let mass = part["mass_kg"].as_f64().expect("a mass");
1875 let found = relative(placed.own.mass_kg, mass);
1876 assert!(
1877 found.abs() <= mass_bound,
1878 "{question}: {class} is {found:e} from OpenRocket's mass"
1879 );
1880 let apart_m = -placed.own.cg_m.z - part["cm_x_m"].as_f64().expect("a station");
1881 assert!(
1882 apart_m.abs() <= station_bound,
1883 "{question}: {class} is {apart_m:e} m from OpenRocket's"
1884 );
1885 }
1886 let (id, theirs) = probe["tube_fins"]
1887 .as_object()
1888 .and_then(|sets| sets.iter().next())
1889 .expect("one tube fin set");
1890 let (_, placed) = layout.find(id).expect("the tube fin set");
1891 let Part::TubeFinSet(ours) = &placed.part else {
1892 panic!("{question}: {:?}", placed.part);
1893 };
1894 let number = |key: &str| theirs[key].as_f64().expect(key);
1895 assert_eq!(
1896 theirs["automatic"].as_bool(),
1897 Some(!question.contains("stated"))
1898 );
1899 assert_eq!(
1900 Some(u64::from(ours.count)),
1901 theirs["count"].as_u64(),
1902 "{question}"
1903 );
1904 let r = number("outer_radius_m");
1905 assert!(
1906 relative(ours.outer_radius_m, r).abs() <= 1e-15,
1907 "{question}: radius {} against OpenRocket's {r}",
1908 ours.outer_radius_m
1909 );
1910 assert!(
1911 relative(ours.thickness_m, number("thickness_m")).abs() <= 1e-15,
1912 "{question}: wall {} against {}",
1913 ours.thickness_m,
1914 number("thickness_m")
1915 );
1916 let body_radius_m = number("body_radius_m");
1917 assert_eq!(placed.body_radius_m, Some(body_radius_m), "{question}");
1918 let (r_i, length_m) = (number("inner_radius_m"), 0.1);
1921 let across = (r * r + r_i * r_i) / 4.0 + length_m * length_m / 12.0;
1922 let about = (r * r + r_i * r_i) / 2.0;
1923 let d2 = (body_radius_m + r).powi(2);
1924 let unit = |i: f64| i / placed.own.mass_kg;
1925 let inertia = &placed.own.inertia_kg_m2;
1926 let their_pitch = number("longitudinal_unit_inertia_m2");
1929 let n = f64::from(ours.count);
1930 assert!(
1931 relative(their_pitch, n * across).abs() <= 1e-14,
1932 "{question}: {their_pitch}"
1933 );
1934 if ours.count == 1 {
1935 let [_, y, z] =
1937 [0, 1, 2].map(|k| theirs["component_cg_xyz_m"][k].as_f64().expect("a center"));
1938 let off = y.hypot(z);
1939 assert!(
1940 (off - (body_radius_m + r)).abs() <= 1e-15,
1941 "{question}: {off}"
1942 );
1943 let ours_off = placed.own.cg_m.x.hypot(placed.own.cg_m.y);
1944 assert!(
1945 (ours_off - off).abs() <= 1e-15,
1946 "{question}: hpr's center {ours_off}"
1947 );
1948 let roll = number("rotational_unit_inertia_m2");
1949 assert!(relative(roll, about).abs() <= 1e-15, "{question}: {roll}");
1950 assert!(
1951 relative(unit(inertia.z_axis.z), roll).abs() <= 1e-14,
1952 "{question}"
1953 );
1954 assert!(
1956 relative(unit(inertia.x_axis.x), across).abs() <= 1e-13,
1957 "{question}"
1958 );
1959 assert!(relative(their_pitch, across).abs() <= 1e-14, "{question}");
1960 } else {
1961 assert!(
1964 relative(unit(inertia.z_axis.z), about + d2).abs() <= 1e-13,
1965 "{question}"
1966 );
1967 let bound = (body_radius_m + 2.0 * r).powi(2);
1968 let roll = number("rotational_unit_inertia_m2");
1969 assert!(
1970 roll > bound,
1971 "{question}: OpenRocket's {roll} within {bound}"
1972 );
1973 if ours.count >= 3 {
1974 let pitch = unit(inertia.x_axis.x);
1975 assert!(
1976 relative(pitch, across + d2 / 2.0).abs() <= 1e-13,
1977 "{question}: {pitch}"
1978 );
1979 assert!(
1980 relative(unit(inertia.y_axis.y), pitch).abs() <= 1e-13,
1981 "{question}"
1982 );
1983 assert!(
1984 their_pitch > pitch * 1.2,
1985 "{question}: {their_pitch} against hpr's {pitch}"
1986 );
1987 }
1988 }
1989 }
1990 }
1991
1992 #[test]
1997 fn every_probe_is_checked() {
1998 let record = record();
1999 assert_eq!(record["openrocket"], "24.12");
2000 assert_eq!(
2001 record["source"],
2002 "validation/oracles/openrocket/conventions.py"
2003 );
2004 assert_eq!(record["saved_default_materials"], serde_json::json!({}));
2005 let mut read: Vec<&str> = READINGS.to_vec();
2006 read.extend(PRECEDENCE.iter().map(|(question, _)| *question));
2007 read.extend(DISAGREEING.iter().map(|(question, _)| *question));
2008 read.extend(INERTIA.iter().map(|(question, _)| *question));
2009 read.extend(ALONE.iter().map(|(question, _)| *question));
2010 read.extend(OLD_FLAG.iter().map(|(question, _)| *question));
2011 read.extend(BORES);
2012 read.push(BORE_REFUSED);
2013 read.extend(TUBE_FINS);
2014 let probes: Vec<&str> = record["probes"]
2015 .as_object()
2016 .expect("probes")
2017 .keys()
2018 .map(String::as_str)
2019 .collect();
2020 for question in &probes {
2021 assert!(
2022 read.contains(question),
2023 "no test reads the probe {question:?}"
2024 );
2025 }
2026 for question in &read {
2027 assert!(probes.contains(question), "no probe asks {question:?}");
2028 }
2029 }
2030
2031 struct TubeFinsSeen {
2033 probe: String,
2034 mach: f64,
2035 slope_ratio: f64,
2037 over_long_ring_limit: f64,
2039 gap_cal: f64,
2041 center_only_cal: f64,
2044 slope_only_cal: f64,
2045 }
2046
2047 fn tube_fin_record() -> Value {
2049 let text = include_str!("../../../validation/fixtures/ork/openrocket-tube-fin-aero.json");
2050 let record: Value = serde_json::from_str(text).expect("the committed record is JSON");
2051 for (name, bytes) in [
2052 (
2053 "tube_fin_aero.py",
2054 include_bytes!("../../../validation/oracles/openrocket/tube_fin_aero.py")
2055 .as_slice(),
2056 ),
2057 (
2058 "automatic_radius.py",
2059 include_bytes!("../../../validation/oracles/openrocket/automatic_radius.py")
2060 .as_slice(),
2061 ),
2062 ] {
2063 assert_eq!(
2064 record["inputs_sha256"][name],
2065 crate::real_flight::sha256_hex(bytes).as_str(),
2066 "{name} changed since the record was written: rerun it"
2067 );
2068 }
2069 record
2070 }
2071
2072 fn tube_fins_seen(record: &Value) -> Vec<TubeFinsSeen> {
2076 let number = |value: &Value| value.as_f64().expect("a number");
2077 let mut seen = Vec::new();
2078 let probes = record["probes"].as_object().expect("probes");
2079 assert_eq!(probes.len(), 14);
2080 for (name, probe) in probes {
2081 let document = probe["document"].as_str().expect("the probe's document");
2082 let read = ork::read(document.as_bytes()).expect("a probe reads");
2083 let layout = ork::rocket(&read.value.document)
2084 .value
2085 .layout()
2086 .expect("a probe lays out");
2087 let model = hpr_aero::AeroModel::new(&layout).expect("hpr models the probe");
2088 let [set] = model.tube_fin_sets() else {
2089 panic!("{name}: not one tube fin set in hpr");
2090 };
2091 let mut slopes = Vec::new();
2092 for asked in probe["machs"].as_array().expect("machs") {
2093 let mach = number(&asked["mach"]);
2094 let cal = number(&asked["reference_length_m"]);
2095 let components = asked["components"].as_object().expect("components");
2096 let of = |class: &str| {
2097 let mut found = components.values().filter(|c| c["class"] == class);
2098 let one = found.next().expect("the component");
2099 assert!(found.next().is_none(), "{name}: two of {class}");
2100 one
2101 };
2102 let rocket = of("Rocket");
2103 let tubes = &components[&set.id];
2104 assert_eq!(tubes["class"], "TubeFinSet", "{name}");
2105 let (outer, inner) = (
2108 number(&tubes["outer_radius_m"]),
2109 number(&tubes["inner_radius_m"]),
2110 );
2111 assert!(
2112 (set.mean_diameter_m - (outer + inner)).abs() <= 1e-12,
2113 "{name}"
2114 );
2115 let axis = number(&tubes["body_radius_m"]) + outer;
2116 assert!((set.axis_radius_m - axis).abs() <= 1e-12, "{name}");
2117 assert!(
2118 (model.reference_area_m2() / number(&asked["reference_area_m2"]) - 1.0).abs()
2119 <= 1e-12,
2120 "{name}"
2121 );
2122 assert_eq!(
2123 tubes["count"].as_u64(),
2124 Some(u64::from(set.count)),
2125 "{name}"
2126 );
2127 let fore_m = number(&tubes["leading_edge_x_m"]);
2128 let length_m = number(&tubes["length_m"]);
2129 assert!((set.fore_station_m - fore_m).abs() <= 1e-12, "{name}");
2130 assert!((set.length_m - length_m).abs() <= 1e-12, "{name}");
2131 let (theirs, their_x) = (number(&tubes["cna_per_rad"]), number(&tubes["cp_x_m"]));
2135 slopes.push(theirs);
2136 let quarter = if mach <= 0.5 { 0.25 } else { 0.0 };
2137 assert!(
2138 ((their_x - fore_m) / length_m - quarter).abs() <= 1e-9,
2139 "{name} at Mach {mach}"
2140 );
2141 if mach >= hpr_aero::tube_fins::TUBE_FIN_MACH_LIMIT {
2142 continue;
2143 }
2144 let (ours, our_x) = set.loading(mach).expect("below the limit");
2145 let whole = model
2146 .normal_force(&hpr_aero::Flow::new(mach, 0.0, 0.0))
2147 .expect("hpr's normal force");
2148 let our_cp = whole.cp_station_m.expect("a center of pressure");
2149 let (their_whole, their_cp) =
2150 (number(&rocket["cna_per_rad"]), number(&rocket["cp_x_m"]));
2151 let rest = whole.slope_per_rad - ours;
2155 let rest_moment = whole.moment_slope_m - ours * our_x;
2156 let (their_rest, their_rest_moment) = (
2157 their_whole - theirs,
2158 their_whole * their_cp - theirs * their_x,
2159 );
2160 assert!(
2161 (rest - their_rest).abs() <= 0.005,
2162 "{name}: {rest} {their_rest}"
2163 );
2164 assert!(
2165 (rest_moment / rest - their_rest_moment / their_rest).abs() <= 0.02 * cal,
2166 "{name}"
2167 );
2168 let center = |slope: f64, x: f64| (rest_moment + slope * x) / (rest + slope);
2169 assert!(
2172 (center(theirs, their_x) - their_cp).abs() <= 0.01 * cal,
2173 "{name} at Mach {mach}: {}",
2174 (center(theirs, their_x) - their_cp) / cal
2175 );
2176 let pi_d2 = std::f64::consts::PI * set.mean_diameter_m * set.mean_diameter_m;
2177 seen.push(TubeFinsSeen {
2178 probe: name.clone(),
2179 mach,
2180 slope_ratio: theirs / ours,
2181 over_long_ring_limit: theirs
2182 / (f64::from(set.count) * pi_d2 / model.reference_area_m2()),
2183 gap_cal: (our_cp - their_cp) / cal,
2184 center_only_cal: (center(ours, their_x) - our_cp) / cal,
2185 slope_only_cal: (center(theirs, our_x) - our_cp) / cal,
2186 });
2187 }
2188 assert!(
2189 slopes.iter().all(|s| (s / slopes[0] - 1.0).abs() <= 1e-12),
2190 "{name}: {slopes:?}"
2191 );
2192 }
2193 seen
2194 }
2195
2196 const TUBE_FIN_GAPS_CAL: [(&str, [f64; 5]); 14] = [
2199 (
2200 "3 tubes of 6 mm radius",
2201 [-2.7269, -2.7278, -2.7294, -2.2099, -2.2126],
2202 ),
2203 (
2204 "4 tubes of 6 mm radius",
2205 [-2.5611, -2.5624, -2.5648, -2.0028, -2.0068],
2206 ),
2207 (
2208 "5 tubes of 6 mm radius",
2209 [-2.3960, -2.3976, -2.4007, -1.8097, -1.8147],
2210 ),
2211 (
2212 "6 tubes of 6 mm radius",
2213 [-2.2474, -2.2493, -2.2529, -1.6410, -1.6468],
2214 ),
2215 (
2216 "8 tubes of 6 mm radius",
2217 [-2.0048, -2.0070, -2.0115, -1.3712, -1.3784],
2218 ),
2219 (
2220 "six touching tubes",
2221 [-1.0427, -1.0501, -1.0647, -0.3634, -0.3867],
2222 ),
2223 (
2224 "six touching tubes 0.025 m long",
2225 [-0.4262, -0.4237, -0.4197, -0.1844, -0.1834],
2226 ),
2227 (
2228 "six touching tubes 0.05 m long",
2229 [-0.7755, -0.7816, -0.7937, -0.3301, -0.3502],
2230 ),
2231 (
2232 "six touching tubes 0.15 m long",
2233 [-1.7202, -1.7285, -1.7448, -0.3262, -0.3518],
2234 ),
2235 (
2236 "six touching tubes 0.3 m long",
2237 [-2.9728, -2.9815, -2.9984, -0.1432, -0.1699],
2238 ),
2239 (
2240 "six touching tubes with a 0.001 m wall",
2241 [-1.0252, -1.0323, -1.0463, -0.3486, -0.3710],
2242 ),
2243 (
2244 "six touching tubes with a 0.003 m wall",
2245 [-0.8964, -0.9026, -0.9150, -0.2324, -0.2521],
2246 ),
2247 (
2248 "six tubes of 0.004 m radius",
2249 [-2.8034, -2.8039, -2.8048, -2.3076, -2.3091],
2250 ),
2251 (
2252 "six tubes of 0.008 m radius",
2253 [-1.7089, -1.7124, -1.7194, -1.0576, -1.0688],
2254 ),
2255 ];
2256
2257 #[test]
2271 fn tube_fin_cp_against_the_oracle_measured_and_pinned() {
2272 let record = tube_fin_record();
2273 let seen = tube_fins_seen(&record);
2274 let close = |found: f64, pinned: f64, what: &str| {
2275 assert!(
2276 (found - pinned).abs() <= 5e-4,
2277 "{what}: {found} against {pinned}"
2278 );
2279 };
2280 assert_eq!(seen.len(), TUBE_FIN_GAPS_CAL.len() * 5);
2282 for (probe, gaps) in TUBE_FIN_GAPS_CAL {
2283 let found: Vec<&TubeFinsSeen> = seen.iter().filter(|s| s.probe == probe).collect();
2284 assert_eq!(found.len(), gaps.len(), "{probe}");
2285 for (s, gap) in found.iter().zip(gaps) {
2286 close(s.gap_cal, gap, &format!("{probe} at Mach {}", s.mach));
2287 }
2288 }
2289 let range = |values: Vec<f64>| {
2290 let least = values.iter().copied().fold(f64::INFINITY, f64::min);
2291 (
2292 least,
2293 values.iter().copied().fold(f64::NEG_INFINITY, f64::max),
2294 )
2295 };
2296 let (least, most) = range(seen.iter().map(|s| s.slope_ratio).collect());
2298 close(least, 1.2631, "the least slope ratio");
2299 close(most, 1.8638, "the largest slope ratio");
2300 let (least, most) = range(seen.iter().map(|s| s.over_long_ring_limit).collect());
2301 close(least, 1.2038, "the least share of the long-ring limit");
2302 close(most, 1.8556, "the largest share of the long-ring limit");
2303 let per_tube: Vec<f64> = [3, 4, 5, 6, 8]
2306 .iter()
2307 .map(|count| {
2308 let asked = &record["probes"][format!("{count} tubes of 6 mm radius")]["machs"][0];
2309 let tubes = asked["components"]
2310 .as_object()
2311 .and_then(|all| all.values().find(|c| c["class"] == "TubeFinSet"))
2312 .expect("the tubes");
2313 tubes["cna_per_rad"].as_f64().expect("a slope") / f64::from(*count)
2314 })
2315 .collect();
2316 assert!(
2317 per_tube
2318 .iter()
2319 .all(|s| (s / per_tube[0] - 1.0).abs() <= 1e-15),
2320 "{per_tube:?}"
2321 );
2322 let touching = seen
2325 .iter()
2326 .find(|s| s.probe == "six touching tubes")
2327 .expect("the probe");
2328 close(
2329 touching.center_only_cal,
2330 0.4952,
2331 "OpenRocket's center alone",
2332 );
2333 close(touching.slope_only_cal, 0.5319, "OpenRocket's slope alone");
2334 let within: Vec<(&str, f64)> = seen
2338 .iter()
2339 .filter(|s| s.gap_cal.abs() <= 0.25)
2340 .map(|s| (s.probe.as_str(), s.mach))
2341 .collect();
2342 assert_eq!(
2343 within,
2344 [
2345 ("six touching tubes 0.025 m long", 0.6),
2346 ("six touching tubes 0.025 m long", 0.75),
2347 ("six touching tubes 0.3 m long", 0.6),
2348 ("six touching tubes 0.3 m long", 0.75),
2349 ("six touching tubes with a 0.003 m wall", 0.6),
2350 ]
2351 );
2352 let flights: Value = serde_json::from_str(include_str!(
2356 "../../../validation/reports/openrocket-flights.json"
2357 ))
2358 .expect("the flight record");
2359 let flight = flights["flights"]
2360 .as_array()
2361 .and_then(|all| all.iter().find(|f| f["design"] == "Tube fin rocket"))
2362 .expect("the Tube fin rocket's flight");
2363 let at = &flight["at_rod_clearance"];
2364 let number = |value: &Value| value.as_f64().expect("a number");
2365 let example = &record["example"]["machs"][0];
2366 assert_eq!(number(&example["mach"]), 0.05);
2367 let rocket = example["components"]
2368 .as_object()
2369 .and_then(|all| all.values().find(|c| c["class"] == "Rocket"))
2370 .expect("the example's rocket");
2371 let theirs = number(&at["openrocket"]["cp_from_nose_m"]);
2372 assert!((number(&rocket["cp_x_m"]) - theirs).abs() <= 1e-4);
2375 assert!(
2376 number(&at["mach"]) < 0.5,
2377 "OpenRocket's quarter-length center"
2378 );
2379 let gap = (number(&at["hpr"]["cp_from_nose_m"]) - theirs)
2380 / number(&at["openrocket"]["reference_length_m"]);
2381 close(gap, -1.0747, "the Tube fin rocket at rod clearance");
2382 }
2383
2384 fn drag_override_record() -> Value {
2386 let text = include_str!("../../../validation/fixtures/ork/openrocket-drag-override.json");
2387 let record: Value = serde_json::from_str(text).expect("the committed record is JSON");
2388 for (name, bytes) in [
2389 (
2390 "drag_override.py",
2391 include_bytes!("../../../validation/oracles/openrocket/drag_override.py")
2392 .as_slice(),
2393 ),
2394 (
2395 "automatic_radius.py",
2396 include_bytes!("../../../validation/oracles/openrocket/automatic_radius.py")
2397 .as_slice(),
2398 ),
2399 ] {
2400 assert_eq!(
2401 record["inputs_sha256"][name],
2402 crate::real_flight::sha256_hex(bytes).as_str(),
2403 "{name} changed since the record was written: rerun it"
2404 );
2405 }
2406 record
2407 }
2408
2409 fn probe_drag(document: &str, mach: f64) -> f64 {
2411 let reynolds_per_m = mach * 340.294 / 1.4607e-5;
2413 let read = ork::read(document.as_bytes()).expect("a probe reads");
2414 let layout = ork::rocket(&read.value.document)
2415 .value
2416 .layout()
2417 .expect("a probe lays out");
2418 hpr_aero::AeroModel::new(&layout)
2419 .expect("hpr models the probe")
2420 .drag(
2421 &hpr_aero::Flow::axial(mach),
2422 &hpr_aero::DragConditions::coasting(reynolds_per_m),
2423 )
2424 .expect("hpr's drag")
2425 .zero_lift_coefficient
2426 }
2427
2428 #[test]
2450 fn a_stated_drag_coefficient_moves_the_drag_as_openrocket_s() {
2451 let record = drag_override_record();
2452 let variants = record["variants"].as_object().expect("variants");
2453 assert_eq!(variants.len(), 25);
2454 let mut changes = std::collections::BTreeMap::new();
2456 for (name, variant) in variants {
2457 let airframe = &variants[variant["airframe"].as_str().expect("an airframe")];
2458 let document = variant["document"].as_str().expect("a document");
2459 let plain = airframe["document"].as_str().expect("a document");
2460 let mut rows = Vec::new();
2461 for (asked, plain_asked) in variant["machs"]
2462 .as_array()
2463 .expect("machs")
2464 .iter()
2465 .zip(airframe["machs"].as_array().expect("machs"))
2466 {
2467 let mach = asked["mach"].as_f64().expect("a Mach number");
2468 let cd = asked["rocket"]["cd"].as_f64().expect("a coefficient");
2469 let theirs = cd - plain_asked["rocket"]["cd"].as_f64().expect("a coefficient");
2470 let ours_cd = probe_drag(document, mach);
2471 rows.push((mach, ours_cd, cd, ours_cd - probe_drag(plain, mach), theirs));
2472 }
2473 changes.insert(name.as_str(), rows);
2474 }
2475 let gap_of = |name: &str, at: usize| {
2476 let (_, _, _, ours, theirs) = changes[name][at];
2477 ours - theirs
2478 };
2479 let mut largest_body_gap: f64 = 0.0;
2480 for (name, rows) in &changes {
2481 let exact = ["the stage at 0.5", "the inner tube", "the ring", "the mass"]
2482 .iter()
2483 .any(|part| name.ends_with(&format!("{part} at 0.5")) || name.ends_with(part));
2484 let lugs_or_buttons = name.contains("the lugs") || name.contains("the buttons");
2485 let subsonic_only = name.contains("the nose") || name.contains("the boattail at");
2486 for (at, &(mach, ours_cd, theirs_cd, ours, theirs)) in rows.iter().enumerate() {
2487 let mut gap = ours - theirs;
2488 let what =
2489 format!("{name} at Mach {mach}: hpr {ours:+.5}, OpenRocket {theirs:+.5}");
2490 if *name == "flare, the stage at 0.5 with its children" {
2491 assert!((ours_cd - 0.5).abs() <= 1e-12, "{what}: hpr {ours_cd}");
2492 assert!(
2493 (theirs_cd - 0.5).abs() <= 1e-12,
2494 "{what}: OpenRocket {theirs_cd}"
2495 );
2496 continue;
2497 }
2498 if *name == "flare, the tube at 0.5 with its children" {
2499 gap -= gap_of("flare, the lugs at 0.5", at)
2500 + gap_of("flare, the buttons at 0.5", at);
2501 }
2502 if exact {
2503 assert!(gap.abs() <= 1e-9, "{what}");
2504 } else if name.contains("the fins") {
2505 assert!(gap.abs() <= 1e-4, "{what}");
2506 } else if lugs_or_buttons {
2507 assert!(gap.abs() <= 0.075, "{what}");
2508 } else if mach <= 0.6 || !subsonic_only {
2509 assert!(gap.abs() <= 0.0065, "{what}");
2510 largest_body_gap = largest_body_gap.max(gap.abs());
2511 }
2512 }
2513 }
2514 assert!(
2515 (largest_body_gap - 0.0059).abs() < 1e-4,
2516 "{largest_body_gap}"
2517 );
2518 }
2519}