1use serde::{Deserialize, Serialize};
31
32use crate::bundled;
33use crate::class::ImpulseClass;
34use crate::curve::ThrustCurve;
35use crate::delay::DelayList;
36use crate::error::MotorError;
37use crate::motor::SolidMotor;
38use crate::{eng, rse};
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
42pub struct Catalog {
43 pub source: String,
45 pub snapshot: Snapshot,
47 pub selection: String,
49 pub motors: Vec<CatalogMotor>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct Snapshot {
57 pub files_url: String,
59 pub captured: String,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
65#[non_exhaustive]
66pub enum MotorType {
67 #[serde(rename = "SU")]
69 SingleUse,
70 #[serde(rename = "reload")]
72 Reload,
73 #[serde(rename = "hybrid")]
76 Hybrid,
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct CatalogMotor {
82 pub motor_id: String,
84 pub manufacturer: String,
86 pub manufacturer_abbrev: String,
88 pub designation: String,
90 pub common_name: String,
92 pub impulse_class: ImpulseClass,
94 pub motor_type: MotorType,
96 pub case_info: Option<String>,
98 pub prop_info: Option<String>,
100 pub delays: Option<String>,
103 pub diameter_mm: f64,
105 pub length_mm: f64,
107 pub total_impulse_ns: f64,
109 pub average_thrust_n: f64,
111 pub max_thrust_n: Option<f64>,
113 pub burn_time_s: f64,
115 pub propellant_mass_g: Option<f64>,
117 pub total_mass_g: Option<f64>,
119 pub curves: Vec<CatalogCurve>,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub struct CatalogCurve {
126 pub simfile_id: String,
128 pub format: CurveFormat,
130 pub source: CurveSource,
132 #[serde(default)]
134 pub license: CurveLicense,
135 pub file: String,
137 pub sha256: String,
139 pub url: String,
141 pub info_url: Option<String>,
143 pub retrieved: String,
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
149#[non_exhaustive]
150pub enum CurveFormat {
151 #[serde(rename = "RASP")]
153 Rasp,
154 #[serde(rename = "RockSim")]
156 RockSim,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
161#[non_exhaustive]
162pub enum CurveSource {
163 #[serde(rename = "cert")]
165 Certification,
166 #[serde(rename = "mfr")]
168 Manufacturer,
169 #[serde(rename = "user")]
171 User,
172}
173
174#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
177#[non_exhaustive]
178pub enum CurveLicense {
179 #[serde(rename = "PD")]
181 PublicDomain,
182 #[serde(rename = "free")]
184 Free,
185 #[serde(rename = "other")]
187 Other,
188 #[default]
190 #[serde(rename = "unknown", alias = "")]
191 Unknown,
192}
193
194impl CurveLicense {
195 pub fn is_bundleable(self) -> bool {
198 self == Self::PublicDomain
199 }
200}
201
202impl Catalog {
203 pub fn bundled() -> Result<Self, MotorError> {
209 Self::from_json(bundled::CATALOG_JSON)
210 }
211
212 pub fn from_json(text: &str) -> Result<Self, MotorError> {
218 serde_json::from_str(text).map_err(|error| MotorError::Catalog(error.to_string()))
219 }
220
221 pub fn find<'a>(&'a self, name: &str) -> impl Iterator<Item = &'a CatalogMotor> + 'a {
224 let key = normalize(name);
225 self.motors.iter().filter(move |motor| {
226 normalize(&motor.designation) == key || normalize(&motor.common_name) == key
227 })
228 }
229}
230
231fn normalize(name: &str) -> String {
233 name.chars()
234 .filter(|c| !c.is_whitespace() && *c != '-')
235 .map(|c| c.to_ascii_lowercase())
236 .collect()
237}
238
239fn read_curve_file(
241 curve: &CatalogCurve,
242 text: &str,
243) -> Result<(ThrustCurve, f64, f64), MotorError> {
244 let one = |count: usize| {
245 if count == 1 {
246 Ok(())
247 } else {
248 Err(MotorError::Inconsistent(format!(
249 "the curve file {} holds {count} motors, not one",
250 curve.file
251 )))
252 }
253 };
254 match curve.format {
255 CurveFormat::Rasp => {
256 let file = eng::parse(text)?.value;
257 one(file.entries.len())?;
258 let entry = &file.entries[0];
259 Ok((
260 entry.thrust_curve()?,
261 entry.propellant_mass_kg,
262 entry.total_mass_kg,
263 ))
264 }
265 CurveFormat::RockSim => {
266 let file = rse::parse(text)?.value;
267 one(file.engines.len())?;
268 let engine = &file.engines[0];
269 Ok((
270 engine.thrust_curve()?,
271 engine.propellant_mass_g * 1e-3,
272 engine.initial_mass_g * 1e-3,
273 ))
274 }
275 }
276}
277
278pub fn bundled_curve_text(file: &str) -> Option<&'static str> {
280 bundled::CURVE_FILES
281 .iter()
282 .find(|(path, _)| *path == file)
283 .map(|(_, text)| *text)
284}
285
286impl CatalogMotor {
287 pub fn delays(&self) -> DelayList {
289 DelayList::parse(self.delays.as_deref().unwrap_or(""))
290 }
291
292 pub fn thrust_curve(
301 &self,
302 curve: &CatalogCurve,
303 text: &str,
304 ) -> Result<ThrustCurve, MotorError> {
305 self.check_own(curve)?;
306 read_curve_file(curve, text).map(|(thrust, _, _)| thrust)
307 }
308
309 fn check_own(&self, curve: &CatalogCurve) -> Result<(), MotorError> {
312 if self.curves.contains(curve) {
313 Ok(())
314 } else {
315 Err(MotorError::Inconsistent(format!(
316 "curve {} is not one of {}'s curves",
317 curve.simfile_id, self.designation
318 )))
319 }
320 }
321
322 pub fn motor(&self, curve: &CatalogCurve, text: &str) -> Result<SolidMotor, MotorError> {
333 if self.motor_type == MotorType::Hybrid {
334 return Err(MotorError::Inconsistent(format!(
335 "{} is a hybrid; hpr models solid motors only",
336 self.designation
337 )));
338 }
339 self.check_own(curve)?;
340 let (thrust, header_propellant_kg, header_total_kg) = read_curve_file(curve, text)?;
341 let propellant_kg = self
342 .propellant_mass_g
343 .map_or(header_propellant_kg, |g| g * 1e-3);
344 let total_kg = self.total_mass_g.map_or(header_total_kg, |g| g * 1e-3);
345 if !(propellant_kg > 0.0 && total_kg > 0.0) {
346 return Err(MotorError::Inconsistent(format!(
347 "{} has no propellant and loaded masses",
348 self.designation
349 )));
350 }
351 SolidMotor::from_envelope(
352 thrust,
353 self.diameter_mm * 1e-3,
354 self.length_mm * 1e-3,
355 propellant_kg,
356 total_kg,
357 )
358 }
359
360 pub fn bundled_motor(&self) -> Result<SolidMotor, MotorError> {
367 let (curve, text) = self
368 .curves
369 .iter()
370 .find_map(|curve| bundled_curve_text(&curve.file).map(|text| (curve, text)))
371 .ok_or_else(|| {
372 MotorError::Inconsistent(format!("{} has no bundled curve", self.designation))
373 })?;
374 self.motor(curve, text)
375 }
376}
377
378#[cfg(test)]
379mod tests {
380 use sha2::{Digest, Sha256};
381
382 use super::*;
383 use crate::delay::Delay;
384
385 fn bundled() -> Catalog {
386 Catalog::bundled().unwrap()
387 }
388
389 #[test]
392 fn bundled_curves_have_permissive_license() {
393 let catalog = bundled();
394 assert!(!catalog.motors.is_empty());
395 let mut files = 0;
396 for motor in &catalog.motors {
397 for curve in &motor.curves {
398 assert!(
399 curve.license.is_bundleable(),
400 "{} {}: {:?}",
401 motor.designation,
402 curve.simfile_id,
403 curve.license
404 );
405 assert!(bundled_curve_text(&curve.file).is_some(), "{}", curve.file);
406 files += 1;
407 }
408 }
409 assert_eq!(files, bundled::CURVE_FILES.len());
411 for motor in &catalog.motors {
413 for curve in &motor.curves {
414 let text = bundled_curve_text(&curve.file).unwrap();
415 let digest: String = Sha256::digest(text.as_bytes())
416 .iter()
417 .map(|byte| format!("{byte:02x}"))
418 .collect();
419 assert_eq!(digest, curve.sha256, "{}", curve.file);
420 assert!(curve.url.contains(&curve.simfile_id));
421 }
422 }
423 assert!(!CurveLicense::Free.is_bundleable());
424 assert!(!CurveLicense::Unknown.is_bundleable());
425 }
426
427 #[test]
434 fn every_bundled_curve_impulse_within_1pct_of_thrustcurve() {
435 let oracle: AnalyzeOracle = serde_json::from_str(include_str!(
436 "../../../validation/fixtures/motor/thrustcurve-analyze-stats.json"
437 ))
438 .unwrap();
439 let catalog = bundled();
440 let mut checked = 0;
441 for motor in &catalog.motors {
442 for curve in &motor.curves {
443 let text = bundled_curve_text(&curve.file).unwrap();
444 let thrust = motor.thrust_curve(curve, text).unwrap();
445 let theirs = oracle
446 .curves
447 .iter()
448 .find(|c| c.file == curve.file)
449 .unwrap_or_else(|| panic!("{}: not in the oracle record", curve.file));
450 for (stated, computed, by_thrustcurve, what) in [
451 (
452 motor.total_impulse_ns,
453 thrust.total_impulse_ns(),
454 theirs.total_impulse_ns,
455 "total impulse",
456 ),
457 (
458 motor.average_thrust_n,
459 thrust.average_thrust_n(),
460 theirs.average_thrust_n,
461 "average thrust",
462 ),
463 (
464 motor.burn_time_s,
465 thrust.burn_time_s(),
466 theirs.burn_time_s,
467 "burn time",
468 ),
469 (
470 motor.max_thrust_n.unwrap(),
471 thrust.peak_thrust_n(),
472 theirs.max_thrust_n,
473 "peak thrust",
474 ),
475 ] {
476 assert_eq!(
477 stated.to_bits(),
478 computed.to_bits(),
479 "{} ({}): the index's {what} {stated} is not its curve's {computed}",
480 motor.designation,
481 curve.simfile_id,
482 );
483 let error = (stated - by_thrustcurve) / by_thrustcurve;
484 assert!(
485 error.abs() <= 0.01,
486 "{} ({}): {what} {stated} against ThrustCurve's code's {by_thrustcurve} \
487 ({:+.2}%)",
488 motor.designation,
489 curve.simfile_id,
490 100.0 * error
491 );
492 }
493 assert_eq!(
494 ImpulseClass::from_total_impulse(motor.total_impulse_ns).unwrap(),
495 motor.impulse_class,
496 "{}",
497 motor.designation
498 );
499 checked += 1;
500 }
501 }
502 assert_eq!(checked, 32);
503 }
504
505 #[test]
509 fn every_bundled_size_mass_and_delay_is_the_header_s() {
510 let catalog = bundled();
511 for motor in &catalog.motors {
512 let [curve] = &motor.curves[..] else {
513 panic!("{}: {} curves", motor.designation, motor.curves.len());
514 };
515 let text = bundled_curve_text(&curve.file).unwrap();
516 let (diameter_mm, length_mm, delays, propellant_g, total_g) = match curve.format {
517 CurveFormat::Rasp => {
518 let entry = eng::parse(text).unwrap().value.entries.remove(0);
519 (
520 entry.diameter_mm,
521 entry.length_mm,
522 Some(entry.delays),
523 entry.propellant_mass_kg * 1e3,
524 entry.total_mass_kg * 1e3,
525 )
526 }
527 CurveFormat::RockSim => {
528 let engine = rse::parse(text).unwrap().value.engines.remove(0);
529 (
530 engine.diameter_mm,
531 engine.length_mm,
532 engine.delays,
533 engine.propellant_mass_g,
534 engine.initial_mass_g,
535 )
536 }
537 };
538 let name = &motor.designation;
539 assert_eq!(motor.diameter_mm, diameter_mm, "{name}");
540 assert_eq!(motor.length_mm, length_mm, "{name}");
541 assert_eq!(motor.delays, delays, "{name}");
542 assert!(
543 (motor.propellant_mass_g.unwrap() - propellant_g).abs() <= 5e-7,
544 "{name}"
545 );
546 assert!(
547 (motor.total_mass_g.unwrap() - total_g).abs() <= 5e-7,
548 "{name}"
549 );
550 }
551 }
552
553 #[test]
556 fn bundled_index_holds_no_thrustcurve_record_fields() {
557 for key in [
558 "cert_org",
559 "updated_on",
560 "availability",
561 "delay_adjustable",
562 "motors_url",
563 "api/v1",
564 ] {
565 assert!(
566 !bundled::CATALOG_JSON.contains(key),
567 "the bundled index holds {key}"
568 );
569 }
570 let catalog = bundled();
571 assert_eq!(
572 catalog.snapshot.files_url,
573 "https://www.thrustcurve.org/simfiles/"
574 );
575 for curve in catalog.motors.iter().flat_map(|m| &m.curves) {
576 assert!(curve.url.starts_with(&catalog.snapshot.files_url));
577 }
578 }
579
580 #[test]
586 fn metadata_overrides_header_envelope() {
587 let catalog = bundled();
588 let bundled_entry = catalog.find("N3300R").next().unwrap();
589 let curve = &bundled_entry.curves[0];
590 let text = bundled_curve_text(&curve.file).unwrap();
591 let header = &eng::parse(text).unwrap().value.entries[0];
592 assert_eq!(header.length_mm, 1060.0);
593 assert_eq!(bundled_entry.length_mm, 1060.0);
594 let motor = CatalogMotor {
595 length_mm: 1000.0,
596 total_mass_g: Some(12000.0),
597 ..bundled_entry.clone()
598 };
599 let built = motor.motor(curve, text).unwrap();
600 assert!((built.dry().cg_m - 0.500).abs() < 1e-12);
602 let loaded = built.state(0.0).total;
603 assert!((loaded.mass_kg - 12.000).abs() < 1e-9);
604 assert!((bundled_entry.bundled_motor().unwrap().dry().cg_m - 0.530).abs() < 1e-12);
605 let bare = CatalogMotor {
607 propellant_mass_g: None,
608 total_mass_g: None,
609 ..motor.clone()
610 };
611 let fallback = bare.motor(curve, text).unwrap();
612 assert!((fallback.state(0.0).total.mass_kg - header.total_mass_kg).abs() < 1e-12);
613 assert!((fallback.dry().cg_m - 0.500).abs() < 1e-12);
614 }
615
616 #[test]
618 fn every_bundled_file_round_trips_bit_for_bit() {
619 let catalog = bundled();
620 for motor in &catalog.motors {
621 for curve in &motor.curves {
622 let text = bundled_curve_text(&curve.file).unwrap();
623 match curve.format {
624 CurveFormat::Rasp => {
625 let first = eng::parse(text).unwrap().value;
626 let written = eng::write(&first).unwrap();
627 let second = eng::parse(&written).unwrap().value;
628 assert_eq!(second, first, "{}", curve.file);
629 assert_eq!(eng::tests::bits(&second), eng::tests::bits(&first));
630 assert_eq!(eng::write(&second).unwrap(), written);
631 }
632 CurveFormat::RockSim => {
633 let first = rse::parse(text).unwrap().value;
634 let written = rse::write(&first).unwrap();
635 let second = rse::parse(&written).unwrap().value;
636 assert_eq!(second, first, "{}", curve.file);
637 assert_eq!(rse::tests::bits(&second), rse::tests::bits(&first));
638 assert_eq!(rse::write(&second).unwrap(), written);
639 }
640 }
641 }
642 }
643 }
644
645 #[test]
646 fn finds_motors_and_reads_delays() {
647 let catalog = bundled();
648 let j760: Vec<_> = catalog.find("j760").collect();
649 assert_eq!(j760.len(), 1);
650 assert_eq!(j760[0].designation, "1266J760-19A");
651 assert_eq!(catalog.find("1266J760-19A").count(), 1);
652 let plugged = catalog.find("L3200").next().unwrap();
653 assert_eq!(plugged.delays().delays, [Delay::Plugged]);
654 assert!(catalog.find("no such motor").next().is_none());
655 for motor in &catalog.motors {
657 let built = motor.bundled_motor().unwrap();
658 assert!(
659 built.propellant_initial_mass_kg() > 0.0,
660 "{}",
661 motor.designation
662 );
663 }
664 }
665
666 #[test]
667 fn catalog_json_reads_thrustcurve_license_values_and_rejects_others() {
668 assert!(matches!(
669 Catalog::from_json("{}"),
670 Err(MotorError::Catalog(_))
671 ));
672 let with = |license: &str| {
673 Catalog::from_json(&bundled::CATALOG_JSON.replacen("\"license\": \"PD\",", license, 1))
674 };
675 let first_license = |catalog: Catalog| catalog.motors[0].curves[0].license;
676 assert!(with("\"license\": \"GPL\",").is_err());
677 assert_eq!(
678 first_license(with("\"license\": \"free\",").unwrap()),
679 CurveLicense::Free
680 );
681 assert_eq!(first_license(with("").unwrap()), CurveLicense::Unknown);
683 assert_eq!(
684 first_license(with("\"license\": \"\",").unwrap()),
685 CurveLicense::Unknown
686 );
687 let hybrid = bundled::CATALOG_JSON.replacen(
688 "\"motor_type\": \"SU\"",
689 "\"motor_type\": \"hybrid\"",
690 1,
691 );
692 let hybrid = Catalog::from_json(&hybrid).unwrap();
693 assert_eq!(hybrid.motors[0].motor_type, MotorType::Hybrid);
694 assert!(hybrid.motors[0].bundled_motor().is_err());
695 }
696
697 #[test]
698 fn a_curve_of_another_motor_is_refused() {
699 let catalog = Catalog::bundled().unwrap();
700 let (this, other) = (&catalog.motors[0], &catalog.motors[1]);
701 let curve = &other.curves[0];
702 let text = bundled_curve_text(&curve.file).unwrap();
703 assert!(other.motor(curve, text).is_ok());
704 assert!(matches!(
705 this.motor(curve, text),
706 Err(MotorError::Inconsistent(message)) if message.contains(&curve.simfile_id)
707 ));
708 assert!(this.thrust_curve(curve, text).is_err());
709 }
710
711 #[derive(Debug, serde::Deserialize)]
712 struct AnalyzeOracle {
713 oracle: String,
714 curves: Vec<AnalyzeCurve>,
715 }
716
717 #[derive(Debug, serde::Deserialize)]
718 struct AnalyzeCurve {
719 file: String,
720 total_impulse_ns: f64,
721 burn_time_s: f64,
722 average_thrust_n: f64,
723 max_thrust_n: f64,
724 burn_start_s: f64,
725 burn_end_s: f64,
726 }
727
728 #[test]
733 fn every_bundled_curve_matches_thrustcurve_statistics_code() {
734 let oracle: AnalyzeOracle = serde_json::from_str(include_str!(
735 "../../../validation/fixtures/motor/thrustcurve-analyze-stats.json"
736 ))
737 .unwrap();
738 assert!(oracle.oracle.contains("analyze.js at commit 577afa6"));
739 let catalog = bundled();
740 assert_eq!(oracle.curves.len(), bundled::CURVE_FILES.len());
741 let mut worst = 0.0f64;
742 for expected in &oracle.curves {
743 let (motor, curve) = catalog
744 .motors
745 .iter()
746 .find_map(|m| {
747 m.curves
748 .iter()
749 .find(|c| c.file == expected.file)
750 .map(|c| (m, c))
751 })
752 .unwrap();
753 let thrust = motor
754 .thrust_curve(curve, bundled_curve_text(&curve.file).unwrap())
755 .unwrap();
756 let (start, end) = thrust.burn_window_s();
757 for (ours, theirs, what) in [
758 (
759 thrust.total_impulse_ns(),
760 expected.total_impulse_ns,
761 "total impulse",
762 ),
763 (thrust.burn_time_s(), expected.burn_time_s, "burn time"),
764 (
765 thrust.average_thrust_n(),
766 expected.average_thrust_n,
767 "average thrust",
768 ),
769 (thrust.peak_thrust_n(), expected.max_thrust_n, "peak thrust"),
770 (start, expected.burn_start_s, "burn start"),
771 (end, expected.burn_end_s, "burn end"),
772 ] {
773 let error = (ours - theirs).abs() / theirs.abs().max(1e-3);
774 worst = worst.max(error);
775 assert!(
776 error <= 1e-12,
777 "{}: {what} {ours} against {theirs}",
778 expected.file
779 );
780 }
781 }
782 eprintln!("largest relative difference from ThrustCurve's code: {worst:.2e}");
783 }
784
785 #[derive(Debug, serde::Deserialize)]
788 struct OpenRocketRecord {
789 openrocket: String,
790 inputs_sha256: std::collections::BTreeMap<String, String>,
791 jar_sha256: String,
792 curves: Vec<OpenRocketCurve>,
793 }
794
795 #[derive(Debug, serde::Deserialize)]
796 struct OpenRocketCurve {
797 file: String,
798 sha256: String,
799 #[serde(default)]
800 motors: Vec<OpenRocketMotor>,
801 #[serde(default)]
802 driver_error: Option<String>,
803 }
804
805 #[derive(Debug, serde::Deserialize)]
806 struct OpenRocketMotor {
807 common_name: String,
808 total_impulse_ns: f64,
809 max_thrust_n: f64,
810 average_thrust_n: f64,
811 burn_time_s: f64,
812 burn_time_estimate_s: f64,
813 first_time_s: f64,
814 points: usize,
815 }
816
817 #[test]
835 fn openrocket_s_total_impulse_matches_every_bundled_curve() {
836 let record: OpenRocketRecord = serde_json::from_str(include_str!(
837 "../../../validation/fixtures/motor/openrocket-curve-stats.json"
838 ))
839 .unwrap();
840 assert_eq!(record.openrocket, "24.12");
841 assert_eq!(
843 record.inputs_sha256["motors.py"],
844 "1049b94c2c0da896dd84887a06b5ff8fd72865350aa98a2f9e1c8b7e14d5e3e7",
845 );
846 assert_eq!(
847 record.jar_sha256,
848 "4959b72f52f5f607941e9722abbb7b7f0c4a38ebbbf84204a329db9f31c4f897",
849 );
850 assert_eq!(record.curves.len(), bundled::CURVE_FILES.len());
851 let catalog = bundled();
852 let mut worst_impulse = 0.0f64;
853 let mut worst_peak = 0.0f64;
854 let mut average = Vec::new();
855 let mut identical = true;
856 let mut files = std::collections::BTreeSet::new();
857 for entry in &record.curves {
858 let file = entry
859 .file
860 .strip_prefix("crates/hpr-motor/data/thrustcurve/")
861 .expect("the record names the bundled file");
862 assert_eq!(entry.driver_error, None, "{file}");
863 let [theirs] = &entry.motors[..] else {
864 panic!("{file}: OpenRocket read {} motors", entry.motors.len());
865 };
866 let (motor, curve) = catalog
867 .motors
868 .iter()
869 .find_map(|m| m.curves.iter().find(|c| c.file == file).map(|c| (m, c)))
870 .unwrap_or_else(|| panic!("{file} is not a bundled curve"));
871 assert_eq!(curve.sha256, entry.sha256, "{file}");
873 assert!(files.insert(file.to_owned()), "{file} twice");
874 assert_eq!(
878 normalize(&motor.common_name),
879 normalize(&theirs.common_name),
880 "{file}"
881 );
882 let text = bundled_curve_text(file).unwrap();
883 let thrust = motor.thrust_curve(curve, text).unwrap();
884 for (ours, theirs, what, bound) in [
885 (
886 thrust.total_impulse_ns(),
887 theirs.total_impulse_ns,
888 "total impulse",
889 1e-3,
890 ),
891 (
892 thrust.peak_thrust_n(),
893 theirs.max_thrust_n,
894 "peak thrust",
895 1e-12,
896 ),
897 (
898 thrust.burn_time_s(),
899 theirs.burn_time_estimate_s,
900 "burn time",
901 1e-12,
902 ),
903 (
904 thrust.end_time_s(),
905 theirs.burn_time_s,
906 "curve duration",
907 1e-12,
908 ),
909 ] {
910 assert!(theirs > 0.0, "{file}: {what} {theirs}");
911 let error = (ours - theirs).abs() / theirs;
912 if what == "total impulse" {
913 worst_impulse = worst_impulse.max(error);
914 } else {
915 worst_peak = worst_peak.max(error);
916 }
917 assert!(error <= bound, "{file}: {what} {ours} against {theirs}");
918 identical &= ours == theirs;
921 }
922 assert_eq!(thrust.times_s()[0], 0.0, "{file}");
925 assert_eq!(theirs.first_time_s, 0.0, "{file}");
926 assert_eq!(thrust.times_s().len(), theirs.points, "{file}");
927 average.push(
930 (thrust.average_thrust_n() - theirs.average_thrust_n) / theirs.average_thrust_n,
931 );
932 }
933 assert_eq!(
934 files.len(),
935 bundled::CURVE_FILES.len(),
936 "a file twice or missing"
937 );
938 assert!(
939 identical,
940 "impulse, peak thrust, burn time and duration are no longer bit for bit OpenRocket's"
941 );
942 average.sort_by(f64::total_cmp);
943 let mid = average.len() / 2;
944 let median = if average.len().is_multiple_of(2) {
945 0.5 * (average[mid - 1] + average[mid])
946 } else {
947 average[mid]
948 };
949 let (low, high) = (average[0] * 100.0, average[average.len() - 1] * 100.0);
950 assert!(low > 0.0, "hpr's average thrust is no longer the higher");
951 eprintln!(
952 "against OpenRocket 24.12 on {} curves: total impulse, peak thrust, the 5% burn-time \
953 window and the curve's duration are all bit for bit equal (worst relative differences \
954 {worst_impulse:.2e} and {worst_peak:.2e}); hpr's average thrust is higher by \
955 {low:+.4}% to {high:+.4}% (median {:+.4}%), because its numerator is the whole curve's \
956 impulse and OpenRocket's is the window's",
957 average.len(),
958 median * 100.0,
959 );
960 }
961}