Skip to main content

celestial_coords/
distance.rs

1use crate::{CoordError, CoordResult};
2use celestial_core::Angle;
3
4#[cfg(feature = "serde")]
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq)]
8#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
9pub struct Distance {
10    parsecs: f64,
11}
12
13impl Distance {
14    /// Creates a Distance from parsecs.
15    ///
16    /// # Valid Range
17    /// Must be positive and finite (0 < parsecs < ∞)
18    ///
19    /// # Errors
20    /// Returns `CoordError::InvalidDistance` if value is ≤0, infinite, or NaN.
21    pub fn from_parsecs(parsecs: f64) -> CoordResult<Self> {
22        if !parsecs.is_finite() || parsecs <= 0.0 {
23            return Err(CoordError::invalid_distance(format!(
24                "Distance must be positive and finite, got {}",
25                parsecs
26            )));
27        }
28        Ok(Self { parsecs })
29    }
30
31    /// Creates a Distance from light-years.
32    ///
33    /// # Valid Range
34    /// Must be positive and finite (0 < ly < ∞)
35    pub fn from_light_years(ly: f64) -> CoordResult<Self> {
36        const LY_TO_PC: f64 = 0.3066013937;
37        Self::from_parsecs(ly * LY_TO_PC)
38    }
39
40    /// Creates a Distance from astronomical units.
41    ///
42    /// # Valid Range
43    /// Must be positive and finite (0 < au < ∞)
44    pub fn from_au(au: f64) -> CoordResult<Self> {
45        const AU_TO_PC: f64 = 4.84813681109536e-6;
46        Self::from_parsecs(au * AU_TO_PC)
47    }
48
49    /// Creates a Distance from kilometers.
50    ///
51    /// # Valid Range
52    /// Must be positive and finite (0 < km < ∞)
53    pub fn from_kilometers(km: f64) -> CoordResult<Self> {
54        const KM_TO_PC: f64 = 3.24077929e-14;
55        Self::from_parsecs(km * KM_TO_PC)
56    }
57
58    /// Creates a Distance from parallax in arcseconds.
59    ///
60    /// # Valid Range
61    /// Must be positive and finite (0 < parallax_arcsec < ∞)
62    ///
63    /// # Note
64    /// Distance (parsecs) = 1 / parallax (arcsec)
65    pub fn from_parallax_arcsec(parallax_arcsec: f64) -> CoordResult<Self> {
66        if !parallax_arcsec.is_finite() || parallax_arcsec <= 0.0 {
67            return Err(CoordError::invalid_distance(format!(
68                "Parallax must be positive and finite, got {} arcsec",
69                parallax_arcsec
70            )));
71        }
72        Self::from_parsecs(1.0 / parallax_arcsec)
73    }
74
75    pub fn from_parallax_milliarcsec(parallax_mas: f64) -> CoordResult<Self> {
76        Self::from_parallax_arcsec(parallax_mas / 1000.0)
77    }
78
79    pub fn from_parallax_angle(parallax: Angle) -> CoordResult<Self> {
80        Self::from_parallax_arcsec(parallax.arcseconds())
81    }
82
83    pub fn parsecs(self) -> f64 {
84        self.parsecs
85    }
86
87    pub fn light_years(self) -> f64 {
88        const PC_TO_LY: f64 = 3.2615637769;
89        self.parsecs * PC_TO_LY
90    }
91
92    pub fn au(self) -> f64 {
93        const PC_TO_AU: f64 = 206264.806247096;
94        self.parsecs * PC_TO_AU
95    }
96
97    pub fn kilometers(self) -> f64 {
98        #[allow(clippy::excessive_precision)]
99        const PC_TO_KM: f64 = 3.0856775814913673e13;
100        self.parsecs * PC_TO_KM
101    }
102
103    pub fn parallax_arcsec(self) -> f64 {
104        1.0 / self.parsecs
105    }
106
107    pub fn parallax_milliarcsec(self) -> f64 {
108        self.parallax_arcsec() * 1000.0
109    }
110
111    pub fn parallax_angle(self) -> Angle {
112        Angle::from_arcseconds(self.parallax_arcsec())
113    }
114
115    pub fn distance_modulus(self) -> f64 {
116        5.0 * libm::log10(self.parsecs) - 5.0
117    }
118
119    pub fn from_distance_modulus(dm: f64) -> CoordResult<Self> {
120        let parsecs = 10.0_f64.powf((dm + 5.0) / 5.0);
121        Self::from_parsecs(parsecs)
122    }
123
124    pub fn is_galactic(self) -> bool {
125        self.parsecs < 100_000.0
126    }
127
128    pub fn is_local_group(self) -> bool {
129        self.parsecs < 2_000_000.0
130    }
131
132    pub fn parallax_uncertainty_mas(self, relative_error: f64) -> f64 {
133        let parallax_mas = self.parallax_milliarcsec();
134        parallax_mas * relative_error
135    }
136
137    pub fn proper_motion_distance_au(self, pm_mas_per_year: f64, dt_years: f64) -> f64 {
138        let pm_rad_per_year =
139            pm_mas_per_year * 1e-3 * (celestial_core::constants::PI / (180.0 * 3600.0));
140        let angular_distance_rad = pm_rad_per_year * dt_years;
141        self.au() * angular_distance_rad
142    }
143}
144
145impl std::ops::Add for Distance {
146    type Output = CoordResult<Self>;
147
148    fn add(self, other: Self) -> Self::Output {
149        Self::from_parsecs(self.parsecs + other.parsecs)
150    }
151}
152
153impl std::ops::Sub for Distance {
154    type Output = CoordResult<Self>;
155
156    fn sub(self, other: Self) -> Self::Output {
157        Self::from_parsecs(self.parsecs - other.parsecs)
158    }
159}
160
161impl std::ops::Mul<f64> for Distance {
162    type Output = CoordResult<Self>;
163
164    fn mul(self, factor: f64) -> Self::Output {
165        Self::from_parsecs(self.parsecs * factor)
166    }
167}
168
169impl std::ops::Div<f64> for Distance {
170    type Output = CoordResult<Self>;
171
172    fn div(self, divisor: f64) -> Self::Output {
173        Self::from_parsecs(self.parsecs / divisor)
174    }
175}
176
177impl PartialOrd for Distance {
178    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
179        self.parsecs.partial_cmp(&other.parsecs)
180    }
181}
182
183impl std::fmt::Display for Distance {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        if self.parsecs < 1e-3 {
186            write!(f, "{:.3} AU", self.au())
187        } else if self.parsecs < 1000.0 {
188            write!(f, "{:.3} pc", self.parsecs)
189        } else if self.parsecs < 1e6 {
190            write!(f, "{:.3} kpc", self.parsecs / 1000.0)
191        } else {
192            write!(f, "{:.3} Mpc", self.parsecs / 1e6)
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_distance_creation() {
203        let d1 = Distance::from_parsecs(10.0).unwrap();
204        assert_eq!(d1.parsecs(), 10.0);
205
206        let d2 = Distance::from_parallax_arcsec(0.1).unwrap();
207        assert_eq!(d2.parsecs(), 10.0);
208
209        assert!(Distance::from_parsecs(-1.0).is_err());
210        assert!(Distance::from_parsecs(0.0).is_err());
211        assert!(Distance::from_parallax_arcsec(0.0).is_err());
212    }
213
214    #[test]
215    fn test_from_light_years() {
216        let d = Distance::from_light_years(1.0).unwrap();
217        assert!((d.parsecs() - 0.3066013937).abs() < 1e-9);
218    }
219
220    #[test]
221    fn test_parallax_angle() {
222        let angle = Angle::from_arcseconds(0.1);
223        let d = Distance::from_parallax_angle(angle).unwrap();
224        assert!((d.parsecs() - 10.0).abs() < 1e-12);
225    }
226
227    #[test]
228    fn test_parallax_uncertainty_mas() {
229        let d = Distance::from_parsecs(100.0).unwrap();
230        let unc = d.parallax_uncertainty_mas(0.01);
231        assert!((unc - 0.1).abs() < 1e-6);
232    }
233
234    #[test]
235    fn test_partial_ord() {
236        let d1 = Distance::from_parsecs(10.0).unwrap();
237        let d2 = Distance::from_parsecs(20.0).unwrap();
238        assert!(d1 < d2);
239    }
240
241    #[test]
242    fn test_unit_conversions() {
243        let distance = Distance::from_parsecs(1.0).unwrap();
244
245        #[allow(clippy::excessive_precision)]
246        {
247            assert!((distance.light_years() - 3.261_563_776_9).abs() < 1e-9);
248            assert!((distance.au() - 206264.806_247_096).abs() < 1e-6);
249            assert!((distance.kilometers() - 3.085_677_581_491_367_3e13).abs() < 1e6);
250        }
251    }
252
253    #[test]
254    fn test_parallax_calculations() {
255        let proxima = Distance::from_parallax_arcsec(0.7687).unwrap();
256        assert!((proxima.parsecs() - 1.3009).abs() < 0.001);
257
258        let distance = Distance::from_parallax_milliarcsec(768.7).unwrap();
259        assert!((distance.parsecs() - 1.3009).abs() < 0.001);
260    }
261
262    #[test]
263    fn test_distance_modulus() {
264        let distance = Distance::from_parsecs(10.0).unwrap();
265        let dm = distance.distance_modulus();
266        assert!((dm - 0.0).abs() < 1e-12);
267
268        let recovered = Distance::from_distance_modulus(dm).unwrap();
269        assert!((recovered.parsecs() - 10.0).abs() < 1e-12);
270    }
271
272    #[test]
273    fn test_distance_scales() {
274        let galactic = Distance::from_parsecs(1000.0).unwrap();
275        assert!(galactic.is_galactic());
276        assert!(galactic.is_local_group());
277
278        let extragalactic = Distance::from_parsecs(10_000_000.0).unwrap();
279        assert!(!extragalactic.is_galactic());
280        assert!(!extragalactic.is_local_group());
281    }
282
283    #[test]
284    fn test_proper_motion_distance() {
285        let distance = Distance::from_parsecs(1.0).unwrap();
286
287        let linear_dist = distance.proper_motion_distance_au(1.0, 1.0);
288
289        assert!(linear_dist > 0.0);
290        assert!(linear_dist < 10.0);
291    }
292
293    #[test]
294    fn test_arithmetic_operations() {
295        let d1 = Distance::from_parsecs(10.0).unwrap();
296        let d2 = Distance::from_parsecs(5.0).unwrap();
297
298        let sum = (d1 + d2).unwrap();
299        assert_eq!(sum.parsecs(), 15.0);
300
301        let diff = (d1 - d2).unwrap();
302        assert_eq!(diff.parsecs(), 5.0);
303
304        let doubled = (d1 * 2.0).unwrap();
305        assert_eq!(doubled.parsecs(), 20.0);
306
307        let halved = (d1 / 2.0).unwrap();
308        assert_eq!(halved.parsecs(), 5.0);
309    }
310
311    #[test]
312    fn test_display() {
313        let close = Distance::from_au(1.0).unwrap();
314        assert!(close.to_string().contains("AU"));
315
316        let nearby = Distance::from_parsecs(10.0).unwrap();
317        assert!(nearby.to_string().contains("pc"));
318
319        let distant = Distance::from_parsecs(10000.0).unwrap();
320        assert!(distant.to_string().contains("kpc"));
321
322        let very_distant = Distance::from_parsecs(10_000_000.0).unwrap();
323        assert!(very_distant.to_string().contains("Mpc"));
324    }
325}