Skip to main content

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}