use core::fmt;
use supernovas_ffi::{
equ2ecl, equ2gal, novas_icrs_to_sys, novas_sys_to_icrs, radec2vector, vector2radec,
};
use super::{Ecliptic, Galactic, Spherical};
use crate::{
Accuracy, Angle, Equinox, ReferenceSystem, TimeAngle,
error::{Error, Result},
unit,
};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Equatorial {
sph: Spherical,
system: Equinox,
}
impl Equatorial {
pub fn new(ra: TimeAngle, dec: Angle, system: Equinox) -> Self {
Equatorial {
sph: Spherical::new(Angle::from(ra), dec),
system,
}
}
pub fn from_hours_and_degrees(ra_hours: f64, dec_deg: f64, system: Equinox) -> Result<Self> {
Ok(Equatorial::new(
TimeAngle::from_hours(ra_hours)?,
Angle::from_degrees(dec_deg)?,
system,
))
}
pub fn from_degrees(ra_deg: f64, dec_deg: f64, system: Equinox) -> Result<Self> {
Ok(Equatorial::new(
TimeAngle::from_radians(ra_deg * unit::DEG)?,
Angle::from_degrees(dec_deg)?,
system,
))
}
pub fn from_radians(ra_rad: f64, dec_rad: f64, system: Equinox) -> Result<Self> {
Ok(Equatorial::new(
TimeAngle::from_radians(ra_rad)?,
Angle::from_radians(dec_rad)?,
system,
))
}
pub fn ra(self) -> TimeAngle {
TimeAngle::from_angle(self.sph.longitude())
}
pub fn dec(self) -> Angle {
self.sph.latitude()
}
pub fn system(self) -> Equinox {
self.system
}
pub fn as_spherical(self) -> Spherical {
self.sph
}
pub fn distance_to(self, other: Equatorial) -> Angle {
self.sph.distance_to(other.sph)
}
pub fn to_system(self, target: Equinox, accuracy: Accuracy) -> Result<Equatorial> {
if approx::AbsDiffEq::abs_diff_eq(
&self.system,
&target,
<Equinox as approx::AbsDiffEq>::default_epsilon(),
) {
return Ok(Equatorial {
sph: self.sph,
system: target,
});
}
let mut v = [0.0_f64; 3];
let rc = unsafe { radec2vector(self.ra().hours(), self.dec().deg(), 1.0, v.as_mut_ptr()) };
if rc != 0 {
return Err(Error::Ffi);
}
let mut to_icrs = [0.0_f64; 3];
let rc = unsafe {
novas_sys_to_icrs(
self.system.system().to_sys(),
v.as_ptr(),
self.system.jd(),
accuracy.to_sys(),
to_icrs.as_mut_ptr(),
)
};
if rc != 0 {
return Err(Error::Ffi);
}
let mut out = [0.0_f64; 3];
let rc = unsafe {
novas_icrs_to_sys(
to_icrs.as_ptr(),
target.jd(),
accuracy.to_sys(),
target.system().to_sys(),
out.as_mut_ptr(),
)
};
if rc != 0 {
return Err(Error::Ffi);
}
let mut ra_h = 0.0_f64;
let mut dec_d = 0.0_f64;
let rc = unsafe { vector2radec(out.as_ptr(), &mut ra_h, &mut dec_d) };
if rc != 0 {
return Err(Error::Ffi);
}
Self::from_hours_and_degrees(ra_h, dec_d, target)
}
pub fn to_icrs(self, accuracy: Accuracy) -> Result<Equatorial> {
self.to_system(Equinox::ICRS, accuracy)
}
pub fn to_j2000(self, accuracy: Accuracy) -> Result<Equatorial> {
self.to_system(Equinox::J2000, accuracy)
}
pub fn to_mod(self, jd_tt: f64, accuracy: Accuracy) -> Result<Equatorial> {
self.to_system(Equinox::mod_at(jd_tt)?, accuracy)
}
pub fn to_tod(self, jd_tt: f64, accuracy: Accuracy) -> Result<Equatorial> {
self.to_system(Equinox::tod_at(jd_tt)?, accuracy)
}
pub fn to_cirs(self, jd_tt: f64, accuracy: Accuracy) -> Result<Equatorial> {
self.to_system(Equinox::cirs_at(jd_tt)?, accuracy)
}
pub fn to_ecliptic(self, accuracy: Accuracy) -> Result<Ecliptic> {
let eq = match self.system.system() {
ReferenceSystem::Cirs => self.to_tod(self.system.jd(), accuracy)?,
ReferenceSystem::Itrs => return Err(Error::UnsupportedSystem),
_ => self,
};
let coord_sys = eq
.system
.equator_type_for_ecliptic()
.ok_or(Error::UnsupportedSystem)?;
let mut elon = 0.0_f64;
let mut elat = 0.0_f64;
let rc = unsafe {
equ2ecl(
eq.system.jd(),
coord_sys,
accuracy.to_sys(),
eq.ra().hours(),
eq.dec().deg(),
&mut elon,
&mut elat,
)
};
if rc != 0 {
return Err(Error::Ffi);
}
Ecliptic::from_degrees(elon, elat, eq.system)
}
pub fn to_galactic(self, accuracy: Accuracy) -> Result<Galactic> {
let icrs = if self.system.is_icrs() {
self
} else {
self.to_icrs(accuracy)?
};
let mut glon = 0.0_f64;
let mut glat = 0.0_f64;
let rc = unsafe { equ2gal(icrs.ra().hours(), icrs.dec().deg(), &mut glon, &mut glat) };
if rc != 0 {
return Err(Error::Ffi);
}
Galactic::from_degrees(glon, glat)
}
}
impl fmt::Display for Equatorial {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let (ra, dec, sys) = (self.ra(), self.dec(), self.system);
if let Some(p) = f.precision() {
write!(f, "RA {ra:.p$} Dec {dec:.p$} ({sys})")
} else {
write!(f, "RA {ra} Dec {dec} ({sys})")
}
}
}
impl approx::AbsDiffEq for Equatorial {
type Epsilon = f64;
fn default_epsilon() -> Self::Epsilon {
unit::UAS
}
fn abs_diff_eq(&self, other: &Self, epsilon: Self::Epsilon) -> bool {
self.sph.abs_diff_eq(&other.sph, epsilon)
}
}
#[cfg(test)]
mod tests {
use approx::assert_abs_diff_eq;
use super::*;
#[test]
fn round_trip_hours_and_degrees() {
let e = Equatorial::from_hours_and_degrees(18.6156, 38.7837, Equinox::J2000).unwrap();
assert!((e.ra().hours() - 18.6156).abs() < 1e-9);
assert!((e.dec().deg() - 38.7837).abs() < 1e-9);
assert_eq!(e.system(), Equinox::J2000);
}
#[test]
fn ra_lifts_negative_angles_into_24h() {
let e = Equatorial::from_degrees(350.0, 0.0, Equinox::ICRS).unwrap();
let h = e.ra().hours();
assert!((0.0..24.0).contains(&h), "RA out of range: {h}");
assert!((h - 350.0 / 15.0).abs() < 1e-9);
}
#[test]
fn distance_to_self_is_zero() {
let e = Equatorial::from_hours_and_degrees(12.0, 30.0, Equinox::J2000).unwrap();
assert!(e.distance_to(e).rad().abs() < 1e-12);
}
#[test]
fn approx_eq_ignores_equinox_tag() {
let a = Equatorial::from_hours_and_degrees(12.0, 30.0, Equinox::ICRS).unwrap();
let b = Equatorial::from_hours_and_degrees(12.0, 30.0, Equinox::J2000).unwrap();
assert_abs_diff_eq!(a, b, epsilon = unit::UAS);
}
fn vega_j2000() -> Equatorial {
Equatorial::from_hours_and_degrees(
18.0 + 36.0 / 60.0 + 56.336 / 3600.0,
38.0 + 47.0 / 60.0 + 1.28 / 3600.0,
Equinox::J2000,
)
.unwrap()
}
#[test]
fn to_same_system_is_essentially_a_no_op() {
let vega = vega_j2000();
let same = vega.to_system(Equinox::J2000, Accuracy::Reduced).unwrap();
assert_eq!(vega.ra().rad(), same.ra().rad());
assert_eq!(vega.dec().rad(), same.dec().rad());
}
#[test]
fn icrs_to_j2000_round_trip() {
let vega = vega_j2000();
let icrs = vega.to_icrs(Accuracy::Reduced).unwrap();
let back = icrs.to_j2000(Accuracy::Reduced).unwrap();
assert_abs_diff_eq!(vega, back, epsilon = unit::MAS);
}
#[test]
fn j2000_to_mod_at_recent_date_drifts() {
let vega = vega_j2000();
let mod_2026 = vega.to_mod(2_461_236.75, Accuracy::Reduced).unwrap();
let sep_arcsec = vega.distance_to(mod_2026).arcsec();
assert!(
(60.0..36_000.0).contains(&sep_arcsec),
"expected ~arcmin-scale precession drift, got {sep_arcsec} arcsec"
);
}
#[test]
fn galactic_round_trip_is_identity() {
let vega = vega_j2000();
let vega_icrs = vega.to_icrs(Accuracy::Reduced).unwrap();
let g = vega_icrs.to_galactic(Accuracy::Reduced).unwrap();
let back = g.to_equatorial_icrs().unwrap();
assert_abs_diff_eq!(vega_icrs, back, epsilon = unit::UAS);
}
#[test]
fn vega_ecliptic_round_trip() {
let vega = vega_j2000();
let ecl = vega.to_ecliptic(Accuracy::Reduced).unwrap();
let back = ecl.to_equatorial(Accuracy::Reduced).unwrap();
assert_abs_diff_eq!(vega, back, epsilon = unit::UAS);
let lon_in_0_360 = (ecl.longitude().deg() + 360.0).rem_euclid(360.0);
assert!(
(lon_in_0_360 - 285.4).abs() < 0.5,
"Vega ecliptic λ = {lon_in_0_360}° should be near 285.4°"
);
assert!(
(ecl.latitude().deg() - 61.7).abs() < 0.5,
"Vega ecliptic β = {} should be near 61.7°",
ecl.latitude().deg()
);
}
#[test]
fn cirs_to_ecliptic_routes_through_tod() {
let vega = vega_j2000();
let cirs = vega.to_cirs(2_461_236.75, Accuracy::Reduced).unwrap();
let ecl = cirs.to_ecliptic(Accuracy::Reduced).unwrap();
let back = ecl.to_equatorial(Accuracy::Reduced).unwrap();
let sep = back.distance_to(cirs.to_tod(2_461_236.75, Accuracy::Reduced).unwrap());
assert!(sep.arcsec() < 1.0);
}
#[test]
fn vega_galactic_matches_known_values() {
let vega_icrs = vega_j2000().to_icrs(Accuracy::Reduced).unwrap();
let g = vega_icrs.to_galactic(Accuracy::Reduced).unwrap();
assert!(
(g.l().deg() - 67.45).abs() < 0.1,
"Vega galactic l = {} should be near 67.45°",
g.l().deg()
);
assert!(
(g.b().deg() - 19.24).abs() < 0.1,
"Vega galactic b = {} should be near 19.24°",
g.b().deg()
);
}
}