Skip to main content

use_astronomical_coordinate/
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 AstronomicalCoordinateError {
20    NonFiniteRightAscension,
21    InvalidRightAscension,
22    NonFiniteDeclination,
23    InvalidDeclination,
24}
25
26impl fmt::Display for AstronomicalCoordinateError {
27    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
28        match self {
29            Self::NonFiniteRightAscension => formatter.write_str("right ascension must be finite"),
30            Self::InvalidRightAscension => {
31                formatter.write_str("right ascension must be within 0.0..=360.0 degrees")
32            },
33            Self::NonFiniteDeclination => formatter.write_str("declination must be finite"),
34            Self::InvalidDeclination => {
35                formatter.write_str("declination must be within -90.0..=90.0 degrees")
36            },
37        }
38    }
39}
40
41impl Error for AstronomicalCoordinateError {}
42
43#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
44pub struct RightAscension(f64);
45
46impl RightAscension {
47    /// Creates right ascension from finite degrees within `0.0..=360.0`.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`AstronomicalCoordinateError::NonFiniteRightAscension`] when `value` is not finite,
52    /// or [`AstronomicalCoordinateError::InvalidRightAscension`] when it is outside `0.0..=360.0`.
53    pub fn from_degrees(value: f64) -> Result<Self, AstronomicalCoordinateError> {
54        if !value.is_finite() {
55            return Err(AstronomicalCoordinateError::NonFiniteRightAscension);
56        }
57
58        if !(0.0..=360.0).contains(&value) {
59            return Err(AstronomicalCoordinateError::InvalidRightAscension);
60        }
61
62        Ok(Self(value))
63    }
64
65    #[must_use]
66    pub const fn degrees(self) -> f64 {
67        self.0
68    }
69}
70
71impl fmt::Display for RightAscension {
72    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
73        self.degrees().fmt(formatter)
74    }
75}
76
77#[derive(Clone, Copy, Debug, PartialEq, PartialOrd)]
78pub struct Declination(f64);
79
80impl Declination {
81    /// Creates declination from finite degrees within `-90.0..=90.0`.
82    ///
83    /// # Errors
84    ///
85    /// Returns [`AstronomicalCoordinateError::NonFiniteDeclination`] when `value` is not finite,
86    /// or [`AstronomicalCoordinateError::InvalidDeclination`] when it is outside `-90.0..=90.0`.
87    pub fn new(value: f64) -> Result<Self, AstronomicalCoordinateError> {
88        if !value.is_finite() {
89            return Err(AstronomicalCoordinateError::NonFiniteDeclination);
90        }
91
92        if !(-90.0..=90.0).contains(&value) {
93            return Err(AstronomicalCoordinateError::InvalidDeclination);
94        }
95
96        Ok(Self(value))
97    }
98
99    #[must_use]
100    pub const fn degrees(self) -> f64 {
101        self.0
102    }
103}
104
105impl fmt::Display for Declination {
106    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
107        self.degrees().fmt(formatter)
108    }
109}
110
111#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
112pub enum CoordinateFrame {
113    Equatorial,
114    Ecliptic,
115    Galactic,
116    Horizontal,
117    Supergalactic,
118    Unknown,
119    Custom(String),
120}
121
122impl fmt::Display for CoordinateFrame {
123    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
124        match self {
125            Self::Equatorial => formatter.write_str("equatorial"),
126            Self::Ecliptic => formatter.write_str("ecliptic"),
127            Self::Galactic => formatter.write_str("galactic"),
128            Self::Horizontal => formatter.write_str("horizontal"),
129            Self::Supergalactic => formatter.write_str("supergalactic"),
130            Self::Unknown => formatter.write_str("unknown"),
131            Self::Custom(value) => formatter.write_str(value),
132        }
133    }
134}
135
136#[derive(Clone, Copy, Debug, Eq, PartialEq)]
137pub enum CoordinateFrameParseError {
138    Empty,
139}
140
141impl fmt::Display for CoordinateFrameParseError {
142    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
143        match self {
144            Self::Empty => formatter.write_str("coordinate frame cannot be empty"),
145        }
146    }
147}
148
149impl Error for CoordinateFrameParseError {}
150
151impl FromStr for CoordinateFrame {
152    type Err = CoordinateFrameParseError;
153
154    fn from_str(value: &str) -> Result<Self, Self::Err> {
155        let trimmed = value.trim();
156
157        if trimmed.is_empty() {
158            return Err(CoordinateFrameParseError::Empty);
159        }
160
161        match normalized_key(trimmed).as_str() {
162            "equatorial" => Ok(Self::Equatorial),
163            "ecliptic" => Ok(Self::Ecliptic),
164            "galactic" => Ok(Self::Galactic),
165            "horizontal" => Ok(Self::Horizontal),
166            "supergalactic" => Ok(Self::Supergalactic),
167            "unknown" => Ok(Self::Unknown),
168            _ => Ok(Self::Custom(trimmed.to_string())),
169        }
170    }
171}
172
173#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
174pub enum CoordinateSystem {
175    ICRS,
176    FK5,
177    J2000,
178    B1950,
179    Apparent,
180    Unknown,
181    Custom(String),
182}
183
184impl fmt::Display for CoordinateSystem {
185    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
186        match self {
187            Self::ICRS => formatter.write_str("icrs"),
188            Self::FK5 => formatter.write_str("fk5"),
189            Self::J2000 => formatter.write_str("j2000"),
190            Self::B1950 => formatter.write_str("b1950"),
191            Self::Apparent => formatter.write_str("apparent"),
192            Self::Unknown => formatter.write_str("unknown"),
193            Self::Custom(value) => formatter.write_str(value),
194        }
195    }
196}
197
198#[derive(Clone, Copy, Debug, Eq, PartialEq)]
199pub enum CoordinateSystemParseError {
200    Empty,
201}
202
203impl fmt::Display for CoordinateSystemParseError {
204    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
205        match self {
206            Self::Empty => formatter.write_str("coordinate system cannot be empty"),
207        }
208    }
209}
210
211impl Error for CoordinateSystemParseError {}
212
213impl FromStr for CoordinateSystem {
214    type Err = CoordinateSystemParseError;
215
216    fn from_str(value: &str) -> Result<Self, Self::Err> {
217        let trimmed = value.trim();
218
219        if trimmed.is_empty() {
220            return Err(CoordinateSystemParseError::Empty);
221        }
222
223        match trimmed.to_ascii_uppercase().as_str() {
224            "ICRS" => Ok(Self::ICRS),
225            "FK5" => Ok(Self::FK5),
226            "J2000" => Ok(Self::J2000),
227            "B1950" => Ok(Self::B1950),
228            _ if normalized_key(trimmed) == "apparent" => Ok(Self::Apparent),
229            _ if normalized_key(trimmed) == "unknown" => Ok(Self::Unknown),
230            _ => Ok(Self::Custom(trimmed.to_string())),
231        }
232    }
233}
234
235#[derive(Clone, Debug, PartialEq)]
236pub struct AstronomicalCoordinate {
237    right_ascension: RightAscension,
238    declination: Declination,
239    frame: CoordinateFrame,
240    system: CoordinateSystem,
241}
242
243impl AstronomicalCoordinate {
244    #[must_use]
245    pub const fn new(
246        right_ascension: RightAscension,
247        declination: Declination,
248        frame: CoordinateFrame,
249        system: CoordinateSystem,
250    ) -> Self {
251        Self {
252            right_ascension,
253            declination,
254            frame,
255            system,
256        }
257    }
258
259    #[must_use]
260    pub const fn right_ascension(&self) -> RightAscension {
261        self.right_ascension
262    }
263
264    #[must_use]
265    pub const fn declination(&self) -> Declination {
266        self.declination
267    }
268
269    #[must_use]
270    pub const fn frame(&self) -> &CoordinateFrame {
271        &self.frame
272    }
273
274    #[must_use]
275    pub const fn system(&self) -> &CoordinateSystem {
276        &self.system
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::{
283        AstronomicalCoordinate, AstronomicalCoordinateError, CoordinateFrame, CoordinateSystem,
284        Declination, RightAscension,
285    };
286
287    #[test]
288    fn valid_right_ascension() {
289        let right_ascension = RightAscension::from_degrees(180.0).unwrap();
290
291        assert!((right_ascension.degrees() - 180.0).abs() < f64::EPSILON);
292    }
293
294    #[test]
295    fn invalid_right_ascension_rejected() {
296        assert_eq!(
297            RightAscension::from_degrees(361.0),
298            Err(AstronomicalCoordinateError::InvalidRightAscension)
299        );
300    }
301
302    #[test]
303    fn valid_declination() {
304        let declination = Declination::new(-16.7161).unwrap();
305
306        assert!((declination.degrees() - -16.7161).abs() < f64::EPSILON);
307    }
308
309    #[test]
310    fn invalid_declination_rejected() {
311        assert_eq!(
312            Declination::new(-91.0),
313            Err(AstronomicalCoordinateError::InvalidDeclination)
314        );
315    }
316
317    #[test]
318    fn coordinate_frame_display_and_parse() {
319        assert_eq!(CoordinateFrame::Galactic.to_string(), "galactic");
320        assert_eq!(
321            "horizontal".parse::<CoordinateFrame>().unwrap(),
322            CoordinateFrame::Horizontal
323        );
324    }
325
326    #[test]
327    fn coordinate_system_display_and_parse() {
328        assert_eq!(CoordinateSystem::ICRS.to_string(), "icrs");
329        assert_eq!(
330            "j2000".parse::<CoordinateSystem>().unwrap(),
331            CoordinateSystem::J2000
332        );
333    }
334
335    #[test]
336    fn custom_coordinate_frame() {
337        assert_eq!(
338            "topocentric".parse::<CoordinateFrame>().unwrap(),
339            CoordinateFrame::Custom("topocentric".to_string())
340        );
341    }
342
343    #[test]
344    fn astronomical_coordinate_construction() {
345        let coordinate = AstronomicalCoordinate::new(
346            RightAscension::from_degrees(279.234_734_79).unwrap(),
347            Declination::new(38.783_688_96).unwrap(),
348            CoordinateFrame::Equatorial,
349            CoordinateSystem::ICRS,
350        );
351
352        assert_eq!(coordinate.frame(), &CoordinateFrame::Equatorial);
353        assert_eq!(coordinate.system(), &CoordinateSystem::ICRS);
354    }
355}