use ::time::OffsetDateTime;
use crate::angle::Angle;
use crate::coords::{precess, separation, Equatorial};
use crate::frames::mean_obliquity;
use crate::observer::{moving_body_crossings, CrossingOutcome, Location};
use crate::sun::solar_coords;
use crate::time::{julian_date, julian_epoch_of, lst};
const J2000_JD: f64 = 2_451_545.0;
const KM_PER_AU: f64 = 149_597_870.7;
const SYNODIC_RATE_DEG_PER_DAY: f64 = 12.190_749;
#[rustfmt::skip]
const LR_TERMS: [(i8, i8, i8, i8, i32, i32); 60] = [
(0, 0, 1, 0, 6_288_774, -20_905_355), (2, 0, -1, 0, 1_274_027, -3_699_111),
(2, 0, 0, 0, 658_314, -2_955_968), (0, 0, 2, 0, 213_618, -569_925),
(0, 1, 0, 0, -185_116, 48_888), (0, 0, 0, 2, -114_332, -3_149),
(2, 0, -2, 0, 58_793, 246_158), (2, -1, -1, 0, 57_066, -152_138),
(2, 0, 1, 0, 53_322, -170_733), (2, -1, 0, 0, 45_758, -204_586),
(0, 1, -1, 0, -40_923, -129_620), (1, 0, 0, 0, -34_720, 108_743),
(0, 1, 1, 0, -30_383, 104_755), (2, 0, 0, -2, 15_327, 10_321),
(0, 0, 1, 2, -12_528, 0), (0, 0, 1, -2, 10_980, 79_661),
(4, 0, -1, 0, 10_675, -34_782), (0, 0, 3, 0, 10_034, -23_210),
(4, 0, -2, 0, 8_548, -21_636), (2, 1, -1, 0, -7_888, 24_208),
(2, 1, 0, 0, -6_766, 30_824), (1, 0, -1, 0, -5_163, -8_379),
(1, 1, 0, 0, 4_987, -16_675), (2, -1, 1, 0, 4_036, -12_831),
(2, 0, 2, 0, 3_994, -10_445), (4, 0, 0, 0, 3_861, -11_650),
(2, 0, -3, 0, 3_665, 14_403), (0, 1, -2, 0, -2_689, -7_003),
(2, 0, -1, 2, -2_602, 0), (2, -1, -2, 0, 2_390, 10_056),
(1, 0, 1, 0, -2_348, 6_322), (2, -2, 0, 0, 2_236, -9_884),
(0, 1, 2, 0, -2_120, 5_751), (0, 2, 0, 0, -2_069, 0),
(2, -2, -1, 0, 2_048, -4_950), (2, 0, 1, -2, -1_773, 4_130),
(2, 0, 0, 2, -1_595, 0), (4, -1, -1, 0, 1_215, -3_958),
(0, 0, 2, 2, -1_110, 0), (3, 0, -1, 0, -892, 3_258),
(2, 1, 1, 0, -810, 2_616), (4, -1, -2, 0, 759, -1_897),
(0, 2, -1, 0, -713, -2_117), (2, 2, -1, 0, -700, 2_354),
(2, 1, -2, 0, 691, 0), (2, -1, 0, -2, 596, 0),
(4, 0, 1, 0, 549, -1_423), (0, 0, 4, 0, 537, -1_117),
(4, -1, 0, 0, 520, -1_571), (1, 0, -2, 0, -487, -1_739),
(2, 1, 0, -2, -399, 0), (0, 0, 2, -2, -381, -4_421),
(1, 1, 1, 0, 351, 0), (3, 0, -2, 0, -340, 0),
(4, 0, -3, 0, 330, 0), (2, -1, 2, 0, 327, 0),
(0, 2, 1, 0, -323, 1_165), (1, 1, -1, 0, 299, 0),
(2, 0, 3, 0, 294, 0), (2, 0, -1, -2, 0, 8_752),
];
#[rustfmt::skip]
const B_TERMS: [(i8, i8, i8, i8, i32); 60] = [
(0, 0, 0, 1, 5_128_122), (0, 0, 1, 1, 280_602), (0, 0, 1, -1, 277_693),
(2, 0, 0, -1, 173_237), (2, 0, -1, 1, 55_413), (2, 0, -1, -1, 46_271),
(2, 0, 0, 1, 32_573), (0, 0, 2, 1, 17_198), (2, 0, 1, -1, 9_266),
(0, 0, 2, -1, 8_822), (2, -1, 0, -1, 8_216), (2, 0, -2, -1, 4_324),
(2, 0, 1, 1, 4_200), (2, 1, 0, -1, -3_359), (2, -1, -1, 1, 2_463),
(2, -1, 0, 1, 2_211), (2, -1, -1, -1, 2_065), (0, 1, -1, -1, -1_870),
(4, 0, -1, -1, 1_828), (0, 1, 0, 1, -1_794), (0, 0, 0, 3, -1_749),
(0, 1, -1, 1, -1_565), (1, 0, 0, 1, -1_491), (0, 1, 1, 1, -1_475),
(0, 1, 1, -1, -1_410), (0, 1, 0, -1, -1_344), (1, 0, 0, -1, -1_335),
(0, 0, 3, 1, 1_107), (4, 0, 0, -1, 1_021), (4, 0, -1, 1, 833),
(0, 0, 1, -3, 777), (4, 0, -2, 1, 671), (2, 0, 0, -3, 607),
(2, 0, 2, -1, 596), (2, -1, 1, -1, 491), (2, 0, -2, 1, -451),
(0, 0, 3, -1, 439), (2, 0, 2, 1, 422), (2, 0, -3, -1, 421),
(2, 1, -1, 1, -366), (2, 1, 0, 1, -351), (4, 0, 0, 1, 331),
(2, -1, 1, 1, 315), (2, -2, 0, -1, 302), (0, 0, 1, 3, -283),
(2, 1, 1, -1, -229), (1, 1, 0, -1, 223), (1, 1, 0, 1, 223),
(0, 1, -2, -1, -220), (2, 1, -1, -1, -220), (1, 0, 1, 1, -185),
(2, -1, -2, -1, 181), (0, 1, 2, 1, -177), (4, 0, -2, -1, 176),
(4, -1, -1, -1, 166), (1, 0, 1, -1, -164), (4, 0, 1, -1, 132),
(1, 0, -1, -1, -119), (4, -1, 0, -1, 115), (2, -2, 0, 1, 107),
];
pub(crate) fn lunar_coords(at: OffsetDateTime) -> (f64, f64, f64) {
let t = (julian_date(at) - J2000_JD) / 36_525.0;
let l1 = 218.316_447_7 + 481_267.881_234_21 * t - 0.001_578_6 * t * t + t * t * t / 538_841.0
- t * t * t * t / 65_194_000.0;
let d = 297.850_192_1 + 445_267.111_403_4 * t - 0.001_881_9 * t * t + t * t * t / 545_868.0
- t * t * t * t / 113_065_000.0;
let m = 357.529_109_2 + 35_999.050_290_9 * t - 0.000_153_6 * t * t + t * t * t / 24_490_000.0;
let m1 = 134.963_396_4 + 477_198.867_505_5 * t + 0.008_741_4 * t * t + t * t * t / 69_699.0
- t * t * t * t / 14_712_000.0;
let f = 93.272_095_0 + 483_202.017_523_3 * t - 0.003_653_9 * t * t - t * t * t / 3_526_000.0
+ t * t * t * t / 863_310_000.0;
let e = 1.0 - 0.002_516 * t - 0.000_007_4 * t * t;
let a1 = (119.75 + 131.849 * t).to_radians();
let a2 = (53.09 + 479_264.29 * t).to_radians();
let a3 = (313.45 + 481_266.484 * t).to_radians();
let (l1_rad, d, m, m1, f) = (
l1.to_radians(),
d.to_radians(),
m.to_radians(),
m1.to_radians(),
f.to_radians(),
);
let ecc = |mult: i8| match mult.abs() {
1 => e,
2 => e * e,
_ => 1.0,
};
let (mut sum_l, mut sum_r, mut sum_b) = (0.0, 0.0, 0.0);
for &(td, tm, tm1, tf, cl, cr) in &LR_TERMS {
let arg = f64::from(td) * d + f64::from(tm) * m + f64::from(tm1) * m1 + f64::from(tf) * f;
sum_l += f64::from(cl) * ecc(tm) * arg.sin();
sum_r += f64::from(cr) * ecc(tm) * arg.cos();
}
for &(td, tm, tm1, tf, cb) in &B_TERMS {
let arg = f64::from(td) * d + f64::from(tm) * m + f64::from(tm1) * m1 + f64::from(tf) * f;
sum_b += f64::from(cb) * ecc(tm) * arg.sin();
}
sum_l += 3_958.0 * a1.sin() + 1_962.0 * (l1_rad - f).sin() + 318.0 * a2.sin();
sum_b += -2_235.0 * l1_rad.sin()
+ 382.0 * a3.sin()
+ 175.0 * ((a1 - f).sin() + (a1 + f).sin())
+ 127.0 * (l1_rad - m1).sin()
- 115.0 * (l1_rad + m1).sin();
let lambda = (l1 + sum_l / 1e6).rem_euclid(360.0);
let beta = sum_b / 1e6;
let delta_km = 385_000.56 + sum_r / 1e3;
(lambda, beta, delta_km)
}
fn ecliptic_to_equatorial(lambda_deg: f64, beta_deg: f64, at: OffsetDateTime) -> Equatorial {
let (l, b) = (lambda_deg.to_radians(), beta_deg.to_radians());
let eps = mean_obliquity(at);
let ra = (l.sin() * eps.cos() - b.tan() * eps.sin()).atan2(l.cos());
let dec = (b.sin() * eps.cos() + b.cos() * eps.sin() * l.sin())
.clamp(-1.0, 1.0)
.asin();
Equatorial::at_epoch(
Angle::from_radians(ra).normalized_0_360(),
Angle::from_radians(dec),
julian_epoch_of(at),
)
.expect("rotation output is in domain by construction")
}
pub fn moon_position(at: OffsetDateTime) -> Equatorial {
let (lambda, beta, _) = lunar_coords(at);
ecliptic_to_equatorial(lambda, beta, at)
}
pub fn moon_distance_km(at: OffsetDateTime) -> f64 {
lunar_coords(at).2
}
pub fn moon_position_topocentric(at: OffsetDateTime, site: &Location) -> Equatorial {
let (lambda, beta, delta_km) = lunar_coords(at);
let geocentric = ecliptic_to_equatorial(lambda, beta, at);
let (ra, dec) = (geocentric.ra().radians(), geocentric.dec().radians());
let phi = site.latitude().radians();
let u = (0.996_647_19 * phi.tan()).atan();
let h_frac = site.elevation_m() / 6_378_140.0;
let rho_sin = 0.996_647_19 * u.sin() + h_frac * phi.sin();
let rho_cos = u.cos() + h_frac * phi.cos();
let sin_pi = 6_378.14 / delta_km;
let h = (lst(at, site.longitude()) - geocentric.ra())
.normalized_pm_180()
.radians();
let delta_ra = (-rho_cos * sin_pi * h.sin()).atan2(dec.cos() - rho_cos * sin_pi * h.cos());
let dec_top = ((dec.sin() - rho_sin * sin_pi) * delta_ra.cos())
.atan2(dec.cos() - rho_cos * sin_pi * h.cos());
Equatorial::at_epoch(
Angle::from_radians(ra + delta_ra).normalized_0_360(),
Angle::from_radians(dec_top),
julian_epoch_of(at),
)
.expect("parallax correction stays in domain")
}
pub fn lunar_separation(target: Equatorial, at: OffsetDateTime, site: &Location) -> Angle {
let target = precess(target, julian_epoch_of(at));
separation(moon_position_topocentric(at, site), target)
}
pub fn moon_crossings(
threshold: Angle,
night_of: OffsetDateTime,
site: &Location,
) -> CrossingOutcome {
moving_body_crossings(
|t| moon_position_topocentric(t, site),
threshold,
night_of,
site,
0.0,
)
}
pub fn moon_phase_angle(at: OffsetDateTime) -> Angle {
let (_, sun_r_au, _) = solar_coords(at);
let r = sun_r_au * KM_PER_AU;
let (lambda, beta, delta) = lunar_coords(at);
let psi = separation(
crate::sun::sun_position(at),
ecliptic_to_equatorial(lambda, beta, at),
)
.radians();
Angle::from_radians((r * psi.sin()).atan2(delta - r * psi.cos()))
}
pub fn moon_illumination(at: OffsetDateTime) -> f64 {
(1.0 + moon_phase_angle(at).radians().cos()) / 2.0
}
pub fn moon_avoidance_lorentzian(
separation_at_full: Angle,
half_width_days: f64,
at: OffsetDateTime,
) -> Angle {
let days_from_full = moon_phase_angle(at).degrees().abs() / SYNODIC_RATE_DEG_PER_DAY;
let h = half_width_days.max(f64::MIN_POSITIVE);
separation_at_full / (1.0 + (days_from_full / h).powi(2))
}
#[cfg(test)]
mod tests {
use super::*;
use ::time::macros::datetime;
#[test]
fn meeus_47a_lunar_position() {
let (lambda, beta, delta) = lunar_coords(datetime!(1992-04-12 00:00 UTC));
assert!((lambda - 133.162_655).abs() * 3600.0 < 1.0, "λ = {lambda}");
assert!((beta + 3.229_126).abs() * 3600.0 < 1.0, "β = {beta}");
assert!((delta - 368_409.7).abs() < 1.0, "Δ = {delta}");
let pi = (6_378.14 / delta).asin().to_degrees();
assert!((pi - 0.991_990).abs() < 1e-5, "π = {pi}");
}
#[test]
fn topocentric_parallax_has_the_expected_magnitude() {
let site =
Location::new(Angle::from_degrees(52.155), Angle::from_degrees(4.485), 6.0).unwrap();
let at = datetime!(2026-07-11 22:00 UTC);
let shift = separation(moon_position(at), moon_position_topocentric(at, &site));
assert!(
(10.0..62.0).contains(&shift.arcminutes()),
"parallax shift {}′",
shift.arcminutes()
);
}
#[test]
fn meeus_48a_illumination() {
let at = datetime!(1992-04-12 00:00 UTC);
let i = moon_phase_angle(at).degrees();
assert!((i - 69.075_6).abs() < 0.05, "i = {i}");
let k = moon_illumination(at);
assert!((k - 0.678_6).abs() < 0.005, "k = {k}");
}
#[test]
fn avoidance_lorentzian_shape() {
let at = datetime!(2026-07-11 22:00 UTC);
let s = Angle::from_degrees(60.0);
let d = moon_phase_angle(at).degrees().abs() / SYNODIC_RATE_DEG_PER_DAY;
let expect = 60.0 / (1.0 + (d / 7.0_f64).powi(2));
assert!((moon_avoidance_lorentzian(s, 7.0, at).degrees() - expect).abs() < 1e-9);
assert!(moon_avoidance_lorentzian(s, 0.0, at).degrees() >= 0.0);
}
}