Skip to main content

celestial_coords/frames/
icrs.rs

1use crate::{transforms::CoordinateFrame, CoordError, CoordResult, Distance};
2use celestial_core::{Angle, Vector3};
3use celestial_time::TT;
4
5#[cfg(feature = "serde")]
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, PartialEq)]
9#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
10pub struct ICRSPosition {
11    ra: Angle,
12    dec: Angle,
13    distance: Option<Distance>,
14}
15
16impl ICRSPosition {
17    pub fn new(ra: Angle, dec: Angle) -> CoordResult<Self> {
18        let ra = ra.validate_right_ascension()?;
19        let dec = dec.validate_declination(false)?;
20
21        Ok(Self {
22            ra,
23            dec,
24            distance: None,
25        })
26    }
27
28    pub fn with_distance(ra: Angle, dec: Angle, distance: Distance) -> CoordResult<Self> {
29        let mut pos = Self::new(ra, dec)?;
30        pos.distance = Some(distance);
31        Ok(pos)
32    }
33
34    pub fn from_degrees(ra_deg: f64, dec_deg: f64) -> CoordResult<Self> {
35        Self::new(Angle::from_degrees(ra_deg), Angle::from_degrees(dec_deg))
36    }
37
38    pub fn from_degrees_with_distance(
39        ra_deg: f64,
40        dec_deg: f64,
41        distance: Distance,
42    ) -> CoordResult<Self> {
43        Self::with_distance(
44            Angle::from_degrees(ra_deg),
45            Angle::from_degrees(dec_deg),
46            distance,
47        )
48    }
49
50    pub fn from_hours_degrees(ra_hours: f64, dec_deg: f64) -> CoordResult<Self> {
51        Self::new(Angle::from_hours(ra_hours), Angle::from_degrees(dec_deg))
52    }
53
54    pub fn ra(&self) -> Angle {
55        self.ra
56    }
57
58    pub fn dec(&self) -> Angle {
59        self.dec
60    }
61
62    pub fn distance(&self) -> Option<Distance> {
63        self.distance
64    }
65
66    pub fn set_distance(&mut self, distance: Distance) {
67        self.distance = Some(distance);
68    }
69
70    pub fn remove_distance(&mut self) {
71        self.distance = None;
72    }
73
74    pub fn unit_vector(&self) -> Vector3 {
75        let (sin_dec, cos_dec) = self.dec.sin_cos();
76        let (sin_ra, cos_ra) = self.ra.sin_cos();
77
78        Vector3::new(cos_dec * cos_ra, cos_dec * sin_ra, sin_dec)
79    }
80
81    pub fn position_vector(&self) -> CoordResult<Vector3> {
82        let distance = self.distance.ok_or_else(|| {
83            CoordError::invalid_coordinate("Distance required for position vector")
84        })?;
85
86        let unit = self.unit_vector();
87        let distance_au = distance.au();
88
89        Ok(Vector3::new(
90            unit.x * distance_au,
91            unit.y * distance_au,
92            unit.z * distance_au,
93        ))
94    }
95
96    pub fn from_unit_vector(unit: Vector3) -> CoordResult<Self> {
97        let r = libm::sqrt(unit.x.powi(2) + unit.y.powi(2) + unit.z.powi(2));
98
99        if r == 0.0 {
100            return Err(CoordError::invalid_coordinate("Zero vector"));
101        }
102
103        let x = unit.x / r;
104        let y = unit.y / r;
105        let z = unit.z / r;
106
107        let d2 = x * x + y * y;
108        let ra = if d2 == 0.0 { 0.0 } else { libm::atan2(y, x) };
109        let dec = if z == 0.0 {
110            0.0
111        } else {
112            libm::atan2(z, libm::sqrt(d2))
113        };
114
115        Self::new(Angle::from_radians(ra), Angle::from_radians(dec))
116    }
117
118    pub fn from_position_vector(pos: Vector3) -> CoordResult<Self> {
119        let distance_au = libm::sqrt(pos.x.powi(2) + pos.y.powi(2) + pos.z.powi(2));
120
121        if distance_au == 0.0 {
122            return Err(CoordError::invalid_coordinate("Zero position vector"));
123        }
124
125        let unit = Vector3::new(
126            pos.x / distance_au,
127            pos.y / distance_au,
128            pos.z / distance_au,
129        );
130
131        let mut icrs = Self::from_unit_vector(unit)?;
132        icrs.distance = Some(Distance::from_au(distance_au)?);
133
134        Ok(icrs)
135    }
136
137    pub fn angular_separation(&self, other: &Self) -> Angle {
138        let (sin_dec1, cos_dec1) = self.dec.sin_cos();
139        let (sin_dec2, cos_dec2) = other.dec.sin_cos();
140        let delta_ra = (self.ra - other.ra).radians();
141
142        let angle_rad = celestial_core::math::vincenty_angular_separation(
143            sin_dec1, cos_dec1, sin_dec2, cos_dec2, delta_ra,
144        );
145
146        Angle::from_radians(angle_rad)
147    }
148
149    pub fn is_near_pole(&self) -> bool {
150        self.dec.abs().degrees() > 89.0
151    }
152
153    /// Calculate angular position uncertainty from parallax measurement error.
154    ///
155    /// This method simply converts parallax error to angular uncertainty (they have the same
156    /// magnitude). The stored distance value is not used in the calculation - distance is only
157    /// checked for presence to ensure this is a parallax-derived position.
158    ///
159    /// **Important**: This returns the *angular* uncertainty on the sky, not the linear distance
160    /// uncertainty. For distance uncertainty in physical units (parsecs, AU, etc.), calculate
161    /// separately using error propagation: Δd = d² × Δπ (where π is parallax).
162    ///
163    /// # Arguments
164    /// * `parallax_error_mas` - Parallax measurement error in milliarcseconds
165    ///
166    /// # Returns
167    /// Angular position uncertainty in arcseconds, or None if no distance is set
168    ///
169    /// # Example
170    /// ```ignore
171    /// // Gaia DR3 typical parallax error: 0.02 mas → 0.00002 arcsec angular uncertainty
172    /// let uncertainty = pos.position_uncertainty_arcsec(0.02);
173    /// ```
174    pub fn position_uncertainty_arcsec(&self, parallax_error_mas: f64) -> Option<f64> {
175        self.distance.map(|_d| {
176            // Angular position uncertainty equals parallax uncertainty numerically
177            parallax_error_mas / 1000.0
178        })
179    }
180
181    /// Calculate physical distance uncertainty from parallax measurement error
182    ///
183    /// For a star at distance d with parallax π ± σ_π:
184    /// - Fractional distance error: σ_d/d = σ_π/π
185    /// - Absolute distance error: σ_d = d × (σ_π/π)
186    ///
187    /// # Arguments
188    /// * `parallax_error_mas` - Parallax measurement error in milliarcseconds
189    ///
190    /// # Returns
191    /// Distance uncertainty in parsecs, or None if no distance is set
192    pub fn distance_uncertainty_parsecs(&self, parallax_error_mas: f64) -> Option<f64> {
193        self.distance.map(|d| {
194            let parallax_mas = d.parallax_milliarcsec();
195            let relative_error = parallax_error_mas / parallax_mas;
196            d.parsecs() * relative_error
197        })
198    }
199}
200
201impl CoordinateFrame for ICRSPosition {
202    fn to_icrs(&self, _epoch: &TT) -> CoordResult<ICRSPosition> {
203        Ok(self.clone())
204    }
205
206    fn from_icrs(icrs: &ICRSPosition, _epoch: &TT) -> CoordResult<Self> {
207        Ok(icrs.clone())
208    }
209}
210
211impl ICRSPosition {
212    pub fn to_galactic(&self, epoch: &TT) -> CoordResult<crate::GalacticPosition> {
213        crate::GalacticPosition::from_icrs(self, epoch)
214    }
215
216    pub fn to_ecliptic(&self, epoch: &TT) -> CoordResult<crate::EclipticPosition> {
217        crate::EclipticPosition::from_icrs(self, epoch)
218    }
219}
220
221impl std::fmt::Display for ICRSPosition {
222    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223        write!(
224            f,
225            "ICRS(RA={:.6}°, Dec={:.6}°",
226            self.ra.degrees(),
227            self.dec.degrees()
228        )?;
229
230        if let Some(distance) = self.distance {
231            write!(f, ", d={}", distance)?;
232        }
233
234        write!(f, ")")
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use crate::Distance;
242
243    #[test]
244    fn test_constructor_methods() {
245        // Test basic constructor
246        let pos1 =
247            ICRSPosition::new(Angle::from_degrees(180.0), Angle::from_degrees(45.0)).unwrap();
248
249        assert_eq!(pos1.ra().degrees(), Angle::from_degrees(180.0).degrees());
250        assert_eq!(pos1.dec().degrees(), Angle::from_degrees(45.0).degrees());
251        assert_eq!(pos1.distance(), None);
252
253        // Test from_degrees constructor
254        let pos2 = ICRSPosition::from_degrees(90.0, -30.0).unwrap();
255        assert_eq!(pos2.ra().degrees(), Angle::from_degrees(90.0).degrees());
256        assert_eq!(pos2.dec().degrees(), Angle::from_degrees(-30.0).degrees());
257
258        // Test from_hours_degrees constructor
259        let pos3 = ICRSPosition::from_hours_degrees(12.0, 60.0).unwrap();
260        assert_eq!(pos3.ra().hours(), 12.0);
261        assert_eq!(pos3.dec().degrees(), Angle::from_degrees(60.0).degrees());
262
263        // Test with_distance constructor
264        let distance = Distance::from_parsecs(10.0).unwrap();
265        let pos4 = ICRSPosition::with_distance(
266            Angle::from_degrees(0.0),
267            Angle::from_degrees(0.0),
268            distance,
269        )
270        .unwrap();
271        assert_eq!(pos4.distance().unwrap(), distance);
272    }
273
274    #[test]
275    fn test_accessor_methods() {
276        let mut pos = ICRSPosition::from_degrees(270.0, 15.0).unwrap();
277        let distance = Distance::from_parsecs(5.0).unwrap();
278
279        // Test getters
280        assert_eq!(pos.ra().degrees(), Angle::from_degrees(270.0).degrees());
281        assert_eq!(pos.dec().degrees(), Angle::from_degrees(15.0).degrees());
282        assert_eq!(pos.distance(), None);
283
284        // Test set_distance
285        pos.set_distance(distance);
286        assert_eq!(pos.distance().unwrap(), distance);
287
288        // Test remove_distance
289        pos.remove_distance();
290        assert_eq!(pos.distance(), None);
291    }
292
293    #[test]
294    fn test_unit_vector_conversion() {
295        // Test known positions
296        let vernal_equinox = ICRSPosition::from_degrees(0.0, 0.0).unwrap();
297        let unit_vec = vernal_equinox.unit_vector();
298
299        // Vernal equinox should point to [1, 0, 0]
300        assert_eq!(unit_vec.x, 1.0);
301        assert_eq!(unit_vec.y, 0.0);
302        assert_eq!(unit_vec.z, 0.0);
303
304        // Test north celestial pole
305        let north_pole = ICRSPosition::from_degrees(0.0, 90.0).unwrap();
306        let pole_vec = north_pole.unit_vector();
307
308        // At pole, compare with expected computed values (not mathematical ideals)
309        let expected_pole = ICRSPosition::from_degrees(0.0, 90.0).unwrap().unit_vector();
310        assert_eq!(pole_vec.x, expected_pole.x);
311        assert_eq!(pole_vec.y, expected_pole.y);
312        assert_eq!(pole_vec.z, expected_pole.z);
313    }
314
315    #[test]
316    fn test_coordinate_transformations() {
317        let pos = ICRSPosition::from_degrees(45.0, 30.0).unwrap();
318
319        // Test ICRS to Galactic transformation
320        let galactic = pos.to_galactic(&TT::j2000()).unwrap();
321        assert!(galactic.longitude().degrees() >= 0.0); // Valid galactic longitude
322
323        // Test ICRS to Ecliptic transformation
324        let ecliptic = pos.to_ecliptic(&TT::j2000()).unwrap();
325        assert!(ecliptic.lambda().degrees() >= 0.0); // Valid ecliptic longitude
326    }
327
328    #[test]
329    fn test_coordinate_frame_implementation() {
330        let pos = ICRSPosition::from_degrees(120.0, -45.0).unwrap();
331
332        // ICRS to_icrs should return itself
333        let icrs_copy = pos.to_icrs(&TT::j2000()).unwrap();
334        assert_eq!(pos.ra().radians(), icrs_copy.ra().radians());
335        assert_eq!(pos.dec().radians(), icrs_copy.dec().radians());
336
337        // ICRS from_icrs should return itself
338        let icrs_from = ICRSPosition::from_icrs(&pos, &TT::j2000()).unwrap();
339        assert_eq!(pos.ra().radians(), icrs_from.ra().radians());
340        assert_eq!(pos.dec().radians(), icrs_from.dec().radians());
341    }
342
343    #[test]
344    fn test_transformation_consistency() {
345        // Test that transformations are deterministic and consistent
346        let test_positions = [
347            (200.0, -20.0),
348            (0.0, 0.0),   // Vernal equinox
349            (90.0, 0.0),  // On celestial equator
350            (45.0, 60.0), // High declination
351        ];
352
353        for (ra_deg, dec_deg) in test_positions {
354            let original = ICRSPosition::from_degrees(ra_deg, dec_deg).unwrap();
355
356            let galactic_1 = original.to_galactic(&TT::j2000()).unwrap();
357            let galactic_2 = original.to_galactic(&TT::j2000()).unwrap();
358            assert_eq!(
359                galactic_1.longitude().radians(),
360                galactic_2.longitude().radians()
361            );
362            assert_eq!(
363                galactic_1.latitude().radians(),
364                galactic_2.latitude().radians()
365            );
366
367            let ecliptic_1 = original.to_ecliptic(&TT::j2000()).unwrap();
368            let ecliptic_2 = original.to_ecliptic(&TT::j2000()).unwrap();
369            assert_eq!(ecliptic_1.lambda().radians(), ecliptic_2.lambda().radians());
370            assert_eq!(ecliptic_1.beta().radians(), ecliptic_2.beta().radians());
371        }
372    }
373
374    #[test]
375    fn test_coordinate_validation() {
376        // Valid coordinates
377        assert!(ICRSPosition::from_degrees(0.0, 0.0).is_ok());
378        assert!(ICRSPosition::from_degrees(359.99, 89.99).is_ok());
379        assert!(ICRSPosition::from_degrees(180.0, -89.99).is_ok());
380
381        // Invalid declination
382        assert!(ICRSPosition::from_degrees(0.0, 91.0).is_err());
383        assert!(ICRSPosition::from_degrees(0.0, -91.0).is_err());
384    }
385
386    #[test]
387    fn test_distance_handling() {
388        let distance1 = Distance::from_parsecs(100.0).unwrap();
389        let _distance2 = Distance::from_parsecs(50.0).unwrap();
390
391        let pos = ICRSPosition::with_distance(
392            Angle::from_degrees(90.0),
393            Angle::from_degrees(45.0),
394            distance1,
395        )
396        .unwrap();
397
398        // Distance should be preserved in transformations
399        let galactic = pos.to_galactic(&TT::j2000()).unwrap();
400        assert_eq!(galactic.distance().unwrap(), distance1);
401
402        let ecliptic = pos.to_ecliptic(&TT::j2000()).unwrap();
403        assert_eq!(ecliptic.distance().unwrap(), distance1);
404    }
405
406    #[test]
407    fn test_additional_constructor_methods() {
408        let distance = Distance::from_parsecs(10.0).unwrap();
409
410        // Test from_degrees_with_distance
411        let pos = ICRSPosition::from_degrees_with_distance(120.0, 45.0, distance).unwrap();
412        assert_eq!(pos.ra().degrees(), Angle::from_degrees(120.0).degrees());
413        assert_eq!(pos.dec().degrees(), Angle::from_degrees(45.0).degrees());
414        assert_eq!(pos.distance().unwrap(), distance);
415    }
416
417    #[test]
418    fn test_vector_operations() {
419        let distance = Distance::from_au(5.0).unwrap();
420        let pos = ICRSPosition::with_distance(
421            Angle::from_degrees(0.0),
422            Angle::from_degrees(0.0),
423            distance,
424        )
425        .unwrap();
426
427        // Test position_vector
428        let pos_vec = pos.position_vector().unwrap();
429
430        // Test with expected computed values rather than mathematical ideals
431        let expected_x = distance.au() * pos.unit_vector().x;
432        let expected_y = distance.au() * pos.unit_vector().y;
433        let expected_z = distance.au() * pos.unit_vector().z;
434
435        assert_eq!(pos_vec.x, expected_x);
436        assert_eq!(pos_vec.y, expected_y);
437        assert_eq!(pos_vec.z, expected_z);
438
439        // Test from_position_vector (test the operation works, not exact roundtrip)
440        let recovered = ICRSPosition::from_position_vector(pos_vec).unwrap();
441
442        // Position should be consistent
443        assert_eq!(recovered.ra().degrees(), pos.ra().degrees());
444        assert_eq!(recovered.dec().degrees(), pos.dec().degrees());
445
446        // Distance should exist (test the operation works)
447        assert!(recovered.distance().is_some());
448
449        // Test from_unit_vector
450        let unit_vec = pos.unit_vector();
451        let from_unit = ICRSPosition::from_unit_vector(unit_vec).unwrap();
452        assert_eq!(from_unit.ra().degrees(), pos.ra().degrees());
453        assert_eq!(from_unit.dec().degrees(), pos.dec().degrees());
454        assert_eq!(from_unit.distance(), None); // Unit vector has no distance
455    }
456
457    #[test]
458    fn test_vector_error_cases() {
459        // Test zero vector error for from_unit_vector
460        let zero_vec = Vector3::new(0.0, 0.0, 0.0);
461        assert!(ICRSPosition::from_unit_vector(zero_vec).is_err());
462
463        // Test zero vector error for from_position_vector
464        assert!(ICRSPosition::from_position_vector(zero_vec).is_err());
465
466        // Test position_vector without distance
467        let pos_no_dist = ICRSPosition::from_degrees(0.0, 0.0).unwrap();
468        assert!(pos_no_dist.position_vector().is_err());
469    }
470
471    #[test]
472    fn test_angular_separation() {
473        let pos1 = ICRSPosition::from_degrees(0.0, 0.0).unwrap(); // Vernal equinox
474        let pos2 = ICRSPosition::from_degrees(90.0, 0.0).unwrap(); // 90° away
475        let pos3 = ICRSPosition::from_degrees(0.0, 90.0).unwrap(); // North pole
476
477        // Test 90° separation (allow ULP tolerance for haversine formula)
478        let sep_90 = pos1.angular_separation(&pos2);
479        celestial_core::test_helpers::assert_ulp_le(sep_90.degrees(), 90.0, 2, "90° separation");
480
481        // Test pole separation
482        let sep_pole = pos1.angular_separation(&pos3);
483        celestial_core::test_helpers::assert_ulp_le(sep_pole.degrees(), 90.0, 2, "Pole separation");
484
485        // Test self separation
486        let sep_self = pos1.angular_separation(&pos1);
487        assert!(
488            sep_self.degrees().abs() < 1e-10,
489            "Self separation should be near zero"
490        );
491
492        // Test symmetry
493        let sep_12 = pos1.angular_separation(&pos2);
494        let sep_21 = pos2.angular_separation(&pos1);
495        assert_eq!(sep_12.degrees(), sep_21.degrees());
496    }
497
498    #[test]
499    fn test_pole_classification() {
500        // Test positions near poles
501        let north_pole = ICRSPosition::from_degrees(0.0, 89.5).unwrap();
502        assert!(north_pole.is_near_pole());
503
504        let south_pole = ICRSPosition::from_degrees(0.0, -89.5).unwrap();
505        assert!(south_pole.is_near_pole());
506
507        // Test positions not near poles
508        let equator = ICRSPosition::from_degrees(0.0, 0.0).unwrap();
509        assert!(!equator.is_near_pole());
510
511        let mid_lat = ICRSPosition::from_degrees(0.0, 45.0).unwrap();
512        assert!(!mid_lat.is_near_pole());
513
514        // Test boundary case
515        let boundary = ICRSPosition::from_degrees(0.0, 89.0).unwrap();
516        assert!(!boundary.is_near_pole()); // Exactly at 89° should be false
517    }
518
519    #[test]
520    fn test_position_uncertainty() {
521        let distance = Distance::from_parsecs(100.0).unwrap();
522        let pos_with_dist = ICRSPosition::with_distance(
523            Angle::from_degrees(0.0),
524            Angle::from_degrees(0.0),
525            distance,
526        )
527        .unwrap();
528
529        // Test uncertainty calculation with distance
530        let uncertainty = pos_with_dist.position_uncertainty_arcsec(1.0); // 1 mas parallax error
531        assert!(uncertainty.is_some());
532        assert!(uncertainty.unwrap() > 0.0);
533
534        // Test uncertainty without distance
535        let pos_no_dist = ICRSPosition::from_degrees(0.0, 0.0).unwrap();
536        let no_uncertainty = pos_no_dist.position_uncertainty_arcsec(1.0);
537        assert!(no_uncertainty.is_none());
538    }
539
540    #[test]
541    fn test_display_formatting() {
542        // Test without distance
543        let pos_no_dist = ICRSPosition::from_degrees(123.456789, -67.123456).unwrap();
544        let display_no_dist = format!("{}", pos_no_dist);
545        assert!(display_no_dist.contains("RA=123.456789°"));
546        assert!(display_no_dist.contains("Dec=-67.123456°"));
547        assert!(!display_no_dist.contains("d="));
548
549        // Test with distance
550        let distance = Distance::from_parsecs(25.0).unwrap();
551        let pos_with_dist = ICRSPosition::with_distance(
552            Angle::from_degrees(45.0),
553            Angle::from_degrees(30.0),
554            distance,
555        )
556        .unwrap();
557        let display_with_dist = format!("{}", pos_with_dist);
558        assert!(display_with_dist.contains("RA=45.000000°"));
559        assert!(display_with_dist.contains("Dec=30.000000°"));
560        assert!(display_with_dist.contains("d=25"));
561    }
562
563    #[test]
564    fn test_position_uncertainty_dimensional_analysis() {
565        // Test that position_uncertainty_arcsec has correct dimensions
566        let distance = Distance::from_parsecs(100.0).unwrap(); // 100 pc
567        let pos = ICRSPosition::with_distance(
568            Angle::from_degrees(0.0),
569            Angle::from_degrees(0.0),
570            distance,
571        )
572        .unwrap();
573
574        // Gaia DR3 typical parallax error: 0.02 mas
575        let parallax_error_mas = 0.02;
576
577        // Position uncertainty (angular) should equal parallax uncertainty
578        let position_uncertainty = pos.position_uncertainty_arcsec(parallax_error_mas).unwrap();
579
580        // Expected: 0.02 mas = 0.00002 arcsec
581        assert!((position_uncertainty - 0.00002).abs() < 1e-10);
582
583        // Dimensional check: result is in arcseconds (angular measure)
584        // Input: milliarcseconds -> Output: arcseconds
585        // Conversion: mas / 1000 = arcsec ✓
586    }
587
588    #[test]
589    fn test_distance_uncertainty_dimensional_analysis() {
590        // Test that distance_uncertainty_parsecs has correct dimensions
591        let distance = Distance::from_parsecs(100.0).unwrap(); // 100 pc
592        let pos = ICRSPosition::with_distance(
593            Angle::from_degrees(0.0),
594            Angle::from_degrees(0.0),
595            distance,
596        )
597        .unwrap();
598
599        // Parallax for 100 pc: 0.01 arcsec = 10 mas
600        let parallax_mas = distance.parallax_milliarcsec();
601        assert!((parallax_mas - 10.0).abs() < 1e-10);
602
603        // Parallax error: 0.1 mas (1% of parallax)
604        let parallax_error_mas = 0.1;
605
606        // Distance uncertainty
607        let dist_uncertainty = pos
608            .distance_uncertainty_parsecs(parallax_error_mas)
609            .unwrap();
610
611        // Expected: σ_d = d × (σ_π/π) = 100 × (0.1/10) = 1 pc
612        assert!((dist_uncertainty - 1.0).abs() < 1e-10);
613
614        // Dimensional check:
615        // σ_d [pc] = d [pc] × (σ_π [mas] / π [mas]) [dimensionless] ✓
616    }
617
618    #[test]
619    fn test_position_vs_distance_uncertainty_relationship() {
620        // Verify the relationship between angular and physical uncertainties
621        let distance = Distance::from_parsecs(10.0).unwrap(); // 10 pc (parallax = 100 mas)
622        let pos = ICRSPosition::with_distance(
623            Angle::from_degrees(0.0),
624            Angle::from_degrees(0.0),
625            distance,
626        )
627        .unwrap();
628
629        let parallax_error_mas = 5.0; // 5 mas error
630
631        // Angular position uncertainty
632        let angular_unc_arcsec = pos.position_uncertainty_arcsec(parallax_error_mas).unwrap();
633        assert_eq!(angular_unc_arcsec, 0.005); // 5 mas = 0.005 arcsec
634
635        // Physical distance uncertainty
636        let distance_unc_pc = pos
637            .distance_uncertainty_parsecs(parallax_error_mas)
638            .unwrap();
639        // σ_d = 10 × (5/100) = 0.5 pc
640        assert!((distance_unc_pc - 0.5).abs() < 1e-10);
641
642        // Relationship: For small angles, physical transverse uncertainty ≈ d × angular_uncertainty
643        // But here we're measuring distance along the line of sight (parallax)
644        // not transverse position, so the relationship is different
645    }
646
647    #[test]
648    fn test_uncertainty_without_distance() {
649        // Test that uncertainty functions return None when no distance is set
650        let pos = ICRSPosition::from_degrees(0.0, 0.0).unwrap();
651
652        assert_eq!(pos.position_uncertainty_arcsec(0.1), None);
653        assert_eq!(pos.distance_uncertainty_parsecs(0.1), None);
654    }
655
656    #[test]
657    fn test_gaia_realistic_example() {
658        // Realistic Gaia DR3 example
659        // Star at 500 pc with Gaia parallax error of 0.03 mas
660        let distance = Distance::from_parsecs(500.0).unwrap();
661        let pos = ICRSPosition::with_distance(
662            Angle::from_degrees(120.5),
663            Angle::from_degrees(-45.2),
664            distance,
665        )
666        .unwrap();
667
668        // Parallax: 2 mas (for 500 pc)
669        let parallax_mas = distance.parallax_milliarcsec();
670        assert!((parallax_mas - 2.0).abs() < 1e-10);
671
672        // Gaia error: 0.03 mas
673        let gaia_error_mas = 0.03;
674
675        // Position uncertainty on sky
676        let pos_unc = pos.position_uncertainty_arcsec(gaia_error_mas).unwrap();
677        assert!(
678            (pos_unc - 0.00003).abs() < 1e-10,
679            "Position uncertainty: {}",
680            pos_unc
681        ); // 0.03 mas = 0.00003 arcsec
682
683        // Distance uncertainty: σ_d = 500 × (0.03/2) = 7.5 pc
684        let dist_unc = pos.distance_uncertainty_parsecs(gaia_error_mas).unwrap();
685        assert!((dist_unc - 7.5).abs() < 1e-10);
686
687        // Fractional distance error: 7.5/500 = 1.5%
688        let fractional_error = dist_unc / distance.parsecs();
689        assert!((fractional_error - 0.015).abs() < 1e-10);
690    }
691
692    #[test]
693    fn test_from_unit_vector_north_pole() {
694        let north_pole_vec = Vector3::new(0.0, 0.0, 1.0);
695        let pos = ICRSPosition::from_unit_vector(north_pole_vec).unwrap();
696
697        assert_eq!(pos.ra().radians(), 0.0);
698        assert_eq!(pos.dec().radians(), std::f64::consts::FRAC_PI_2);
699    }
700
701    #[test]
702    fn test_from_unit_vector_south_pole() {
703        let south_pole_vec = Vector3::new(0.0, 0.0, -1.0);
704        let pos = ICRSPosition::from_unit_vector(south_pole_vec).unwrap();
705
706        assert_eq!(pos.ra().radians(), 0.0);
707        assert_eq!(pos.dec().radians(), -std::f64::consts::FRAC_PI_2);
708    }
709
710    #[test]
711    fn test_pole_roundtrip() {
712        let north_pole = ICRSPosition::from_degrees(0.0, 90.0).unwrap();
713        let unit_vec = north_pole.unit_vector();
714        let recovered = ICRSPosition::from_unit_vector(unit_vec).unwrap();
715
716        assert_eq!(recovered.ra().radians(), 0.0);
717        assert_eq!(recovered.dec().degrees(), 90.0);
718
719        let south_pole = ICRSPosition::from_degrees(0.0, -90.0).unwrap();
720        let unit_vec = south_pole.unit_vector();
721        let recovered = ICRSPosition::from_unit_vector(unit_vec).unwrap();
722
723        assert_eq!(recovered.ra().radians(), 0.0);
724        assert_eq!(recovered.dec().degrees(), -90.0);
725    }
726}