use core::fmt;
use supernovas_ffi::ecl2equ;
use super::{Equatorial, Galactic, Spherical};
use crate::{
Accuracy, Angle, Equinox,
error::{Error, Result},
unit,
};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Ecliptic {
sph: Spherical,
system: Equinox,
}
impl Ecliptic {
#[must_use]
pub fn new(longitude: Angle, latitude: Angle, system: Equinox) -> Self {
Ecliptic {
sph: Spherical::new(longitude, latitude),
system,
}
}
pub fn from_radians(longitude: f64, latitude: f64, system: Equinox) -> Result<Self> {
Ok(Ecliptic::new(
Angle::from_radians(longitude)?,
Angle::from_radians(latitude)?,
system,
))
}
pub fn from_degrees(longitude_deg: f64, latitude_deg: f64, system: Equinox) -> Result<Self> {
Ok(Ecliptic::new(
Angle::from_degrees(longitude_deg)?,
Angle::from_degrees(latitude_deg)?,
system,
))
}
#[must_use]
pub fn longitude(self) -> Angle {
self.sph.longitude()
}
#[must_use]
pub fn latitude(self) -> Angle {
self.sph.latitude()
}
#[must_use]
pub fn system(self) -> Equinox {
self.system
}
#[must_use]
pub fn as_spherical(self) -> Spherical {
self.sph
}
#[must_use]
pub fn distance_to(self, other: Ecliptic) -> Angle {
self.sph.distance_to(other.sph)
}
pub fn to_equatorial(self, accuracy: Accuracy) -> Result<Equatorial> {
let coord_sys = self
.system
.equator_type_for_ecliptic()
.ok_or(Error::UnsupportedSystem)?;
let mut ra_h = 0.0_f64;
let mut dec_d = 0.0_f64;
let rc = unsafe {
ecl2equ(
self.system.jd(),
coord_sys,
accuracy.to_sys(),
self.longitude().deg(),
self.latitude().deg(),
&raw mut ra_h,
&raw mut dec_d,
)
};
if rc != 0 {
return Err(Error::ffi(rc));
}
Equatorial::from_hours_and_degrees(ra_h, dec_d, self.system)
}
pub fn to_system(self, target: Equinox, accuracy: Accuracy) -> Result<Ecliptic> {
if approx::AbsDiffEq::abs_diff_eq(
&self.system,
&target,
<Equinox as approx::AbsDiffEq>::default_epsilon(),
) {
return Ok(Ecliptic {
sph: self.sph,
system: target,
});
}
self.to_equatorial(accuracy)?
.to_system(target, accuracy)?
.to_ecliptic(accuracy)
}
pub fn to_galactic(self, accuracy: Accuracy) -> Result<Galactic> {
self.to_equatorial(accuracy)?.to_galactic(accuracy)
}
}
impl fmt::Display for Ecliptic {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let lon_deg = self.longitude().deg().rem_euclid(360.0);
let lat = self.latitude();
let sys = self.system;
if let Some(p) = f.precision() {
write!(f, "λ={lon_deg:.p$}° β={lat:.p$} ({sys})")
} else {
write!(f, "λ={lon_deg:.3}° β={lat} ({sys})")
}
}
}
impl approx::AbsDiffEq for Ecliptic {
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_degrees() {
let e = Ecliptic::from_degrees(120.0, 5.0, Equinox::J2000).unwrap();
assert!((e.longitude().deg() - 120.0).abs() < 1e-9);
assert!((e.latitude().deg() - 5.0).abs() < 1e-9);
assert_eq!(e.system(), Equinox::J2000);
}
#[test]
fn approx_eq_ignores_equinox_tag() {
let a = Ecliptic::from_degrees(120.0, 5.0, Equinox::ICRS).unwrap();
let b = Ecliptic::from_degrees(120.0, 5.0, Equinox::J2000).unwrap();
assert_abs_diff_eq!(a, b, epsilon = unit::UAS);
}
#[test]
fn ecliptic_to_equatorial_round_trip() {
use crate::Accuracy;
let e = Ecliptic::from_degrees(120.0, 5.0, Equinox::J2000).unwrap();
let eq = e.to_equatorial(Accuracy::Reduced).unwrap();
let back = eq.to_ecliptic(Accuracy::Reduced).unwrap();
assert_abs_diff_eq!(e, back, epsilon = unit::UAS);
}
#[test]
fn ecliptic_to_galactic_via_equatorial() {
use crate::Accuracy;
let e = Ecliptic::from_degrees(0.0, 0.0, Equinox::J2000).unwrap();
let g = e.to_galactic(Accuracy::Reduced).unwrap();
let l = g.l().deg();
let b = g.b().deg();
assert!(l.is_finite() && b.is_finite());
assert!((-90.0..=90.0).contains(&b));
}
#[test]
fn from_radians_round_trip() {
use core::f64::consts::PI;
let e = Ecliptic::from_radians(PI / 3.0, PI / 8.0, Equinox::J2000).unwrap();
assert!((e.longitude().rad() - PI / 3.0).abs() < 1e-12);
assert!((e.latitude().rad() - PI / 8.0).abs() < 1e-12);
assert_eq!(e.system(), Equinox::J2000);
}
#[test]
fn as_spherical_gives_same_coords() {
let e = Ecliptic::from_degrees(45.0, -10.0, Equinox::ICRS).unwrap();
let s = e.as_spherical();
assert!((s.longitude().deg() - 45.0).abs() < 1e-9);
assert!((s.latitude().deg() - -10.0).abs() < 1e-9);
}
#[test]
fn to_system_same_equinox_is_identity() {
use crate::Accuracy;
let e = Ecliptic::from_degrees(90.0, 5.0, Equinox::J2000).unwrap();
let same = e.to_system(Equinox::J2000, Accuracy::Reduced).unwrap();
assert_abs_diff_eq!(e, same, epsilon = unit::UAS);
}
#[test]
fn distance_to_orthogonal_is_90_deg() {
use core::f64::consts::FRAC_PI_2;
let a = Ecliptic::from_degrees(0.0, 0.0, Equinox::J2000).unwrap();
let b = Ecliptic::from_degrees(90.0, 0.0, Equinox::J2000).unwrap();
assert!((a.distance_to(b).rad() - FRAC_PI_2).abs() < 1e-12);
}
#[test]
fn to_system_different_equinox_gives_nearby_result() {
use crate::Accuracy;
let e = Ecliptic::from_degrees(120.0, 5.0, Equinox::J2000).unwrap();
let mod_eq = Equinox::mod_at(2_461_236.75).unwrap();
let converted = e.to_system(mod_eq, Accuracy::Reduced).unwrap();
let sep_arcsec = e.distance_to(converted).arcsec();
assert!(
sep_arcsec < 3600.0,
"separation {sep_arcsec} arcsec looks too large"
);
assert!(sep_arcsec > 0.0, "should have non-zero drift over 26 years");
}
#[test]
fn display_with_precision() {
let e = Ecliptic::from_degrees(90.0, 5.0, Equinox::J2000).unwrap();
let s = format!("{e:.2}");
assert!(s.contains("λ=90.00°"), "got: {s}");
assert!(s.contains('β'), "got: {s}");
}
#[test]
fn display_contains_lambda_beta_system() {
let e = Ecliptic::from_degrees(90.0, 5.0, Equinox::J2000).unwrap();
let s = format!("{e}");
assert!(s.contains('λ'), "got: {s}");
assert!(s.contains('β'), "got: {s}");
}
#[test]
fn display_normalizes_longitude_to_0_360() {
let e = Ecliptic::from_degrees(-74.0, 62.0, Equinox::J2000).unwrap();
let s = format!("{e}");
assert!(s.contains("286."), "expected 286.xxx°, got: {s}");
}
}