celestial-coords 0.1.1-alpha.2

Astronomical coordinate transformations
Documentation
use crate::{CoordResult, ICRSPosition};
use celestial_core::constants::{ARCSEC_TO_RAD, DEG_TO_RAD, J2000_JD, TWOPI};
use celestial_core::utils::{normalize_angle_rad, normalize_angle_to_positive};
use celestial_core::Angle;
use celestial_time::TT;

const SOLAR_EQUATOR_INCLINATION_DEG: f64 = 7.25;
const SOLAR_EQUATOR_INCLINATION_RAD: f64 = SOLAR_EQUATOR_INCLINATION_DEG * DEG_TO_RAD;

const SOLAR_ASCENDING_NODE_J2000_DEG: f64 = 75.76;

pub const CARRINGTON_EPOCH_JD: f64 = 2398220.0;
pub const CARRINGTON_SYNODIC_PERIOD: f64 = 27.2753;

pub struct SolarOrientation {
    pub b0: Angle,
    pub l0: Angle,
    pub p: Angle,
}

pub fn compute_solar_orientation(epoch: &TT) -> SolarOrientation {
    let jd = epoch.to_julian_date();
    let d = (jd.jd1() - J2000_JD) + jd.jd2();
    let t = d / celestial_core::constants::DAYS_PER_JULIAN_CENTURY;

    let (sun_lon, sun_lat, obliquity) = solar_ecliptic_coords(t);
    let (b0, l0, p) = heliographic_coords(t, sun_lon, sun_lat, obliquity);

    SolarOrientation {
        b0: Angle::from_radians(b0),
        l0: Angle::from_radians(l0),
        p: Angle::from_radians(p),
    }
}

pub fn compute_b0(epoch: &TT) -> Angle {
    compute_solar_orientation(epoch).b0
}

pub fn compute_l0(epoch: &TT) -> Angle {
    compute_solar_orientation(epoch).l0
}

pub fn compute_p(epoch: &TT) -> Angle {
    compute_solar_orientation(epoch).p
}

pub fn carrington_rotation_number(epoch: &TT) -> u32 {
    let jd = epoch.to_julian_date();
    let jd_days = (jd.jd1() - CARRINGTON_EPOCH_JD) + jd.jd2();
    libm::floor(jd_days / CARRINGTON_SYNODIC_PERIOD) as u32 + 1
}

pub fn sun_earth_distance(epoch: &TT) -> f64 {
    let jd = epoch.to_julian_date();
    let d = (jd.jd1() - J2000_JD) + jd.jd2();
    let t = d / celestial_core::constants::DAYS_PER_JULIAN_CENTURY;

    let m = (357.52911 + 35999.05029 * t - 0.0001537 * t * t) * DEG_TO_RAD;
    let e = 0.016708634 - 0.000042037 * t - 0.0000001267 * t * t;

    let c_rad = (1.914602 - 0.004817 * t - 0.000014 * t * t) * DEG_TO_RAD * libm::sin(m)
        + (0.019993 - 0.000101 * t) * DEG_TO_RAD * libm::sin(2.0 * m)
        + 0.000289 * DEG_TO_RAD * libm::sin(3.0 * m);

    let true_anomaly = m + c_rad;
    let a = 1.000001018; // semi-major axis in AU

    a * (1.0 - e * e) / (1.0 + e * libm::cos(true_anomaly))
}

fn heliographic_coords(t: f64, sun_lon: f64, _sun_lat: f64, obliquity: f64) -> (f64, f64, f64) {
    let i = SOLAR_EQUATOR_INCLINATION_RAD;
    let k = (SOLAR_ASCENDING_NODE_J2000_DEG + 1.3958333 * t) * DEG_TO_RAD;

    let lambda = sun_lon;
    let theta = lambda - k;
    let (sin_theta, cos_theta) = libm::sincos(theta);
    let (sin_i, cos_i) = libm::sincos(i);
    let (_sin_obl, cos_obl) = libm::sincos(obliquity);

    let b0 = libm::asin(sin_theta * sin_i);

    let eta = libm::atan2(sin_i * cos_theta, cos_i);
    let jd_days =
        t * celestial_core::constants::DAYS_PER_JULIAN_CENTURY + J2000_JD - CARRINGTON_EPOCH_JD;
    let l0_raw = 360.0 / CARRINGTON_SYNODIC_PERIOD * jd_days;
    let l0 = normalize_angle_to_positive((l0_raw * DEG_TO_RAD - eta) % TWOPI);

    let rho = libm::atan(cos_theta * sin_i / cos_obl);
    let sigma = libm::atan(sin_theta * cos_i);
    let p = normalize_angle_rad(rho + sigma);

    (b0, l0, p)
}

fn solar_ecliptic_coords(t: f64) -> (f64, f64, f64) {
    let l0 = 280.46646 + 36000.76983 * t + 0.0003032 * t * t;
    let m = 357.52911 + 35999.05029 * t - 0.0001537 * t * t;
    let m_rad = m * DEG_TO_RAD;

    let c = (1.914602 - 0.004817 * t - 0.000014 * t * t) * libm::sin(m_rad)
        + (0.019993 - 0.000101 * t) * libm::sin(2.0 * m_rad)
        + 0.000289 * libm::sin(3.0 * m_rad);

    let sun_true_lon = l0 + c;

    let omega = 125.04 - 1934.136 * t;
    let omega_rad = omega * DEG_TO_RAD;
    let apparent_lon = sun_true_lon - 0.00569 - 0.00478 * libm::sin(omega_rad);

    let obliquity = mean_obliquity(t);

    (
        normalize_angle_to_positive(apparent_lon * DEG_TO_RAD),
        0.0,
        obliquity,
    )
}

