Skip to main content

celestial_coords/frames/
gcrs.rs

1use crate::{
2    aberration::{apply_aberration, compute_earth_state, remove_aberration},
3    frames::{CIRSPosition, ICRSPosition},
4    transforms::CoordinateFrame,
5    CoordError, CoordResult, Distance,
6};
7use celestial_core::{matrix::RotationMatrix3, Angle, Vector3};
8use celestial_time::{transforms::NutationCalculator, TT};
9
10#[cfg(feature = "serde")]
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, PartialEq)]
14#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
15pub struct GCRSPosition {
16    ra: Angle,
17    dec: Angle,
18    epoch: TT,
19    distance: Option<Distance>,
20}
21
22impl GCRSPosition {
23    pub fn new(ra: Angle, dec: Angle, epoch: TT) -> CoordResult<Self> {
24        let ra = ra.validate_right_ascension()?;
25        let dec = dec.validate_declination(false)?;
26
27        Ok(Self {
28            ra,
29            dec,
30            epoch,
31            distance: None,
32        })
33    }
34
35    pub fn with_distance(
36        ra: Angle,
37        dec: Angle,
38        epoch: TT,
39        distance: Distance,
40    ) -> CoordResult<Self> {
41        let mut pos = Self::new(ra, dec, epoch)?;
42        pos.distance = Some(distance);
43        Ok(pos)
44    }
45
46    pub fn from_degrees(ra_deg: f64, dec_deg: f64, epoch: TT) -> CoordResult<Self> {
47        Self::new(
48            Angle::from_degrees(ra_deg),
49            Angle::from_degrees(dec_deg),
50            epoch,
51        )
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 epoch(&self) -> TT {
63        self.epoch
64    }
65
66    pub fn distance(&self) -> Option<Distance> {
67        self.distance
68    }
69
70    pub fn set_distance(&mut self, distance: Distance) {
71        self.distance = Some(distance);
72    }
73
74    pub fn remove_distance(&mut self) {
75        self.distance = None;
76    }
77
78    pub fn unit_vector(&self) -> Vector3 {
79        let (sin_dec, cos_dec) = self.dec.sin_cos();
80        let (sin_ra, cos_ra) = self.ra.sin_cos();
81
82        Vector3::new(cos_dec * cos_ra, cos_dec * sin_ra, sin_dec)
83    }
84
85    pub fn from_unit_vector(unit: Vector3, epoch: TT) -> CoordResult<Self> {
86        let r = libm::sqrt(unit.x.powi(2) + unit.y.powi(2) + unit.z.powi(2));
87
88        if r == 0.0 {
89            return Err(CoordError::invalid_coordinate("Zero vector"));
90        }
91
92        let x = unit.x / r;
93        let y = unit.y / r;
94        let z = unit.z / r;
95
96        let d2 = x * x + y * y;
97        let ra = if d2 == 0.0 { 0.0 } else { libm::atan2(y, x) };
98        let dec = if z == 0.0 {
99            0.0
100        } else {
101            libm::atan2(z, libm::sqrt(d2))
102        };
103
104        Self::new(Angle::from_radians(ra), Angle::from_radians(dec), epoch)
105    }
106
107    pub fn to_cirs(&self) -> CoordResult<CIRSPosition> {
108        let npb_matrix = Self::gcrs_to_cirs_matrix(&self.epoch)?;
109
110        let gcrs_vec = self.unit_vector();
111        let cirs_vec = npb_matrix * gcrs_vec;
112
113        let mut cirs = CIRSPosition::from_unit_vector(cirs_vec, self.epoch)?;
114
115        if let Some(distance) = self.distance {
116            cirs.set_distance(distance);
117        }
118
119        Ok(cirs)
120    }
121
122    pub fn from_cirs(cirs: &CIRSPosition) -> CoordResult<Self> {
123        let npb_matrix = Self::gcrs_to_cirs_matrix(&cirs.epoch())?;
124        let cirs_to_gcrs = npb_matrix.transpose();
125
126        let cirs_vec = cirs.unit_vector();
127        let gcrs_vec = cirs_to_gcrs * cirs_vec;
128
129        let mut gcrs = Self::from_unit_vector(gcrs_vec, cirs.epoch())?;
130
131        if let Some(distance) = cirs.distance() {
132            gcrs.distance = Some(distance);
133        }
134
135        Ok(gcrs)
136    }
137
138    fn gcrs_to_cirs_matrix(epoch: &TT) -> CoordResult<RotationMatrix3> {
139        let jd = epoch.to_julian_date();
140        let t = celestial_core::utils::jd_to_centuries(jd.jd1(), jd.jd2());
141
142        let nutation = epoch
143            .nutation_iau2006a()
144            .map_err(|e| CoordError::CoreError {
145                message: format!("Nutation calculation failed: {}", e),
146            })?;
147
148        let precession_calc = celestial_core::precession::PrecessionIAU2006::new();
149        let npb_matrix = precession_calc.npb_matrix_iau2006a(
150            t,
151            nutation.nutation_longitude(),
152            nutation.nutation_obliquity(),
153        );
154
155        let cio_solution = celestial_core::CioSolution::calculate(&npb_matrix, t).map_err(|e| {
156            CoordError::CoreError {
157                message: format!("CIO calculation failed: {}", e),
158            }
159        })?;
160
161        let c2i_matrix = celestial_core::gcrs_to_cirs_matrix(
162            cio_solution.cip.x,
163            cio_solution.cip.y,
164            cio_solution.s,
165        );
166
167        Ok(c2i_matrix)
168    }
169}
170
171impl CoordinateFrame for GCRSPosition {
172    fn to_icrs(&self, _epoch: &TT) -> CoordResult<ICRSPosition> {
173        let gcrs_vec = self.unit_vector();
174
175        let earth_state = compute_earth_state(&self.epoch)?;
176        let sun_earth_dist = earth_state.heliocentric_position.magnitude();
177        let icrs_vec =
178            remove_aberration(gcrs_vec, earth_state.barycentric_velocity, sun_earth_dist);
179
180        let mut icrs = ICRSPosition::from_unit_vector(icrs_vec)?;
181
182        if let Some(distance) = self.distance {
183            icrs.set_distance(distance);
184        }
185
186        Ok(icrs)
187    }
188
189    fn from_icrs(icrs: &ICRSPosition, epoch: &TT) -> CoordResult<Self> {
190        let icrs_vec = icrs.unit_vector();
191
192        let earth_state = compute_earth_state(epoch)?;
193        let sun_earth_dist = earth_state.heliocentric_position.magnitude();
194        let gcrs_vec = apply_aberration(icrs_vec, earth_state.barycentric_velocity, sun_earth_dist);
195
196        let mut gcrs = Self::from_unit_vector(gcrs_vec, *epoch)?;
197
198        if let Some(distance) = icrs.distance() {
199            gcrs.distance = Some(distance);
200        }
201
202        Ok(gcrs)
203    }
204}
205
206impl std::fmt::Display for GCRSPosition {
207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208        write!(
209            f,
210            "GCRS(RA={:.6}°, Dec={:.6}°, epoch=J{:.1}",
211            self.ra.degrees(),
212            self.dec.degrees(),
213            self.epoch.julian_year()
214        )?;
215
216        if let Some(distance) = self.distance {
217            write!(f, ", d={}", distance)?;
218        }
219
220        write!(f, ")")
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn test_gcrs_creation() {
230        let epoch = TT::j2000();
231        let pos = GCRSPosition::from_degrees(180.0, 45.0, epoch).unwrap();
232
233        assert_eq!(pos.ra().degrees(), 180.0);
234        assert_eq!(pos.dec().degrees(), 45.0);
235        assert_eq!(pos.epoch(), epoch);
236        assert_eq!(pos.distance(), None);
237    }
238
239    #[test]
240    fn test_gcrs_with_distance() {
241        let epoch = TT::j2000();
242        let distance = Distance::from_parsecs(10.0).unwrap();
243        let pos = GCRSPosition::with_distance(
244            Angle::from_degrees(90.0),
245            Angle::from_degrees(30.0),
246            epoch,
247            distance,
248        )
249        .unwrap();
250
251        assert_eq!(pos.distance().unwrap(), distance);
252    }
253
254    #[test]
255    fn test_unit_vector_conversion() {
256        let epoch = TT::j2000();
257
258        let vernal_equinox = GCRSPosition::from_degrees(0.0, 0.0, epoch).unwrap();
259        let unit_vec = vernal_equinox.unit_vector();
260
261        assert_eq!(unit_vec.x, 1.0);
262        assert_eq!(unit_vec.y, 0.0);
263        assert_eq!(unit_vec.z, 0.0);
264
265        let recovered = GCRSPosition::from_unit_vector(unit_vec, epoch).unwrap();
266        assert_eq!(recovered.ra().degrees(), vernal_equinox.ra().degrees());
267        assert_eq!(recovered.dec().degrees(), vernal_equinox.dec().degrees());
268    }
269
270    #[test]
271    fn test_icrs_to_gcrs_applies_aberration() {
272        let epoch = TT::j2000();
273        let icrs = ICRSPosition::from_degrees(90.0, 23.0).unwrap();
274
275        let gcrs = GCRSPosition::from_icrs(&icrs, &epoch).unwrap();
276
277        let sep_arcsec = icrs
278            .angular_separation(&ICRSPosition::from_unit_vector(gcrs.unit_vector()).unwrap())
279            .arcseconds();
280
281        assert!(
282            sep_arcsec > 15.0 && sep_arcsec < 25.0,
283            "ICRS→GCRS aberration should be ~20 arcsec, got {:.2} arcsec",
284            sep_arcsec
285        );
286    }
287
288    #[test]
289    fn test_gcrs_to_icrs_roundtrip() {
290        let test_positions = [
291            (0.0, 0.0),
292            (90.0, 0.0),
293            (180.0, 45.0),
294            (270.0, -60.0),
295            (45.0, 89.0),
296        ];
297
298        for (ra, dec) in test_positions {
299            let epoch = TT::j2000();
300            let icrs = ICRSPosition::from_degrees(ra, dec).unwrap();
301            let gcrs = GCRSPosition::from_icrs(&icrs, &epoch).unwrap();
302            let recovered = gcrs.to_icrs(&epoch).unwrap();
303
304            // Iterative inverse aberration gives ~70 nano-arcsec precision
305            let diff_arcsec = icrs.angular_separation(&recovered).arcseconds();
306            assert!(
307                diff_arcsec < 1e-7,
308                "Roundtrip for ({}, {}) should be < 100 nano-arcsec, got {:.2e} arcsec",
309                ra,
310                dec,
311                diff_arcsec
312            );
313        }
314    }
315
316    #[test]
317    fn test_gcrs_to_cirs_to_gcrs_roundtrip() {
318        let epoch = TT::j2000();
319        let original = GCRSPosition::from_degrees(120.0, 30.0, epoch).unwrap();
320
321        let cirs = original.to_cirs().unwrap();
322        let recovered = GCRSPosition::from_cirs(&cirs).unwrap();
323
324        // GCRS→CIRS→GCRS is just matrix multiplication (transpose is exact inverse)
325        // This should be very precise - use angular separation for robustness
326        let sep_arcsec = {
327            let orig_vec = original.unit_vector();
328            let rec_vec = recovered.unit_vector();
329            let dot = orig_vec.x * rec_vec.x + orig_vec.y * rec_vec.y + orig_vec.z * rec_vec.z;
330            dot.clamp(-1.0, 1.0).acos() * 206264.806247
331        };
332        assert!(
333            sep_arcsec < 1e-10,
334            "GCRS→CIRS→GCRS roundtrip should be < 0.1 nano-arcsec, got {:.2e} arcsec",
335            sep_arcsec
336        );
337    }
338
339    #[test]
340    fn test_icrs_to_gcrs_to_cirs_chain() {
341        // Note: GCRS applies only aberration, while CIRS (from ICRS) also applies
342        // gravitational light deflection by the Sun. The difference between paths
343        // is the light deflection effect, which can be up to ~1.75" at the solar limb.
344        let epoch = TT::j2000();
345        let icrs = ICRSPosition::from_degrees(180.0, 45.0).unwrap();
346
347        let gcrs = GCRSPosition::from_icrs(&icrs, &epoch).unwrap();
348        let cirs_via_gcrs = gcrs.to_cirs().unwrap();
349
350        let cirs_direct = CIRSPosition::from_icrs(&icrs, &epoch).unwrap();
351
352        // Light deflection causes a difference between paths.
353        // For typical stars not near the Sun, this is ~1-10 mas.
354        let ra_diff_arcsec =
355            (cirs_via_gcrs.ra().radians() - cirs_direct.ra().radians()).abs() * 206264.806247;
356        let dec_diff_arcsec =
357            (cirs_via_gcrs.dec().radians() - cirs_direct.dec().radians()).abs() * 206264.806247;
358
359        // Light deflection should be < 0.1" for stars far from the Sun
360        assert!(
361            ra_diff_arcsec < 0.1,
362            "RA difference (light deflection) should be < 0.1\", got {:.4}\"",
363            ra_diff_arcsec
364        );
365        assert!(
366            dec_diff_arcsec < 0.1,
367            "Dec difference (light deflection) should be < 0.1\", got {:.4}\"",
368            dec_diff_arcsec
369        );
370    }
371
372    #[test]
373    fn test_aberration_varies_with_epoch() {
374        let icrs = ICRSPosition::from_degrees(180.0, 45.0).unwrap();
375
376        let epoch_jan = TT::from_julian_date(celestial_time::JulianDate::new(2451545.0, 0.0));
377        let epoch_jul = TT::from_julian_date(celestial_time::JulianDate::new(2451545.0, 182.5));
378
379        let gcrs_jan = GCRSPosition::from_icrs(&icrs, &epoch_jan).unwrap();
380        let gcrs_jul = GCRSPosition::from_icrs(&icrs, &epoch_jul).unwrap();
381
382        let ra_diff_arcsec = (gcrs_jan.ra().degrees() - gcrs_jul.ra().degrees()).abs() * 3600.0;
383        let dec_diff_arcsec = (gcrs_jan.dec().degrees() - gcrs_jul.dec().degrees()).abs() * 3600.0;
384
385        assert!(
386            ra_diff_arcsec > 1.0 || dec_diff_arcsec > 1.0,
387            "Aberration should differ between epochs: RA={:.2}\", Dec={:.2}\"",
388            ra_diff_arcsec,
389            dec_diff_arcsec
390        );
391    }
392
393    #[test]
394    fn test_distance_preservation() {
395        let epoch = TT::j2000();
396        let distance = Distance::from_parsecs(100.0).unwrap();
397        let icrs = ICRSPosition::from_degrees_with_distance(90.0, 45.0, distance).unwrap();
398
399        let gcrs = GCRSPosition::from_icrs(&icrs, &epoch).unwrap();
400        assert_eq!(gcrs.distance().unwrap(), distance);
401
402        let cirs = gcrs.to_cirs().unwrap();
403        assert_eq!(cirs.distance().unwrap(), distance);
404
405        let recovered_gcrs = GCRSPosition::from_cirs(&cirs).unwrap();
406        assert_eq!(recovered_gcrs.distance().unwrap(), distance);
407
408        let recovered_icrs = recovered_gcrs.to_icrs(&epoch).unwrap();
409        assert_eq!(recovered_icrs.distance().unwrap(), distance);
410    }
411
412    #[test]
413    fn test_coordinate_validation() {
414        let epoch = TT::j2000();
415
416        assert!(GCRSPosition::from_degrees(0.0, 0.0, epoch).is_ok());
417        assert!(GCRSPosition::from_degrees(359.99, 89.99, epoch).is_ok());
418
419        assert!(GCRSPosition::from_degrees(0.0, 91.0, epoch).is_err());
420        assert!(GCRSPosition::from_degrees(0.0, -91.0, epoch).is_err());
421    }
422
423    #[test]
424    fn test_display_formatting() {
425        let epoch = TT::j2000();
426        let pos = GCRSPosition::from_degrees(123.456789, -67.123456, epoch).unwrap();
427        let display = format!("{}", pos);
428
429        assert!(display.contains("GCRS"));
430        assert!(display.contains("RA=123.456789°"));
431        assert!(display.contains("Dec=-67.123456°"));
432        assert!(display.contains("J2000.0"));
433    }
434
435    #[test]
436    fn test_from_unit_vector_north_pole() {
437        let epoch = TT::j2000();
438        let north_pole_vec = Vector3::new(0.0, 0.0, 1.0);
439        let pos = GCRSPosition::from_unit_vector(north_pole_vec, epoch).unwrap();
440
441        assert_eq!(pos.ra().radians(), 0.0);
442        assert_eq!(pos.dec().radians(), std::f64::consts::FRAC_PI_2);
443    }
444
445    #[test]
446    fn test_from_unit_vector_south_pole() {
447        let epoch = TT::j2000();
448        let south_pole_vec = Vector3::new(0.0, 0.0, -1.0);
449        let pos = GCRSPosition::from_unit_vector(south_pole_vec, epoch).unwrap();
450
451        assert_eq!(pos.ra().radians(), 0.0);
452        assert_eq!(pos.dec().radians(), -std::f64::consts::FRAC_PI_2);
453    }
454
455    #[test]
456    fn test_pole_roundtrip() {
457        let epoch = TT::j2000();
458
459        let north_pole = GCRSPosition::from_degrees(0.0, 90.0, epoch).unwrap();
460        let unit_vec = north_pole.unit_vector();
461        let recovered = GCRSPosition::from_unit_vector(unit_vec, epoch).unwrap();
462
463        assert_eq!(recovered.ra().radians(), 0.0);
464        assert_eq!(recovered.dec().degrees(), 90.0);
465
466        let south_pole = GCRSPosition::from_degrees(0.0, -90.0, epoch).unwrap();
467        let unit_vec = south_pole.unit_vector();
468        let recovered = GCRSPosition::from_unit_vector(unit_vec, epoch).unwrap();
469
470        assert_eq!(recovered.ra().radians(), 0.0);
471        assert_eq!(recovered.dec().degrees(), -90.0);
472    }
473}