Skip to main content

pleiades_eclipse/
engine.rs

1//! The public eclipse search engine.
2
3use crate::ephemeris::{apparent_sun_longitude_deg, sample_sun_moon};
4use crate::error::{EclipseError, WINDOW_END_JD, WINDOW_START_JD};
5use crate::geometry::{classify_lunar, classify_solar, sub_shadow_point};
6use crate::local::{is_locally_visible, local_circumstances_for, LocalCircumstances};
7use crate::saros::saros_series;
8use crate::syzygy::{find_syzygies, Syzygy, STEP_DAYS};
9use crate::types::{Eclipse, EclipseFilter, EclipseKind, EclipseType, Node};
10use pleiades_apparent::Atmosphere;
11use pleiades_backend::EphemerisBackend;
12use pleiades_types::{Instant, JulianDay, Longitude, ObserverLocation, TimeScale};
13
14/// Backstop cap on how many global eclipses `next/previous_local_eclipse`
15/// inspect before returning `None` (a locally-visible eclipse always occurs
16/// far sooner; this only guards against a pathological non-terminating walk).
17const MAX_LOCAL_SEARCH: usize = 4000;
18
19/// Searches for global/geocentric eclipses over a chosen [`EphemerisBackend`].
20///
21/// Backed by the packaged data, results are valid only within the
22/// 1900-01-01..2100-01-01 window (see [`crate`]); out-of-window requests fail
23/// closed with [`EclipseError::OutOfWindow`].
24pub struct EclipseEngine<B> {
25    backend: B,
26}
27
28impl<B: EphemerisBackend> EclipseEngine<B> {
29    /// Creates an engine that draws Sun/Moon positions from `backend`.
30    pub fn new(backend: B) -> Self {
31        Self { backend }
32    }
33
34    /// Returns every eclipse admitted by `filter` with greatest eclipse in
35    /// `[start, end]`, in chronological order. Fails closed if either bound is
36    /// outside the supported window.
37    pub fn eclipses_in_range(
38        &self,
39        start: Instant,
40        end: Instant,
41        filter: EclipseFilter,
42    ) -> Result<Vec<Eclipse>, EclipseError> {
43        let start_jd = start.julian_day.days();
44        let end_jd = end.julian_day.days();
45        self.check_window(start_jd)?;
46        self.check_window(end_jd)?;
47
48        // The syzygy scanner (`find_one`) probes one STEP_DAYS past its
49        // requested end for sign-change detection, and `sample_sun_moon` issues a
50        // light-time-retarded Sun query ~0.006 days before the nominal epoch.
51        // Together these mean:
52        //   - At the END: the scanner queries up to `scan_end + STEP_DAYS`; the
53        //     packaged backend has no data beyond WINDOW_END_JD, so we clamp
54        //     `scan_end` to `WINDOW_END_JD - STEP_DAYS`.
55        //   - At the START: the retarded query falls `~light_time` before the
56        //     first sample; clamping `scan_start` to `WINDOW_START_JD + STEP_DAYS`
57        //     (> max light time ~0.006 d) keeps every retarded lookup within coverage.
58        // Both clamps are safe: no corpus eclipse falls within 0.5 d of either bound.
59        let scan_start = start_jd.max(WINDOW_START_JD + STEP_DAYS);
60        let scan_end = end_jd.min(WINDOW_END_JD - STEP_DAYS);
61
62        let mut out = Vec::new();
63        for event in find_syzygies(&self.backend, scan_start, scan_end)? {
64            if let Some(eclipse) = self.build(event.syzygy, event.julian_day)? {
65                if filter.admits(eclipse.kind) {
66                    out.push(eclipse);
67                }
68            }
69        }
70        Ok(out)
71    }
72
73    /// Returns the first eclipse admitted by `filter` whose greatest eclipse is
74    /// strictly after `after`, or `None` if none remains before the window end.
75    pub fn next_eclipse(
76        &self,
77        after: Instant,
78        filter: EclipseFilter,
79    ) -> Result<Option<Eclipse>, EclipseError> {
80        let after_jd = after.julian_day.days();
81        // `eclipses_in_range` clamps the scan end to WINDOW_END_JD - STEP_DAYS,
82        // so passing WINDOW_END_JD directly is safe and correct.
83        let end = Instant::new(JulianDay::from_days(WINDOW_END_JD), TimeScale::Tdb);
84        Ok(self
85            .eclipses_in_range(after, end, filter)?
86            .into_iter()
87            .find(|e| e.greatest_eclipse.julian_day.days() > after_jd))
88    }
89
90    /// Returns the last eclipse admitted by `filter` whose greatest eclipse is
91    /// strictly before `before`, or `None` if none exists after the window start.
92    pub fn previous_eclipse(
93        &self,
94        before: Instant,
95        filter: EclipseFilter,
96    ) -> Result<Option<Eclipse>, EclipseError> {
97        let before_jd = before.julian_day.days();
98        let start = Instant::new(JulianDay::from_days(WINDOW_START_JD), TimeScale::Tdb);
99        Ok(self
100            .eclipses_in_range(start, before, filter)?
101            .into_iter()
102            .rev()
103            .find(|e| e.greatest_eclipse.julian_day.days() < before_jd))
104    }
105
106    /// Local (per-observer) circumstances for an already-found `eclipse`.
107    ///
108    /// Returns full circumstances even when the eclipse is not visible from
109    /// `observer` (all contacts below the horizon); inspect `any_phase_visible`
110    /// (via the returned variant) to test visibility. Solar contact instants are
111    /// observer-dependent (topocentric); lunar contact instants are global with
112    /// per-observer visibility.
113    ///
114    /// `atmosphere` is `pleiades_apparent::Atmosphere` from the
115    /// `pleiades-apparent` release this crate pins (0.6 and later); build it
116    /// from that same release.
117    pub fn local_circumstances(
118        &self,
119        eclipse: &Eclipse,
120        observer: &ObserverLocation,
121        atmosphere: Atmosphere,
122    ) -> Result<LocalCircumstances, EclipseError> {
123        observer
124            .validate()
125            .map_err(|e| EclipseError::InvalidObserver {
126                detail: e.to_string(),
127            })?;
128        check_atmosphere(atmosphere)?;
129        local_circumstances_for(&self.backend, eclipse, observer, atmosphere)
130    }
131
132    /// The next eclipse admitted by `filter`, strictly after `after`, that is
133    /// locally visible from `observer` (any phase above the horizon), paired with
134    /// its local circumstances. Walks the global `next_eclipse` sequence and
135    /// returns the first locally-visible one, so the result is a strict refinement
136    /// of the global engine.
137    pub fn next_local_eclipse(
138        &self,
139        after: Instant,
140        observer: &ObserverLocation,
141        filter: EclipseFilter,
142        atmosphere: Atmosphere,
143    ) -> Result<Option<(Eclipse, LocalCircumstances)>, EclipseError> {
144        observer
145            .validate()
146            .map_err(|e| EclipseError::InvalidObserver {
147                detail: e.to_string(),
148            })?;
149        check_atmosphere(atmosphere)?;
150        let mut cursor = after;
151        // Bounded walk: no more than MAX_LOCAL_SEARCH global eclipses inspected
152        // before giving up (backstop; a locally-visible eclipse always occurs well
153        // within the window). ~2 eclipses/year × 200 yr ≈ 1200 global eclipses max.
154        for _ in 0..MAX_LOCAL_SEARCH {
155            let Some(eclipse) = self.next_eclipse(cursor, filter)? else {
156                return Ok(None);
157            };
158            let local = local_circumstances_for(&self.backend, &eclipse, observer, atmosphere)?;
159            if is_locally_visible(&local) {
160                return Ok(Some((eclipse, local)));
161            }
162            cursor = eclipse.greatest_eclipse;
163        }
164        Ok(None)
165    }
166
167    /// The previous eclipse admitted by `filter`, strictly before `before`, that
168    /// is locally visible from `observer`, paired with its local circumstances.
169    pub fn previous_local_eclipse(
170        &self,
171        before: Instant,
172        observer: &ObserverLocation,
173        filter: EclipseFilter,
174        atmosphere: Atmosphere,
175    ) -> Result<Option<(Eclipse, LocalCircumstances)>, EclipseError> {
176        observer
177            .validate()
178            .map_err(|e| EclipseError::InvalidObserver {
179                detail: e.to_string(),
180            })?;
181        check_atmosphere(atmosphere)?;
182        let mut cursor = before;
183        for _ in 0..MAX_LOCAL_SEARCH {
184            let Some(eclipse) = self.previous_eclipse(cursor, filter)? else {
185                return Ok(None);
186            };
187            let local = local_circumstances_for(&self.backend, &eclipse, observer, atmosphere)?;
188            if is_locally_visible(&local) {
189                return Ok(Some((eclipse, local)));
190            }
191            cursor = eclipse.greatest_eclipse;
192        }
193        Ok(None)
194    }
195
196    fn check_window(&self, jd: f64) -> Result<(), EclipseError> {
197        if !(WINDOW_START_JD..=WINDOW_END_JD).contains(&jd) {
198            Err(EclipseError::OutOfWindow { julian_day: jd })
199        } else {
200            Ok(())
201        }
202    }
203
204    fn build(&self, syzygy: Syzygy, syzygy_jd: f64) -> Result<Option<Eclipse>, EclipseError> {
205        let greatest_jd = self.refine_greatest(syzygy, syzygy_jd)?;
206        let sample = sample_sun_moon(&self.backend, greatest_jd)?;
207        let greatest_eclipse = Instant::new(JulianDay::from_days(greatest_jd), TimeScale::Tdb);
208        // Compute the apparent geocentric solar longitude of date for eclipsed_longitude.
209        // Geometric sampling (separation, classification) uses the Mean frame — that is
210        // correct and unchanged. But eclipsed_longitude must be apparent-of-date per the
211        // spec (Task 10 gate: ≤1 arcsecond); mean would be ~20–25″ off.
212        let apparent_sun_lon = apparent_sun_longitude_deg(&self.backend, greatest_jd)?;
213        let eclipsed_longitude = match syzygy {
214            Syzygy::NewMoon => Longitude::from_degrees(apparent_sun_lon),
215            // For lunar eclipses the eclipsed body is the Moon, which is opposite the Sun;
216            // eclipsed_longitude is the apparent solar longitude + 180° (corpus MANIFEST).
217            Syzygy::FullMoon => Longitude::from_degrees(apparent_sun_lon + 180.0),
218        };
219        // Node: ascending (North) if the Moon's latitude is increasing through 0.
220        let later = sample_sun_moon(&self.backend, greatest_jd + 0.01)?;
221        let near_node = if later.moon_latitude_deg >= sample.moon_latitude_deg {
222            Node::North
223        } else {
224            Node::South
225        };
226
227        let eclipse = match syzygy {
228            Syzygy::NewMoon => {
229                let Some(c) = classify_solar(&sample) else {
230                    return Ok(None);
231                };
232                Eclipse {
233                    kind: EclipseKind::Solar,
234                    eclipse_type: EclipseType::Solar(c.eclipse_type),
235                    greatest_eclipse,
236                    magnitude: c.magnitude,
237                    gamma: c.gamma,
238                    saros_series: saros_series(EclipseKind::Solar, greatest_jd),
239                    eclipsed_longitude,
240                    near_node,
241                    greatest_eclipse_location: Some(sub_shadow_point(&sample, greatest_jd)),
242                }
243            }
244            Syzygy::FullMoon => {
245                let Some(c) = classify_lunar(&sample) else {
246                    return Ok(None);
247                };
248                Eclipse {
249                    kind: EclipseKind::Lunar,
250                    eclipse_type: EclipseType::Lunar(c.eclipse_type),
251                    greatest_eclipse,
252                    magnitude: c.magnitude,
253                    gamma: c.gamma,
254                    saros_series: saros_series(EclipseKind::Lunar, greatest_jd),
255                    eclipsed_longitude,
256                    near_node,
257                    greatest_eclipse_location: None,
258                }
259            }
260        };
261        Ok(Some(eclipse))
262    }
263
264    /// Golden-section minimize the Sun–Moon (or Moon–antisolar) separation in a
265    /// ±0.25-day bracket around the syzygy to find greatest eclipse.
266    fn refine_greatest(&self, syzygy: Syzygy, syzygy_jd: f64) -> Result<f64, EclipseError> {
267        use crate::geometry::separation_for;
268        let phi = 0.618_033_988_75_f64;
269        let (mut a, mut b) = (syzygy_jd - 0.25, syzygy_jd + 0.25);
270        let mut c = b - (b - a) * phi;
271        let mut d = a + (b - a) * phi;
272        let mut fc = separation_for(syzygy, &sample_sun_moon(&self.backend, c)?);
273        let mut fd = separation_for(syzygy, &sample_sun_moon(&self.backend, d)?);
274        while (b - a) > 0.5 / 86_400.0 {
275            if fc < fd {
276                b = d;
277                d = c;
278                fd = fc;
279                c = b - (b - a) * phi;
280                fc = separation_for(syzygy, &sample_sun_moon(&self.backend, c)?);
281            } else {
282                a = c;
283                c = d;
284                fc = fd;
285                d = a + (b - a) * phi;
286                fd = separation_for(syzygy, &sample_sun_moon(&self.backend, d)?);
287            }
288        }
289        Ok(0.5 * (a + b))
290    }
291}
292
293fn check_atmosphere(atmos: Atmosphere) -> Result<(), EclipseError> {
294    if !atmos.pressure_mbar.is_finite() || !atmos.temperature_c.is_finite() {
295        return Err(EclipseError::InvalidAtmosphere {
296            detail: format!(
297                "pressure={} temp={}",
298                atmos.pressure_mbar, atmos.temperature_c
299            ),
300        });
301    }
302    Ok(())
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308    use pleiades_backend::test_backend::LinearSunMoon;
309    use pleiades_types::{Instant, JulianDay, TimeScale};
310
311    fn at(jd: f64) -> Instant {
312        Instant::new(JulianDay::from_days(jd), TimeScale::Tdb)
313    }
314
315    #[test]
316    fn out_of_window_start_fails_closed() {
317        let engine = EclipseEngine::new(LinearSunMoon::new_moon_at(2_451_550.0));
318        let err = engine
319            .eclipses_in_range(at(2_400_000.0), at(2_451_551.0), EclipseFilter::All)
320            .unwrap_err();
321        assert!(matches!(err, EclipseError::OutOfWindow { .. }));
322    }
323
324    #[test]
325    fn filter_excludes_lunar() {
326        // The on-node analytic backend yields a solar eclipse at every new moon
327        // and a lunar one at every full moon; SolarOnly must drop the lunar ones.
328        let engine =
329            EclipseEngine::new(LinearSunMoon::new_moon_at(2_451_550.0).with_moon_latitude(0.0));
330        let solar = engine
331            .eclipses_in_range(at(2_451_549.0), at(2_451_551.0), EclipseFilter::SolarOnly)
332            .unwrap();
333        assert!(solar.iter().all(|e| e.kind == EclipseKind::Solar));
334    }
335
336    #[test]
337    fn local_circumstances_returns_solar_for_a_solar_eclipse() {
338        use pleiades_apparent::Atmosphere;
339        use pleiades_types::{Latitude, Longitude, ObserverLocation};
340        let engine =
341            EclipseEngine::new(LinearSunMoon::new_moon_at(2_451_550.0).with_moon_latitude(0.0));
342        let eclipse = engine
343            .next_eclipse(at(2_451_549.0), EclipseFilter::SolarOnly)
344            .unwrap()
345            .expect("a solar eclipse");
346        let observer = ObserverLocation::new(
347            Latitude::from_degrees(0.0),
348            Longitude::from_degrees(0.0),
349            Some(0.0),
350        );
351        let local = engine
352            .local_circumstances(&eclipse, &observer, Atmosphere::default())
353            .unwrap();
354        assert!(matches!(local, crate::LocalCircumstances::Solar(_)));
355    }
356}