Skip to main content

target_match/
angle.rs

1//! Angle and equatorial-coordinate primitives.
2//!
3//! - [`Angle`] — a unit-aware angle (degrees / radians / arcminutes / arcseconds
4//!   / hours) stored internally in radians.
5//! - [`Epoch`] — J2000 or epoch-of-date (a Julian year).
6//! - [`Equatorial`] — an RA/Dec sky position tagged with an epoch, with
7//!   sexagesimal and decimal parsing and formatting.
8//! - [`separation`] — great-circle (haversine) angular separation.
9//! - [`precess`] — IAU 1976 precession between J2000 and epoch-of-date.
10//!
11//! Matching treats supplied positions as **coordinates only** — no name is ever
12//! read (see the crate-level docs).
13
14use 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;
21/// Exact number of arcseconds in one radian (supersedes the rounded `206.265`).
22pub(crate) const ARCSEC_PER_RADIAN: f64 = 206_264.806_247_096_36;
23
24// ── Angle ──────────────────────────────────────────────────────────────────────
25
26/// A unit-aware angle, stored internally in radians.
27///
28/// Construction and read-out are available in degrees, radians, arcminutes,
29/// arcseconds, and hours (1 hour = 15°). Normalization is explicit — an `Angle`
30/// holds whatever finite value it was given until you ask for a normalized form.
31#[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    /// Construct from radians.
39    #[must_use]
40    pub const fn from_radians(radians: f64) -> Self {
41        Self { radians }
42    }
43    /// Construct from decimal degrees.
44    #[must_use]
45    pub fn from_degrees(degrees: f64) -> Self {
46        Self {
47            radians: degrees * RAD_PER_DEG,
48        }
49    }
50    /// Construct from arcminutes (1/60 degree).
51    #[must_use]
52    pub fn from_arcminutes(arcmin: f64) -> Self {
53        Self::from_degrees(arcmin / 60.0)
54    }
55    /// Construct from arcseconds (1/3600 degree).
56    #[must_use]
57    pub fn from_arcseconds(arcsec: f64) -> Self {
58        Self::from_degrees(arcsec / 3600.0)
59    }
60    /// Construct from hours of right ascension (1 hour = 15°).
61    #[must_use]
62    pub fn from_hours(hours: f64) -> Self {
63        Self::from_degrees(hours * 15.0)
64    }
65
66    /// Value in radians.
67    #[must_use]
68    pub const fn radians(self) -> f64 {
69        self.radians
70    }
71    /// Value in decimal degrees.
72    #[must_use]
73    pub fn degrees(self) -> f64 {
74        self.radians * DEG_PER_RAD
75    }
76    /// Value in arcminutes.
77    #[must_use]
78    pub fn arcminutes(self) -> f64 {
79        self.degrees() * 60.0
80    }
81    /// Value in arcseconds.
82    #[must_use]
83    pub fn arcseconds(self) -> f64 {
84        self.degrees() * 3600.0
85    }
86    /// Value in hours (degrees / 15).
87    #[must_use]
88    pub fn hours(self) -> f64 {
89        self.degrees() / 15.0
90    }
91
92    /// Return an equivalent angle wrapped into `[0, 360)` degrees.
93    #[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    /// Return an equivalent angle wrapped into `(-180, 180]` degrees.
102    #[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// ── Epoch ──────────────────────────────────────────────────────────────────────
138
139/// The reference epoch of a sky position.
140///
141/// `OfDate` carries a Julian year (e.g. `2026.5`) — the observation instant to
142/// day precision, which is far finer than precession needs. Because the year is
143/// always present, a "JNow without a date" state is unrepresentable and
144/// precession is always well-defined.
145#[derive(Debug, Clone, Copy, PartialEq)]
146#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
147pub enum Epoch {
148    /// The J2000.0 standard epoch (≈ ICRS for planning-grade work).
149    J2000,
150    /// Epoch of date, as a Julian year.
151    OfDate(f64),
152}
153
154impl Epoch {
155    /// Julian centuries of this epoch measured from J2000 (`J2000` → 0).
156    #[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// ── Equatorial ─────────────────────────────────────────────────────────────────
166
167/// An equatorial sky position (right ascension, declination) tagged with an
168/// [`Epoch`]. After construction, `ra ∈ [0, 360)`° and `dec ∈ [-90, 90]`°.
169#[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    /// Construct from RA/Dec angles at an epoch, validating domains.
179    ///
180    /// # Errors
181    /// [`Error::OutOfRange`] if RA ∉ [0, 360), Dec ∉ [-90, 90], or the epoch
182    /// year is non-finite.
183    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    /// Construct a J2000 position from RA/Dec angles.
210    ///
211    /// # Errors
212    /// See [`Equatorial::new`].
213    pub fn j2000(ra: Angle, dec: Angle) -> Result<Self> {
214        Self::new(ra, dec, Epoch::J2000)
215    }
216
217    /// Parse RA and Dec strings (sexagesimal or decimal) at an epoch.
218    ///
219    /// RA accepts `HH:MM:SS(.s)`, `HH MM SS`, or a bare decimal in **degrees**
220    /// (sexagesimal RA is hours ×15). Dec accepts `±DD:MM:SS(.s)`, `±DD MM SS`,
221    /// or decimal degrees.
222    ///
223    /// # Errors
224    /// [`Error::ParseCoord`] on malformed input; [`Error::OutOfRange`] on domain.
225    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    /// Parse a J2000 position from RA/Dec strings.
236    ///
237    /// # Errors
238    /// See [`Equatorial::parse`].
239    pub fn parse_j2000(ra: &str, dec: &str) -> Result<Self> {
240        Self::parse(ra, dec, Epoch::J2000)
241    }
242
243    /// Right ascension.
244    #[must_use]
245    pub fn ra(self) -> Angle {
246        self.ra
247    }
248    /// Declination.
249    #[must_use]
250    pub fn dec(self) -> Angle {
251        self.dec
252    }
253    /// Reference epoch.
254    #[must_use]
255    pub fn epoch(self) -> Epoch {
256        self.epoch
257    }
258    /// `(ra_degrees, dec_degrees)`.
259    #[must_use]
260    pub fn to_degrees(self) -> (f64, f64) {
261        (self.ra.degrees(), self.dec.degrees())
262    }
263
264    /// Format RA as `HH:MM:SS.sss` with the given number of fractional-second digits.
265    #[must_use]
266    pub fn ra_to_sexagesimal(self, decimals: usize) -> String {
267        format_sexagesimal(self.ra.hours(), 24.0, false, decimals)
268    }
269    /// Format Dec as `±DD:MM:SS.sss` with the given number of fractional-second digits.
270    #[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    /// Unit direction vector `(x, y, z)` on the celestial sphere.
276    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    /// Build a position from a unit vector at the given epoch (RA normalized to
282    /// `[0, 360)`).
283    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// ── Separation ─────────────────────────────────────────────────────────────────
291
292/// Great-circle angular separation between two positions (haversine form).
293///
294/// The result is in `[0, 180]`°, symmetric in its arguments, and numerically
295/// stable for the small separations that dominate frame matching. Epochs are not
296/// reconciled here — this is a purely geometric operation on the given numbers.
297#[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    // hav(θ) = sin²(Δδ/2) + cos δ1 · cos δ2 · sin²(Δα/2)
305    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// ── Precession (IAU 1976, Meeus ch. 21) ──────────────────────────────────────────
311
312/// Precess a position to another epoch using IAU 1976 precession.
313///
314/// Handles J2000 → epoch-of-date, epoch-of-date → J2000, and date → date (via
315/// J2000). Accurate to ≤ ~1 arcsecond over several centuries — well inside
316/// planning grade. Apparent-place terms (nutation, aberration, proper motion)
317/// are out of scope. Precessing to the same epoch is the identity.
318#[must_use]
319pub fn precess(pos: Equatorial, to: Epoch) -> Equatorial {
320    if pos.epoch == to {
321        return pos;
322    }
323    // Reduce to J2000 first, then forward to the target.
324    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
340/// IAU 1976 precession matrix taking a J2000 unit vector to epoch-of-`year`.
341///
342/// `P = R3(-z) · R2(θ) · R3(-ζ)` with the accumulated angles (T = 0 form, since
343/// the reference epoch is always J2000).
344fn precession_matrix(year: f64) -> [[f64; 3]; 3] {
345    let t = (year - 2000.0) / 100.0; // Julian centuries from J2000
346    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
387// ── Sexagesimal parse / format ───────────────────────────────────────────────────
388
389/// Parse right ascension into decimal degrees. Sexagesimal is hours (×15); a bare
390/// decimal is degrees.
391fn 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
399/// Parse declination into decimal degrees (always degrees).
400fn 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
421/// Parse `±D:M:S(.s)` / `±D M S` into a signed decimal value (degrees for Dec,
422/// hours for RA). Minutes/seconds must be non-negative; the sign comes from the
423/// leading field.
424fn 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
465/// Format a signed decimal value as sexagesimal `[-]AA:BB:CC.cc`. `wrap` is the
466/// modulus for the leading field (24 for hours, 90 unused for dec since dec is
467/// bounded). `signed` prints a leading `+`/`-`.
468fn format_sexagesimal(value: f64, _wrap: f64, signed: bool, decimals: usize) -> String {
469    let neg = value < 0.0;
470    let mut v = value.abs();
471    // Round at the requested precision to avoid 59.9999 rollovers.
472    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    // ── Angle ──
503    #[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    // ── Epoch ──
546    #[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    // ── Equatorial construction / validation ──
561    #[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    // ── Parsing ──
592    #[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        // RA "06:00:00" = 6h = 90°; Dec "06:00:00" = 6°.
604        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    // ── Formatting / round-trip ──
633    #[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    // ── Separation ──
651    #[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        // RA compression at dec 60°.
660        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        // M31 ↔ M110 ≈ 0.62°.
664        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    // ── Precession ──
682    #[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        // At (α=0, δ=0), precessing J2000 → +1 century shifts Dec by ≈ θ (2004.31").
700        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        // The shift for 26 years is tens of arcminutes (non-negligible for JNow).
708        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}