Skip to main content

celestial_core/cio/
coordinates.rs

1//! CIP (Celestial Intermediate Pole) coordinates for the IAU 2000/2006 precession-nutation model.
2//!
3//! The Celestial Intermediate Pole defines the axis around which Earth rotates in the
4//! Celestial Intermediate Reference System (CIRS). Its position relative to the GCRS
5//! is described by two small angles X and Y, which encode the combined effects of
6//! precession and nutation.
7//!
8//! # When to use this
9//!
10//! CIP coordinates are needed when:
11//! - Converting between GCRS (geocentric celestial) and CIRS (intermediate) frames
12//! - Computing Earth Rotation Angle for sidereal time
13//! - Implementing the full IAU 2000/2006 transformation chain
14//!
15//! For most high-precision applications, you'll extract CIP coordinates from a
16//! precession-nutation matrix rather than computing them directly.
17//!
18//! # Coordinate ranges
19//!
20//! X and Y are stored in radians. Current values are on the order of 10^-7 radians
21//! (~0.02 arcseconds). The validation threshold of 0.2 radians (~11 degrees) catches
22//! obviously invalid matrices while allowing for long-term secular drift.
23
24use crate::errors::{AstroError, AstroResult};
25use crate::matrix::RotationMatrix3;
26
27/// Position of the Celestial Intermediate Pole in the GCRS.
28///
29/// X and Y are the direction cosines of the CIP unit vector projected onto
30/// the GCRS equatorial plane. They're extracted from elements [2][0] and [2][1]
31/// of the NPB (nutation-precession-bias) matrix.
32///
33/// Units are radians internally. Use [`to_arcseconds`](Self::to_arcseconds) for display.
34#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct CipCoordinates {
36    pub x: f64,
37    pub y: f64,
38}
39
40impl CipCoordinates {
41    /// Creates CIP coordinates from X and Y values in radians.
42    ///
43    /// No validation is performed. For coordinates extracted from a real
44    /// precession-nutation matrix, use [`from_npb_matrix`](Self::from_npb_matrix).
45    pub fn new(x: f64, y: f64) -> Self {
46        Self { x, y }
47    }
48
49    /// Extracts CIP coordinates from a nutation-precession-bias matrix.
50    ///
51    /// The NPB matrix transforms GCRS to mean-of-date coordinates. X and Y
52    /// are taken from the third row (elements [2][0] and [2][1]), which
53    /// represents the CIP direction in GCRS.
54    ///
55    /// # Errors
56    ///
57    /// Returns an error if X or Y exceeds 0.2 radians, indicating the matrix
58    /// doesn't represent a valid Earth orientation.
59    pub fn from_npb_matrix(npb_matrix: &RotationMatrix3) -> AstroResult<Self> {
60        let matrix = npb_matrix.elements();
61
62        let x = matrix[2][0];
63        let y = matrix[2][1];
64
65        if x.abs() > 0.2 || y.abs() > 0.2 {
66            return Err(AstroError::math_error(
67                "CIP coordinate extraction",
68                crate::errors::MathErrorKind::InvalidInput,
69                &format!(
70                    "CIP coordinates out of reasonable range: X={:.6}, Y={:.6}",
71                    x, y
72                ),
73            ));
74        }
75
76        Ok(Self { x, y })
77    }
78
79    /// Distance of the CIP from the GCRS pole, in radians.
80    ///
81    /// This is sqrt(X^2 + Y^2), useful for gauging the total pole offset.
82    pub fn magnitude(&self) -> f64 {
83        libm::sqrt(self.x * self.x + self.y * self.y)
84    }
85
86    /// Returns (X, Y) converted to degrees.
87    pub fn to_degrees(&self) -> (f64, f64) {
88        (
89            self.x * crate::constants::RAD_TO_DEG,
90            self.y * crate::constants::RAD_TO_DEG,
91        )
92    }
93
94    /// Returns (X, Y) converted to arcseconds.
95    ///
96    /// Arcseconds are the conventional unit for reporting CIP coordinates
97    /// in publications and IERS bulletins.
98    pub fn to_arcseconds(&self) -> (f64, f64) {
99        (
100            self.x * crate::constants::RAD_TO_DEG * 3600.0,
101            self.y * crate::constants::RAD_TO_DEG * 3600.0,
102        )
103    }
104}
105
106impl std::fmt::Display for CipCoordinates {
107    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108        let (x_as, y_as) = self.to_arcseconds();
109        write!(f, "CIP(X={:.3}\", Y={:.3}\")", x_as, y_as)
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::matrix::RotationMatrix3;
117
118    #[test]
119    fn test_cip_coordinates_creation() {
120        let cip = CipCoordinates::new(1e-6, -2e-6);
121        assert_eq!(cip.x, 1e-6);
122        assert_eq!(cip.y, -2e-6);
123    }
124
125    #[test]
126    fn test_cip_magnitude() {
127        let cip = CipCoordinates::new(3e-6, 4e-6);
128        assert!((cip.magnitude() - 5e-6).abs() < 1e-12);
129    }
130
131    #[test]
132    fn test_cip_display() {
133        let cip = CipCoordinates::new(1e-6, -1e-6);
134        let display = format!("{}", cip);
135        assert!(display.contains("CIP"));
136        assert!(display.contains("0.206")); // ~1e-6 radians in arcseconds
137    }
138
139    #[test]
140    fn test_cip_from_identity_matrix() {
141        // Identity matrix should give CIP coordinates of exactly zero
142        let identity = RotationMatrix3::identity();
143        let cip = CipCoordinates::from_npb_matrix(&identity).unwrap();
144
145        // For identity matrix, third column is [0, 0, 1]
146        // So X = 0, Y = 0 exactly
147        assert_eq!(cip.x, 0.0);
148        assert_eq!(cip.y, 0.0);
149    }
150
151    #[test]
152    fn test_cip_validation_error() {
153        // Create a matrix with unreasonably large CIP coordinates in third row
154        // CIP X,Y are extracted from matrix[2][0] and matrix[2][1]
155        // This should trigger the validation error (threshold is 0.2 radians ~= 11.5 degrees)
156        let invalid_matrix = RotationMatrix3::from_array([
157            [1.0, 0.0, 0.0],
158            [0.0, 1.0, 0.0],
159            [0.25, 0.05, 1.0], // X = 0.25 (too large - exceeds 0.2 rad threshold)
160        ]);
161
162        let result = CipCoordinates::from_npb_matrix(&invalid_matrix);
163
164        // Should return an error due to coordinates being out of reasonable range
165        assert!(result.is_err());
166
167        let error_message = format!("{}", result.unwrap_err());
168        assert!(error_message.contains("CIP coordinates out of reasonable range"));
169    }
170
171    #[test]
172    fn test_cip_reasonable_values() {
173        // Test that reasonable CIP coordinate values work correctly
174        let reasonable_cip = CipCoordinates::new(1e-6, 1e-6);
175
176        // These are the exact computed values for our test case
177        assert_eq!(reasonable_cip.x, 1e-6);
178        assert_eq!(reasonable_cip.y, 1e-6);
179        assert_eq!(reasonable_cip.magnitude(), libm::sqrt(2e-12_f64)); // sqrt(x² + y²)
180    }
181}