1mod bundled;
19pub mod catalog;
20pub mod class;
21pub mod convert;
22pub mod curve;
23pub mod delay;
24pub mod eng;
25pub mod error;
26pub mod grains;
27pub mod mass;
28pub mod motor;
29pub mod rse;
30pub mod text;
31
32pub use catalog::{Catalog, CatalogCurve, CatalogMotor};
33pub use class::ImpulseClass;
34pub use curve::ThrustCurve;
35pub use delay::{Delay, DelayList, DelayWarning};
36pub use error::MotorError;
37pub use grains::{BatesGrains, GrainShape};
38pub use mass::MassElement;
39pub use motor::{
40 EXHAUST_VELOCITY_RANGE_M_S, MotorState, Nozzle, Propellant, PropellantColumn, SolidMotor,
41};
42pub use text::{ParseWarning, Parsed, WarningKind};
43
44#[cfg(test)]
45mod tests {
46 use super::*;
47
48 #[test]
51 fn impulse_class_upper_bound_inclusive() {
52 let class = |ns: f64| ImpulseClass::from_total_impulse(ns).unwrap().label();
53 assert_eq!(class(2.5), "A");
54 assert_eq!(class(2.500_000_000_000_001), "B");
55 assert_eq!(class(5.0), "B");
56 assert_eq!(class(10.0), "C");
57 assert_eq!(class(160.0), "G");
58 assert_eq!(class(160.000_1), "H");
59 assert_eq!(class(40960.0), "O");
60 assert_eq!(class(0.3125), "1/8A");
61 assert_eq!(class(0.1), "1/8A");
62 assert_eq!(class(0.3126), "1/4A");
63 assert_eq!(class(1.25), "1/2A");
64 assert_eq!(class(1.2501), "A");
65 }
66
67 #[test]
70 fn cg_and_inertia_move_from_loaded_to_burnout() {
71 let curve =
72 ThrustCurve::new(vec![0.05, 0.2, 1.8, 2.0], vec![900.0, 800.0, 700.0, 0.0]).unwrap();
73 let grains = BatesGrains {
76 count: 3,
77 density_kg_m3: 1815.0,
78 outer_radius_m: 0.0165,
79 initial_inner_radius_m: 0.006,
80 initial_height_m: 0.09,
81 separation_m: 0.005,
82 center_m: 0.2,
83 inhibited_ends: false,
84 };
85 let dry = MassElement {
86 mass_kg: 0.45,
87 cg_m: 0.10,
88 axial_inertia_kg_m2: 1.6e-4,
89 transverse_inertia_kg_m2: 7.6e-3,
90 };
91 let motor = SolidMotor::new(curve, Propellant::Grains(grains), dry, None).unwrap();
92 let loaded = motor.state(0.0);
93 let burnout = motor.state(motor.burnout_time_s());
94
95 let m_p = grains.initial_mass_kg();
97 assert_eq!(loaded.propellant.mass_kg, m_p);
98 let cg = (0.45 * 0.10 + m_p * 0.2) / (0.45 + m_p);
99 assert!((loaded.total.cg_m - cg).abs() < 1e-15);
100 let full = grains.mass_element(m_p);
101 let transverse = dry.transverse_inertia_kg_m2
102 + 0.45 * (0.10 - cg).powi(2)
103 + full.transverse_inertia_kg_m2
104 + m_p * (0.2 - cg).powi(2);
105 assert!((loaded.total.transverse_inertia_kg_m2 - transverse).abs() < 1e-15);
106
107 assert_eq!(burnout.propellant.mass_kg, 0.0);
109 assert_eq!(burnout.total.mass_kg, 0.45);
110 assert!((burnout.total.cg_m - 0.10).abs() < 1e-15);
111 assert!((burnout.total.axial_inertia_kg_m2 - 1.6e-4).abs() < 1e-18);
112 assert!((burnout.total.transverse_inertia_kg_m2 - 7.6e-3).abs() < 1e-15);
113
114 let states: Vec<MotorState> = (0..=200)
116 .map(|i| motor.state(f64::from(i) * 0.01))
117 .collect();
118 for pair in states.windows(2) {
119 assert!(pair[1].total.cg_m <= pair[0].total.cg_m + 1e-15);
120 assert!(pair[1].total.mass_kg <= pair[0].total.mass_kg);
121 assert!(pair[1].total.axial_inertia_kg_m2 <= pair[0].total.axial_inertia_kg_m2 + 1e-18);
122 }
123 assert!(loaded.total.cg_m - burnout.total.cg_m > 0.03);
124 assert!(
125 loaded.total.transverse_inertia_kg_m2 > 1.3 * burnout.total.transverse_inertia_kg_m2
126 );
127 }
128
129 #[test]
132 fn burn_time_uses_the_nfpa_1125_definition() {
133 let curve = ThrustCurve::new(
136 vec![0.0, 1.0, 1.001, 2.0, 3.0, 3.001, 6.0, 6.001],
137 vec![0.0, 200.0, 100.0, 100.0, 0.0, 2.0, 2.0, 0.0],
138 )
139 .unwrap();
140 assert_eq!(curve.end_time_s(), 6.001);
141 let (start, end) = curve.burn_window_s();
143 assert!((start - 0.05).abs() < 1e-12, "{start}");
144 assert!((end - 2.9).abs() < 1e-12, "{end}");
145 assert!((curve.burn_time_s() - 2.85).abs() < 1e-12);
146 assert!(
147 (curve.average_thrust_n() - curve.total_impulse_ns() / 2.85).abs() < 1e-9,
148 "average thrust is the total impulse over the NFPA burn time"
149 );
150 }
151}