fn mean_obliquity(t: f64) -> f64 {
    let eps0_arcsec = 84381.448 - 46.8150 * t - 0.00059 * t * t + 0.001813 * t * t * t;
    eps0_arcsec * ARCSEC_TO_RAD
}

pub(crate) fn get_sun_icrs(epoch: &TT) -> CoordResult<ICRSPosition> {
    let jd = epoch.to_julian_date();
    let d = (jd.jd1() - J2000_JD) + jd.jd2();
    let t = d / celestial_core::constants::DAYS_PER_JULIAN_CENTURY;

    let l0 = 280.46646 + 36000.76983 * t + 0.0003032 * t * t;
    let m = 357.52911 + 35999.05029 * t - 0.0001537 * t * t;
    let m_rad = m * DEG_TO_RAD;

    let c = (1.914602 - 0.004817 * t - 0.000014 * t * t) * libm::sin(m_rad)
        + (0.019993 - 0.000101 * t) * libm::sin(2.0 * m_rad)
        + 0.000289 * libm::sin(3.0 * m_rad);

    let sun_true_lon = l0 + c;
    let omega = 125.04 - 1934.136 * t;
    let omega_rad = omega * DEG_TO_RAD;
    let apparent_lon = sun_true_lon - 0.00569 - 0.00478 * libm::sin(omega_rad);

    let lambda = apparent_lon * DEG_TO_RAD;
    let eps = (23.439291 - 0.0130042 * t) * DEG_TO_RAD;

    let (sin_lambda, cos_lambda) = libm::sincos(lambda);
    let (sin_eps, cos_eps) = libm::sincos(eps);

    let ra = libm::atan2(sin_lambda * cos_eps, cos_lambda);
    let dec = libm::asin(sin_lambda * sin_eps);

    ICRSPosition::new(
        Angle::from_radians(normalize_angle_to_positive(ra)),
        Angle::from_radians(dec),
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use celestial_time::julian::JulianDate;

    #[test]
    fn test_b0_range() {
        let epochs = [
            TT::j2000(),
            TT::from_julian_date(JulianDate::new(J2000_JD + 91.0, 0.0)),
            TT::from_julian_date(JulianDate::new(J2000_JD + 182.0, 0.0)),
            TT::from_julian_date(JulianDate::new(J2000_JD + 273.0, 0.0)),
        ];

        for epoch in &epochs {
            let b0 = compute_b0(epoch);
            assert!(
                b0.degrees().abs() <= 7.3,
                "B0 = {} degrees exceeds expected range ±7.25°",
                b0.degrees()
            );
        }
    }

    #[test]
    fn test_l0_range() {
        let epoch = TT::j2000();
        let l0 = compute_l0(&epoch);
        assert!(
            l0.degrees() >= 0.0 && l0.degrees() < 360.0,
            "L0 = {} degrees outside [0, 360) range",
            l0.degrees()
        );
    }

    #[test]
    fn test_p_range() {
        let epochs = [
            TT::j2000(),
            TT::from_julian_date(JulianDate::new(J2000_JD + 91.0, 0.0)),
            TT::from_julian_date(JulianDate::new(J2000_JD + 182.0, 0.0)),
            TT::from_julian_date(JulianDate::new(J2000_JD + 273.0, 0.0)),
        ];

        for epoch in &epochs {
            let p = compute_p(epoch);
            assert!(
                p.degrees().abs() <= 45.0,
                "P = {} degrees exceeds expected range ±45°",
                p.degrees()
            );
        }
    }

    #[test]
    fn test_carrington_rotation_period() {
        let epoch1 = TT::j2000();
        let l0_1 = compute_l0(&epoch1);

        let epoch2 =
            TT::from_julian_date(JulianDate::new(J2000_JD + CARRINGTON_SYNODIC_PERIOD, 0.0));
        let l0_2 = compute_l0(&epoch2);

        let diff = (l0_2.degrees() - l0_1.degrees()).abs();
        assert!(
            (diff - 360.0).abs() < 5.0 || diff < 5.0,
            "L0 should change by ~360° in one Carrington rotation, got {} degrees",
            diff
        );
    }

    #[test]
    fn test_solar_orientation_combined() {
        let epoch = TT::j2000();
        let orientation = compute_solar_orientation(&epoch);

        assert!(
            orientation.b0.degrees().abs() <= 7.3,
            "B0 = {} out of range",
            orientation.b0.degrees()
        );
        assert!(
            orientation.l0.degrees() >= 0.0 && orientation.l0.degrees() < 360.0,
            "L0 = {} out of range",
            orientation.l0.degrees()
        );
        assert!(
            orientation.p.degrees().abs() <= 30.0,
            "P = {} out of range",
            orientation.p.degrees()
        );
    }
}