use siderust::ephemeris::Vsop87Ephemeris;
use siderust::event::lunar::phase::*;
use siderust::qtty::*;
use siderust::time::{Interval, JulianDate};
fn jd_from_utc(year: i32, month: u32, day: u32, hour: u32, min: u32) -> JulianDate {
use chrono::{TimeZone, Utc};
let dt = Utc
.with_ymd_and_hms(year, month, day, hour, min, 0)
.unwrap();
JulianDate::from_chrono(dt)
}
#[test]
fn l1_illuminated_fraction_bounded_1000_points() {
let start = siderust::J2000;
for i in 0..1000 {
let jd = start + Days::new(i as f64 * 0.37); let geom = moon_phase_geocentric::<Vsop87Ephemeris>(jd);
assert!(
geom.illuminated_fraction.value() >= 0.0 && geom.illuminated_fraction.value() <= 1.0,
"Fraction out of bounds at step {}: {:.4}",
i,
geom.illuminated_fraction.value()
);
let pa = geom.phase_angle;
assert!(
pa >= Radians::new(0.0) && pa <= Radians::new(std::f64::consts::PI),
"Phase angle out of [0,π] at step {}: {}",
i,
pa
);
let e = geom.elongation;
assert!(
e >= Radians::new(0.0) && e < Radians::new(2.0 * std::f64::consts::PI),
"Elongation out of [0,2π) at step {}: {}",
i,
e
);
}
}
#[test]
fn l2_full_moon_high_illumination() {
let full_moons = [
jd_from_utc(2000, 1, 21, 4, 41),
jd_from_utc(2024, 4, 23, 23, 49),
jd_from_utc(2025, 12, 4, 23, 14),
];
for (i, jd) in full_moons.iter().enumerate() {
let geom = moon_phase_geocentric::<Vsop87Ephemeris>(*jd);
assert!(
geom.illuminated_fraction.value() >= 0.99,
"Full moon #{} illumination too low: {:.4}",
i,
geom.illuminated_fraction.value()
);
}
}
#[test]
fn l3_new_moon_low_illumination() {
let new_moons = [
jd_from_utc(2000, 1, 6, 18, 14),
jd_from_utc(2024, 4, 8, 18, 21),
jd_from_utc(2025, 11, 20, 6, 47),
];
for (i, jd) in new_moons.iter().enumerate() {
let geom = moon_phase_geocentric::<Vsop87Ephemeris>(*jd);
assert!(
geom.illuminated_fraction.value() <= 0.01,
"New moon #{} illumination too high: {:.4}",
i,
geom.illuminated_fraction.value()
);
}
}
#[test]
fn l4_find_phase_events_golden_regression() {
let start = jd_from_utc(2000, 1, 1, 0, 0).to::<tempoch::MJD>();
let end = jd_from_utc(2000, 2, 1, 0, 0).to::<tempoch::MJD>();
let window = Interval::new(start, end);
let events = find_phase_events::<Vsop87Ephemeris>(window, PhaseSearchOpts::default());
let new_moons: Vec<_> = events
.iter()
.filter(|e| e.kind == PhaseKind::NewMoon)
.collect();
let full_moons: Vec<_> = events
.iter()
.filter(|e| e.kind == PhaseKind::FullMoon)
.collect();
assert!(!new_moons.is_empty(), "No new moon found in Jan 2000");
assert!(!full_moons.is_empty(), "No full moon found in Jan 2000");
let expected_new = jd_from_utc(2000, 1, 6, 18, 14).to::<tempoch::MJD>();
let found_new = new_moons[0].mjd;
let diff_new = if found_new > expected_new {
found_new.raw() - expected_new.raw()
} else {
expected_new.raw() - found_new.raw()
};
assert!(
diff_new < Days::new(1.0 / 24.0),
"New moon off by more than 60 minutes"
);
let expected_full = jd_from_utc(2000, 1, 21, 4, 41).to::<tempoch::MJD>();
let found_full = full_moons[0].mjd;
let diff_full = if found_full > expected_full {
found_full.raw() - expected_full.raw()
} else {
expected_full.raw() - found_full.raw()
};
assert!(
diff_full < Days::new(1.0 / 24.0),
"Full moon off by more than 60 minutes"
);
}
#[test]
fn l5_topocentric_parallax_bound() {
use siderust::coordinates::centers::Geodetic;
use siderust::coordinates::frames::ECEF;
let sites = [
Geodetic::<ECEF>::new(Degrees::new(0.0), Degrees::new(51.48), Meters::new(0.0)),
Geodetic::<ECEF>::new(
Degrees::new(-17.892),
Degrees::new(28.762),
Meters::new(2396.0),
),
Geodetic::<ECEF>::new(Degrees::new(0.0), Degrees::new(0.0), Meters::new(0.0)),
];
for (s, site) in sites.iter().enumerate() {
for i in 0..10 {
let jd = siderust::J2000 + Days::new(i as f64 * 30.0);
let geo = moon_phase_geocentric::<Vsop87Ephemeris>(jd);
let topo = moon_phase_topocentric::<Vsop87Ephemeris>(jd, *site);
let mut diff = geo.elongation - topo.elongation;
if diff < Radians::new(0.0) {
diff = -diff;
}
let diff_deg = diff.to::<Degree>();
assert!(
diff_deg < Degrees::new(3.0),
"Site {} epoch {}: elongation diff {} exceeds 3°",
s,
i,
diff_deg
);
let frac_diff = (geo.illuminated_fraction - topo.illuminated_fraction)
.value()
.abs();
assert!(
frac_diff < 0.02,
"Site {} epoch {}: fraction diff {:.4} exceeds 2%",
s,
i,
frac_diff
);
}
}
}
#[test]
fn l6_label_round_trip_all_octants() {
let th = PhaseThresholds::default();
let cases = [
(0.0, MoonPhaseLabel::NewMoon),
(10.0, MoonPhaseLabel::NewMoon),
(30.0, MoonPhaseLabel::WaxingCrescent),
(60.0, MoonPhaseLabel::WaxingCrescent),
(80.0, MoonPhaseLabel::FirstQuarter),
(100.0, MoonPhaseLabel::FirstQuarter),
(120.0, MoonPhaseLabel::WaxingGibbous),
(150.0, MoonPhaseLabel::WaxingGibbous),
(170.0, MoonPhaseLabel::FullMoon),
(190.0, MoonPhaseLabel::FullMoon),
(210.0, MoonPhaseLabel::WaningGibbous),
(240.0, MoonPhaseLabel::WaningGibbous),
(260.0, MoonPhaseLabel::LastQuarter),
(280.0, MoonPhaseLabel::LastQuarter),
(300.0, MoonPhaseLabel::WaningCrescent),
(330.0, MoonPhaseLabel::WaningCrescent),
(350.0, MoonPhaseLabel::NewMoon),
];
for (elong, expected) in &cases {
let got = MoonPhaseLabel::from_elongation((*elong).into(), &th);
assert_eq!(
got, *expected,
"Elongation {:.0}° → {:?}, expected {:?}",
elong, got, expected
);
}
}
#[test]
fn l6_waxing_waning_flags() {
assert!(MoonPhaseLabel::WaxingCrescent.is_waxing());
assert!(MoonPhaseLabel::FirstQuarter.is_waxing());
assert!(MoonPhaseLabel::WaxingGibbous.is_waxing());
assert!(!MoonPhaseLabel::FullMoon.is_waxing());
assert!(!MoonPhaseLabel::NewMoon.is_waxing());
assert!(MoonPhaseLabel::WaningGibbous.is_waning());
assert!(MoonPhaseLabel::LastQuarter.is_waning());
assert!(MoonPhaseLabel::WaningCrescent.is_waning());
assert!(!MoonPhaseLabel::FullMoon.is_waning());
assert!(!MoonPhaseLabel::NewMoon.is_waning());
}
#[test]
fn l4b_all_four_phase_kinds_found() {
let start = siderust::J2000.to::<tempoch::MJD>();
let end = start + Days::new(35.0);
let window = Interval::new(start, end);
let events = find_phase_events::<Vsop87Ephemeris>(window, PhaseSearchOpts::default());
let has = |k: PhaseKind| events.iter().any(|e| e.kind == k);
assert!(has(PhaseKind::NewMoon), "Missing NewMoon event");
assert!(has(PhaseKind::FirstQuarter), "Missing FirstQuarter event");
assert!(has(PhaseKind::FullMoon), "Missing FullMoon event");
assert!(has(PhaseKind::LastQuarter), "Missing LastQuarter event");
}
#[test]
fn series_sample_correct_length() {
let start = siderust::J2000.to::<tempoch::MJD>();
let end = start + Days::new(29.0);
let step = Days::new(1.0);
let series = MoonPhaseSeries::<Vsop87Ephemeris>::sample(start, end, step);
assert_eq!(series.len(), 30); }
#[test]
fn series_topocentric_works() {
use siderust::coordinates::centers::Geodetic;
use siderust::coordinates::frames::ECEF;
let site = Geodetic::<ECEF>::new(Degrees::new(0.0), Degrees::new(51.48), Meters::new(0.0));
let start = siderust::J2000.to::<tempoch::MJD>();
let end = start + Days::new(5.0);
let step = Days::new(1.0);
let series = MoonPhaseSeries::<Vsop87Ephemeris>::sample_topocentric(start, end, step, site);
assert_eq!(series.len(), 6);
for (_, geom) in &series {
assert!(
geom.illuminated_fraction.value() >= 0.0 && geom.illuminated_fraction.value() <= 1.0
);
}
}
#[test]
fn display_impls_produce_text() {
assert_eq!(format!("{}", MoonPhaseLabel::FullMoon), "Full Moon");
assert_eq!(format!("{}", PhaseKind::NewMoon), "New Moon");
assert_eq!(
format!("{}", MoonPhaseLabel::WaxingCrescent),
"Waxing Crescent"
);
assert_eq!(format!("{}", PhaseKind::LastQuarter), "Last Quarter");
}