use_astronomical_coordinate/
lib.rs1#![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 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 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}