Skip to main content

use_orbital_element/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7fn normalized_key(value: &str) -> String {
8    value
9        .trim()
10        .chars()
11        .map(|character| match character {
12            '_' | ' ' => '-',
13            other => other.to_ascii_lowercase(),
14        })
15        .collect()
16}
17
18#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
19pub enum OrbitalElementKind {
20    SemiMajorAxis,
21    Eccentricity,
22    Inclination,
23    LongitudeOfAscendingNode,
24    ArgumentOfPeriapsis,
25    TrueAnomaly,
26    MeanAnomaly,
27    Epoch,
28    Unknown,
29    Custom(String),
30}
31
32impl fmt::Display for OrbitalElementKind {
33    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34        match self {
35            Self::SemiMajorAxis => formatter.write_str("semi-major-axis"),
36            Self::Eccentricity => formatter.write_str("eccentricity"),
37            Self::Inclination => formatter.write_str("inclination"),
38            Self::LongitudeOfAscendingNode => formatter.write_str("longitude-of-ascending-node"),
39            Self::ArgumentOfPeriapsis => formatter.write_str("argument-of-periapsis"),
40            Self::TrueAnomaly => formatter.write_str("true-anomaly"),
41            Self::MeanAnomaly => formatter.write_str("mean-anomaly"),
42            Self::Epoch => formatter.write_str("epoch"),
43            Self::Unknown => formatter.write_str("unknown"),
44            Self::Custom(value) => formatter.write_str(value),
45        }
46    }
47}
48
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub enum OrbitalElementKindParseError {
51    Empty,
52}
53
54impl fmt::Display for OrbitalElementKindParseError {
55    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56        match self {
57            Self::Empty => formatter.write_str("orbital element kind cannot be empty"),
58        }
59    }
60}
61
62impl Error for OrbitalElementKindParseError {}
63
64impl FromStr for OrbitalElementKind {
65    type Err = OrbitalElementKindParseError;
66
67    fn from_str(value: &str) -> Result<Self, Self::Err> {
68        let trimmed = value.trim();
69
70        if trimmed.is_empty() {
71            return Err(OrbitalElementKindParseError::Empty);
72        }
73
74        match normalized_key(trimmed).as_str() {
75            "semi-major-axis" | "semimajoraxis" => Ok(Self::SemiMajorAxis),
76            "eccentricity" => Ok(Self::Eccentricity),
77            "inclination" => Ok(Self::Inclination),
78            "longitude-of-ascending-node" | "loan" => Ok(Self::LongitudeOfAscendingNode),
79            "argument-of-periapsis" | "aop" => Ok(Self::ArgumentOfPeriapsis),
80            "true-anomaly" | "trueanomaly" => Ok(Self::TrueAnomaly),
81            "mean-anomaly" | "meananomaly" => Ok(Self::MeanAnomaly),
82            "epoch" => Ok(Self::Epoch),
83            "unknown" => Ok(Self::Unknown),
84            _ => Ok(Self::Custom(trimmed.to_string())),
85        }
86    }
87}
88
89#[derive(Clone, Debug, Eq, PartialEq)]
90pub enum OrbitalElementValueError {
91    NonFiniteValue,
92    EmptyUnitLabel,
93    NegativeEccentricity,
94    InvalidInclination,
95}
96
97impl fmt::Display for OrbitalElementValueError {
98    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
99        match self {
100            Self::NonFiniteValue => formatter.write_str("orbital element value must be finite"),
101            Self::EmptyUnitLabel => {
102                formatter.write_str("orbital element unit label cannot be empty")
103            },
104            Self::NegativeEccentricity => formatter.write_str("eccentricity cannot be negative"),
105            Self::InvalidInclination => {
106                formatter.write_str("inclination must be within 0.0..=180.0 degrees")
107            },
108        }
109    }
110}
111
112impl Error for OrbitalElementValueError {}
113
114#[derive(Clone, Debug, PartialEq)]
115pub struct OrbitalElementValue {
116    value: f64,
117    unit_label: Option<String>,
118}
119
120impl OrbitalElementValue {
121    /// Creates a unitless orbital element value from a finite number.
122    ///
123    /// # Errors
124    ///
125    /// Returns [`OrbitalElementValueError::NonFiniteValue`] when `value` is not finite.
126    pub const fn new(value: f64) -> Result<Self, OrbitalElementValueError> {
127        if !value.is_finite() {
128            return Err(OrbitalElementValueError::NonFiniteValue);
129        }
130
131        Ok(Self {
132            value,
133            unit_label: None,
134        })
135    }
136
137    /// Creates an orbital element value with a non-empty unit label.
138    ///
139    /// # Errors
140    ///
141    /// Returns [`OrbitalElementValueError::NonFiniteValue`] when `value` is not finite, or
142    /// [`OrbitalElementValueError::EmptyUnitLabel`] when the trimmed unit label is empty.
143    pub fn with_unit(
144        value: f64,
145        unit_label: impl AsRef<str>,
146    ) -> Result<Self, OrbitalElementValueError> {
147        let unit_label = unit_label.as_ref().trim();
148        if unit_label.is_empty() {
149            return Err(OrbitalElementValueError::EmptyUnitLabel);
150        }
151
152        let mut element_value = Self::new(value)?;
153        element_value.unit_label = Some(unit_label.to_string());
154        Ok(element_value)
155    }
156
157    #[must_use]
158    pub const fn value(&self) -> f64 {
159        self.value
160    }
161
162    #[must_use]
163    pub fn unit_label(&self) -> Option<&str> {
164        self.unit_label.as_deref()
165    }
166}
167
168#[derive(Clone, Debug, PartialEq)]
169pub struct OrbitalElement {
170    kind: OrbitalElementKind,
171    value: OrbitalElementValue,
172}
173
174impl OrbitalElement {
175    /// Creates an orbital element with domain validation for constrained element kinds.
176    ///
177    /// # Errors
178    ///
179    /// Returns [`OrbitalElementValueError::NegativeEccentricity`] for negative eccentricity values,
180    /// or [`OrbitalElementValueError::InvalidInclination`] for inclinations outside `0.0..=180.0`.
181    pub fn new(
182        kind: OrbitalElementKind,
183        value: OrbitalElementValue,
184    ) -> Result<Self, OrbitalElementValueError> {
185        match kind {
186            OrbitalElementKind::Eccentricity if value.value() < 0.0 => {
187                return Err(OrbitalElementValueError::NegativeEccentricity);
188            },
189            OrbitalElementKind::Inclination if !(0.0..=180.0).contains(&value.value()) => {
190                return Err(OrbitalElementValueError::InvalidInclination);
191            },
192            _ => {},
193        }
194
195        Ok(Self { kind, value })
196    }
197
198    #[must_use]
199    pub const fn kind(&self) -> &OrbitalElementKind {
200        &self.kind
201    }
202
203    #[must_use]
204    pub const fn value(&self) -> &OrbitalElementValue {
205        &self.value
206    }
207}
208
209#[derive(Clone, Debug, Default, PartialEq)]
210pub struct OrbitalElementSet {
211    elements: Vec<OrbitalElement>,
212}
213
214impl OrbitalElementSet {
215    #[must_use]
216    pub const fn new(elements: Vec<OrbitalElement>) -> Self {
217        Self { elements }
218    }
219
220    #[must_use]
221    pub fn elements(&self) -> &[OrbitalElement] {
222        &self.elements
223    }
224
225    #[must_use]
226    pub const fn len(&self) -> usize {
227        self.elements.len()
228    }
229
230    #[must_use]
231    pub const fn is_empty(&self) -> bool {
232        self.elements.is_empty()
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::{
239        OrbitalElement, OrbitalElementKind, OrbitalElementSet, OrbitalElementValue,
240        OrbitalElementValueError,
241    };
242
243    #[test]
244    fn orbital_element_kind_display_and_parse() {
245        assert_eq!(
246            OrbitalElementKind::SemiMajorAxis.to_string(),
247            "semi-major-axis"
248        );
249        assert_eq!(
250            "mean anomaly".parse::<OrbitalElementKind>().unwrap(),
251            OrbitalElementKind::MeanAnomaly
252        );
253    }
254
255    #[test]
256    fn custom_orbital_element_kind() {
257        assert_eq!(
258            "perihelion-time".parse::<OrbitalElementKind>().unwrap(),
259            OrbitalElementKind::Custom("perihelion-time".to_string())
260        );
261    }
262
263    #[test]
264    fn valid_eccentricity() {
265        let element = OrbitalElement::new(
266            OrbitalElementKind::Eccentricity,
267            OrbitalElementValue::new(0.0167).unwrap(),
268        )
269        .unwrap();
270
271        assert!((element.value().value() - 0.0167).abs() < f64::EPSILON);
272    }
273
274    #[test]
275    fn negative_eccentricity_rejected() {
276        assert_eq!(
277            OrbitalElement::new(
278                OrbitalElementKind::Eccentricity,
279                OrbitalElementValue::new(-0.1).unwrap(),
280            ),
281            Err(OrbitalElementValueError::NegativeEccentricity)
282        );
283    }
284
285    #[test]
286    fn valid_inclination() {
287        let element = OrbitalElement::new(
288            OrbitalElementKind::Inclination,
289            OrbitalElementValue::with_unit(98.7, "deg").unwrap(),
290        )
291        .unwrap();
292
293        assert_eq!(element.value().unit_label(), Some("deg"));
294    }
295
296    #[test]
297    fn invalid_inclination_rejected() {
298        assert_eq!(
299            OrbitalElement::new(
300                OrbitalElementKind::Inclination,
301                OrbitalElementValue::new(181.0).unwrap(),
302            ),
303            Err(OrbitalElementValueError::InvalidInclination)
304        );
305    }
306
307    #[test]
308    fn orbital_element_set_construction() {
309        let elements = vec![
310            OrbitalElement::new(
311                OrbitalElementKind::SemiMajorAxis,
312                OrbitalElementValue::with_unit(1.0, "AU").unwrap(),
313            )
314            .unwrap(),
315            OrbitalElement::new(
316                OrbitalElementKind::Eccentricity,
317                OrbitalElementValue::new(0.0167).unwrap(),
318            )
319            .unwrap(),
320        ];
321
322        let set = OrbitalElementSet::new(elements);
323
324        assert_eq!(set.len(), 2);
325        assert!(!set.is_empty());
326    }
327}