1use core::f64::consts::PI;
15use core::ops::{Add, Mul, Neg, Sub};
16
17use crate::error::{Error, Result};
18
19const DEG_PER_RAD: f64 = 180.0 / PI;
20const RAD_PER_DEG: f64 = PI / 180.0;
21pub(crate) const ARCSEC_PER_RADIAN: f64 = 206_264.806_247_096_36;
23
24#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
32#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
33pub struct Angle {
34 radians: f64,
35}
36
37impl Angle {
38 #[must_use]
40 pub const fn from_radians(radians: f64) -> Self {
41 Self { radians }
42 }
43 #[must_use]
45 pub fn from_degrees(degrees: f64) -> Self {
46 Self {
47 radians: degrees * RAD_PER_DEG,
48 }
49 }
50 #[must_use]
52 pub fn from_arcminutes(arcmin: f64) -> Self {
53 Self::from_degrees(arcmin / 60.0)
54 }
55 #[must_use]
57 pub fn from_arcseconds(arcsec: f64) -> Self {
58 Self::from_degrees(arcsec / 3600.0)
59 }
60 #[must_use]
62 pub fn from_hours(hours: f64) -> Self {
63 Self::from_degrees(hours * 15.0)
64 }
65
66 #[must_use]
68 pub const fn radians(self) -> f64 {
69 self.radians
70 }
71 #[must_use]
73 pub fn degrees(self) -> f64 {
74 self.radians * DEG_PER_RAD
75 }
76 #[must_use]
78 pub fn arcminutes(self) -> f64 {
79 self.degrees() * 60.0
80 }
81 #[must_use]
83 pub fn arcseconds(self) -> f64 {
84 self.degrees() * 3600.0
85 }
86 #[must_use]
88 pub fn hours(self) -> f64 {
89 self.degrees() / 15.0
90 }
91
92 #[must_use]
94 pub fn normalized_0_360(self) -> Self {
95 let mut d = self.degrees() % 360.0;
96 if d < 0.0 {
97 d += 360.0;
98 }
99 Self::from_degrees(d)
100 }
101 #[must_use]
103 pub fn normalized_pm_180(self) -> Self {
104 let mut d = self.normalized_0_360().degrees();
105 if d > 180.0 {
106 d -= 360.0;
107 }
108 Self::from_degrees(d)
109 }
110}
111
112impl Add for Angle {
113 type Output = Angle;
114 fn add(self, rhs: Angle) -> Angle {
115 Angle::from_radians(self.radians + rhs.radians)
116 }
117}
118impl Sub for Angle {
119 type Output = Angle;
120 fn sub(self, rhs: Angle) -> Angle {
121 Angle::from_radians(self.radians - rhs.radians)
122 }
123}
124impl Neg for Angle {
125 type Output = Angle;
126 fn neg(self) -> Angle {
127 Angle::from_radians(-self.radians)
128 }
129}
130impl Mul<f64> for Angle {
131 type Output = Angle;
132 fn mul(self, rhs: f64) -> Angle {
133 Angle::from_radians(self.radians * rhs)
134 }
135}
136
137#[derive(Debug, Clone, Copy, PartialEq)]
146#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
147pub enum Epoch {
148 J2000,
150 OfDate(f64),
152}
153
154impl Epoch {
155 #[must_use]
157 pub fn julian_centuries_from_j2000(self) -> f64 {
158 match self {
159 Epoch::J2000 => 0.0,
160 Epoch::OfDate(year) => (year - 2000.0) / 100.0,
161 }
162 }
163}
164
165#[derive(Debug, Clone, Copy, PartialEq)]
170#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
171pub struct Equatorial {
172 ra: Angle,
173 dec: Angle,
174 epoch: Epoch,
175}
176
177impl Equatorial {
178 pub fn new(ra: Angle, dec: Angle, epoch: Epoch) -> Result<Self> {
184 let ra_deg = ra.degrees();
185 let dec_deg = dec.degrees();
186 if !ra_deg.is_finite() || !(0.0..360.0).contains(&ra_deg) {
187 return Err(Error::OutOfRange {
188 what: "right ascension",
189 value: ra_deg,
190 });
191 }
192 if !dec_deg.is_finite() || !(-90.0..=90.0).contains(&dec_deg) {
193 return Err(Error::OutOfRange {
194 what: "declination",
195 value: dec_deg,
196 });
197 }
198 if let Epoch::OfDate(year) = epoch {
199 if !year.is_finite() {
200 return Err(Error::OutOfRange {
201 what: "epoch year",
202 value: year,
203 });
204 }
205 }
206 Ok(Self { ra, dec, epoch })
207 }
208
209 pub fn j2000(ra: Angle, dec: Angle) -> Result<Self> {
214 Self::new(ra, dec, Epoch::J2000)
215 }
216
217 pub fn parse(ra: &str, dec: &str, epoch: Epoch) -> Result<Self> {
226 let ra_deg = parse_ra_degrees(ra)?;
227 let dec_deg = parse_dec_degrees(dec)?;
228 Self::new(
229 Angle::from_degrees(ra_deg),
230 Angle::from_degrees(dec_deg),
231 epoch,
232 )
233 }
234
235 pub fn parse_j2000(ra: &str, dec: &str) -> Result<Self> {
240 Self::parse(ra, dec, Epoch::J2000)
241 }
242
243 #[must_use]
245 pub fn ra(self) -> Angle {
246 self.ra
247 }
248 #[must_use]
250 pub fn dec(self) -> Angle {
251 self.dec
252 }
253 #[must_use]
255 pub fn epoch(self) -> Epoch {
256 self.epoch
257 }
258 #[must_use]
260 pub fn to_degrees(self) -> (f64, f64) {
261 (self.ra.degrees(), self.dec.degrees())
262 }
263
264 #[must_use]
266 pub fn ra_to_sexagesimal(self, decimals: usize) -> String {
267 format_sexagesimal(self.ra.hours(), 24.0, false, decimals)
268 }
269 #[must_use]
271 pub fn dec_to_sexagesimal(self, decimals: usize) -> String {
272 format_sexagesimal(self.dec.degrees(), 90.0, true, decimals)
273 }
274
275 pub(crate) fn to_unit_vector(self) -> [f64; 3] {
277 let (a, d) = (self.ra.radians(), self.dec.radians());
278 [d.cos() * a.cos(), d.cos() * a.sin(), d.sin()]
279 }
280
281 pub(crate) fn from_unit_vector(v: [f64; 3], epoch: Epoch) -> Self {
284 let ra = Angle::from_radians(v[1].atan2(v[0])).normalized_0_360();
285 let dec = Angle::from_radians(v[2].atan2((v[0] * v[0] + v[1] * v[1]).sqrt()));
286 Self { ra, dec, epoch }
287 }
288}
289
290#[must_use]
298pub fn separation(a: Equatorial, b: Equatorial) -> Angle {
299 let (ra1, dec1) = (a.ra.radians(), a.dec.radians());
300 let (ra2, dec2) = (b.ra.radians(), b.dec.radians());
301 let (dra, ddec) = (ra2 - ra1, dec2 - dec1);
302 let sin_ddec = (ddec / 2.0).sin();
303 let sin_dra = (dra / 2.0).sin();
304 let h = sin_ddec.mul_add(sin_ddec, dec1.cos() * dec2.cos() * sin_dra * sin_dra);
306 let central = 2.0 * h.sqrt().clamp(0.0, 1.0).asin();
307 Angle::from_radians(central)
308}
309
310#[must_use]
319pub fn precess(pos: Equatorial, to: Epoch) -> Equatorial {
320 if pos.epoch == to {
321 return pos;
322 }
323 let at_j2000 = match pos.epoch {
325 Epoch::J2000 => pos,
326 Epoch::OfDate(year) => {
327 let v = apply_matrix(&transpose(&precession_matrix(year)), pos.to_unit_vector());
328 Equatorial::from_unit_vector(v, Epoch::J2000)
329 }
330 };
331 match to {
332 Epoch::J2000 => at_j2000,
333 Epoch::OfDate(year) => {
334 let v = apply_matrix(&precession_matrix(year), at_j2000.to_unit_vector());
335 Equatorial::from_unit_vector(v, to)
336 }
337 }
338}
339
340fn precession_matrix(year: f64) -> [[f64; 3]; 3] {
345 let t = (year - 2000.0) / 100.0; let arcsec = |a: f64| a * (RAD_PER_DEG / 3600.0);
347 let zeta = arcsec(2306.2181 * t + 0.301_88 * t * t + 0.017_998 * t * t * t);
348 let z = arcsec(2306.2181 * t + 1.094_68 * t * t + 0.018_203 * t * t * t);
349 let theta = arcsec(2004.3109 * t - 0.426_65 * t * t - 0.041_833 * t * t * t);
350 mat_mul(&mat_mul(&rot_z(-z), &rot_y(theta)), &rot_z(-zeta))
351}
352
353fn rot_z(phi: f64) -> [[f64; 3]; 3] {
354 let (s, c) = phi.sin_cos();
355 [[c, s, 0.0], [-s, c, 0.0], [0.0, 0.0, 1.0]]
356}
357fn rot_y(phi: f64) -> [[f64; 3]; 3] {
358 let (s, c) = phi.sin_cos();
359 [[c, 0.0, -s], [0.0, 1.0, 0.0], [s, 0.0, c]]
360}
361fn mat_mul(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
362 let mut out = [[0.0; 3]; 3];
363 for (i, row) in out.iter_mut().enumerate() {
364 for (j, cell) in row.iter_mut().enumerate() {
365 *cell = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j];
366 }
367 }
368 out
369}
370fn transpose(m: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
371 let mut t = [[0.0; 3]; 3];
372 for i in 0..3 {
373 for j in 0..3 {
374 t[i][j] = m[j][i];
375 }
376 }
377 t
378}
379fn apply_matrix(m: &[[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
380 [
381 m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
382 m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
383 m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
384 ]
385}
386
387fn parse_ra_degrees(raw: &str) -> Result<f64> {
392 if looks_sexagesimal(raw) {
393 Ok(parse_sexagesimal(raw)? * 15.0)
394 } else {
395 parse_decimal(raw)
396 }
397}
398
399fn parse_dec_degrees(raw: &str) -> Result<f64> {
401 if looks_sexagesimal(raw) {
402 parse_sexagesimal(raw)
403 } else {
404 parse_decimal(raw)
405 }
406}
407
408fn looks_sexagesimal(raw: &str) -> bool {
409 let t = raw.trim();
410 t.contains(':') || t.split_whitespace().count() > 1
411}
412
413fn parse_decimal(raw: &str) -> Result<f64> {
414 raw.trim()
415 .parse::<f64>()
416 .ok()
417 .filter(|v| v.is_finite())
418 .ok_or_else(|| Error::ParseCoord(format!("not a finite number: {raw:?}")))
419}
420
421fn parse_sexagesimal(raw: &str) -> Result<f64> {
425 let trimmed = raw.trim();
426 if trimmed.is_empty() {
427 return Err(Error::ParseCoord("empty coordinate".to_owned()));
428 }
429 let normalized = trimmed.replace(':', " ");
430 let mut parts = normalized.split_whitespace();
431 let deg_str = parts
432 .next()
433 .ok_or_else(|| Error::ParseCoord(format!("no leading field: {raw:?}")))?;
434 let negative = deg_str.starts_with('-');
435 let deg: f64 = deg_str
436 .parse()
437 .ok()
438 .filter(|v: &f64| v.is_finite())
439 .ok_or_else(|| Error::ParseCoord(format!("bad degrees/hours field: {raw:?}")))?;
440 let min = next_field(&mut parts, raw, "minutes")?;
441 let sec = next_field(&mut parts, raw, "seconds")?;
442 if parts.next().is_some() {
443 return Err(Error::ParseCoord(format!("too many fields: {raw:?}")));
444 }
445 if min < 0.0 || sec < 0.0 || min >= 60.0 || sec >= 60.0 {
446 return Err(Error::ParseCoord(format!(
447 "minutes/seconds out of range: {raw:?}"
448 )));
449 }
450 let magnitude = deg.abs() + min / 60.0 + sec / 3600.0;
451 Ok(if negative { -magnitude } else { magnitude })
452}
453
454fn next_field<'a>(parts: &mut impl Iterator<Item = &'a str>, raw: &str, what: &str) -> Result<f64> {
455 match parts.next() {
456 None => Ok(0.0),
457 Some(s) => s
458 .parse::<f64>()
459 .ok()
460 .filter(|v| v.is_finite())
461 .ok_or_else(|| Error::ParseCoord(format!("bad {what} field: {raw:?}"))),
462 }
463}
464
465fn format_sexagesimal(value: f64, _wrap: f64, signed: bool, decimals: usize) -> String {
469 let neg = value < 0.0;
470 let mut v = value.abs();
471 let sec_scale = 3600.0 * 10f64.powi(decimals as i32);
473 let total_units = (v * sec_scale).round() / sec_scale;
474 v = total_units;
475 let a = v.trunc();
476 let rem_min = (v - a) * 60.0;
477 let b = rem_min.trunc();
478 let c = (rem_min - b) * 60.0;
479 let sign = if neg {
480 "-"
481 } else if signed {
482 "+"
483 } else {
484 ""
485 };
486 let width = if decimals > 0 { decimals + 3 } else { 2 };
487 format!(
488 "{sign}{a:02}:{b:02.0}:{c:0width$.decimals$}",
489 a = a as i64,
490 b = b
491 )
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497
498 fn approx(a: f64, b: f64, eps: f64) -> bool {
499 (a - b).abs() < eps
500 }
501
502 #[test]
504 fn angle_conversions() {
505 let a = Angle::from_degrees(90.0);
506 assert!(approx(a.radians(), PI / 2.0, 1e-12));
507 assert!(approx(a.arcminutes(), 5400.0, 1e-6));
508 assert!(approx(a.arcseconds(), 324_000.0, 1e-3));
509 assert!(approx(Angle::from_hours(1.0).degrees(), 15.0, 1e-12));
510 assert!(approx(Angle::from_arcminutes(60.0).degrees(), 1.0, 1e-12));
511 assert!(approx(Angle::from_arcseconds(3600.0).degrees(), 1.0, 1e-12));
512 }
513
514 #[test]
515 fn angle_normalization() {
516 assert!(approx(
517 Angle::from_degrees(370.0).normalized_0_360().degrees(),
518 10.0,
519 1e-9
520 ));
521 assert!(approx(
522 Angle::from_degrees(-10.0).normalized_0_360().degrees(),
523 350.0,
524 1e-9
525 ));
526 assert!(approx(
527 Angle::from_degrees(350.0).normalized_pm_180().degrees(),
528 -10.0,
529 1e-9
530 ));
531 }
532
533 #[test]
534 fn angle_ops() {
535 let s = Angle::from_degrees(10.0) + Angle::from_degrees(5.0);
536 assert!(approx(s.degrees(), 15.0, 1e-12));
537 assert!(approx(
538 (Angle::from_degrees(10.0) * 3.0).degrees(),
539 30.0,
540 1e-12
541 ));
542 assert!(approx((-Angle::from_degrees(10.0)).degrees(), -10.0, 1e-12));
543 }
544
545 #[test]
547 fn epoch_centuries() {
548 assert!(approx(
549 Epoch::J2000.julian_centuries_from_j2000(),
550 0.0,
551 1e-15
552 ));
553 assert!(approx(
554 Epoch::OfDate(2100.0).julian_centuries_from_j2000(),
555 1.0,
556 1e-12
557 ));
558 }
559
560 #[test]
562 fn equatorial_validates_domain() {
563 assert!(Equatorial::j2000(Angle::from_degrees(10.0), Angle::from_degrees(41.0)).is_ok());
564 assert!(matches!(
565 Equatorial::j2000(Angle::from_degrees(360.0), Angle::from_degrees(0.0)),
566 Err(Error::OutOfRange {
567 what: "right ascension",
568 ..
569 })
570 ));
571 assert!(matches!(
572 Equatorial::j2000(Angle::from_degrees(0.0), Angle::from_degrees(90.1)),
573 Err(Error::OutOfRange {
574 what: "declination",
575 ..
576 })
577 ));
578 assert!(matches!(
579 Equatorial::new(
580 Angle::from_degrees(0.0),
581 Angle::from_degrees(0.0),
582 Epoch::OfDate(f64::NAN)
583 ),
584 Err(Error::OutOfRange {
585 what: "epoch year",
586 ..
587 })
588 ));
589 }
590
591 #[test]
593 fn parse_sexagesimal_and_decimal_agree() {
594 let a = Equatorial::parse_j2000("00:42:44.3", "+41:16:09").unwrap();
595 assert!(approx(a.ra().degrees(), 10.6846, 1e-3));
596 assert!(approx(a.dec().degrees(), 41.2692, 1e-3));
597 let b = Equatorial::parse_j2000("10.6846", "41.2692").unwrap();
598 assert!(separation(a, b).arcseconds() < 1.0);
599 }
600
601 #[test]
602 fn parse_ra_is_hours_dec_is_degrees() {
603 let p = Equatorial::parse_j2000("06:00:00", "06:00:00").unwrap();
605 assert!(approx(p.ra().degrees(), 90.0, 1e-9));
606 assert!(approx(p.dec().degrees(), 6.0, 1e-9));
607 }
608
609 #[test]
610 fn parse_space_separated_and_negative_dec() {
611 let p = Equatorial::parse_j2000("05 35 17", "-05 23 28").unwrap();
612 assert!(approx(p.ra().degrees(), 83.821, 1e-2));
613 assert!(approx(p.dec().degrees(), -5.391, 1e-2));
614 }
615
616 #[test]
617 fn parse_rejects_malformed() {
618 assert!(matches!(
619 Equatorial::parse_j2000("", "0").unwrap_err(),
620 Error::ParseCoord(_)
621 ));
622 assert!(matches!(
623 Equatorial::parse_j2000("00:70:00", "0").unwrap_err(),
624 Error::ParseCoord(_)
625 ));
626 assert!(matches!(
627 Equatorial::parse_j2000("abc", "0").unwrap_err(),
628 Error::ParseCoord(_)
629 ));
630 }
631
632 #[test]
634 fn sexagesimal_round_trip() {
635 let p = Equatorial::parse_j2000("00:42:44.300", "+41:16:09.00").unwrap();
636 let ra = p.ra_to_sexagesimal(3);
637 let dec = p.dec_to_sexagesimal(2);
638 let q = Equatorial::parse_j2000(&ra, &dec).unwrap();
639 assert!(separation(p, q).arcseconds() < 1e-3, "ra={ra} dec={dec}");
640 }
641
642 #[test]
643 fn dec_formats_with_sign() {
644 let p = Equatorial::j2000(Angle::from_degrees(0.0), Angle::from_degrees(-5.5)).unwrap();
645 assert!(p.dec_to_sexagesimal(0).starts_with("-05:"));
646 let q = Equatorial::j2000(Angle::from_degrees(0.0), Angle::from_degrees(5.5)).unwrap();
647 assert!(q.dec_to_sexagesimal(0).starts_with("+05:"));
648 }
649
650 #[test]
652 fn separation_known_cases() {
653 let m31 =
654 Equatorial::j2000(Angle::from_degrees(10.6847), Angle::from_degrees(41.2688)).unwrap();
655 assert!(separation(m31, m31).arcseconds() < 1e-6);
656 let a = Equatorial::j2000(Angle::from_degrees(100.0), Angle::from_degrees(0.0)).unwrap();
657 let b = Equatorial::j2000(Angle::from_degrees(101.0), Angle::from_degrees(0.0)).unwrap();
658 assert!(approx(separation(a, b).degrees(), 1.0, 1e-9));
659 let c = Equatorial::j2000(Angle::from_degrees(100.0), Angle::from_degrees(60.0)).unwrap();
661 let d = Equatorial::j2000(Angle::from_degrees(101.0), Angle::from_degrees(60.0)).unwrap();
662 assert!(approx(separation(c, d).degrees(), 0.5, 1e-3));
663 let m110 =
665 Equatorial::j2000(Angle::from_degrees(10.0921), Angle::from_degrees(41.6853)).unwrap();
666 assert!((0.4..0.9).contains(&separation(m31, m110).degrees()));
667 }
668
669 #[test]
670 fn separation_symmetric_and_antipodal() {
671 let a = Equatorial::j2000(Angle::from_degrees(0.0), Angle::from_degrees(0.0)).unwrap();
672 let b = Equatorial::j2000(Angle::from_degrees(180.0), Angle::from_degrees(0.0)).unwrap();
673 assert!(approx(separation(a, b).degrees(), 180.0, 1e-6));
674 assert!(approx(
675 separation(a, b).degrees(),
676 separation(b, a).degrees(),
677 1e-12
678 ));
679 }
680
681 #[test]
683 fn precession_identity_on_same_epoch() {
684 let p = Equatorial::j2000(Angle::from_degrees(45.0), Angle::from_degrees(20.0)).unwrap();
685 assert_eq!(precess(p, Epoch::J2000), p);
686 }
687
688 #[test]
689 fn precession_round_trip() {
690 let p = Equatorial::j2000(Angle::from_degrees(45.0), Angle::from_degrees(20.0)).unwrap();
691 let to_date = precess(p, Epoch::OfDate(2050.0));
692 assert_eq!(to_date.epoch(), Epoch::OfDate(2050.0));
693 let back = precess(to_date, Epoch::J2000);
694 assert!(separation(p, back).arcseconds() < 1e-6, "round-trip drift");
695 }
696
697 #[test]
698 fn precession_rate_matches_iau() {
699 let p = Equatorial::j2000(Angle::from_degrees(0.0), Angle::from_degrees(0.0)).unwrap();
701 let d = precess(p, Epoch::OfDate(2100.0));
702 assert!(
703 approx(d.dec().arcseconds(), 2004.31, 2.0),
704 "dec shift {}",
705 d.dec().arcseconds()
706 );
707 let d26 = precess(p, Epoch::OfDate(2026.0));
709 let shift = separation(p, d26).arcminutes();
710 assert!((5.0..30.0).contains(&shift), "26yr shift {shift} arcmin");
711 }
712}