Skip to main content

use_epoch/
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, Copy, Debug, Eq, PartialEq)]
19pub enum EpochError {
20    EmptyLabel,
21    NonFiniteJulianDate,
22    NonFiniteModifiedJulianDate,
23}
24
25impl fmt::Display for EpochError {
26    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
27        match self {
28            Self::EmptyLabel => formatter.write_str("astronomical epoch label cannot be empty"),
29            Self::NonFiniteJulianDate => formatter.write_str("Julian date must be finite"),
30            Self::NonFiniteModifiedJulianDate => {
31                formatter.write_str("modified Julian date must be finite")
32            },
33        }
34    }
35}
36
37impl Error for EpochError {}
38
39#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
40pub enum EpochKind {
41    J2000,
42    B1950,
43    Julian,
44    Besselian,
45    Observation,
46    Unknown,
47    Custom(String),
48}
49
50impl fmt::Display for EpochKind {
51    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
52        match self {
53            Self::J2000 => formatter.write_str("j2000"),
54            Self::B1950 => formatter.write_str("b1950"),
55            Self::Julian => formatter.write_str("julian"),
56            Self::Besselian => formatter.write_str("besselian"),
57            Self::Observation => formatter.write_str("observation"),
58            Self::Unknown => formatter.write_str("unknown"),
59            Self::Custom(value) => formatter.write_str(value),
60        }
61    }
62}
63
64#[derive(Clone, Copy, Debug, Eq, PartialEq)]
65pub enum EpochKindParseError {
66    Empty,
67}
68
69impl fmt::Display for EpochKindParseError {
70    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
71        match self {
72            Self::Empty => formatter.write_str("epoch kind cannot be empty"),
73        }
74    }
75}
76
77impl Error for EpochKindParseError {}
78
79impl FromStr for EpochKind {
80    type Err = EpochKindParseError;
81
82    fn from_str(value: &str) -> Result<Self, Self::Err> {
83        let trimmed = value.trim();
84
85        if trimmed.is_empty() {
86            return Err(EpochKindParseError::Empty);
87        }
88
89        match normalized_key(trimmed).as_str() {
90            "j2000" => Ok(Self::J2000),
91            "b1950" => Ok(Self::B1950),
92            "julian" => Ok(Self::Julian),
93            "besselian" => Ok(Self::Besselian),
94            "observation" => Ok(Self::Observation),
95            "unknown" => Ok(Self::Unknown),
96            _ => Ok(Self::Custom(trimmed.to_string())),
97        }
98    }
99}
100
101#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
102pub struct JulianDate(f64);
103
104impl JulianDate {
105    /// Creates a Julian date from a finite numeric value.
106    ///
107    /// # Errors
108    ///
109    /// Returns [`EpochError::NonFiniteJulianDate`] when `value` is not finite.
110    pub const fn new(value: f64) -> Result<Self, EpochError> {
111        if !value.is_finite() {
112            return Err(EpochError::NonFiniteJulianDate);
113        }
114
115        Ok(Self(value))
116    }
117
118    #[must_use]
119    pub const fn value(self) -> f64 {
120        self.0
121    }
122}
123
124#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
125pub struct ModifiedJulianDate(f64);
126
127impl ModifiedJulianDate {
128    /// Creates a modified Julian date from a finite numeric value.
129    ///
130    /// # Errors
131    ///
132    /// Returns [`EpochError::NonFiniteModifiedJulianDate`] when `value` is not finite.
133    pub const fn new(value: f64) -> Result<Self, EpochError> {
134        if !value.is_finite() {
135            return Err(EpochError::NonFiniteModifiedJulianDate);
136        }
137
138        Ok(Self(value))
139    }
140
141    #[must_use]
142    pub const fn value(self) -> f64 {
143        self.0
144    }
145}
146
147#[derive(Clone, Debug, Eq, PartialEq)]
148pub struct AstronomicalEpoch {
149    label: String,
150    kind: EpochKind,
151}
152
153impl AstronomicalEpoch {
154    /// Creates an astronomical epoch with a non-empty label and kind.
155    ///
156    /// # Errors
157    ///
158    /// Returns [`EpochError::EmptyLabel`] when the trimmed label is empty.
159    pub fn new(label: impl AsRef<str>, kind: EpochKind) -> Result<Self, EpochError> {
160        let trimmed = label.as_ref().trim();
161
162        if trimmed.is_empty() {
163            return Err(EpochError::EmptyLabel);
164        }
165
166        Ok(Self {
167            label: trimmed.to_string(),
168            kind,
169        })
170    }
171
172    #[must_use]
173    pub fn j2000() -> Self {
174        Self {
175            label: "J2000".to_string(),
176            kind: EpochKind::J2000,
177        }
178    }
179
180    #[must_use]
181    pub fn b1950() -> Self {
182        Self {
183            label: "B1950".to_string(),
184            kind: EpochKind::B1950,
185        }
186    }
187
188    #[must_use]
189    pub fn label(&self) -> &str {
190        &self.label
191    }
192
193    #[must_use]
194    pub const fn kind(&self) -> &EpochKind {
195        &self.kind
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::{AstronomicalEpoch, EpochKind, JulianDate, ModifiedJulianDate};
202
203    #[test]
204    fn epoch_kind_display_and_parse() {
205        assert_eq!(EpochKind::J2000.to_string(), "j2000");
206        assert_eq!(
207            "observation".parse::<EpochKind>().unwrap(),
208            EpochKind::Observation
209        );
210    }
211
212    #[test]
213    fn custom_epoch_kind() {
214        assert_eq!(
215            "catalog-epoch".parse::<EpochKind>().unwrap(),
216            EpochKind::Custom("catalog-epoch".to_string())
217        );
218    }
219
220    #[test]
221    fn julian_date_construction() {
222        let julian_date = JulianDate::new(2_451_545.0).unwrap();
223
224        assert!((julian_date.value() - 2_451_545.0).abs() < f64::EPSILON);
225    }
226
227    #[test]
228    fn modified_julian_date_construction() {
229        let modified_julian_date = ModifiedJulianDate::new(51_544.5).unwrap();
230
231        assert!((modified_julian_date.value() - 51_544.5).abs() < f64::EPSILON);
232    }
233
234    #[test]
235    fn known_epoch_labels() {
236        assert_eq!(AstronomicalEpoch::j2000().label(), "J2000");
237        assert_eq!(AstronomicalEpoch::b1950().label(), "B1950");
238    }
239}