hpr/motor.rs
1//! A solid rocket motor, ready to go in a rocket's motor tube.
2
3use hpr_motor::catalog::bundled_curve_text;
4use hpr_motor::{Catalog, Delay, MotorError, SolidMotor, eng, rse};
5use serde::Serialize;
6
7use crate::error::{Error, non_negative, positive};
8
9/// A commercial solid motor: its designation, its case's size, its thrust curve and masses, and
10/// the delay of its ejection charge, if it has one.
11///
12/// Three ways to get one:
13///
14/// - [`Motor::from_catalog`]: a motor from the catalog built into hpr-sim, by its designation or
15/// common name, with the catalog's size, masses and thrust curve.
16/// - [`Motor::from_eng`]: a RASP `.eng` file's text, as ThrustCurve.org serves it, or
17/// [`Motor::from_rse`]: a RockSim `.rse` file's.
18/// - [`Motor::new`]: a [`SolidMotor`] you built with [`hpr_motor`], and its case's size.
19///
20/// ```
21/// use hpr::Motor;
22///
23/// let motor = Motor::from_catalog("H54")?.with_delay_s(10.0)?;
24/// assert_eq!(motor.designation(), "168H54-10A");
25/// assert_eq!(motor.diameter_m(), 0.029);
26/// # Ok::<(), hpr::Error>(())
27/// ```
28///
29/// It serializes, for a record of what was flown, but doesn't deserialize: its constructors check
30/// what goes in.
31#[derive(Debug, Clone, PartialEq, Serialize)]
32pub struct Motor {
33 designation: String,
34 diameter_m: f64,
35 length_m: f64,
36 motor: SolidMotor,
37 delay: Option<Delay>,
38}
39
40impl Motor {
41 /// A motor of case diameter `diameter_m` and length `length_m`, called `designation`, with no
42 /// ejection delay.
43 ///
44 /// # Errors
45 ///
46 /// [`Error::EmptyDesignation`] for an empty designation, and [`Error::Domain`] for a diameter
47 /// or length that isn't finite and positive.
48 pub fn new(
49 designation: &str,
50 motor: SolidMotor,
51 diameter_m: f64,
52 length_m: f64,
53 ) -> Result<Self, Error> {
54 if designation.trim().is_empty() {
55 return Err(Error::EmptyDesignation);
56 }
57 Ok(Self {
58 designation: designation.to_owned(),
59 diameter_m: positive("motor diameter, m", diameter_m)?,
60 length_m: positive("motor length, m", length_m)?,
61 motor,
62 delay: None,
63 })
64 }
65
66 /// The motor in hpr-sim's built-in catalog whose designation or common name is `name`,
67 /// ignoring case, spaces and hyphens (`"H54"`, `"168H54-10A"`, `"k 400"`), with its first
68 /// bundled thrust curve. Only motors with a bundled curve can be found: 32 today, listed on
69 /// the [motor page][motor-page]. The delay is not set, whatever the designation says: give it
70 /// with [`Motor::with_delay_s`].
71 ///
72 /// [motor-page]: https://nrdptel.github.io/hpr-sim/physics/motor.html
73 ///
74 /// # Errors
75 ///
76 /// - [`Error::NoSuchMotor`] if no motor with a bundled curve matches.
77 /// - [`Error::AmbiguousMotor`] if several motors match, as a common name can (`"I175"`
78 /// matches two); each is listed by its designation, which finds it alone.
79 /// - [`Error::Motor`] if the catalog or the motor's curve can't be read (never, for the
80 /// bundled ones: their tests read them all).
81 pub fn from_catalog(name: &str) -> Result<Self, Error> {
82 let catalog = Catalog::bundled()?;
83 let matches = catalog
84 .find(name)
85 .filter(|entry| {
86 entry
87 .curves
88 .iter()
89 .any(|curve| bundled_curve_text(&curve.file).is_some())
90 })
91 .collect::<Vec<_>>();
92 let entry = match matches[..] {
93 [] => return Err(Error::NoSuchMotor(name.to_owned())),
94 [entry] => entry,
95 _ => {
96 return Err(Error::AmbiguousMotor {
97 name: name.to_owned(),
98 candidates: matches
99 .iter()
100 .map(|entry| {
101 format!("{} ({})", entry.designation, entry.manufacturer_abbrev)
102 })
103 .collect(),
104 });
105 }
106 };
107 Self::new(
108 &entry.designation,
109 entry.bundled_motor()?,
110 entry.diameter_mm / 1000.0,
111 entry.length_mm / 1000.0,
112 )
113 }
114
115 /// The one motor in the text of a RASP `.eng` file, with the file's size, masses and thrust
116 /// curve ([`SolidMotor::from_envelope`]). The file's warnings and its list of delays are
117 /// dropped (read the file with [`hpr_motor::eng::parse`] to see them): set the delay with
118 /// [`Motor::with_delay_s`]. A `.eng` file doesn't say what kind of motor it holds, so a
119 /// hybrid's file is read as a solid motor; hpr models solid motors only.
120 ///
121 /// # Errors
122 ///
123 /// [`Error::Motor`] if the text isn't a motor file or its numbers don't make a motor, and
124 /// [`Error::MotorCount`] if it holds more than one motor or none.
125 pub fn from_eng(text: &str) -> Result<Self, Error> {
126 let parsed = eng::parse(text)?;
127 let [entry] = &parsed.value.entries[..] else {
128 return Err(Error::MotorCount(parsed.value.entries.len()));
129 };
130 let diameter_m = entry.diameter_mm / 1000.0;
131 let length_m = entry.length_mm / 1000.0;
132 let motor = SolidMotor::from_envelope(
133 entry.thrust_curve()?,
134 diameter_m,
135 length_m,
136 entry.propellant_mass_kg,
137 entry.total_mass_kg,
138 )?;
139 Self::new(&entry.name, motor, diameter_m, length_m)
140 }
141
142 /// The one motor in the text of a RockSim `.rse` file, with the file's size, masses (grams,
143 /// converted) and thrust curve ([`SolidMotor::from_envelope`]). As with [`Motor::from_eng`],
144 /// the file's warnings, its delays and the center-of-gravity column some files carry are
145 /// dropped (read the file with [`hpr_motor::rse::parse`] to see them). A hybrid, which the
146 /// file's `Type` names, is refused: hpr models solid motors only.
147 ///
148 /// # Errors
149 ///
150 /// [`Error::Motor`] if the text isn't a motor file, its numbers don't make a motor, or it is a
151 /// hybrid; [`Error::MotorCount`] if it holds more than one motor.
152 pub fn from_rse(text: &str) -> Result<Self, Error> {
153 let parsed = rse::parse(text)?;
154 let [engine] = &parsed.value.engines[..] else {
155 return Err(Error::MotorCount(parsed.value.engines.len()));
156 };
157 if engine
158 .motor_type
159 .as_deref()
160 .is_some_and(|kind| kind.trim().eq_ignore_ascii_case("hybrid"))
161 {
162 return Err(Error::Motor(MotorError::Inconsistent(format!(
163 "{} is a hybrid; hpr models solid motors only",
164 engine.code
165 ))));
166 }
167 let diameter_m = engine.diameter_mm / 1000.0;
168 let length_m = engine.length_mm / 1000.0;
169 let motor = SolidMotor::from_envelope(
170 engine.thrust_curve()?,
171 diameter_m,
172 length_m,
173 engine.propellant_mass_g / 1000.0,
174 engine.initial_mass_g / 1000.0,
175 )?;
176 Self::new(&engine.code, motor, diameter_m, length_m)
177 }
178
179 /// The same motor with an ejection charge `delay_s` seconds after burnout.
180 ///
181 /// # Errors
182 ///
183 /// [`Error::Domain`] for a delay that is negative or not finite.
184 pub fn with_delay_s(mut self, delay_s: f64) -> Result<Self, Error> {
185 self.delay = Some(Delay::Seconds(non_negative("motor delay, s", delay_s)?));
186 Ok(self)
187 }
188
189 /// The same motor with `delay` as its ejection delay: a time, or a plugged motor
190 /// ([`Delay::Plugged`]), which has no ejection charge.
191 ///
192 /// # Errors
193 ///
194 /// [`Error::Domain`] for a time that is negative or not finite.
195 pub fn with_delay(mut self, delay: Delay) -> Result<Self, Error> {
196 if let Delay::Seconds(delay_s) = delay {
197 non_negative("motor delay, s", delay_s)?;
198 }
199 self.delay = Some(delay);
200 Ok(self)
201 }
202
203 /// The designation, such as `168H54-10A`.
204 #[must_use]
205 pub fn designation(&self) -> &str {
206 &self.designation
207 }
208
209 /// The case's outer diameter, m.
210 #[must_use]
211 pub fn diameter_m(&self) -> f64 {
212 self.diameter_m
213 }
214
215 /// The case's length, m.
216 #[must_use]
217 pub fn length_m(&self) -> f64 {
218 self.length_m
219 }
220
221 /// The ejection delay, if one is set.
222 #[must_use]
223 pub fn delay(&self) -> Option<Delay> {
224 self.delay
225 }
226
227 /// The motor model: thrust curve, propellant and case masses.
228 #[must_use]
229 pub fn solid_motor(&self) -> &SolidMotor {
230 &self.motor
231 }
232}