Skip to main content

celestial_coords/frames/
cirs.rs

1use crate::{
2    aberration::{
3        apply_aberration, apply_light_deflection, compute_earth_state, remove_aberration,
4        remove_light_deflection,
5    },
6    frames::{ICRSPosition, TIRSPosition},
7    transforms::CoordinateFrame,
8    CoordError, CoordResult, Distance,
9};
10use celestial_core::{matrix::RotationMatrix3, Angle, Vector3};
11use celestial_time::{
12    scales::conversions::ToUT1WithDeltaT, sidereal::GAST, transforms::NutationCalculator, TT,
13};
14
15#[cfg(feature = "serde")]
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, PartialEq)]
19#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
20pub struct CIRSPosition {
21    ra: Angle,
22    dec: Angle,
23    epoch: TT,
24    distance: Option<Distance>,
25}
26
27impl CIRSPosition {
28    pub fn new(ra: Angle, dec: Angle, epoch: TT) -> CoordResult<Self> {
29        let ra = ra.validate_right_ascension()?;
30        let dec = dec.validate_declination(false)?;
31
32        Ok(Self {
33            ra,
34            dec,
35            epoch,
36            distance: None,
37        })
38    }
39
40    pub fn with_distance(
41        ra: Angle,
42        dec: Angle,
43        epoch: TT,
44        distance: Distance,
45    ) -> CoordResult<Self> {
46        let mut pos = Self::new(ra, dec, epoch)?;
47        pos.distance = Some(distance);
48        Ok(pos)
49    }
50
51    pub fn from_degrees(ra_deg: f64, dec_deg: f64, epoch: TT) -> CoordResult<Self> {
52        Self::new(
53            Angle::from_degrees(ra_deg),
54            Angle::from_degrees(dec_deg),
55            epoch,
56        )
57    }
58
59    pub fn ra(&self) -> Angle {
60        self.ra
61    }
62
63    pub fn dec(&self) -> Angle {
64        self.dec
65    }
66
67    pub fn epoch(&self) -> TT {
68        self.epoch
69    }
70
71    pub fn distance(&self) -> Option<Distance> {
72        self.distance
73    }
74
75    pub fn set_distance(&mut self, distance: Distance) {
76        self.distance = Some(distance);
77    }
78
79    pub fn remove_distance(&mut self) {
80        self.distance = None;
81    }
82
83    pub fn unit_vector(&self) -> Vector3 {
84        let (sin_dec, cos_dec) = self.dec.sin_cos();
85        let (sin_ra, cos_ra) = self.ra.sin_cos();
86
87        Vector3::new(cos_dec * cos_ra, cos_dec * sin_ra, sin_dec)
88    }
89
90    pub fn from_unit_vector(unit: Vector3, epoch: TT) -> CoordResult<Self> {
91        let r = libm::sqrt(unit.x.powi(2) + unit.y.powi(2) + unit.z.powi(2));
92
93        if r == 0.0 {
94            return Err(CoordError::invalid_coordinate("Zero vector"));
95        }
96
97        let x = unit.x / r;
98        let y = unit.y / r;
99        let z = unit.z / r;
100
101        let d2 = x * x + y * y;
102
103        let ra = if d2 == 0.0 { 0.0 } else { libm::atan2(y, x) };
104        let dec = if z == 0.0 {
105            0.0
106        } else {
107            libm::atan2(z, libm::sqrt(d2))
108        };
109
110        Self::new(Angle::from_radians(ra), Angle::from_radians(dec), epoch)
111    }
112
113    fn icrs_to_cirs_matrix(epoch: &TT) -> CoordResult<RotationMatrix3> {
114        Self::icrs_to_cirs_matrix_with_eop(epoch, None)
115    }
116
117    fn icrs_to_cirs_matrix_with_eop(
118        epoch: &TT,
119        eop: Option<&crate::eop::EopParameters>,
120    ) -> CoordResult<RotationMatrix3> {
121        let jd = epoch.to_julian_date();
122        let t = celestial_core::utils::jd_to_centuries(jd.jd1(), jd.jd2());
123
124        let nutation = epoch
125            .nutation_iau2006a()
126            .map_err(|e| CoordError::CoreError {
127                message: format!("Nutation calculation failed: {}", e),
128            })?;
129
130        let precession_calc = celestial_core::precession::PrecessionIAU2006::new();
131        let npb_matrix = precession_calc.npb_matrix_iau2006a(
132            t,
133            nutation.nutation_longitude(),
134            nutation.nutation_obliquity(),
135        );
136
137        let cio_solution = celestial_core::CioSolution::calculate(&npb_matrix, t).map_err(|e| {
138            CoordError::CoreError {
139                message: format!("CIO calculation failed: {}", e),
140            }
141        })?;
142
143        let (x, y) = match eop {
144            Some(eop) => (
145                eop.corrected_cip_x(cio_solution.cip.x),
146                eop.corrected_cip_y(cio_solution.cip.y),
147            ),
148            None => (cio_solution.cip.x, cio_solution.cip.y),
149        };
150
151        Ok(celestial_core::gcrs_to_cirs_matrix(x, y, cio_solution.s))
152    }
153
154    /// Transforms this CIRS position to Terrestrial Intermediate Reference System (TIRS).
155    ///
156    /// The position vector is scaled by distance (in AU) before transformation. If no distance
157    /// is set, a unit vector is used. The resulting TIRS vector will have the same units (AU or
158    /// dimensionless) as the input.
159    pub fn to_tirs(&self, eop: &crate::eop::EopParameters) -> CoordResult<TIRSPosition> {
160        let cirs_vec = if let Some(distance) = self.distance {
161            self.unit_vector() * distance.au()
162        } else {
163            self.unit_vector()
164        };
165
166        TIRSPosition::from_cirs(cirs_vec, &self.epoch, eop)
167    }
168
169    pub fn to_hour_angle(
170        &self,
171        observer: &celestial_core::Location,
172        delta_t: f64,
173    ) -> CoordResult<crate::frames::HourAnglePosition> {
174        let ut1 = self.epoch.to_ut1_with_delta_t(delta_t)?;
175        let gast = GAST::from_ut1_and_tt(&ut1, &self.epoch)?;
176
177        let last = gast.to_last(observer);
178
179        let ha_rad = last.radians() - self.ra.radians();
180        let ha = celestial_core::angle::wrap_pm_pi(ha_rad);
181
182        crate::frames::HourAnglePosition::new(
183            Angle::from_radians(ha),
184            self.dec,
185            *observer,
186            self.epoch,
187        )
188    }
189}
190
191impl CoordinateFrame for CIRSPosition {
192    fn to_icrs(&self, _epoch: &TT) -> CoordResult<ICRSPosition> {
193        let icrs_to_cirs = Self::icrs_to_cirs_matrix(&self.epoch)?;
194        let cirs_to_icrs = icrs_to_cirs.transpose();
195
196        // Step 1: Remove precession-nutation
197        let cirs_vec = self.unit_vector();
198        let apparent_vec = cirs_to_icrs * cirs_vec;
199
200        let earth_state = compute_earth_state(&self.epoch)?;
201        let sun_earth_dist = earth_state.heliocentric_position.magnitude();
202
203        // Step 2: Remove stellar aberration
204        let deflected_vec = remove_aberration(
205            apparent_vec,
206            earth_state.barycentric_velocity,
207            sun_earth_dist,
208        );
209
210        // Sun to observer unit vector (heliocentric position normalized)
211        let sun_to_earth = Vector3::new(
212            earth_state.heliocentric_position.x / sun_earth_dist,
213            earth_state.heliocentric_position.y / sun_earth_dist,
214            earth_state.heliocentric_position.z / sun_earth_dist,
215        );
216
217        // Step 3: Remove gravitational light deflection
218        let icrs_vec = remove_light_deflection(deflected_vec, sun_to_earth, sun_earth_dist);
219
220        let mut icrs = ICRSPosition::from_unit_vector(icrs_vec)?;
221
222        if let Some(distance) = self.distance {
223            icrs.set_distance(distance);
224        }
225
226        Ok(icrs)
227    }
228
229    fn from_icrs(icrs: &ICRSPosition, epoch: &TT) -> CoordResult<Self> {
230        let icrs_to_cirs = Self::icrs_to_cirs_matrix(epoch)?;
231
232        let icrs_vec = icrs.unit_vector();
233
234        let earth_state = compute_earth_state(epoch)?;
235        let sun_earth_dist = earth_state.heliocentric_position.magnitude();
236
237        // Sun to observer unit vector (heliocentric position normalized)
238        // heliocentric_position is Earth's position relative to Sun, so normalizing gives Sun→Earth direction
239        let sun_to_earth = Vector3::new(
240            earth_state.heliocentric_position.x / sun_earth_dist,
241            earth_state.heliocentric_position.y / sun_earth_dist,
242            earth_state.heliocentric_position.z / sun_earth_dist,
243        );
244
245        // Step 1: Apply gravitational light deflection by the Sun
246        let deflected_vec = apply_light_deflection(icrs_vec, sun_to_earth, sun_earth_dist);
247
248        // Step 2: Apply stellar aberration
249        let apparent_vec = apply_aberration(
250            deflected_vec,
251            earth_state.barycentric_velocity,
252            sun_earth_dist,
253        );
254
255        // Step 3: Apply precession-nutation (C2I matrix)
256        let cirs_vec = icrs_to_cirs * apparent_vec;
257
258        let mut cirs = Self::from_unit_vector(cirs_vec, *epoch)?;
259
260        if let Some(distance) = icrs.distance() {
261            cirs.distance = Some(distance);
262        }
263
264        Ok(cirs)
265    }
266}
267
268impl std::fmt::Display for CIRSPosition {
269    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
270        write!(
271            f,
272            "CIRS(RA={:.6}°, Dec={:.6}°, epoch=J{:.1}",
273            self.ra.degrees(),
274            self.dec.degrees(),
275            self.epoch.julian_year()
276        )?;
277
278        if let Some(distance) = self.distance {
279            write!(f, ", d={}", distance)?;
280        }
281
282        write!(f, ")")
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn test_cirs_creation() {
292        let epoch = TT::j2000();
293        let pos = CIRSPosition::from_degrees(180.0, 45.0, epoch).unwrap();
294
295        assert_eq!(pos.ra().degrees(), 180.0);
296        assert_eq!(pos.dec().degrees(), 45.0);
297        assert_eq!(pos.epoch(), epoch);
298        assert_eq!(pos.distance(), None);
299    }
300
301    #[test]
302    fn test_cirs_with_distance() {
303        let epoch = TT::j2000();
304        let distance = Distance::from_parsecs(10.0).unwrap();
305        let pos = CIRSPosition::with_distance(
306            Angle::from_degrees(90.0),
307            Angle::from_degrees(30.0),
308            epoch,
309            distance,
310        )
311        .unwrap();
312
313        assert_eq!(pos.distance().unwrap(), distance);
314    }
315
316    #[test]
317    fn test_unit_vector_conversion() {
318        let epoch = TT::j2000();
319
320        // Test vernal equinox direction
321        let vernal_equinox = CIRSPosition::from_degrees(0.0, 0.0, epoch).unwrap();
322        let unit_vec = vernal_equinox.unit_vector();
323
324        assert_eq!(unit_vec.x, 1.0);
325        assert_eq!(unit_vec.y, 0.0);
326        assert_eq!(unit_vec.z, 0.0);
327
328        // Test round-trip
329        let recovered = CIRSPosition::from_unit_vector(unit_vec, epoch).unwrap();
330        assert_eq!(recovered.ra().degrees(), vernal_equinox.ra().degrees());
331        assert_eq!(recovered.dec().degrees(), vernal_equinox.dec().degrees());
332    }
333
334    #[test]
335    fn test_icrs_to_cirs_transformation() {
336        let epoch = TT::j2000();
337        let icrs = ICRSPosition::from_degrees(180.0, 45.0).unwrap();
338
339        // Transform to CIRS
340        let cirs = CIRSPosition::from_icrs(&icrs, &epoch).unwrap();
341
342        // At J2000, CIRS should be very close to ICRS (small precession/nutation effects)
343        // But not exactly equal due to frame bias
344        assert!((cirs.ra().degrees() - icrs.ra().degrees()).abs() < 1.0);
345        assert!((cirs.dec().degrees() - icrs.dec().degrees()).abs() < 1.0);
346    }
347
348    #[test]
349    fn test_cirs_to_icrs_roundtrip() {
350        let epoch = TT::j2000();
351        let original_icrs = ICRSPosition::from_degrees(120.0, 30.0).unwrap();
352
353        // ICRS → CIRS → ICRS
354        let cirs = CIRSPosition::from_icrs(&original_icrs, &epoch).unwrap();
355        let recovered_icrs = cirs.to_icrs(&epoch).unwrap();
356
357        // Iterative inverse (aberration + light deflection) gives ~50 nano-arcsec precision
358        let diff_arcsec = original_icrs
359            .angular_separation(&recovered_icrs)
360            .arcseconds();
361        assert!(
362            diff_arcsec < 1e-7,
363            "CIRS roundtrip should be < 100 nano-arcsec, got {:.2e} arcsec",
364            diff_arcsec
365        );
366    }
367
368    #[test]
369    fn test_coordinate_validation() {
370        let epoch = TT::j2000();
371
372        // Valid coordinates
373        assert!(CIRSPosition::from_degrees(0.0, 0.0, epoch).is_ok());
374        assert!(CIRSPosition::from_degrees(359.99, 89.99, epoch).is_ok());
375
376        // Invalid declination
377        assert!(CIRSPosition::from_degrees(0.0, 91.0, epoch).is_err());
378        assert!(CIRSPosition::from_degrees(0.0, -91.0, epoch).is_err());
379    }
380
381    #[test]
382    fn test_distance_preservation() {
383        let epoch = TT::j2000();
384        let distance = Distance::from_parsecs(100.0).unwrap();
385        let icrs = ICRSPosition::from_degrees_with_distance(90.0, 45.0, distance).unwrap();
386
387        // Distance should be preserved through transformation
388        let cirs = CIRSPosition::from_icrs(&icrs, &epoch).unwrap();
389        assert_eq!(cirs.distance().unwrap(), distance);
390
391        let recovered_icrs = cirs.to_icrs(&epoch).unwrap();
392        assert_eq!(recovered_icrs.distance().unwrap(), distance);
393    }
394
395    #[test]
396    fn test_display_formatting() {
397        let epoch = TT::j2000();
398        let pos = CIRSPosition::from_degrees(123.456789, -67.123456, epoch).unwrap();
399        let display = format!("{}", pos);
400
401        assert!(display.contains("CIRS"));
402        assert!(display.contains("RA=123.456789°"));
403        assert!(display.contains("Dec=-67.123456°"));
404        assert!(display.contains("J2000.0"));
405    }
406
407    #[test]
408    fn test_aberration_applied_in_transformation() {
409        let epoch = TT::j2000();
410        let icrs = ICRSPosition::from_degrees(90.0, 23.0).unwrap();
411
412        let icrs_vec = icrs.unit_vector();
413        let cirs = CIRSPosition::from_icrs(&icrs, &epoch).unwrap();
414        let cirs_vec = cirs.unit_vector();
415
416        let npb_only = CIRSPosition::icrs_to_cirs_matrix(&epoch).unwrap() * icrs_vec;
417
418        let diff_with_aberr = (cirs_vec.x - npb_only.x).powi(2)
419            + (cirs_vec.y - npb_only.y).powi(2)
420            + (cirs_vec.z - npb_only.z).powi(2);
421        let aberr_arcsec = libm::sqrt(diff_with_aberr) * 206264.806247;
422
423        assert!(
424            aberr_arcsec > 15.0 && aberr_arcsec < 25.0,
425            "Aberration should be ~20 arcsec, got {:.2} arcsec",
426            aberr_arcsec
427        );
428    }
429
430    #[test]
431    fn test_aberration_varies_with_epoch() {
432        let icrs = ICRSPosition::from_degrees(180.0, 45.0).unwrap();
433
434        let epoch_jan = TT::from_julian_date(celestial_time::JulianDate::new(2451545.0, 0.0));
435        let epoch_jul = TT::from_julian_date(celestial_time::JulianDate::new(2451545.0, 182.5));
436
437        let cirs_jan = CIRSPosition::from_icrs(&icrs, &epoch_jan).unwrap();
438        let cirs_jul = CIRSPosition::from_icrs(&icrs, &epoch_jul).unwrap();
439
440        let ra_diff_arcsec = (cirs_jan.ra().degrees() - cirs_jul.ra().degrees()).abs() * 3600.0;
441        let dec_diff_arcsec = (cirs_jan.dec().degrees() - cirs_jul.dec().degrees()).abs() * 3600.0;
442
443        assert!(
444            ra_diff_arcsec > 1.0 || dec_diff_arcsec > 1.0,
445            "Aberration should cause measurable difference between epochs: RA={:.2}\", Dec={:.2}\"",
446            ra_diff_arcsec,
447            dec_diff_arcsec
448        );
449    }
450
451    #[test]
452    fn test_aberration_roundtrip_precision() {
453        let test_positions = [
454            (0.0, 0.0),
455            (90.0, 0.0),
456            (180.0, 45.0),
457            (270.0, -60.0),
458            (45.0, 89.0),
459        ];
460
461        for (ra, dec) in test_positions {
462            let epoch = TT::j2000();
463            let icrs = ICRSPosition::from_degrees(ra, dec).unwrap();
464            let cirs = CIRSPosition::from_icrs(&icrs, &epoch).unwrap();
465            let recovered = cirs.to_icrs(&epoch).unwrap();
466
467            // Iterative inverse (aberration + light deflection) gives ~70 nano-arcsec precision
468            let diff_arcsec = icrs.angular_separation(&recovered).arcseconds();
469            assert!(
470                diff_arcsec < 1e-7,
471                "Roundtrip for ({}, {}) should be < 100 nano-arcsec, got {:.2e} arcsec",
472                ra,
473                dec,
474                diff_arcsec
475            );
476        }
477    }
478
479    #[test]
480    fn test_from_unit_vector_north_pole() {
481        let epoch = TT::j2000();
482        let north_pole_vec = Vector3::new(0.0, 0.0, 1.0);
483        let pos = CIRSPosition::from_unit_vector(north_pole_vec, epoch).unwrap();
484
485        assert_eq!(pos.ra().radians(), 0.0);
486        assert_eq!(pos.dec().radians(), std::f64::consts::FRAC_PI_2);
487    }
488
489    #[test]
490    fn test_from_unit_vector_south_pole() {
491        let epoch = TT::j2000();
492        let south_pole_vec = Vector3::new(0.0, 0.0, -1.0);
493        let pos = CIRSPosition::from_unit_vector(south_pole_vec, epoch).unwrap();
494
495        assert_eq!(pos.ra().radians(), 0.0);
496        assert_eq!(pos.dec().radians(), -std::f64::consts::FRAC_PI_2);
497    }
498
499    #[test]
500    fn test_pole_roundtrip() {
501        let epoch = TT::j2000();
502
503        let north_pole = CIRSPosition::from_degrees(0.0, 90.0, epoch).unwrap();
504        let unit_vec = north_pole.unit_vector();
505        let recovered = CIRSPosition::from_unit_vector(unit_vec, epoch).unwrap();
506
507        assert_eq!(recovered.ra().radians(), 0.0);
508        assert_eq!(recovered.dec().degrees(), 90.0);
509
510        let south_pole = CIRSPosition::from_degrees(0.0, -90.0, epoch).unwrap();
511        let unit_vec = south_pole.unit_vector();
512        let recovered = CIRSPosition::from_unit_vector(unit_vec, epoch).unwrap();
513
514        assert_eq!(recovered.ra().radians(), 0.0);
515        assert_eq!(recovered.dec().degrees(), -90.0);
516    }
517}