Skip to main content

hpr_motor/
class.rs

1//! Impulse classes: the letter in a motor's designation that bands its total impulse.
2//!
3//! Each class tops out at twice the one before it. The National Association of Rocketry's
4//! *Standard Motor Codes* table gives the upper limits, from `1/8A` (0.3125 N·s) through `O`
5//! (40 960 N·s), and its text states the bands as, for example, "5.01 to 10.0 N-sec" for `C`: a
6//! class's **upper limit belongs to it**, and the lower limit belongs to the class below.
7//!
8//! ```text
9//! upper(k) = 1.25 · 2^k N·s,   k = −2 for 1/8A, −1 for 1/4A, 0 for 1/2A, 1 for A, …, 15 for O
10//! ```
11//!
12//! `1/8A` starts at zero. Letters past `O` (`P`, `Q`, …) continue the doubling, as research and
13//! amateur rocketry use them; COTS motors stop at `O`. See `docs/physics/motor.md`.
14
15use std::fmt;
16use std::str::FromStr;
17
18use serde::{Deserialize, Serialize};
19
20use crate::error::MotorError;
21
22/// The lowest class exponent, `1/8A`.
23const MIN_EXPONENT: i8 = -2;
24/// The highest class exponent, `Z`.
25const MAX_EXPONENT: i8 = 26;
26
27/// A motor's impulse class, such as `1/4A`, `C` or `M`.
28///
29/// Serializes as its label (`"1/2A"`, `"H"`).
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
31#[serde(try_from = "String", into = "String")]
32pub struct ImpulseClass {
33    /// `k` in `upper(k) = 1.25 · 2^k N·s`: −2 for `1/8A`, 1 for `A`, 26 for `Z`.
34    exponent: i8,
35}
36
37impl ImpulseClass {
38    /// The class a total impulse falls in. Upper limits are inclusive: 2.5 N·s is an `A`, and
39    /// anything above it up to 5 N·s is a `B`.
40    ///
41    /// # Errors
42    ///
43    /// [`MotorError::Domain`] when the impulse is not finite, not positive, or above the `Z` limit
44    /// (1.25 · 2²⁶ N·s).
45    pub fn from_total_impulse(total_impulse_ns: f64) -> Result<Self, MotorError> {
46        if !(total_impulse_ns.is_finite() && total_impulse_ns > 0.0) {
47            return Err(MotorError::Domain {
48                what: "total impulse (N·s)",
49                value: total_impulse_ns,
50            });
51        }
52        // Powers of two times 1.25 are exact in f64, so the band edges compare exactly.
53        (MIN_EXPONENT..=MAX_EXPONENT)
54            .map(|exponent| Self { exponent })
55            .find(|class| total_impulse_ns <= class.upper_limit_ns())
56            .ok_or(MotorError::Domain {
57                what: "total impulse (N·s)",
58                value: total_impulse_ns,
59            })
60    }
61
62    /// The largest total impulse in the class, N·s (inclusive).
63    pub fn upper_limit_ns(self) -> f64 {
64        1.25 * 2f64.powi(i32::from(self.exponent))
65    }
66
67    /// The largest total impulse of the class below, N·s (exclusive); zero for `1/8A`.
68    pub fn lower_limit_ns(self) -> f64 {
69        if self.exponent == MIN_EXPONENT {
70            0.0
71        } else {
72            0.5 * self.upper_limit_ns()
73        }
74    }
75
76    /// The class label: `1/8A`, `1/4A`, `1/2A`, then `A` to `Z`.
77    pub fn label(self) -> String {
78        match self.exponent {
79            -2 => "1/8A".to_owned(),
80            -1 => "1/4A".to_owned(),
81            0 => "1/2A".to_owned(),
82            k => {
83                // `k` is in 1..=26 by construction, so the letter is in 'A'..='Z'.
84                let offset = u8::try_from(k - 1).unwrap_or(0);
85                char::from(b'A' + offset).to_string()
86            }
87        }
88    }
89}
90
91impl fmt::Display for ImpulseClass {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        f.write_str(&self.label())
94    }
95}
96
97impl FromStr for ImpulseClass {
98    type Err = MotorError;
99
100    /// Parses a label (`1/2A`, `c`, `M`), case-insensitively.
101    fn from_str(s: &str) -> Result<Self, Self::Err> {
102        let label = s.trim().to_ascii_uppercase();
103        let exponent = match label.as_str() {
104            "1/8A" => -2,
105            "1/4A" => -1,
106            "1/2A" => 0,
107            _ => match label.as_bytes() {
108                [letter @ b'A'..=b'Z'] => i8::try_from(letter - b'A').unwrap_or(0) + 1,
109                _ => {
110                    return Err(MotorError::Parse {
111                        what: "impulse class",
112                        text: s.to_owned(),
113                    });
114                }
115            },
116        };
117        Ok(Self { exponent })
118    }
119}
120
121impl TryFrom<String> for ImpulseClass {
122    type Error = MotorError;
123
124    fn try_from(value: String) -> Result<Self, Self::Error> {
125        value.parse()
126    }
127}
128
129impl From<ImpulseClass> for String {
130    fn from(class: ImpulseClass) -> Self {
131        class.label()
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn nar_table_upper_limits() {
141        // NAR, Standard Motor Codes, "Impulse Limit" column.
142        let table = [
143            ("1/8A", 0.3125),
144            ("1/4A", 0.625),
145            ("1/2A", 1.25),
146            ("A", 2.5),
147            ("B", 5.0),
148            ("C", 10.0),
149            ("D", 20.0),
150            ("E", 40.0),
151            ("F", 80.0),
152            ("G", 160.0),
153            ("H", 320.0),
154            ("I", 640.0),
155            ("J", 1280.0),
156            ("K", 2560.0),
157            ("L", 5120.0),
158            ("M", 10240.0),
159            ("N", 20480.0),
160            ("O", 40960.0),
161        ];
162        for (label, limit) in table {
163            let class: ImpulseClass = label.parse().unwrap();
164            assert_eq!(class.upper_limit_ns(), limit, "{label}");
165            assert_eq!(class.label(), label);
166            assert_eq!(ImpulseClass::from_total_impulse(limit).unwrap(), class);
167        }
168    }
169
170    #[test]
171    fn labels_round_trip_through_serde() {
172        for exponent in MIN_EXPONENT..=MAX_EXPONENT {
173            let class = ImpulseClass { exponent };
174            let json = serde_json::to_string(&class).unwrap();
175            assert_eq!(serde_json::from_str::<ImpulseClass>(&json).unwrap(), class);
176        }
177        assert!(serde_json::from_str::<ImpulseClass>("\"1/16A\"").is_err());
178        assert!("AA".parse::<ImpulseClass>().is_err());
179    }
180
181    #[test]
182    fn rejects_impulses_outside_the_classes() {
183        for bad in [
184            0.0,
185            -1.0,
186            f64::NAN,
187            f64::INFINITY,
188            1.25 * 2f64.powi(26) * 1.000_001,
189        ] {
190            assert!(ImpulseClass::from_total_impulse(bad).is_err(), "{bad}");
191        }
192        assert_eq!(
193            ImpulseClass::from_total_impulse(f64::MIN_POSITIVE)
194                .unwrap()
195                .label(),
196            "1/8A"
197        );
198    }
199}