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 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 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 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}