Skip to main content

skymath/
coords.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Equatorial coordinates, spherical geometry, and precession.
6//!
7//! Provenance: [`Epoch`], [`Equatorial`], [`separation`], and [`precess`] are
8//! extracted from the sibling crate `target-match`; [`position_angle`] and the
9//! tangent-offset decomposition are hoisted from its matcher and made public;
10//! [`apply_offset`] (the inverse, spherical destination-point) is new here.
11//!
12//! Precession is IAU 1976 (Meeus ch. 21), planning grade: ≤ ~1 arcsecond over
13//! several centuries. Apparent-place terms (nutation, aberration, proper
14//! motion) are out of scope by design.
15
16use crate::angle::{
17    decompose_magnitude, format_dec, format_ra, parse_dec, parse_ra, Angle, ParseMode, SexaStyle,
18};
19use crate::error::{Error, Result};
20
21const RAD_PER_DEG: f64 = core::f64::consts::PI / 180.0;
22
23// ── Epoch ──────────────────────────────────────────────────────────────────────
24
25/// The reference epoch of a sky position.
26///
27/// `OfDate` carries a Julian year (e.g. `2026.5`) — the observation instant to
28/// day precision, which is far finer than precession needs. Because the year is
29/// always present, a "JNow without a date" state is unrepresentable and
30/// precession is always well-defined.
31#[derive(Debug, Clone, Copy, PartialEq)]
32#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
33pub enum Epoch {
34    /// The J2000.0 standard epoch (≈ ICRS for planning-grade work).
35    J2000,
36    /// Epoch of date, as a Julian year.
37    OfDate(f64),
38}
39
40impl Epoch {
41    /// Julian centuries of this epoch measured from J2000 (`J2000` → 0).
42    ///
43    /// ```
44    /// use skymath::Epoch;
45    ///
46    /// assert_eq!(Epoch::J2000.julian_centuries_from_j2000(), 0.0);
47    /// assert!((Epoch::OfDate(2100.0).julian_centuries_from_j2000() - 1.0).abs() < 1e-9);
48    /// ```
49    #[must_use]
50    pub fn julian_centuries_from_j2000(self) -> f64 {
51        match self {
52            Epoch::J2000 => 0.0,
53            Epoch::OfDate(year) => (year - 2000.0) / 100.0,
54        }
55    }
56}
57
58// ── Equatorial ─────────────────────────────────────────────────────────────────
59
60/// An equatorial sky position (right ascension, declination) tagged with an
61/// [`Epoch`]. After construction, `ra ∈ [0, 360)`° and `dec ∈ [-90, 90]`°.
62///
63/// ```
64/// use skymath::{Equatorial, ParseMode, SexaStyle};
65///
66/// let m31 = Equatorial::parse_j2000("00:42:44.3", "+41:16:09", ParseMode::Strict)?;
67/// assert_eq!(m31.ra_sexagesimal(SexaStyle::default()), "00:42:44.30");
68/// assert_eq!(m31.dec_sexagesimal(SexaStyle::default()), "+41:16:09.00");
69/// # Ok::<(), skymath::Error>(())
70/// ```
71#[derive(Debug, Clone, Copy, PartialEq)]
72#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
73pub struct Equatorial {
74    ra: Angle,
75    dec: Angle,
76    epoch: Epoch,
77}
78
79impl Equatorial {
80    /// Construct from RA/Dec angles at an epoch, validating domains.
81    ///
82    /// # Errors
83    /// [`Error::OutOfRange`] if RA ∉ [0, 360), Dec ∉ [-90, 90], or the epoch
84    /// year is non-finite.
85    ///
86    /// ```
87    /// use skymath::{Angle, Epoch, Equatorial};
88    ///
89    /// let m31 = Equatorial::at_epoch(
90    ///     Angle::from_degrees(10.6847),
91    ///     Angle::from_degrees(41.2688),
92    ///     Epoch::J2000,
93    /// )?;
94    /// assert_eq!(m31.epoch(), Epoch::J2000);
95    /// # Ok::<(), skymath::Error>(())
96    /// ```
97    pub fn at_epoch(ra: Angle, dec: Angle, epoch: Epoch) -> Result<Self> {
98        let ra_deg = ra.degrees();
99        let dec_deg = dec.degrees();
100        if !ra_deg.is_finite() || !(0.0..360.0).contains(&ra_deg) {
101            return Err(Error::OutOfRange {
102                what: "right ascension",
103                value: ra_deg,
104            });
105        }
106        if !dec_deg.is_finite() || !(-90.0..=90.0).contains(&dec_deg) {
107            return Err(Error::OutOfRange {
108                what: "declination",
109                value: dec_deg,
110            });
111        }
112        if let Epoch::OfDate(year) = epoch {
113            if !year.is_finite() {
114                return Err(Error::OutOfRange {
115                    what: "epoch year",
116                    value: year,
117                });
118            }
119        }
120        Ok(Self { ra, dec, epoch })
121    }
122
123    /// Construct a J2000 position from RA/Dec angles.
124    ///
125    /// # Errors
126    /// See [`Equatorial::at_epoch`].
127    ///
128    /// ```
129    /// use skymath::{Angle, Equatorial};
130    ///
131    /// let m31 = Equatorial::j2000(Angle::from_degrees(10.6847), Angle::from_degrees(41.2688))?;
132    /// assert!((m31.ra().degrees() - 10.6847).abs() < 1e-9);
133    /// # Ok::<(), skymath::Error>(())
134    /// ```
135    pub fn j2000(ra: Angle, dec: Angle) -> Result<Self> {
136        Self::at_epoch(ra, dec, Epoch::J2000)
137    }
138
139    /// Construct a J2000 position from RA/Dec in decimal degrees, normalizing
140    /// out-of-range-but-finite values instead of rejecting them: RA wraps into
141    /// `[0, 360)` and Dec clamps into `[-90, 90]`. Useful for catalog or
142    /// user-entered values that may drift outside domain (e.g. RA of `370.0`
143    /// or Dec of `91.0`) but are still meaningful positions once normalized.
144    ///
145    /// # Errors
146    /// [`Error::OutOfRange`] if `ra_deg` or `dec_deg` is non-finite (NaN/±inf)
147    /// — normalization cannot recover a position from those.
148    ///
149    /// ```
150    /// use skymath::Equatorial;
151    ///
152    /// let wrapped = Equatorial::j2000_lenient(370.0, 91.0)?;
153    /// assert!((wrapped.ra().degrees() - 10.0).abs() < 1e-9);
154    /// assert!((wrapped.dec().degrees() - 90.0).abs() < 1e-9);
155    ///
156    /// assert!(Equatorial::j2000_lenient(f64::NAN, 0.0).is_err());
157    /// # Ok::<(), skymath::Error>(())
158    /// ```
159    pub fn j2000_lenient(ra_deg: f64, dec_deg: f64) -> Result<Self> {
160        if !ra_deg.is_finite() {
161            return Err(Error::OutOfRange {
162                what: "right ascension",
163                value: ra_deg,
164            });
165        }
166        if !dec_deg.is_finite() {
167            return Err(Error::OutOfRange {
168                what: "declination",
169                value: dec_deg,
170            });
171        }
172        let ra = Angle::from_degrees(ra_deg).normalized_0_360();
173        let dec = Angle::from_degrees(dec_deg.clamp(-90.0, 90.0));
174        Self::j2000(ra, dec)
175    }
176
177    /// Parse RA and Dec strings (sexagesimal, or decimal in lenient mode) at an
178    /// epoch. Sexagesimal RA is hours (×15); Dec is degrees.
179    ///
180    /// # Errors
181    /// [`Error::ParseCoord`] on malformed input; [`Error::OutOfRange`] on domain.
182    ///
183    /// ```
184    /// use skymath::{Epoch, Equatorial, ParseMode};
185    ///
186    /// let m31 = Equatorial::parse_at_epoch("00:42:44.3", "+41:16:09", Epoch::J2000, ParseMode::Strict)?;
187    /// assert_eq!(m31.epoch(), Epoch::J2000);
188    /// # Ok::<(), skymath::Error>(())
189    /// ```
190    pub fn parse_at_epoch(ra: &str, dec: &str, epoch: Epoch, mode: ParseMode) -> Result<Self> {
191        Self::at_epoch(parse_ra(ra, mode)?, parse_dec(dec, mode)?, epoch)
192    }
193
194    /// Parse a J2000 position from RA/Dec strings.
195    ///
196    /// # Errors
197    /// See [`Equatorial::parse_at_epoch`].
198    ///
199    /// ```
200    /// use skymath::{Equatorial, ParseMode};
201    ///
202    /// let m31 = Equatorial::parse_j2000("00:42:44.3", "+41:16:09", ParseMode::Strict)?;
203    /// assert!((m31.dec().degrees() - 41.269_17).abs() < 1e-3);
204    /// # Ok::<(), skymath::Error>(())
205    /// ```
206    pub fn parse_j2000(ra: &str, dec: &str, mode: ParseMode) -> Result<Self> {
207        Self::parse_at_epoch(ra, dec, Epoch::J2000, mode)
208    }
209
210    /// Right ascension.
211    ///
212    /// ```
213    /// use skymath::{Equatorial, ParseMode};
214    ///
215    /// let m31 = Equatorial::parse_j2000("00:42:44.3", "+41:16:09", ParseMode::Strict)?;
216    /// assert!((m31.ra().hours() - 0.7123).abs() < 1e-3);
217    /// # Ok::<(), skymath::Error>(())
218    /// ```
219    #[must_use]
220    pub fn ra(self) -> Angle {
221        self.ra
222    }
223    /// Declination.
224    ///
225    /// ```
226    /// use skymath::{Equatorial, ParseMode};
227    ///
228    /// let m31 = Equatorial::parse_j2000("00:42:44.3", "+41:16:09", ParseMode::Strict)?;
229    /// assert!((m31.dec().degrees() - 41.269_17).abs() < 1e-3);
230    /// # Ok::<(), skymath::Error>(())
231    /// ```
232    #[must_use]
233    pub fn dec(self) -> Angle {
234        self.dec
235    }
236    /// Reference epoch.
237    ///
238    /// ```
239    /// use skymath::{Epoch, Equatorial, ParseMode};
240    ///
241    /// let m31 = Equatorial::parse_j2000("00:42:44.3", "+41:16:09", ParseMode::Strict)?;
242    /// assert_eq!(m31.epoch(), Epoch::J2000);
243    /// # Ok::<(), skymath::Error>(())
244    /// ```
245    #[must_use]
246    pub fn epoch(self) -> Epoch {
247        self.epoch
248    }
249    /// `(ra_degrees, dec_degrees)`.
250    ///
251    /// ```
252    /// use skymath::{Equatorial, ParseMode};
253    ///
254    /// let m31 = Equatorial::parse_j2000("00:42:44.3", "+41:16:09", ParseMode::Strict)?;
255    /// let (ra_deg, dec_deg) = m31.to_degrees();
256    /// assert!((ra_deg - 10.6846).abs() < 1e-3);
257    /// assert!((dec_deg - 41.2692).abs() < 1e-3);
258    /// # Ok::<(), skymath::Error>(())
259    /// ```
260    #[must_use]
261    pub fn to_degrees(self) -> (f64, f64) {
262        (self.ra.degrees(), self.dec.degrees())
263    }
264
265    /// Format RA as sexagesimal hours in the given style.
266    ///
267    /// ```
268    /// use skymath::{Equatorial, ParseMode, SexaStyle};
269    ///
270    /// let m31 = Equatorial::parse_j2000("00:42:44.3", "+41:16:09", ParseMode::Strict)?;
271    /// assert_eq!(m31.ra_sexagesimal(SexaStyle::default()), "00:42:44.30");
272    /// # Ok::<(), skymath::Error>(())
273    /// ```
274    #[must_use]
275    pub fn ra_sexagesimal(self, style: SexaStyle) -> String {
276        format_ra(self.ra, style)
277    }
278    /// Format Dec as signed sexagesimal degrees in the given style.
279    ///
280    /// ```
281    /// use skymath::{Equatorial, ParseMode, SexaStyle};
282    ///
283    /// let m31 = Equatorial::parse_j2000("00:42:44.3", "+41:16:09", ParseMode::Strict)?;
284    /// assert_eq!(m31.dec_sexagesimal(SexaStyle::default()), "+41:16:09.00");
285    /// # Ok::<(), skymath::Error>(())
286    /// ```
287    #[must_use]
288    pub fn dec_sexagesimal(self, style: SexaStyle) -> String {
289        format_dec(self.dec, style)
290    }
291
292    /// Right ascension as raw sexagesimal components: `(hours, minutes,
293    /// seconds)`. Hours are wrapped into `[0, 24)` (RA has no sign). `seconds`
294    /// is not rounded to a display precision — carries (e.g. `59.9996s`
295    /// rolling into the next minute) are the caller's responsibility if they
296    /// build a display string themselves; use [`Equatorial::ra_sexagesimal`]
297    /// for carry-safe formatted output.
298    ///
299    /// ```
300    /// use skymath::{Equatorial, ParseMode};
301    ///
302    /// let m31 = Equatorial::parse_j2000("00:42:44.3", "+41:16:09", ParseMode::Strict)?;
303    /// let (h, m, s) = m31.ra_hms();
304    /// assert_eq!((h, m), (0, 42));
305    /// assert!((s - 44.3).abs() < 1e-2);
306    /// # Ok::<(), skymath::Error>(())
307    /// ```
308    #[must_use]
309    pub fn ra_hms(self) -> (u32, u32, f64) {
310        let hours = self.ra.normalized_0_360().degrees() / 15.0;
311        decompose_magnitude(hours)
312    }
313
314    /// Declination as raw sexagesimal components: `(negative, degrees,
315    /// minutes, seconds)`. `seconds` is not rounded to a display precision —
316    /// see [`Equatorial::ra_hms`].
317    ///
318    /// ```
319    /// use skymath::{Equatorial, ParseMode};
320    ///
321    /// let m31 = Equatorial::parse_j2000("00:42:44.3", "+41:16:09", ParseMode::Strict)?;
322    /// let (neg, d, m, s) = m31.dec_dms();
323    /// assert!(!neg);
324    /// assert_eq!((d, m), (41, 16));
325    /// assert!((s - 9.0).abs() < 1e-2);
326    /// # Ok::<(), skymath::Error>(())
327    /// ```
328    #[must_use]
329    pub fn dec_dms(self) -> (bool, u32, u32, f64) {
330        let deg = self.dec.degrees();
331        let neg = deg.is_sign_negative() && deg != 0.0;
332        let (d, m, s) = decompose_magnitude(deg.abs());
333        (neg, d, m, s)
334    }
335
336    /// Unit direction vector `(x, y, z)` on the celestial sphere.
337    pub(crate) fn to_unit_vector(self) -> [f64; 3] {
338        let (a, d) = (self.ra.radians(), self.dec.radians());
339        [d.cos() * a.cos(), d.cos() * a.sin(), d.sin()]
340    }
341
342    /// Build a position from a unit vector at the given epoch (RA normalized to
343    /// `[0, 360)`).
344    pub(crate) fn from_unit_vector(v: [f64; 3], epoch: Epoch) -> Self {
345        let ra = Angle::from_radians(v[1].atan2(v[0])).normalized_0_360();
346        let dec = Angle::from_radians(v[2].atan2((v[0] * v[0] + v[1] * v[1]).sqrt()));
347        Self { ra, dec, epoch }
348    }
349}
350
351// ── Separation & position angle ────────────────────────────────────────────────
352
353/// Great-circle angular separation between two positions (haversine form).
354///
355/// The result is in `[0, 180]`°, symmetric in its arguments, and numerically
356/// stable for small separations. Epochs are not reconciled here — this is a
357/// purely geometric operation on the given numbers.
358///
359/// ```
360/// use skymath::{separation, Equatorial, ParseMode};
361///
362/// let m31 = Equatorial::parse_j2000("00:42:44.3", "+41:16:09", ParseMode::Strict)?;
363/// let m110 = Equatorial::parse_j2000("00:40:22.1", "+41:41:07", ParseMode::Lenient)?;
364/// assert!((separation(m31, m110).arcminutes() - 36.5).abs() < 1.0);
365/// # Ok::<(), skymath::Error>(())
366/// ```
367#[must_use]
368pub fn separation(a: Equatorial, b: Equatorial) -> Angle {
369    let (ra1, dec1) = (a.ra.radians(), a.dec.radians());
370    let (ra2, dec2) = (b.ra.radians(), b.dec.radians());
371    let (dra, ddec) = (ra2 - ra1, dec2 - dec1);
372    let sin_ddec = (ddec / 2.0).sin();
373    let sin_dra = (dra / 2.0).sin();
374    // hav(θ) = sin²(Δδ/2) + cos δ1 · cos δ2 · sin²(Δα/2)
375    let h = sin_ddec.mul_add(sin_ddec, dec1.cos() * dec2.cos() * sin_dra * sin_dra);
376    let central = 2.0 * h.sqrt().clamp(0.0, 1.0).asin();
377    Angle::from_radians(central)
378}
379
380/// Position angle from `from` to `to`, measured East of North, in `[0, 360)`°.
381///
382/// At the celestial poles every direction is "south"/"north"; the atan2
383/// convention there yields a defined (if arbitrary) angle rather than NaN.
384///
385/// ```
386/// use skymath::{position_angle, Equatorial, ParseMode};
387///
388/// let m31 = Equatorial::parse_j2000("00:42:44.3", "+41:16:09", ParseMode::Strict)?;
389/// let m110 = Equatorial::parse_j2000("00:40:22.1", "+41:41:07", ParseMode::Lenient)?;
390/// // M110 sits west and slightly north of M31.
391/// assert!((180.0..360.0).contains(&position_angle(m31, m110).degrees()));
392/// # Ok::<(), skymath::Error>(())
393/// ```
394#[must_use]
395pub fn position_angle(from: Equatorial, to: Equatorial) -> Angle {
396    let (a0, d0) = (from.ra.radians(), from.dec.radians());
397    let (a, d) = (to.ra.radians(), to.dec.radians());
398    let da = a - a0;
399    let y = d.cos() * da.sin();
400    let x = d0.cos() * d.sin() - d0.sin() * d.cos() * da.cos();
401    Angle::from_radians(y.atan2(x)).normalized_0_360()
402}
403
404/// Parallel-transport a sky position angle along the shortest great-circle
405/// path from one position to another.
406///
407/// The input and result are measured East of North in their respective local
408/// tangent bases. The result is normalized to `[0, 360)`°. Epochs are not
409/// reconciled; this is a geometric operation on the supplied coordinates.
410///
411/// Returns `None` when the positions are antipodal or numerically close enough
412/// to antipodal that the shortest path is not unique. Identical positions
413/// preserve the physical tangent direction. At a celestial pole, different
414/// right ascensions select different local bases, so aliases of the same pole
415/// can produce different numeric angles.
416///
417/// ```
418/// use skymath::{transport_position_angle, Angle, Equatorial};
419///
420/// let from = Equatorial::j2000(Angle::from_degrees(10.0), Angle::from_degrees(0.0))?;
421/// let to = Equatorial::j2000(Angle::from_degrees(20.0), Angle::from_degrees(0.0))?;
422/// let transported = transport_position_angle(from, to, Angle::from_degrees(0.0)).unwrap();
423/// assert!(transported.degrees().abs() < 1e-12);
424/// # Ok::<(), skymath::Error>(())
425/// ```
426#[must_use]
427pub fn transport_position_angle(from: Equatorial, to: Equatorial, angle: Angle) -> Option<Angle> {
428    let from_vector = from.to_unit_vector();
429    let to_vector = to.to_unit_vector();
430    let dot = dot_product(from_vector, to_vector).clamp(-1.0, 1.0);
431
432    // Within roughly 0.3 arcseconds of the antipode, the transport path is
433    // too ill-conditioned to select a meaningful orientation.
434    if dot <= -1.0 + 1e-12 {
435        return None;
436    }
437
438    let (from_east, from_north) = local_tangent_basis(from);
439    let tangent = add_vectors(
440        scale_vector(from_east, angle.radians().sin()),
441        scale_vector(from_north, angle.radians().cos()),
442    );
443    let axis = cross_product(from_vector, to_vector);
444    let axis_length = dot_product(axis, axis).sqrt();
445    let transported = if axis_length <= f64::EPSILON {
446        // Coincident sky vectors still need to be expressed in `to`'s local
447        // basis: pole coordinates with different RA values are such aliases.
448        tangent
449    } else {
450        let unit_axis = scale_vector(axis, 1.0 / axis_length);
451        add_vectors(
452            add_vectors(
453                scale_vector(tangent, dot),
454                scale_vector(cross_product(unit_axis, tangent), axis_length),
455            ),
456            scale_vector(unit_axis, dot_product(unit_axis, tangent) * (1.0 - dot)),
457        )
458    };
459
460    let (to_east, to_north) = local_tangent_basis(to);
461    Some(
462        Angle::from_radians(
463            dot_product(transported, to_east).atan2(dot_product(transported, to_north)),
464        )
465        .normalized_0_360(),
466    )
467}
468
469fn local_tangent_basis(position: Equatorial) -> ([f64; 3], [f64; 3]) {
470    let (ra, dec) = (position.ra.radians(), position.dec.radians());
471    let east = [-ra.sin(), ra.cos(), 0.0];
472    let north = [-dec.sin() * ra.cos(), -dec.sin() * ra.sin(), dec.cos()];
473    (east, north)
474}
475
476fn dot_product(a: [f64; 3], b: [f64; 3]) -> f64 {
477    a[0].mul_add(b[0], a[1].mul_add(b[1], a[2] * b[2]))
478}
479
480fn cross_product(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
481    [
482        a[1] * b[2] - a[2] * b[1],
483        a[2] * b[0] - a[0] * b[2],
484        a[0] * b[1] - a[1] * b[0],
485    ]
486}
487
488fn scale_vector(vector: [f64; 3], scale: f64) -> [f64; 3] {
489    [vector[0] * scale, vector[1] * scale, vector[2] * scale]
490}
491
492fn add_vectors(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
493    [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
494}
495
496// ── Gnomonic projection ───────────────────────────────────────────────────────
497
498/// A position in a gnomonic tangent plane.
499///
500/// Both coordinates are dimensionless ratios on the projection plane. For
501/// small offsets, their numeric values approximate angular offsets in radians.
502#[derive(Debug, Clone, Copy, PartialEq)]
503#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
504pub struct GnomonicPoint {
505    /// Coordinate toward increasing right ascension (East).
506    pub east: f64,
507    /// Coordinate toward the North celestial pole.
508    pub north: f64,
509}
510
511/// Project a sky position onto a gnomonic plane tangent at `center`.
512///
513/// Returns `None` for positions at or beyond the tangent horizon, where the
514/// projection is undefined. To avoid unstable plane coordinates, values whose
515/// projection denominator is at most `16 × f64::EPSILON` are treated as being
516/// on the horizon. Epochs are not reconciled.
517///
518/// ```
519/// use skymath::{gnomonic_project, Angle, Equatorial, GnomonicPoint};
520///
521/// let center = Equatorial::j2000(Angle::from_degrees(10.0), Angle::from_degrees(20.0))?;
522/// assert_eq!(
523///     gnomonic_project(center, center),
524///     Some(GnomonicPoint { east: 0.0, north: 0.0 }),
525/// );
526/// # Ok::<(), skymath::Error>(())
527/// ```
528#[must_use]
529pub fn gnomonic_project(center: Equatorial, point: Equatorial) -> Option<GnomonicPoint> {
530    let (ra0, dec0) = (center.ra.radians(), center.dec.radians());
531    let (ra, dec) = (point.ra.radians(), point.dec.radians());
532    let delta_ra = ra - ra0;
533    let denominator = dec0.sin() * dec.sin() + dec0.cos() * dec.cos() * delta_ra.cos();
534
535    // Values at this scale are numerically indistinguishable from the horizon
536    // and would amplify rounding into unusably large plane coordinates.
537    if denominator <= 16.0 * f64::EPSILON {
538        return None;
539    }
540
541    Some(GnomonicPoint {
542        east: dec.cos() * delta_ra.sin() / denominator,
543        north: (dec0.cos() * dec.sin() - dec0.sin() * dec.cos() * delta_ra.cos()) / denominator,
544    })
545}
546
547/// Convert a finite gnomonic tangent-plane position back to the sky.
548///
549/// The result uses `center`'s epoch. Returns `None` when either coordinate is
550/// non-finite or their magnitude cannot be represented by `f64`.
551///
552/// ```
553/// use skymath::{gnomonic_project, gnomonic_unproject, separation, Angle, Equatorial};
554///
555/// let center = Equatorial::j2000(Angle::from_degrees(359.0), Angle::from_degrees(40.0))?;
556/// let point = Equatorial::j2000(Angle::from_degrees(1.0), Angle::from_degrees(41.0))?;
557/// let projected = gnomonic_project(center, point).unwrap();
558/// let restored = gnomonic_unproject(center, projected).unwrap();
559/// assert!(separation(point, restored).arcseconds() < 1e-6);
560/// # Ok::<(), skymath::Error>(())
561/// ```
562#[must_use]
563pub fn gnomonic_unproject(center: Equatorial, point: GnomonicPoint) -> Option<Equatorial> {
564    if !point.east.is_finite() || !point.north.is_finite() {
565        return None;
566    }
567
568    let radius = point.east.hypot(point.north);
569    if !radius.is_finite() {
570        return None;
571    }
572    if radius == 0.0 {
573        return Some(center);
574    }
575
576    let angular_distance = radius.atan();
577    let (sin_distance, cos_distance) = angular_distance.sin_cos();
578    let (ra0, dec0) = (center.ra.radians(), center.dec.radians());
579    let dec = (cos_distance * dec0.sin() + point.north * sin_distance * dec0.cos() / radius)
580        .clamp(-1.0, 1.0)
581        .asin();
582    let ra = ra0
583        + (point.east * sin_distance)
584            .atan2(radius * dec0.cos() * cos_distance - point.north * dec0.sin() * sin_distance);
585
586    Some(Equatorial {
587        ra: Angle::from_radians(ra).normalized_0_360(),
588        dec: Angle::from_radians(dec),
589        epoch: center.epoch,
590    })
591}
592
593// ── Tangent-plane offsets ──────────────────────────────────────────────────────
594
595/// A sky-tangent offset decomposed into East and North components.
596#[derive(Debug, Clone, Copy, PartialEq)]
597#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
598pub struct TangentOffset {
599    /// Offset toward increasing RA (East), as an angle on the sky.
600    pub east: Angle,
601    /// Offset toward the North celestial pole, as an angle on the sky.
602    pub north: Angle,
603}
604
605/// Decompose the great-circle arc from `from` to `to` into East/North
606/// components via separation and position angle (robust polar form — never
607/// divides by the cosine of the separation). Inverse of [`apply_offset`].
608///
609/// ```
610/// use skymath::{apply_offset, separation, tangent_offset, Equatorial, ParseMode};
611///
612/// let m31 = Equatorial::parse_j2000("00:42:44.3", "+41:16:09", ParseMode::Strict)?;
613/// let m110 = Equatorial::parse_j2000("00:40:22.1", "+41:41:07", ParseMode::Lenient)?;
614/// let offset = tangent_offset(m31, m110);
615/// let back = apply_offset(m31, offset);
616/// assert!(separation(m110, back).arcseconds() < 1e-3);
617/// # Ok::<(), skymath::Error>(())
618/// ```
619#[must_use]
620pub fn tangent_offset(from: Equatorial, to: Equatorial) -> TangentOffset {
621    let sep = separation(from, to).radians();
622    let pa = position_angle(from, to).radians();
623    TangentOffset {
624        east: Angle::from_radians(sep * pa.sin()),
625        north: Angle::from_radians(sep * pa.cos()),
626    }
627}
628
629/// Apply an East/North offset to a position: travel the great circle whose
630/// initial bearing is the offset's position angle for the offset's arc length
631/// (spherical destination-point formula). Inverse of [`tangent_offset`].
632///
633/// The result keeps `from`'s epoch. Declination is clamped to the valid domain
634/// against floating-point drift at the poles.
635///
636/// ```
637/// use skymath::{apply_offset, Angle, Equatorial, ParseMode, TangentOffset};
638///
639/// let m31 = Equatorial::parse_j2000("00:42:44.3", "+41:16:09", ParseMode::Strict)?;
640/// let offset = TangentOffset {
641///     east: Angle::from_arcminutes(10.0),
642///     north: Angle::from_arcminutes(0.0),
643/// };
644/// let shifted = apply_offset(m31, offset);
645/// assert!(shifted.ra().degrees() > m31.ra().degrees());
646/// # Ok::<(), skymath::Error>(())
647/// ```
648#[must_use]
649pub fn apply_offset(from: Equatorial, offset: TangentOffset) -> Equatorial {
650    let (e, n) = (offset.east.radians(), offset.north.radians());
651    let sep = e.hypot(n);
652    if sep == 0.0 {
653        return from;
654    }
655    let pa = e.atan2(n);
656    let (phi1, lam1) = (from.dec.radians(), from.ra.radians());
657    let (sin_phi2_raw, cos_sep) = (
658        phi1.sin() * sep.cos() + phi1.cos() * sep.sin() * pa.cos(),
659        sep.cos(),
660    );
661    let sin_phi2 = sin_phi2_raw.clamp(-1.0, 1.0);
662    let phi2 = sin_phi2.asin();
663    let lam2 = lam1 + (pa.sin() * sep.sin() * phi1.cos()).atan2(cos_sep - phi1.sin() * sin_phi2);
664    Equatorial {
665        ra: Angle::from_radians(lam2).normalized_0_360(),
666        dec: Angle::from_degrees(phi2.to_degrees().clamp(-90.0, 90.0)),
667        epoch: from.epoch,
668    }
669}
670
671// ── Precession (IAU 1976, Meeus ch. 21) ────────────────────────────────────────
672
673/// Precess a position to another epoch using IAU 1976 precession.
674///
675/// Handles J2000 → epoch-of-date, epoch-of-date → J2000, and date → date (via
676/// J2000). Accurate to ≤ ~1 arcsecond over several centuries — well inside
677/// planning grade. Precessing to the same epoch is the identity.
678///
679/// ```
680/// use skymath::{julian_epoch_of, precess, Epoch, Equatorial, ParseMode};
681/// use time::OffsetDateTime;
682///
683/// let m31 = Equatorial::parse_j2000("00:42:44.3", "+41:16:09", ParseMode::Strict)?;
684/// let tonight = julian_epoch_of(OffsetDateTime::now_utc());
685/// let of_date = precess(m31, tonight);
686/// assert_eq!(of_date.epoch(), tonight);
687/// assert_eq!(precess(m31, Epoch::J2000), m31);
688/// # Ok::<(), skymath::Error>(())
689/// ```
690#[must_use]
691pub fn precess(pos: Equatorial, to: Epoch) -> Equatorial {
692    if pos.epoch == to {
693        return pos;
694    }
695    // Reduce to J2000 first, then forward to the target.
696    let at_j2000 = match pos.epoch {
697        Epoch::J2000 => pos,
698        Epoch::OfDate(year) => {
699            let v = apply_matrix(&transpose(&precession_matrix(year)), pos.to_unit_vector());
700            Equatorial::from_unit_vector(v, Epoch::J2000)
701        }
702    };
703    match to {
704        Epoch::J2000 => at_j2000,
705        Epoch::OfDate(year) => {
706            let v = apply_matrix(&precession_matrix(year), at_j2000.to_unit_vector());
707            Equatorial::from_unit_vector(v, to)
708        }
709    }
710}
711
712/// IAU 1976 precession matrix taking a J2000 unit vector to epoch-of-`year`.
713///
714/// `P = R3(-z) · R2(θ) · R3(-ζ)` with the accumulated angles (T = 0 form, since
715/// the reference epoch is always J2000).
716fn precession_matrix(year: f64) -> [[f64; 3]; 3] {
717    let t = (year - 2000.0) / 100.0; // Julian centuries from J2000
718    let arcsec = |a: f64| a * (RAD_PER_DEG / 3600.0);
719    let zeta = arcsec(2306.2181 * t + 0.301_88 * t * t + 0.017_998 * t * t * t);
720    let z = arcsec(2306.2181 * t + 1.094_68 * t * t + 0.018_203 * t * t * t);
721    let theta = arcsec(2004.3109 * t - 0.426_65 * t * t - 0.041_833 * t * t * t);
722    mat_mul(&mat_mul(&rot_z(-z), &rot_y(theta)), &rot_z(-zeta))
723}
724
725fn rot_z(phi: f64) -> [[f64; 3]; 3] {
726    let (s, c) = phi.sin_cos();
727    [[c, s, 0.0], [-s, c, 0.0], [0.0, 0.0, 1.0]]
728}
729fn rot_y(phi: f64) -> [[f64; 3]; 3] {
730    let (s, c) = phi.sin_cos();
731    [[c, 0.0, -s], [0.0, 1.0, 0.0], [s, 0.0, c]]
732}
733fn mat_mul(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
734    let mut out = [[0.0; 3]; 3];
735    for (i, row) in out.iter_mut().enumerate() {
736        for (j, cell) in row.iter_mut().enumerate() {
737            *cell = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j];
738        }
739    }
740    out
741}
742fn transpose(m: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
743    let mut t = [[0.0; 3]; 3];
744    for i in 0..3 {
745        for j in 0..3 {
746            t[i][j] = m[j][i];
747        }
748    }
749    t
750}
751pub(crate) fn apply_matrix(m: &[[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
752    [
753        m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2],
754        m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2],
755        m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2],
756    ]
757}
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762
763    fn approx(a: f64, b: f64, eps: f64) -> bool {
764        (a - b).abs() < eps
765    }
766    fn eq(ra: f64, dec: f64) -> Equatorial {
767        Equatorial::j2000(Angle::from_degrees(ra), Angle::from_degrees(dec)).unwrap()
768    }
769
770    #[test]
771    fn equatorial_validates_domain() {
772        assert!(eq(10.0, 41.0).ra().degrees() > 0.0);
773        assert!(matches!(
774            Equatorial::j2000(Angle::from_degrees(360.0), Angle::from_degrees(0.0)),
775            Err(Error::OutOfRange {
776                what: "right ascension",
777                ..
778            })
779        ));
780        assert!(matches!(
781            Equatorial::j2000(Angle::from_degrees(0.0), Angle::from_degrees(90.1)),
782            Err(Error::OutOfRange {
783                what: "declination",
784                ..
785            })
786        ));
787        assert!(matches!(
788            Equatorial::at_epoch(
789                Angle::from_degrees(0.0),
790                Angle::from_degrees(0.0),
791                Epoch::OfDate(f64::NAN)
792            ),
793            Err(Error::OutOfRange {
794                what: "epoch year",
795                ..
796            })
797        ));
798    }
799
800    #[test]
801    fn j2000_lenient_wraps_ra_and_clamps_dec() {
802        let p = Equatorial::j2000_lenient(370.0, 91.0).unwrap();
803        assert!(approx(p.ra().degrees(), 10.0, 1e-9));
804        assert!(approx(p.dec().degrees(), 90.0, 1e-9));
805
806        let n = Equatorial::j2000_lenient(-10.0, -91.0).unwrap();
807        assert!(approx(n.ra().degrees(), 350.0, 1e-9));
808        assert!(approx(n.dec().degrees(), -90.0, 1e-9));
809
810        let unchanged = Equatorial::j2000_lenient(10.6847, 41.2688).unwrap();
811        assert!(approx(unchanged.ra().degrees(), 10.6847, 1e-9));
812        assert!(approx(unchanged.dec().degrees(), 41.2688, 1e-9));
813    }
814
815    #[test]
816    fn j2000_lenient_rejects_non_finite() {
817        assert!(matches!(
818            Equatorial::j2000_lenient(f64::NAN, 0.0),
819            Err(Error::OutOfRange {
820                what: "right ascension",
821                ..
822            })
823        ));
824        assert!(matches!(
825            Equatorial::j2000_lenient(0.0, f64::INFINITY),
826            Err(Error::OutOfRange {
827                what: "declination",
828                ..
829            })
830        ));
831    }
832
833    #[test]
834    fn ra_hms_and_dec_dms_components() {
835        let m31 = Equatorial::parse_j2000("00:42:44.3", "+41:16:09", ParseMode::Strict).unwrap();
836        let (h, m, s) = m31.ra_hms();
837        assert_eq!((h, m), (0, 42));
838        assert!(approx(s, 44.3, 1e-2));
839
840        let (neg, d, m, s) = m31.dec_dms();
841        assert!(!neg);
842        assert_eq!((d, m), (41, 16));
843        assert!(approx(s, 9.0, 1e-2));
844    }
845
846    #[test]
847    fn dec_dms_negative_sign_and_zero_edge() {
848        let south = Equatorial::parse_j2000("10:00:00", "-05:30:00", ParseMode::Strict).unwrap();
849        let (neg, d, m, _) = south.dec_dms();
850        assert!(neg);
851        assert_eq!((d, m), (5, 30));
852
853        let zero = Equatorial::parse_j2000("00:00:00", "00:00:00", ParseMode::Strict).unwrap();
854        let (neg, d, m, s) = zero.dec_dms();
855        assert!(!neg);
856        assert_eq!((d, m), (0, 0));
857        assert!(approx(s, 0.0, 1e-9));
858    }
859
860    #[test]
861    fn ra_hms_wraps_into_0_24() {
862        let p = eq(360.0 - 1e-9, 0.0);
863        let (h, _, _) = p.ra_hms();
864        assert!(h < 24);
865    }
866
867    #[test]
868    fn parse_ra_is_hours_dec_is_degrees() {
869        let p = Equatorial::parse_j2000("06:00:00", "06:00:00", ParseMode::Strict).unwrap();
870        assert!(approx(p.ra().degrees(), 90.0, 1e-9));
871        assert!(approx(p.dec().degrees(), 6.0, 1e-9));
872    }
873
874    #[test]
875    fn separation_known_cases() {
876        let m31 = eq(10.6847, 41.2688);
877        assert!(separation(m31, m31).arcseconds() < 1e-6);
878        let (a, b) = (eq(100.0, 0.0), eq(101.0, 0.0));
879        assert!(approx(separation(a, b).degrees(), 1.0, 1e-9));
880        let (c, d) = (eq(100.0, 60.0), eq(101.0, 60.0));
881        assert!(approx(separation(c, d).degrees(), 0.5, 1e-3));
882        let m110 = eq(10.0921, 41.6853);
883        assert!((0.4..0.9).contains(&separation(m31, m110).degrees()));
884    }
885
886    #[test]
887    fn position_angle_cardinal_directions() {
888        let c = eq(180.0, 0.0);
889        // Due north (+dec) → PA 0; due east (+ra) → PA 90.
890        assert!(approx(
891            position_angle(c, eq(180.0, 1.0)).degrees(),
892            0.0,
893            1e-6
894        ));
895        assert!(approx(
896            position_angle(c, eq(181.0, 0.0)).degrees(),
897            90.0,
898            1e-6
899        ));
900        assert!(approx(
901            position_angle(c, eq(180.0, -1.0)).degrees(),
902            180.0,
903            1e-6
904        ));
905        assert!(approx(
906            position_angle(c, eq(179.0, 0.0)).degrees(),
907            270.0,
908            1e-6
909        ));
910    }
911
912    #[test]
913    fn transport_identity_and_equator() {
914        let from = eq(10.0, 0.0);
915        assert!(approx(
916            transport_position_angle(from, from, Angle::from_degrees(370.0))
917                .unwrap()
918                .degrees(),
919            10.0,
920            1e-12
921        ));
922
923        let to = eq(80.0, 0.0);
924        assert!(approx(
925            transport_position_angle(from, to, Angle::from_degrees(0.0))
926                .unwrap()
927                .degrees(),
928            0.0,
929            1e-12
930        ));
931    }
932
933    #[test]
934    fn transport_tangent_arrives_on_same_geodesic() {
935        let from = eq(12.0, 34.0);
936        let to = eq(123.0, 56.0);
937        let departure = position_angle(from, to);
938        let expected_arrival =
939            (position_angle(to, from) + Angle::from_degrees(180.0)).normalized_0_360();
940        let transported = transport_position_angle(from, to, departure).unwrap();
941        assert!(approx(
942            crate::angle::circular_distance(transported, expected_arrival).degrees(),
943            0.0,
944            1e-10
945        ));
946    }
947
948    #[test]
949    fn transport_round_trips_across_ra_wrap_and_high_declination() {
950        let from = eq(359.8, 82.0);
951        let to = eq(0.3, 84.0);
952        let initial = Angle::from_degrees(217.0);
953        let at_to = transport_position_angle(from, to, initial).unwrap();
954        let restored = transport_position_angle(to, from, at_to).unwrap();
955        assert!(crate::angle::circular_distance(initial, restored).degrees() < 1e-10);
956
957        let pole = eq(45.0, 90.0);
958        let near_pole = eq(180.0, 89.0);
959        assert!(transport_position_angle(pole, near_pole, initial)
960            .unwrap()
961            .degrees()
962            .is_finite());
963    }
964
965    #[test]
966    fn transport_reexpresses_north_pole_ra_aliases() {
967        let from = eq(0.0, 90.0);
968        let to = eq(90.0, 90.0);
969        let transported = transport_position_angle(from, to, Angle::from_degrees(0.0)).unwrap();
970        assert!(
971            crate::angle::circular_distance(transported, Angle::from_degrees(90.0)).degrees()
972                < 1e-10
973        );
974    }
975
976    #[test]
977    fn transport_reexpresses_south_pole_ra_aliases() {
978        let from = eq(0.0, -90.0);
979        let to = eq(90.0, -90.0);
980        let transported = transport_position_angle(from, to, Angle::from_degrees(0.0)).unwrap();
981        assert!(
982            crate::angle::circular_distance(transported, Angle::from_degrees(270.0)).degrees()
983                < 1e-10
984        );
985    }
986
987    #[test]
988    fn transport_rejects_antipodal_ambiguity() {
989        let from = eq(0.0, 0.0);
990        assert!(
991            transport_position_angle(from, eq(180.0, 0.0), Angle::from_degrees(10.0)).is_none()
992        );
993        assert!(
994            transport_position_angle(from, eq(180.0 - 1e-7, 0.0), Angle::from_degrees(10.0))
995                .is_none()
996        );
997    }
998
999    #[test]
1000    fn gnomonic_origin_known_offset_and_ra_wrap_round_trip() {
1001        let center = eq(0.0, 0.0);
1002        assert_eq!(
1003            gnomonic_project(center, center),
1004            Some(GnomonicPoint {
1005                east: 0.0,
1006                north: 0.0
1007            })
1008        );
1009        let east_45 = gnomonic_project(center, eq(45.0, 0.0)).unwrap();
1010        assert!(approx(east_45.east, 1.0, 1e-12));
1011        assert!(approx(east_45.north, 0.0, 1e-12));
1012
1013        let wrap_center = eq(359.5, 70.0);
1014        let point = eq(0.5, 71.0);
1015        let projected = gnomonic_project(wrap_center, point).unwrap();
1016        let restored = gnomonic_unproject(wrap_center, projected).unwrap();
1017        assert!(separation(point, restored).arcseconds() < 1e-6);
1018    }
1019
1020    #[test]
1021    fn gnomonic_rejects_horizon_and_non_finite_plane_points() {
1022        let center = eq(0.0, 0.0);
1023        assert!(gnomonic_project(center, eq(90.0, 0.0)).is_none());
1024        assert!(gnomonic_project(center, eq(100.0, 0.0)).is_none());
1025        assert!(gnomonic_unproject(
1026            center,
1027            GnomonicPoint {
1028                east: f64::NAN,
1029                north: 0.0
1030            }
1031        )
1032        .is_none());
1033    }
1034
1035    #[test]
1036    fn gnomonic_remains_finite_near_horizon() {
1037        let center = eq(0.0, 0.0);
1038        let projected = gnomonic_project(center, eq(89.999, 0.0)).unwrap();
1039        assert!(projected.east.is_finite());
1040        assert!(projected.east > 50_000.0);
1041        let restored = gnomonic_unproject(center, projected).unwrap();
1042        assert!(separation(restored, eq(89.999, 0.0)).arcseconds() < 1e-5);
1043    }
1044
1045    #[test]
1046    fn offset_round_trip() {
1047        let from = eq(10.6847, 41.2688);
1048        let to = eq(10.0921, 41.6853);
1049        let off = tangent_offset(from, to);
1050        let back = apply_offset(from, off);
1051        assert!(
1052            separation(to, back).arcseconds() < 1e-3,
1053            "drift {}",
1054            separation(to, back).arcseconds()
1055        );
1056    }
1057
1058    #[test]
1059    fn offset_across_ra_wrap() {
1060        let from = eq(359.5, 10.0);
1061        let to = eq(0.5, 10.2);
1062        let off = tangent_offset(from, to);
1063        assert!(off.east.degrees() > 0.0, "east across wrap");
1064        let back = apply_offset(from, off);
1065        assert!(separation(to, back).arcseconds() < 1e-3);
1066    }
1067
1068    #[test]
1069    fn zero_offset_is_identity() {
1070        let p = eq(50.0, -30.0);
1071        let off = TangentOffset {
1072            east: Angle::from_degrees(0.0),
1073            north: Angle::from_degrees(0.0),
1074        };
1075        assert_eq!(apply_offset(p, off), p);
1076    }
1077
1078    #[test]
1079    fn precession_identity_and_round_trip() {
1080        let p = eq(45.0, 20.0);
1081        assert_eq!(precess(p, Epoch::J2000), p);
1082        let to_date = precess(p, Epoch::OfDate(2050.0));
1083        assert_eq!(to_date.epoch(), Epoch::OfDate(2050.0));
1084        let back = precess(to_date, Epoch::J2000);
1085        assert!(separation(p, back).arcseconds() < 1e-6);
1086    }
1087
1088    #[test]
1089    fn precession_rate_matches_iau() {
1090        let p = eq(0.0, 0.0);
1091        let d = precess(p, Epoch::OfDate(2100.0));
1092        assert!(
1093            approx(d.dec().arcseconds(), 2004.31, 2.0),
1094            "dec shift {}",
1095            d.dec().arcseconds()
1096        );
1097        let d26 = precess(p, Epoch::OfDate(2026.0));
1098        let shift = separation(p, d26).arcminutes();
1099        assert!((5.0..30.0).contains(&shift), "26yr shift {shift} arcmin");
1100    }
1101}