1use std::f64::consts::PI;
30
31use serde::{Deserialize, Serialize};
32
33use crate::curve::ThrustCurve;
34use crate::error::MotorError;
35use crate::grains::BatesGrains;
36use crate::mass::MassElement;
37
38pub const UNITS_HINT: &str = "check the units of the masses and the curve";
42
43pub const STANDARD_SEA_LEVEL_PRESSURE_PA: f64 = 101_325.0;
49
50#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
52#[serde(deny_unknown_fields)]
53pub struct PropellantColumn {
54 pub mass_kg: f64,
56 pub center_m: f64,
58 pub outer_radius_m: f64,
60 pub inner_radius_m: f64,
62 pub length_m: f64,
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
71#[serde(tag = "model", rename_all = "snake_case")]
72#[non_exhaustive]
73pub enum Propellant {
74 Column(PropellantColumn),
76 Grains(BatesGrains),
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
82#[serde(deny_unknown_fields)]
83pub struct Nozzle {
84 pub exit_radius_m: f64,
86 pub throat_radius_m: Option<f64>,
88 #[serde(deserialize_with = "Option::deserialize")]
102 #[schemars(required, extend("type" = ["number", "null"]))]
104 pub reference_pressure_pa: Option<f64>,
105}
106
107impl Nozzle {
108 pub fn exit_area_m2(&self) -> f64 {
110 PI * self.exit_radius_m * self.exit_radius_m
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
120#[serde(try_from = "MotorData", into = "MotorData")]
121pub struct SolidMotor {
122 curve: ThrustCurve,
123 propellant: Propellant,
124 dry: MassElement,
125 nozzle: Option<Nozzle>,
126 propellant_mass_kg: f64,
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
132#[serde(deny_unknown_fields)]
133struct MotorData {
134 curve: ThrustCurve,
135 propellant: Propellant,
136 dry: MassElement,
137 nozzle: Option<Nozzle>,
138}
139
140impl TryFrom<MotorData> for SolidMotor {
141 type Error = MotorError;
142
143 fn try_from(data: MotorData) -> Result<Self, Self::Error> {
144 Self::new(data.curve, data.propellant, data.dry, data.nozzle)
145 }
146}
147
148impl From<SolidMotor> for MotorData {
149 fn from(motor: SolidMotor) -> Self {
150 Self {
151 curve: motor.curve,
152 propellant: motor.propellant,
153 dry: motor.dry,
154 nozzle: motor.nozzle,
155 }
156 }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
161pub struct MotorState {
162 pub time_s: f64,
164 pub thrust_n: f64,
166 pub mass_flow_kg_s: f64,
169 pub propellant: MassElement,
171 pub total: MassElement,
173}
174
175pub const EXHAUST_VELOCITY_RANGE_M_S: std::ops::RangeInclusive<f64> = 200.0..=5000.0;
195
196impl SolidMotor {
197 pub fn new(
212 curve: ThrustCurve,
213 propellant: Propellant,
214 dry: MassElement,
215 nozzle: Option<Nozzle>,
216 ) -> Result<Self, MotorError> {
217 dry.validate([
218 "dry mass (kg)",
219 "dry center of mass (m)",
220 "dry axial inertia (kg·m²)",
221 "dry transverse inertia (kg·m²)",
222 ])?;
223 if dry.mass_kg <= 0.0 {
224 return Err(MotorError::Domain {
225 what: "dry mass (kg), which must be positive",
226 value: dry.mass_kg,
227 });
228 }
229 let propellant_mass_kg = match &propellant {
230 Propellant::Column(column) => {
231 for (value, what) in [
232 (column.mass_kg, "propellant mass (kg)"),
233 (column.outer_radius_m, "propellant outer radius (m)"),
234 (column.length_m, "propellant length (m)"),
235 ] {
236 if !(value.is_finite() && value > 0.0) {
237 return Err(MotorError::Domain { what, value });
238 }
239 }
240 if !column.center_m.is_finite() {
241 return Err(MotorError::Domain {
242 what: "propellant center (m)",
243 value: column.center_m,
244 });
245 }
246 if !(column.inner_radius_m.is_finite() && column.inner_radius_m >= 0.0) {
247 return Err(MotorError::Domain {
248 what: "propellant bore radius (m)",
249 value: column.inner_radius_m,
250 });
251 }
252 if column.inner_radius_m >= column.outer_radius_m {
253 return Err(MotorError::Inconsistent(format!(
254 "propellant bore radius {} m is not inside the outer radius {} m",
255 column.inner_radius_m, column.outer_radius_m
256 )));
257 }
258 column.mass_kg
259 }
260 Propellant::Grains(grains) => {
261 grains.validate()?;
262 grains.initial_mass_kg()
263 }
264 };
265 if let Some(nozzle) = &nozzle {
266 if !(nozzle.exit_radius_m.is_finite() && nozzle.exit_radius_m > 0.0) {
267 return Err(MotorError::Domain {
268 what: "nozzle exit radius (m)",
269 value: nozzle.exit_radius_m,
270 });
271 }
272 if let Some(throat) = nozzle.throat_radius_m
273 && !(throat.is_finite() && throat > 0.0 && throat <= nozzle.exit_radius_m)
274 {
275 return Err(MotorError::Domain {
276 what: "nozzle throat radius (m), which must be in (0, exit radius]",
277 value: throat,
278 });
279 }
280 if let Some(reference) = nozzle.reference_pressure_pa
281 && !(reference.is_finite() && reference >= 0.0)
282 {
283 return Err(MotorError::Domain {
284 what: "thrust-curve reference pressure (Pa)",
285 value: reference,
286 });
287 }
288 }
289 let exhaust_velocity_m_s = curve.total_impulse_ns() / propellant_mass_kg;
293 if !EXHAUST_VELOCITY_RANGE_M_S.contains(&exhaust_velocity_m_s) {
294 return Err(MotorError::Inconsistent(format!(
295 "a total impulse of {} N·s from {propellant_mass_kg} kg of propellant is an \
296 effective exhaust velocity of {exhaust_velocity_m_s} m/s, outside the {} to {} \
297 m/s a solid motor can have; {UNITS_HINT}",
298 curve.total_impulse_ns(),
299 EXHAUST_VELOCITY_RANGE_M_S.start(),
300 EXHAUST_VELOCITY_RANGE_M_S.end()
301 )));
302 }
303 Ok(Self {
304 curve,
305 propellant,
306 dry,
307 nozzle,
308 propellant_mass_kg,
309 })
310 }
311
312 pub fn from_envelope(
332 curve: ThrustCurve,
333 diameter_m: f64,
334 length_m: f64,
335 propellant_mass_kg: f64,
336 loaded_mass_kg: f64,
337 ) -> Result<Self, MotorError> {
338 for (value, what) in [
339 (propellant_mass_kg, "propellant mass (kg)"),
340 (loaded_mass_kg, "loaded motor mass (kg)"),
341 (diameter_m, "motor diameter (m)"),
342 (length_m, "motor length (m)"),
343 ] {
344 if !(value.is_finite() && value > 0.0) {
345 return Err(MotorError::Domain { what, value });
346 }
347 }
348 if propellant_mass_kg >= loaded_mass_kg {
349 return Err(MotorError::Inconsistent(format!(
350 "propellant mass {propellant_mass_kg} kg is not below the loaded mass \
351 {loaded_mass_kg} kg"
352 )));
353 }
354 let radius = 0.5 * diameter_m;
355 let center = 0.5 * length_m;
356 let column = PropellantColumn {
357 mass_kg: propellant_mass_kg,
358 center_m: center,
359 outer_radius_m: radius,
360 inner_radius_m: 0.0,
361 length_m,
362 };
363 let dry = MassElement::thin_tube(
364 loaded_mass_kg - propellant_mass_kg,
365 center,
366 radius,
367 length_m,
368 );
369 Self::new(curve, Propellant::Column(column), dry, None)
370 }
371
372 pub fn with_added_dry_mass(mut self, hardware: MassElement) -> Result<Self, MotorError> {
380 hardware.validate([
381 "added dry mass (kg)",
382 "added dry mass center (m)",
383 "added dry axial inertia (kg·m²)",
384 "added dry transverse inertia (kg·m²)",
385 ])?;
386 self.dry = MassElement::combine([&self.dry, &hardware]);
387 Ok(self)
388 }
389
390 pub fn curve(&self) -> &ThrustCurve {
392 &self.curve
393 }
394
395 pub fn propellant(&self) -> &Propellant {
397 &self.propellant
398 }
399
400 pub fn dry(&self) -> MassElement {
402 self.dry
403 }
404
405 pub fn nozzle(&self) -> Option<Nozzle> {
407 self.nozzle
408 }
409
410 pub fn propellant_initial_mass_kg(&self) -> f64 {
412 self.propellant_mass_kg
413 }
414
415 pub fn exhaust_velocity_m_s(&self) -> f64 {
417 self.curve.total_impulse_ns() / self.propellant_mass_kg
418 }
419
420 pub fn burnout_time_s(&self) -> f64 {
422 self.curve.end_time_s()
423 }
424
425 pub fn propellant_mass_kg(&self, t: f64) -> f64 {
427 if t.is_nan() {
428 return f64::NAN;
429 }
430 let fraction = self.curve.impulse_ns(t) / self.curve.total_impulse_ns();
431 (self.propellant_mass_kg * (1.0 - fraction)).max(0.0)
432 }
433
434 pub fn state(&self, t: f64) -> MotorState {
437 let thrust_n = self.curve.thrust_n(t);
438 let mass = self.propellant_mass_kg(t);
439 let propellant = match &self.propellant {
440 Propellant::Column(column) => MassElement::hollow_cylinder(
441 mass,
442 column.center_m,
443 column.outer_radius_m,
444 column.inner_radius_m,
445 column.length_m,
446 ),
447 Propellant::Grains(grains) => grains.mass_element(mass),
448 };
449 MotorState {
450 time_s: t,
451 thrust_n,
452 mass_flow_kg_s: thrust_n / self.exhaust_velocity_m_s(),
453 propellant,
454 total: MassElement::combine([&self.dry, &propellant]),
455 }
456 }
457
458 pub fn thrust_at_pressure_n(&self, t: f64, ambient_pa: f64) -> f64 {
469 let thrust = self.curve.thrust_n(t);
470 match self.nozzle {
471 Some(
472 nozzle @ Nozzle {
473 reference_pressure_pa: Some(reference_pa),
474 ..
475 },
476 ) if t > 0.0 && t < self.curve.end_time_s() && (thrust > 0.0 || thrust.is_nan()) => {
477 let corrected = thrust + (reference_pa - ambient_pa) * nozzle.exit_area_m2();
478 if corrected.is_nan() {
479 corrected
480 } else {
481 corrected.max(0.0)
482 }
483 }
484 _ => thrust,
485 }
486 }
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492
493 fn curve() -> ThrustCurve {
494 ThrustCurve::new(vec![0.0, 0.1, 1.0, 1.2], vec![0.0, 500.0, 400.0, 0.0]).unwrap()
495 }
496
497 #[test]
498 fn column_mass_follows_the_impulse_fraction() {
499 let motor = SolidMotor::from_envelope(curve(), 0.038, 0.25, 0.3, 0.6).unwrap();
500 let total = motor.curve().total_impulse_ns();
501 assert!((total - (25.0 + 405.0 + 40.0)).abs() < 1e-12);
502 assert!((motor.exhaust_velocity_m_s() - total / 0.3).abs() < 1e-9);
503 for t in [-1.0, 0.0, 0.05, 0.5, 1.1, 1.2, 5.0] {
504 let state = motor.state(t);
505 let expected = 0.3 * (1.0 - motor.curve().impulse_ns(t) / total);
506 assert!((state.propellant.mass_kg - expected).abs() < 1e-15, "{t}");
507 assert!((state.total.mass_kg - (0.3 + expected)).abs() < 1e-15);
508 assert!(
509 (state.mass_flow_kg_s * motor.exhaust_velocity_m_s() - state.thrust_n).abs() < 1e-9
510 );
511 }
512 assert_eq!(motor.state(0.0).propellant.mass_kg, 0.3);
513 assert_eq!(motor.state(1.2).propellant.mass_kg, 0.0);
514 let n = 12_000;
516 let dt = 1.2 / f64::from(n);
517 let burned: f64 = (0..n)
518 .map(|i| {
519 let (a, b) = (f64::from(i) * dt, f64::from(i + 1) * dt);
520 0.5 * (motor.state(a).mass_flow_kg_s + motor.state(b).mass_flow_kg_s) * dt
521 })
522 .sum();
523 assert!((burned - 0.3).abs() < 1e-6, "{burned}");
524 }
525
526 #[test]
527 fn a_units_slip_is_refused_by_its_exhaust_velocity() {
528 let i175 = ThrustCurve::new(vec![0.0, 0.1, 2.3, 2.4], vec![0.0, 220.0, 150.0, 0.0])
533 .expect("a plausible I-class curve");
534 let impulse = i175.total_impulse_ns();
536 assert!((impulse - 425.5).abs() < 0.1, "{impulse}");
537 let slipped = SolidMotor::from_envelope(i175.clone(), 38.0, 245.0, 228.9, 437.5)
538 .expect_err("millimeters and grams read as meters and kilograms");
539 assert!(
540 matches!(&slipped, MotorError::Inconsistent(message)
541 if message.contains("exhaust velocity") && message.contains("check the units")),
542 "{slipped}"
543 );
544 let motor = SolidMotor::from_envelope(i175.clone(), 0.038, 0.245, 0.2289, 0.4375)
547 .expect("the same motor in meters and kilograms");
548 let c = motor.curve().total_impulse_ns() / motor.propellant_mass_kg(0.0);
549 assert!((1700.0..1900.0).contains(&c), "{c}");
550
551 let dry = MassElement::thin_tube(0.2086, 0.1225, 0.019, 0.245);
553 let column = PropellantColumn {
554 mass_kg: 2.289,
555 center_m: 0.1225,
556 outer_radius_m: 0.017,
557 inner_radius_m: 0.005,
558 length_m: 0.2,
559 };
560 let ten_times = SolidMotor::new(i175, Propellant::Column(column), dry, None)
561 .expect_err("ten times the propellant for the same impulse");
562 assert!(
563 matches!(&ten_times, MotorError::Inconsistent(message) if message.contains("185.8")),
565 "{ten_times}"
566 );
567
568 assert!(!EXHAUST_VELOCITY_RANGE_M_S.contains(&1.8));
569 }
570
571 #[test]
572 fn every_bundled_motor_is_inside_the_range() {
573 let catalog = crate::Catalog::bundled().expect("the bundled catalog parses");
577 let mut lowest = f64::INFINITY;
578 let mut highest: f64 = 0.0;
579 let (mut low_name, mut high_name) = (String::new(), String::new());
580 for entry in &catalog.motors {
581 let motor = entry
582 .bundled_motor()
583 .unwrap_or_else(|error| panic!("{}: {error}", entry.designation));
584 let c = motor.curve().total_impulse_ns() / motor.propellant_mass_kg(0.0);
585 assert!(
586 EXHAUST_VELOCITY_RANGE_M_S.contains(&c),
587 "{}: c = {c} m/s",
588 entry.designation
589 );
590 if c < lowest {
591 lowest = c;
592 low_name = entry.common_name.clone();
593 }
594 if c > highest {
595 highest = c;
596 high_name = entry.common_name.clone();
597 }
598 }
599 assert!((lowest - 708.59).abs() < 0.01, "{low_name} at {lowest}");
601 assert!((highest - 2651.64).abs() < 0.01, "{high_name} at {highest}");
602 }
603
604 #[test]
605 fn a_curve_ending_above_zero_is_empty_and_silent_at_its_end() {
606 let cut = ThrustCurve::new(vec![0.0, 1.0], vec![20.0, 20.0]).unwrap();
607 let motor = SolidMotor::from_envelope(cut, 0.038, 0.25, 0.01, 0.5).unwrap();
610 let before = motor.state(1.0 - 1e-9);
611 assert_eq!(before.thrust_n, 20.0);
612 assert!(before.mass_flow_kg_s > 0.0 && before.propellant.mass_kg > 0.0);
613 let end = motor.state(motor.burnout_time_s());
614 assert_eq!(
615 (end.thrust_n, end.mass_flow_kg_s, end.propellant.mass_kg),
616 (0.0, 0.0, 0.0)
617 );
618 assert_eq!(end.total.mass_kg, 0.49);
619 }
620
621 #[test]
622 fn envelope_defaults_are_centered_tubes_and_columns() {
623 let motor = SolidMotor::from_envelope(curve(), 0.038, 0.25, 0.3, 0.6).unwrap();
624 let loaded = motor.state(0.0);
625 assert_eq!(loaded.total.cg_m, 0.125);
626 let r2 = 0.019f64 * 0.019;
627 let dry_axial = 0.3 * r2;
628 let prop_axial = 0.5 * 0.3 * r2;
629 assert!((loaded.total.axial_inertia_kg_m2 - (dry_axial + prop_axial)).abs() < 1e-15);
630 let dry_t = 0.3 * (r2 / 2.0 + 0.25f64.powi(2) / 12.0);
631 let prop_t = 0.3 * (r2 / 4.0 + 0.25f64.powi(2) / 12.0);
632 assert!((loaded.total.transverse_inertia_kg_m2 - (dry_t + prop_t)).abs() < 1e-15);
633 assert!(motor.nozzle().is_none());
634 assert_eq!(
635 motor.thrust_at_pressure_n(0.5, 0.0),
636 motor.curve().thrust_n(0.5)
637 );
638 }
639
640 #[test]
641 fn pressure_correction_uses_the_exit_area() {
642 let dry = MassElement::thin_tube(0.3, 0.125, 0.019, 0.25);
643 let column = PropellantColumn {
644 mass_kg: 0.3,
645 center_m: 0.15,
646 outer_radius_m: 0.017,
647 inner_radius_m: 0.005,
648 length_m: 0.2,
649 };
650 let nozzle = Nozzle {
651 exit_radius_m: 0.01,
652 throat_radius_m: Some(0.004),
653 reference_pressure_pa: Some(STANDARD_SEA_LEVEL_PRESSURE_PA),
654 };
655 let motor =
656 SolidMotor::new(curve(), Propellant::Column(column), dry, Some(nozzle)).unwrap();
657 let area = PI * 1e-4;
658 let f = motor.curve().thrust_n(0.5);
659 let vacuum = motor.thrust_at_pressure_n(0.5, 0.0);
660 assert!((vacuum - (f + 101_325.0 * area)).abs() < 1e-9);
661 assert_eq!(motor.thrust_at_pressure_n(0.5, 101_325.0), f);
662 assert_eq!(motor.thrust_at_pressure_n(2.0, 0.0), 0.0);
663 assert_eq!(motor.thrust_at_pressure_n(0.001, 1e9), 0.0);
664 let high = SolidMotor::new(
666 curve(),
667 Propellant::Column(column),
668 dry,
669 Some(Nozzle {
670 reference_pressure_pa: Some(80_000.0),
671 ..nozzle
672 }),
673 )
674 .unwrap();
675 assert!((high.thrust_at_pressure_n(0.5, 0.0) - (f + 80_000.0 * area)).abs() < 1e-9);
676 assert!((high.thrust_at_pressure_n(0.5, 101_325.0) - (f - 21_325.0 * area)).abs() < 1e-9);
677 let uncorrected = SolidMotor::new(
680 curve(),
681 Propellant::Column(column),
682 dry,
683 Some(Nozzle {
684 reference_pressure_pa: None,
685 ..nozzle
686 }),
687 )
688 .unwrap();
689 for pressure in [0.0, 50_000.0, 101_325.0, 1e9] {
690 assert_eq!(uncorrected.thrust_at_pressure_n(0.5, pressure), f);
691 }
692 let read = |text: &str| serde_json::from_str::<Nozzle>(text);
695 assert_eq!(
696 read(
697 r#"{"exit_radius_m": 0.01, "throat_radius_m": null, "reference_pressure_pa": null}"#
698 )
699 .unwrap()
700 .reference_pressure_pa,
701 None
702 );
703 assert!(read(r#"{"exit_radius_m": 0.01, "throat_radius_m": null}"#).is_err());
704 assert_eq!(motor.thrust_at_pressure_n(0.0, 0.0), 0.0);
706 assert_eq!(motor.thrust_at_pressure_n(1.2, 0.0), 0.0);
707 assert!(motor.thrust_at_pressure_n(0.5, f64::NAN).is_nan());
708 assert!(motor.thrust_at_pressure_n(f64::NAN, 0.0).is_nan());
709 let state = motor.state(f64::NAN);
711 assert!(state.total.mass_kg.is_nan() && state.propellant.mass_kg.is_nan());
712 assert!(state.total.cg_m.is_nan());
713 let gap = ThrustCurve::new(
717 vec![0.0, 1.0, 1.0, 2.0, 2.0, 3.0],
718 vec![400.0, 400.0, 0.0, 0.0, 400.0, 0.0],
719 )
720 .unwrap();
721 let gapped = SolidMotor::new(gap, Propellant::Column(column), dry, Some(nozzle)).unwrap();
722 assert_eq!(gapped.thrust_at_pressure_n(1.5, 0.0), 0.0);
723 assert!(gapped.thrust_at_pressure_n(2.5, 0.0) > gapped.curve().thrust_n(2.5));
724 }
725
726 #[test]
727 fn rejects_bad_inputs() {
728 let dry = MassElement::thin_tube(0.3, 0.125, 0.019, 0.25);
729 let column = PropellantColumn {
730 mass_kg: 0.3,
731 center_m: 0.15,
732 outer_radius_m: 0.017,
733 inner_radius_m: 0.005,
734 length_m: 0.2,
735 };
736 let build = |column: PropellantColumn, dry: MassElement, nozzle: Option<Nozzle>| {
737 SolidMotor::new(curve(), Propellant::Column(column), dry, nozzle)
738 };
739 assert!(build(column, dry, None).is_ok());
740 assert!(
741 build(
742 PropellantColumn {
743 mass_kg: 0.0,
744 ..column
745 },
746 dry,
747 None
748 )
749 .is_err()
750 );
751 assert!(
752 build(
753 PropellantColumn {
754 inner_radius_m: 0.017,
755 ..column
756 },
757 dry,
758 None
759 )
760 .is_err()
761 );
762 assert!(
763 build(
764 PropellantColumn {
765 center_m: f64::NAN,
766 ..column
767 },
768 dry,
769 None
770 )
771 .is_err()
772 );
773 assert!(
774 build(
775 column,
776 MassElement {
777 mass_kg: -0.1,
778 ..dry
779 },
780 None
781 )
782 .is_err()
783 );
784 assert!(
786 build(
787 column,
788 MassElement {
789 mass_kg: 0.0,
790 ..dry
791 },
792 None
793 )
794 .is_err()
795 );
796 let nozzle = |exit, throat, reference| {
797 Some(Nozzle {
798 exit_radius_m: exit,
799 throat_radius_m: throat,
800 reference_pressure_pa: Some(reference),
801 })
802 };
803 assert!(build(column, dry, nozzle(0.01, Some(0.004), 101_325.0)).is_ok());
804 assert!(build(column, dry, nozzle(0.0, None, 101_325.0)).is_err());
805 assert!(build(column, dry, nozzle(0.01, Some(0.02), 101_325.0)).is_err());
806 assert!(build(column, dry, nozzle(0.01, None, -1.0)).is_err());
807 assert!(build(column, dry, nozzle(0.01, None, f64::NAN)).is_err());
808 assert!(SolidMotor::from_envelope(curve(), 0.038, 0.25, 0.7, 0.6).is_err());
809 assert!(SolidMotor::from_envelope(curve(), 0.038, 0.25, 0.6, 0.6).is_err());
810 assert!(matches!(
811 SolidMotor::from_envelope(curve(), 0.038, 0.25, f64::NAN, 0.6),
812 Err(MotorError::Domain {
813 what: "propellant mass (kg)",
814 ..
815 })
816 ));
817 assert!(SolidMotor::from_envelope(curve(), 0.0, 0.25, 0.3, 0.6).is_err());
818 }
819
820 #[test]
821 fn added_hardware_joins_the_dry_mass() {
822 let motor = SolidMotor::from_envelope(curve(), 0.038, 0.25, 0.3, 0.6).unwrap();
823 let retainer = MassElement::thin_tube(0.05, -0.01, 0.022, 0.02);
824 let with = motor.clone().with_added_dry_mass(retainer).unwrap();
825 let burnout = with.state(with.burnout_time_s()).total;
826 let expected = MassElement::combine([&motor.dry(), &retainer]);
827 assert_eq!(
828 burnout,
829 MassElement::combine([
830 &expected,
831 &MassElement {
832 cg_m: 0.125,
833 ..MassElement::ZERO
834 }
835 ])
836 );
837 assert!((burnout.mass_kg - 0.35).abs() < 1e-15);
838 assert!(burnout.cg_m < 0.125);
839 assert!(
840 motor
841 .with_added_dry_mass(MassElement {
842 mass_kg: f64::NAN,
843 ..retainer
844 })
845 .is_err()
846 );
847 }
848
849 #[derive(Debug, serde::Deserialize)]
850 struct Oracle {
851 oracle: String,
852 cases: Vec<OracleCase>,
853 }
854
855 #[derive(Debug, serde::Deserialize)]
856 struct OracleCase {
857 name: String,
858 inputs: OracleInputs,
859 scalars: OracleScalars,
860 series: OracleSeries,
861 }
862
863 #[derive(Debug, serde::Deserialize)]
864 struct OracleInputs {
865 thrust_file: String,
866 thrust_file_sha256: String,
867 dry_mass: f64,
868 dry_inertia: [f64; 3],
869 center_of_dry_mass_position: f64,
870 nozzle_position: f64,
871 nozzle_radius: f64,
872 throat_radius: f64,
873 grain_number: u32,
874 grain_density: f64,
875 grain_outer_radius: f64,
876 grain_initial_inner_radius: f64,
877 grain_initial_height: f64,
878 grain_separation: f64,
879 grains_center_of_mass_position: f64,
880 coordinate_system_orientation: String,
881 only_radial_burn: bool,
882 interpolation_method: String,
883 burn_time: Option<f64>,
884 }
885
886 #[derive(Debug, serde::Deserialize)]
887 struct OracleScalars {
888 total_impulse_ns: f64,
889 burn_out_time_s: f64,
890 exhaust_velocity_mps: f64,
891 propellant_initial_mass_kg: f64,
892 }
893
894 #[derive(Debug, serde::Deserialize)]
895 struct OracleSeries {
896 time_s: Vec<f64>,
897 thrust: Vec<f64>,
898 mass_flow_rate: Vec<f64>,
899 propellant_mass: Vec<f64>,
900 total_mass: Vec<f64>,
901 center_of_propellant_mass: Vec<f64>,
902 center_of_mass: Vec<f64>,
903 grain_inner_radius: Vec<f64>,
904 grain_height: Vec<f64>,
905 #[serde(rename = "propellant_I_11")]
906 propellant_i_11: Vec<f64>,
907 #[serde(rename = "propellant_I_33")]
908 propellant_i_33: Vec<f64>,
909 #[serde(rename = "I_11")]
910 i_11: Vec<f64>,
911 #[serde(rename = "I_33")]
912 i_33: Vec<f64>,
913 }
914
915 #[test]
931 fn matches_rocketpy_solid_motor_for_three_bundled_motors() {
932 let oracle: Oracle = serde_json::from_str(include_str!(
933 "../../../validation/fixtures/motor/rocketpy-solid-motor.json"
934 ))
935 .unwrap();
936 assert_eq!(oracle.oracle, "rocketpy 1.13.0");
937 assert_eq!(oracle.cases.len(), 3);
938 let catalog = crate::catalog::Catalog::bundled().unwrap();
939 for case in &oracle.cases {
940 let inputs = &case.inputs;
941 let (entry, curve) = catalog
942 .motors
943 .iter()
944 .find_map(|m| {
945 m.curves
946 .iter()
947 .find(|c| c.file == inputs.thrust_file)
948 .map(|c| (m, c))
949 })
950 .unwrap();
951 assert_eq!(curve.sha256, inputs.thrust_file_sha256, "{}", case.name);
953 let text = crate::catalog::bundled_curve_text(&curve.file).unwrap();
954 let thrust = entry.thrust_curve(curve, text).unwrap();
955
956 let sign = match inputs.coordinate_system_orientation.as_str() {
959 "nozzle_to_combustion_chamber" => 1.0,
960 "combustion_chamber_to_nozzle" => -1.0,
961 other => panic!("unknown orientation {other}"),
962 };
963 let to_hpr = |z: f64| sign * (z - inputs.nozzle_position);
964 let grains = BatesGrains {
965 count: inputs.grain_number,
966 density_kg_m3: inputs.grain_density,
967 outer_radius_m: inputs.grain_outer_radius,
968 initial_inner_radius_m: inputs.grain_initial_inner_radius,
969 initial_height_m: inputs.grain_initial_height,
970 separation_m: inputs.grain_separation,
971 center_m: to_hpr(inputs.grains_center_of_mass_position),
972 inhibited_ends: inputs.only_radial_burn,
973 };
974 let dry = MassElement {
975 mass_kg: inputs.dry_mass,
976 cg_m: to_hpr(inputs.center_of_dry_mass_position),
977 axial_inertia_kg_m2: inputs.dry_inertia[2],
978 transverse_inertia_kg_m2: inputs.dry_inertia[0],
979 };
980 let nozzle = Nozzle {
981 exit_radius_m: inputs.nozzle_radius,
982 throat_radius_m: Some(inputs.throat_radius),
983 reference_pressure_pa: Some(STANDARD_SEA_LEVEL_PRESSURE_PA),
984 };
985 let motor =
986 SolidMotor::new(thrust, Propellant::Grains(grains), dry, Some(nozzle)).unwrap();
987 assert_eq!(inputs.interpolation_method, "linear");
989 assert_eq!(inputs.burn_time, None);
990 let hand = match case.name.as_str() {
993 "cti-411i175-38mm-radial-burnout" => (0.2086, 0.11, 0.13),
995 "cti-1633k940-54mm-axial-burnout-chamber-to-nozzle" => (0.5985, 0.154, 0.214),
998 "loki-m1378lr-54mm-inhibited-ends" => (1.731, 0.50, 0.56),
1000 other => panic!("no hand values for {other}"),
1001 };
1002 let (dry_kg, dry_z, grains_z) = hand;
1003 let m_p = case.scalars.propellant_initial_mass_kg;
1004 let loaded_cg = (dry_kg * dry_z + m_p * grains_z) / (dry_kg + m_p);
1005 assert!(
1006 (motor.state(0.0).total.cg_m - loaded_cg).abs() < 1e-12,
1007 "{}",
1008 case.name
1009 );
1010 assert!(
1011 (motor.state(motor.burnout_time_s()).total.cg_m - dry_z).abs() < 1e-12,
1012 "{}",
1013 case.name
1014 );
1015
1016 let scalars = &case.scalars;
1017 let close = |a: f64, b: f64| (a - b).abs() <= 1e-9 * b.abs();
1018 assert!(
1019 close(motor.curve().total_impulse_ns(), scalars.total_impulse_ns),
1020 "{}",
1021 case.name
1022 );
1023 assert!(
1024 close(motor.burnout_time_s(), scalars.burn_out_time_s),
1025 "{}",
1026 case.name
1027 );
1028 assert!(close(
1029 motor.propellant_initial_mass_kg(),
1030 scalars.propellant_initial_mass_kg
1031 ));
1032 assert!(close(
1033 motor.exhaust_velocity_m_s(),
1034 scalars.exhaust_velocity_mps
1035 ));
1036
1037 let series = &case.series;
1038 let length = entry.length_mm * 1e-3;
1039 let mut worst: Vec<(&str, f64)> = Vec::new();
1040 let mut record = |what: &'static str, error: f64| match worst
1041 .iter_mut()
1042 .find(|(name, _)| *name == what)
1043 {
1044 Some((_, max)) => *max = max.max(error),
1045 None => worst.push((what, error)),
1046 };
1047 let loaded = motor.state(0.0);
1048 let peak_flow = motor.curve().peak_thrust_n() / motor.exhaust_velocity_m_s();
1049 for (i, &t) in series.time_s.iter().enumerate() {
1050 let state = motor.state(t);
1051 let shape = grains.shape(state.propellant.mass_kg);
1052 let relative = |ours: f64, theirs: f64| (ours - theirs).abs() / theirs.abs();
1053 let scaled = |ours: f64, theirs: f64, scale: f64| (ours - theirs).abs() / scale;
1054 record(
1055 "thrust",
1056 scaled(
1057 state.thrust_n,
1058 series.thrust[i],
1059 motor.curve().peak_thrust_n(),
1060 ),
1061 );
1062 record(
1063 "mass flow",
1064 scaled(state.mass_flow_kg_s, -series.mass_flow_rate[i], peak_flow),
1065 );
1066 record(
1067 "propellant mass",
1068 scaled(
1069 state.propellant.mass_kg,
1070 series.propellant_mass[i],
1071 scalars.propellant_initial_mass_kg,
1072 ),
1073 );
1074 record(
1075 "total mass",
1076 relative(state.total.mass_kg, series.total_mass[i]),
1077 );
1078 record(
1079 "propellant center",
1080 scaled(
1081 state.propellant.cg_m,
1082 to_hpr(series.center_of_propellant_mass[i]),
1083 length,
1084 ),
1085 );
1086 record(
1087 "center of mass",
1088 scaled(state.total.cg_m, to_hpr(series.center_of_mass[i]), length),
1089 );
1090 record(
1091 "grain bore radius",
1092 relative(shape.inner_radius_m, series.grain_inner_radius[i]),
1093 );
1094 record(
1095 "grain height",
1096 scaled(
1097 shape.height_m,
1098 series.grain_height[i],
1099 inputs.grain_initial_height,
1100 ),
1101 );
1102 record(
1103 "propellant I_11",
1104 scaled(
1105 state.propellant.transverse_inertia_kg_m2,
1106 series.propellant_i_11[i],
1107 loaded.propellant.transverse_inertia_kg_m2,
1108 ),
1109 );
1110 record(
1111 "propellant I_33",
1112 scaled(
1113 state.propellant.axial_inertia_kg_m2,
1114 series.propellant_i_33[i],
1115 loaded.propellant.axial_inertia_kg_m2,
1116 ),
1117 );
1118 record(
1119 "motor I_11",
1120 relative(state.total.transverse_inertia_kg_m2, series.i_11[i]),
1121 );
1122 record(
1123 "motor I_33",
1124 relative(state.total.axial_inertia_kg_m2, series.i_33[i]),
1125 );
1126 }
1127 eprintln!("{}: largest errors against RocketPy", case.name);
1128 for (what, error) in &worst {
1129 eprintln!(" {what:<18} {:.3e}", error);
1130 assert!(
1131 *error <= 1e-3,
1132 "{}: {what} differs from RocketPy by {:.4}%",
1133 case.name,
1134 100.0 * error
1135 );
1136 }
1137 }
1138 }
1139
1140 #[test]
1141 fn serde_round_trips_and_rechecks() {
1142 let motor = SolidMotor::from_envelope(curve(), 0.038, 0.25, 0.3, 0.6).unwrap();
1143 let json = serde_json::to_string(&motor).unwrap();
1144 assert_eq!(serde_json::from_str::<SolidMotor>(&json).unwrap(), motor);
1145 let broken = json.replace(
1146 "\"mass_kg\":0.3,\"center_m\"",
1147 "\"mass_kg\":-0.3,\"center_m\"",
1148 );
1149 assert_ne!(broken, json);
1150 assert!(serde_json::from_str::<SolidMotor>(&broken).is_err());
1151 }
1152}