1use std::fmt;
16use std::str::FromStr;
17
18use serde::{Deserialize, Serialize};
19
20use crate::error::MotorError;
21
22const MIN_EXPONENT: i8 = -2;
24const MAX_EXPONENT: i8 = 26;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
31#[serde(try_from = "String", into = "String")]
32pub struct ImpulseClass {
33 exponent: i8,
35}
36
37impl ImpulseClass {
38 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 (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 pub fn upper_limit_ns(self) -> f64 {
64 1.25 * 2f64.powi(i32::from(self.exponent))
65 }
66
67 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 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 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 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 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}