use crate::ephemeris::{apparent_sun_longitude_deg, sample_sun_moon};
use crate::error::{EclipseError, WINDOW_END_JD, WINDOW_START_JD};
use crate::geometry::{classify_lunar, classify_solar, sub_shadow_point};
use crate::local::{is_locally_visible, local_circumstances_for, LocalCircumstances};
use crate::saros::saros_series;
use crate::syzygy::{find_syzygies, Syzygy, STEP_DAYS};
use crate::types::{Eclipse, EclipseFilter, EclipseKind, EclipseType, Node};
use pleiades_apparent::Atmosphere;
use pleiades_backend::EphemerisBackend;
use pleiades_types::{Instant, JulianDay, Longitude, ObserverLocation, TimeScale};
const MAX_LOCAL_SEARCH: usize = 4000;
const GREATEST_BRACKET_DAYS: f64 = 0.25;
const FIRST_SEARCH_SPAN_DAYS: f64 = 400.0;
pub struct EclipseEngine<B> {
backend: B,
}
impl<B: EphemerisBackend> EclipseEngine<B> {
pub fn new(backend: B) -> Self {
Self { backend }
}
pub fn eclipses_in_range(
&self,
start: Instant,
end: Instant,
filter: EclipseFilter,
) -> Result<Vec<Eclipse>, EclipseError> {
let start_jd = start.julian_day.days();
let end_jd = end.julian_day.days();
self.check_window(start_jd)?;
self.check_window(end_jd)?;
let scan_start = (start_jd - GREATEST_BRACKET_DAYS).max(WINDOW_START_JD + STEP_DAYS);
let scan_end = (end_jd + GREATEST_BRACKET_DAYS).min(WINDOW_END_JD - STEP_DAYS);
let mut out = Vec::new();
for event in find_syzygies(&self.backend, scan_start, scan_end)? {
let Some(eclipse) = self.build(event.syzygy, event.julian_day)? else {
continue;
};
let greatest_jd = eclipse.greatest_eclipse.julian_day.days();
if filter.admits(eclipse.kind) && (start_jd..=end_jd).contains(&greatest_jd) {
out.push(eclipse);
}
}
Ok(out)
}
pub fn next_eclipse(
&self,
after: Instant,
filter: EclipseFilter,
) -> Result<Option<Eclipse>, EclipseError> {
self.search_forward(after.julian_day.days(), FIRST_SEARCH_SPAN_DAYS, filter)
}
pub fn previous_eclipse(
&self,
before: Instant,
filter: EclipseFilter,
) -> Result<Option<Eclipse>, EclipseError> {
self.search_backward(before.julian_day.days(), FIRST_SEARCH_SPAN_DAYS, filter)
}
fn search_forward(
&self,
after_jd: f64,
first_span: f64,
filter: EclipseFilter,
) -> Result<Option<Eclipse>, EclipseError> {
self.check_window(after_jd)?;
let mut near = after_jd;
let mut span = first_span;
loop {
let far = (near + span).min(WINDOW_END_JD);
let found = self
.eclipses_in_range(tdb(near), tdb(far), filter)?
.into_iter()
.find(|e| e.greatest_eclipse.julian_day.days() > after_jd);
if found.is_some() || far >= WINDOW_END_JD {
return Ok(found);
}
near = far;
span *= 2.0;
}
}
fn search_backward(
&self,
before_jd: f64,
first_span: f64,
filter: EclipseFilter,
) -> Result<Option<Eclipse>, EclipseError> {
self.check_window(before_jd)?;
let mut near = before_jd;
let mut span = first_span;
loop {
let far = (near - span).max(WINDOW_START_JD);
let found = self
.eclipses_in_range(tdb(far), tdb(near), filter)?
.into_iter()
.rev()
.find(|e| e.greatest_eclipse.julian_day.days() < before_jd);
if found.is_some() || far <= WINDOW_START_JD {
return Ok(found);
}
near = far;
span *= 2.0;
}
}
pub fn local_circumstances(
&self,
eclipse: &Eclipse,
observer: &ObserverLocation,
atmosphere: Atmosphere,
) -> Result<LocalCircumstances, EclipseError> {
observer
.validate()
.map_err(|e| EclipseError::InvalidObserver {
detail: e.to_string(),
})?;
check_atmosphere(atmosphere)?;
local_circumstances_for(&self.backend, eclipse, observer, atmosphere)
}
pub fn next_local_eclipse(
&self,
after: Instant,
observer: &ObserverLocation,
filter: EclipseFilter,
atmosphere: Atmosphere,
) -> Result<Option<(Eclipse, LocalCircumstances)>, EclipseError> {
observer
.validate()
.map_err(|e| EclipseError::InvalidObserver {
detail: e.to_string(),
})?;
check_atmosphere(atmosphere)?;
let mut cursor = after;
for _ in 0..MAX_LOCAL_SEARCH {
let Some(eclipse) = self.next_eclipse(cursor, filter)? else {
return Ok(None);
};
let local = local_circumstances_for(&self.backend, &eclipse, observer, atmosphere)?;
if is_locally_visible(&local) {
return Ok(Some((eclipse, local)));
}
cursor = eclipse.greatest_eclipse;
}
Ok(None)
}
pub fn previous_local_eclipse(
&self,
before: Instant,
observer: &ObserverLocation,
filter: EclipseFilter,
atmosphere: Atmosphere,
) -> Result<Option<(Eclipse, LocalCircumstances)>, EclipseError> {
observer
.validate()
.map_err(|e| EclipseError::InvalidObserver {
detail: e.to_string(),
})?;
check_atmosphere(atmosphere)?;
let mut cursor = before;
for _ in 0..MAX_LOCAL_SEARCH {
let Some(eclipse) = self.previous_eclipse(cursor, filter)? else {
return Ok(None);
};
let local = local_circumstances_for(&self.backend, &eclipse, observer, atmosphere)?;
if is_locally_visible(&local) {
return Ok(Some((eclipse, local)));
}
cursor = eclipse.greatest_eclipse;
}
Ok(None)
}
fn check_window(&self, jd: f64) -> Result<(), EclipseError> {
if !(WINDOW_START_JD..=WINDOW_END_JD).contains(&jd) {
Err(EclipseError::OutOfWindow { julian_day: jd })
} else {
Ok(())
}
}
fn build(&self, syzygy: Syzygy, syzygy_jd: f64) -> Result<Option<Eclipse>, EclipseError> {
let greatest_jd = self.refine_greatest(syzygy, syzygy_jd)?;
let sample = sample_sun_moon(&self.backend, greatest_jd)?;
let greatest_eclipse = Instant::new(JulianDay::from_days(greatest_jd), TimeScale::Tdb);
let apparent_sun_lon = apparent_sun_longitude_deg(&self.backend, greatest_jd)?;
let eclipsed_longitude = match syzygy {
Syzygy::NewMoon => Longitude::from_degrees(apparent_sun_lon),
Syzygy::FullMoon => Longitude::from_degrees(apparent_sun_lon + 180.0),
};
let later = sample_sun_moon(&self.backend, greatest_jd + 0.01)?;
let near_node = if later.moon_latitude_deg >= sample.moon_latitude_deg {
Node::North
} else {
Node::South
};
let eclipse = match syzygy {
Syzygy::NewMoon => {
let Some(c) = classify_solar(&sample) else {
return Ok(None);
};
Eclipse {
kind: EclipseKind::Solar,
eclipse_type: EclipseType::Solar(c.eclipse_type),
greatest_eclipse,
magnitude: c.magnitude,
gamma: c.gamma,
saros_series: saros_series(EclipseKind::Solar, greatest_jd),
eclipsed_longitude,
near_node,
greatest_eclipse_location: Some(sub_shadow_point(&sample, greatest_jd)),
}
}
Syzygy::FullMoon => {
let Some(c) = classify_lunar(&sample) else {
return Ok(None);
};
Eclipse {
kind: EclipseKind::Lunar,
eclipse_type: EclipseType::Lunar(c.eclipse_type),
greatest_eclipse,
magnitude: c.magnitude,
gamma: c.gamma,
saros_series: saros_series(EclipseKind::Lunar, greatest_jd),
eclipsed_longitude,
near_node,
greatest_eclipse_location: None,
}
}
};
Ok(Some(eclipse))
}
fn refine_greatest(&self, syzygy: Syzygy, syzygy_jd: f64) -> Result<f64, EclipseError> {
use crate::geometry::separation_for;
let phi = 0.618_033_988_75_f64;
let (mut a, mut b) = (
syzygy_jd - GREATEST_BRACKET_DAYS,
syzygy_jd + GREATEST_BRACKET_DAYS,
);
let mut c = b - (b - a) * phi;
let mut d = a + (b - a) * phi;
let mut fc = separation_for(syzygy, &sample_sun_moon(&self.backend, c)?);
let mut fd = separation_for(syzygy, &sample_sun_moon(&self.backend, d)?);
while (b - a) > 0.5 / 86_400.0 {
if fc < fd {
b = d;
d = c;
fd = fc;
c = b - (b - a) * phi;
fc = separation_for(syzygy, &sample_sun_moon(&self.backend, c)?);
} else {
a = c;
c = d;
fc = fd;
d = a + (b - a) * phi;
fd = separation_for(syzygy, &sample_sun_moon(&self.backend, d)?);
}
}
Ok(0.5 * (a + b))
}
}
fn tdb(jd: f64) -> Instant {
Instant::new(JulianDay::from_days(jd), TimeScale::Tdb)
}
fn check_atmosphere(atmos: Atmosphere) -> Result<(), EclipseError> {
if !atmos.pressure_mbar.is_finite() || !atmos.temperature_c.is_finite() {
return Err(EclipseError::InvalidAtmosphere {
detail: format!(
"pressure={} temp={}",
atmos.pressure_mbar, atmos.temperature_c
),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use pleiades_backend::test_backend::LinearSunMoon;
use pleiades_types::{Instant, JulianDay, TimeScale};
fn at(jd: f64) -> Instant {
Instant::new(JulianDay::from_days(jd), TimeScale::Tdb)
}
#[test]
fn out_of_window_start_fails_closed() {
let engine = EclipseEngine::new(LinearSunMoon::new_moon_at(2_451_550.0));
let err = engine
.eclipses_in_range(at(2_400_000.0), at(2_451_551.0), EclipseFilter::All)
.unwrap_err();
assert!(matches!(err, EclipseError::OutOfWindow { .. }));
}
#[test]
fn filter_excludes_lunar() {
let engine =
EclipseEngine::new(LinearSunMoon::new_moon_at(2_451_550.0).with_moon_latitude(0.0));
let solar = engine
.eclipses_in_range(at(2_451_549.0), at(2_451_551.0), EclipseFilter::SolarOnly)
.unwrap();
assert!(solar.iter().all(|e| e.kind == EclipseKind::Solar));
}
#[test]
fn outward_search_result_does_not_depend_on_the_first_span() {
let engine = EclipseEngine::new(pleiades_data::packaged_backend());
for t in [WINDOW_START_JD + 40.0, WINDOW_END_JD - 40.0] {
for filter in [
EclipseFilter::All,
EclipseFilter::SolarOnly,
EclipseFilter::LunarOnly,
] {
assert_eq!(
engine.search_forward(t, 1.0, filter).unwrap(),
engine
.search_forward(t, FIRST_SEARCH_SPAN_DAYS, filter)
.unwrap(),
"forward from JD {t}, {filter:?}"
);
assert_eq!(
engine.search_backward(t, 1.0, filter).unwrap(),
engine
.search_backward(t, FIRST_SEARCH_SPAN_DAYS, filter)
.unwrap(),
"backward from JD {t}, {filter:?}"
);
}
}
}
#[test]
fn outward_search_with_no_eclipse_ends_at_the_window_edge() {
let engine =
EclipseEngine::new(LinearSunMoon::new_moon_at(2_451_550.0).with_moon_latitude(5.0));
let t = WINDOW_END_JD - 30.0;
assert_eq!(
engine.search_forward(t, 1.0, EclipseFilter::All).unwrap(),
None
);
let t = WINDOW_START_JD + 30.0;
assert_eq!(
engine.search_backward(t, 1.0, EclipseFilter::All).unwrap(),
None
);
}
#[test]
fn next_and_previous_eclipse_fail_closed_outside_the_window() {
let engine = EclipseEngine::new(LinearSunMoon::new_moon_at(2_451_550.0));
for jd in [WINDOW_START_JD - 1.0, WINDOW_END_JD + 1.0, f64::NAN] {
assert!(matches!(
engine.next_eclipse(at(jd), EclipseFilter::All),
Err(EclipseError::OutOfWindow { .. })
));
assert!(matches!(
engine.previous_eclipse(at(jd), EclipseFilter::All),
Err(EclipseError::OutOfWindow { .. })
));
}
}
#[test]
fn local_circumstances_returns_solar_for_a_solar_eclipse() {
use pleiades_apparent::Atmosphere;
use pleiades_types::{Latitude, Longitude, ObserverLocation};
let engine =
EclipseEngine::new(LinearSunMoon::new_moon_at(2_451_550.0).with_moon_latitude(0.0));
let eclipse = engine
.next_eclipse(at(2_451_549.0), EclipseFilter::SolarOnly)
.unwrap()
.expect("a solar eclipse");
let observer = ObserverLocation::new(
Latitude::from_degrees(0.0),
Longitude::from_degrees(0.0),
Some(0.0),
);
let local = engine
.local_circumstances(&eclipse, &observer, Atmosphere::default())
.unwrap();
assert!(matches!(local, crate::LocalCircumstances::Solar(_)));
}
}