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 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 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 let deflected_vec = remove_aberration(
205 apparent_vec,
206 earth_state.barycentric_velocity,
207 sun_earth_dist,
208 );
209
210 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 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 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 let deflected_vec = apply_light_deflection(icrs_vec, sun_to_earth, sun_earth_dist);
247
248 let apparent_vec = apply_aberration(
250 deflected_vec,
251 earth_state.barycentric_velocity,
252 sun_earth_dist,
253 );
254
255 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 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 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 let cirs = CIRSPosition::from_icrs(&icrs, &epoch).unwrap();
341
342 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 let cirs = CIRSPosition::from_icrs(&original_icrs, &epoch).unwrap();
355 let recovered_icrs = cirs.to_icrs(&epoch).unwrap();
356
357 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 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 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 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 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}