use super::*;
use crate::astro::frames::transforms::GeodeticStationKm;
fn spk() -> Spk {
Spk::from_bytes(include_bytes!(
"../../../tests/fixtures/almanac/almanac_de421.spk"
))
.expect("almanac SPK fixture")
}
fn utc(
year: i32,
month: i32,
day: i32,
hour: i32,
minute: i32,
second: i32,
microsecond: i32,
) -> UtcInstant {
UtcInstant::from_utc(year, month, day, hour, minute, second, microsecond).expect("valid UTC")
}
fn seconds_between_abs(a: UtcInstant, b: UtcInstant) -> f64 {
(a.unix_microseconds() - b.unix_microseconds()).abs() as f64 / 1_000_000.0
}
fn assert_within(actual: UtcInstant, expected: UtcInstant, tolerance_seconds: f64, label: &str) {
let error = seconds_between_abs(actual, expected);
assert!(
error <= tolerance_seconds,
"{label} time error {error} s exceeds {tolerance_seconds} s"
);
}
fn greenwich() -> GeodeticStationKm {
GeodeticStationKm {
latitude_deg: 51.4769,
longitude_deg: 0.0,
altitude_km: 0.046,
}
}
#[test]
fn seasons_match_reference_2025() {
let spk = spk();
let events = seasons(
EphemerisSource::Spk(&spk),
utc(2025, 1, 1, 0, 0, 0, 0),
utc(2026, 1, 1, 0, 0, 0, 0),
21_600.0,
1.0,
)
.expect("seasons");
let expected = [
(
SeasonKind::MarchEquinox,
utc(2025, 3, 20, 9, 1, 28, 934_474),
utc(2025, 3, 20, 9, 1, 0, 0),
),
(
SeasonKind::JuneSolstice,
utc(2025, 6, 21, 2, 42, 15, 683_236),
utc(2025, 6, 21, 2, 42, 0, 0),
),
(
SeasonKind::SeptemberEquinox,
utc(2025, 9, 22, 18, 19, 20, 499_855),
utc(2025, 9, 22, 18, 19, 0, 0),
),
(
SeasonKind::DecemberSolstice,
utc(2025, 12, 21, 15, 3, 5, 141_637),
utc(2025, 12, 21, 15, 3, 0, 0),
),
];
assert_eq!(events.len(), expected.len());
for ((kind, skyfield_time, usno_time), event) in expected.into_iter().zip(events.iter()) {
assert_eq!(event.kind, kind);
assert_within(event.time, skyfield_time, 5.0, "Skyfield season");
assert_within(event.time, usno_time, 60.0, "USNO season");
}
assert!(events.windows(2).all(|pair| pair[0].time < pair[1].time));
}
#[test]
fn moon_phases_match_reference_2025() {
let spk = spk();
let events = moon_phases(
EphemerisSource::Spk(&spk),
utc(2025, 1, 1, 0, 0, 0, 0),
utc(2025, 2, 1, 0, 0, 0, 0),
21_600.0,
1.0,
)
.expect("moon phases");
let full = events
.iter()
.find(|event| event.kind == MoonPhaseKind::Full)
.expect("full moon");
let new = events
.iter()
.find(|event| event.kind == MoonPhaseKind::New)
.expect("new moon");
assert_within(
full.time,
utc(2025, 1, 13, 22, 26, 54, 547_309),
5.0,
"full moon",
);
assert_within(
new.time,
utc(2025, 1, 29, 12, 35, 58, 908_240),
5.0,
"new moon",
);
let mut previous =
moon_phase_deg(EphemerisSource::Spk(&spk), utc(2025, 1, 2, 0, 0, 0, 0)).expect("phase");
let mut unwrapped = previous;
for day in 3..29 {
let phase = moon_phase_deg(EphemerisSource::Spk(&spk), utc(2025, 1, day, 0, 0, 0, 0))
.expect("phase");
let mut delta = phase - previous;
if delta < -180.0 {
delta += 360.0;
}
unwrapped += delta;
assert!(delta > 0.0, "phase did not advance on January {day}");
previous = phase;
}
assert!(unwrapped > 300.0);
}
#[test]
fn planetary_opposition_matches_reference() {
let spk = spk();
let mars = planetary_events(
EphemerisSource::Spk(&spk),
Planet::Mars,
PlanetaryEventKind::Opposition,
utc(2025, 1, 1, 0, 0, 0, 0),
utc(2025, 2, 1, 0, 0, 0, 0),
21_600.0,
1.0,
)
.expect("mars opposition");
assert_eq!(mars.len(), 1);
assert_within(
mars[0].time,
utc(2025, 1, 16, 2, 38, 35, 259_890),
5.0,
"Mars opposition",
);
assert!((mars[0].elongation_deg - 180.0).abs() < 0.01);
let saturn = planetary_events(
EphemerisSource::Spk(&spk),
Planet::Saturn,
PlanetaryEventKind::Opposition,
utc(2025, 9, 1, 0, 0, 0, 0),
utc(2025, 10, 15, 0, 0, 0, 0),
21_600.0,
1.0,
)
.expect("saturn opposition");
assert_eq!(saturn.len(), 1);
assert_within(
saturn[0].time,
utc(2025, 9, 21, 5, 45, 38, 456_186),
5.0,
"Saturn opposition",
);
assert!((saturn[0].elongation_deg - 180.0).abs() < 0.01);
let venus_error = planetary_events(
EphemerisSource::Spk(&spk),
Planet::Venus,
PlanetaryEventKind::Opposition,
utc(2025, 1, 1, 0, 0, 0, 0),
utc(2025, 12, 31, 0, 0, 0, 0),
21_600.0,
1.0,
)
.expect_err("Venus opposition must be rejected");
assert_eq!(venus_error, AlmanacError::InferiorPlanetOpposition);
let venus_conjunction = planetary_events(
EphemerisSource::Spk(&spk),
Planet::Venus,
PlanetaryEventKind::Conjunction,
utc(2025, 3, 1, 0, 0, 0, 0),
utc(2025, 4, 15, 0, 0, 0, 0),
21_600.0,
1.0,
)
.expect("Venus conjunction");
assert!(!venus_conjunction.is_empty());
}
#[test]
fn meridian_transits_reproduce_moon_reference() {
let station = greenwich();
let start = utc(2024, 4, 23, 0, 0, 0, 0);
let end = utc(2024, 4, 24, 0, 0, 0, 0);
let legacy =
crate::astro::bodies::rise_set::find_moon_transits(&station, start, end, 300.0, 1.0)
.expect("legacy moon transits");
let general = meridian_transits(
EphemerisSource::Analytic,
TransitBody::Moon,
&station,
start,
end,
300.0,
1.0,
)
.expect("general moon transits");
assert_eq!(legacy.len(), general.len());
for (legacy, general) in legacy.iter().zip(general.iter()) {
let expected_kind = match legacy.kind {
crate::astro::bodies::rise_set::MoonTransitKind::Upper => CulminationKind::Upper,
crate::astro::bodies::rise_set::MoonTransitKind::Lower => CulminationKind::Lower,
};
assert_eq!(general.kind, expected_kind);
assert_within(general.time, legacy.time, 1.0, "Moon transit delegation");
}
let sun = meridian_transits(
EphemerisSource::Analytic,
TransitBody::Sun,
&station,
utc(2025, 3, 20, 0, 0, 0, 0),
utc(2025, 3, 21, 0, 0, 0, 0),
300.0,
1.0,
)
.expect("Sun transits");
assert!(sun.iter().any(|event| {
event.kind == CulminationKind::Upper
&& (11.5..12.5).contains(
&((event.time.unix_microseconds()
- utc(2025, 3, 20, 0, 0, 0, 0).unix_microseconds()) as f64
/ 3_600_000_000.0),
)
}));
}
#[test]
fn eclipses_detect_and_classify() {
let spk = spk();
let lunar = lunar_solar_eclipses(
EphemerisSource::Spk(&spk),
utc(2025, 3, 14, 0, 0, 0, 0),
utc(2025, 3, 15, 0, 0, 0, 0),
21_600.0,
1.0,
)
.expect("lunar eclipse");
let lunar = lunar
.iter()
.find(|event| event.kind == EclipseKind::LunarTotal)
.expect("total lunar eclipse");
assert_within(
lunar.time_maximum,
utc(2025, 3, 14, 6, 59, 0, 0),
5.0 * 60.0,
"total lunar eclipse",
);
assert!(lunar.magnitude > 1.1);
assert!(!lunar.uncertain);
let solar = lunar_solar_eclipses(
EphemerisSource::Spk(&spk),
utc(2026, 8, 12, 0, 0, 0, 0),
utc(2026, 8, 13, 0, 0, 0, 0),
21_600.0,
1.0,
)
.expect("solar eclipse");
let solar = solar
.iter()
.find(|event| event.kind == EclipseKind::SolarTotal)
.expect("total solar eclipse");
assert_within(
solar.time_maximum,
utc(2026, 8, 12, 17, 46, 0, 0),
5.0 * 60.0,
"total solar eclipse",
);
assert!(
(1.0..=1.08).contains(&solar.magnitude),
"solar magnitude {}",
solar.magnitude
);
assert!(
(solar.gamma - 0.8978).abs() < 0.02,
"solar gamma {}",
solar.gamma
);
assert!(!solar.uncertain, "solar event should not be uncertain");
let none = lunar_solar_eclipses(
EphemerisSource::Spk(&spk),
utc(2025, 2, 1, 0, 0, 0, 0),
utc(2025, 2, 3, 0, 0, 0, 0),
21_600.0,
1.0,
)
.expect("control window");
assert!(none.is_empty());
}
#[test]
fn analytic_tier_looser_tolerance() {
let season = seasons(
EphemerisSource::Analytic,
utc(2025, 3, 1, 0, 0, 0, 0),
utc(2025, 4, 1, 0, 0, 0, 0),
21_600.0,
1.0,
)
.expect("analytic season");
assert_eq!(season.len(), 1);
assert_within(
season[0].time,
utc(2025, 3, 20, 9, 1, 0, 0),
15.0 * 60.0,
"analytic season",
);
let phases = moon_phases(
EphemerisSource::Analytic,
utc(2025, 1, 10, 0, 0, 0, 0),
utc(2025, 1, 31, 0, 0, 0, 0),
21_600.0,
1.0,
)
.expect("analytic phases");
assert!(phases.iter().any(|event| {
event.kind == MoonPhaseKind::Full
&& seconds_between_abs(event.time, utc(2025, 1, 13, 22, 27, 0, 0)) <= 15.0 * 60.0
}));
assert!(phases.iter().any(|event| {
event.kind == MoonPhaseKind::New
&& seconds_between_abs(event.time, utc(2025, 1, 29, 12, 36, 0, 0)) <= 15.0 * 60.0
}));
let lunar = lunar_solar_eclipses(
EphemerisSource::Analytic,
utc(2025, 3, 14, 0, 0, 0, 0),
utc(2025, 3, 15, 0, 0, 0, 0),
21_600.0,
1.0,
)
.expect("analytic eclipse");
assert!(lunar
.iter()
.any(|event| event.kind == EclipseKind::LunarTotal));
let error = planetary_events(
EphemerisSource::Analytic,
Planet::Mars,
PlanetaryEventKind::Opposition,
utc(2025, 1, 1, 0, 0, 0, 0),
utc(2025, 2, 1, 0, 0, 0, 0),
21_600.0,
1.0,
)
.expect_err("analytic planet event must fail");
assert_eq!(error, AlmanacError::EphemerisRequired);
}
#[test]
fn finder_edge_cases() {
let spk = spk();
let empty = seasons(
EphemerisSource::Spk(&spk),
utc(2025, 1, 1, 0, 0, 0, 0),
utc(2025, 1, 10, 0, 0, 0, 0),
21_600.0,
1.0,
)
.expect("event-free window");
assert!(empty.is_empty());
let inverted = seasons(
EphemerisSource::Spk(&spk),
utc(2025, 1, 2, 0, 0, 0, 0),
utc(2025, 1, 1, 0, 0, 0, 0),
21_600.0,
1.0,
)
.expect_err("inverted window");
assert!(matches!(inverted, AlmanacError::Finder(_)));
let invalid = seasons(
EphemerisSource::Spk(&spk),
utc(2025, 1, 1, 0, 0, 0, 0),
utc(2025, 1, 2, 0, 0, 0, 0),
f64::NAN,
1.0,
)
.expect_err("non-finite step");
assert!(matches!(invalid, AlmanacError::InvalidInput { .. }));
let spk_error = seasons(
EphemerisSource::Spk(&spk),
utc(2027, 1, 1, 0, 0, 0, 0),
utc(2027, 1, 10, 0, 0, 0, 0),
21_600.0,
1.0,
)
.expect_err("SPK coverage error");
assert!(matches!(spk_error, AlmanacError::Spk(_)));
let eclipse_error = lunar_solar_eclipses(
EphemerisSource::Spk(&spk),
utc(2027, 1, 1, 0, 0, 0, 0),
utc(2027, 1, 10, 0, 0, 0, 0),
21_600.0,
1.0,
)
.expect_err("SPK coverage error through eclipse path");
assert!(matches!(eclipse_error, AlmanacError::Spk(_)));
}