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/// Half-width of the bracket around a syzygy in which `refine_greatest` looks
20/// for greatest eclipse. `eclipses_in_range` widens its syzygy scan by the same
21/// amount so that an eclipse whose greatest instant lies inside the requested
22/// range is found even when its syzygy falls just outside it.
23const GREATEST_BRACKET_DAYS: f64 = 0.25;
24
25/// Width of the first span `next_eclipse`/`previous_eclipse` search outward
26/// from the query instant; later spans double. Under every filter the admitted
27/// eclipses recur well within a year, so away from the window edges the first
28/// span holds the answer; the value only tunes cost, never the result.
29const FIRST_SEARCH_SPAN_DAYS: f64 = 400.0;
30
31/// Searches for global/geocentric eclipses over a chosen [`EphemerisBackend`].
32///
33/// Backed by the packaged data, results are valid only within the
34/// 1900-01-01..2100-01-01 window (see [`crate`]); out-of-window requests fail
35/// closed with [`EclipseError::OutOfWindow`].
36pub struct EclipseEngine<B> {
37    backend: B,
38}
39
40impl<B: EphemerisBackend> EclipseEngine<B> {
41    /// Creates an engine that draws Sun/Moon positions from `backend`.
42    pub fn new(backend: B) -> Self {
43        Self { backend }
44    }
45
46    /// Returns every eclipse admitted by `filter` with greatest eclipse in
47    /// `[start, end]` (both bounds inclusive), in chronological order. Fails
48    /// closed if either bound is outside the supported window.
49    ///
50    /// Selection is by the greatest-eclipse instant, not by the new/full moon
51    /// that precedes or follows it by minutes, and that instant does not depend
52    /// on the range searched. Adjacent ranges that meet at an instant therefore
53    /// partition the eclipses between them, except that an eclipse whose
54    /// greatest instant falls exactly on the shared bound appears in both.
55    pub fn eclipses_in_range(
56        &self,
57        start: Instant,
58        end: Instant,
59        filter: EclipseFilter,
60    ) -> Result<Vec<Eclipse>, EclipseError> {
61        let start_jd = start.julian_day.days();
62        let end_jd = end.julian_day.days();
63        self.check_window(start_jd)?;
64        self.check_window(end_jd)?;
65
66        // Greatest eclipse lies within GREATEST_BRACKET_DAYS of its syzygy, so a
67        // syzygy up to that far outside `[start, end]` can still carry an eclipse
68        // whose greatest instant is inside it. Scan that wider span, then select
69        // on the greatest instant below.
70        //
71        // The syzygy scanner (`find_one`) samples a grid of STEP_DAYS multiples
72        // that may begin one step before its requested start and probes one step
73        // past its requested end, and `sample_sun_moon` issues a light-time-
74        // retarded Sun query ~0.006 days before the nominal epoch. Together these
75        // mean:
76        //   - At the END: the scanner queries up to `scan_end + STEP_DAYS`; the
77        //     packaged backend has no data beyond WINDOW_END_JD, so we clamp
78        //     `scan_end` to `WINDOW_END_JD - STEP_DAYS`.
79        //   - At the START: WINDOW_START_JD is itself a multiple of STEP_DAYS, so
80        //     clamping `scan_start` to `WINDOW_START_JD + STEP_DAYS` keeps the
81        //     first grid point at or after it, and the retarded query (~0.006 d
82        //     earlier) within coverage.
83        // Both clamps are safe: no corpus eclipse falls within 0.5 d of either bound.
84        let scan_start = (start_jd - GREATEST_BRACKET_DAYS).max(WINDOW_START_JD + STEP_DAYS);
85        let scan_end = (end_jd + GREATEST_BRACKET_DAYS).min(WINDOW_END_JD - STEP_DAYS);
86
87        let mut out = Vec::new();
88        for event in find_syzygies(&self.backend, scan_start, scan_end)? {
89            let Some(eclipse) = self.build(event.syzygy, event.julian_day)? else {
90                continue;
91            };
92            let greatest_jd = eclipse.greatest_eclipse.julian_day.days();
93            if filter.admits(eclipse.kind) && (start_jd..=end_jd).contains(&greatest_jd) {
94                out.push(eclipse);
95            }
96        }
97        Ok(out)
98    }
99
100    /// Returns the first eclipse admitted by `filter` whose greatest eclipse is
101    /// strictly after `after`, or `None` if none remains before the window end.
102    /// Fails closed if `after` is outside the supported window.
103    ///
104    /// The cost grows with the distance to the eclipse found, not to the window
105    /// end.
106    pub fn next_eclipse(
107        &self,
108        after: Instant,
109        filter: EclipseFilter,
110    ) -> Result<Option<Eclipse>, EclipseError> {
111        self.search_forward(after.julian_day.days(), FIRST_SEARCH_SPAN_DAYS, filter)
112    }
113
114    /// Returns the last eclipse admitted by `filter` whose greatest eclipse is
115    /// strictly before `before`, or `None` if none exists after the window start.
116    /// Fails closed if `before` is outside the supported window.
117    ///
118    /// The cost grows with the distance to the eclipse found, not to the window
119    /// start.
120    pub fn previous_eclipse(
121        &self,
122        before: Instant,
123        filter: EclipseFilter,
124    ) -> Result<Option<Eclipse>, EclipseError> {
125        self.search_backward(before.julian_day.days(), FIRST_SEARCH_SPAN_DAYS, filter)
126    }
127
128    // Outward search behind `next_eclipse`/`previous_eclipse`.
129    //
130    // Scans adjacent spans moving away from the query instant (widths
131    // `first_span`, 2×, 4×, …, the last clamped to the window edge) and returns
132    // the first admitted eclipse past the query instant in the first span that
133    // has one. This is exactly the eclipse a single scan from the query instant
134    // to the window edge returns first: `eclipses_in_range` selects by the
135    // greatest-eclipse instant inside its inclusive bounds, and that instant does
136    // not depend on the range searched (the syzygy grid is anchored to absolute
137    // STEP_DAYS multiples), so the spans together cover the same instants with
138    // the same eclipses and the earlier, empty spans hold nothing nearer. An
139    // eclipse exactly on a shared span bound shows up in both spans; harmless,
140    // since the strict comparison against the query instant decides and the
141    // nearer span is searched first.
142
143    fn search_forward(
144        &self,
145        after_jd: f64,
146        first_span: f64,
147        filter: EclipseFilter,
148    ) -> Result<Option<Eclipse>, EclipseError> {
149        self.check_window(after_jd)?;
150        let mut near = after_jd;
151        let mut span = first_span;
152        loop {
153            // `eclipses_in_range` clamps its scan end to WINDOW_END_JD - STEP_DAYS,
154            // so ending the last span at WINDOW_END_JD itself is safe and correct.
155            let far = (near + span).min(WINDOW_END_JD);
156            let found = self
157                .eclipses_in_range(tdb(near), tdb(far), filter)?
158                .into_iter()
159                .find(|e| e.greatest_eclipse.julian_day.days() > after_jd);
160            // `span` stays positive and grows, so `far` strictly advances until
161            // it reaches the window end, which ends the loop.
162            if found.is_some() || far >= WINDOW_END_JD {
163                return Ok(found);
164            }
165            near = far;
166            span *= 2.0;
167        }
168    }
169
170    fn search_backward(
171        &self,
172        before_jd: f64,
173        first_span: f64,
174        filter: EclipseFilter,
175    ) -> Result<Option<Eclipse>, EclipseError> {
176        self.check_window(before_jd)?;
177        let mut near = before_jd;
178        let mut span = first_span;
179        loop {
180            let far = (near - span).max(WINDOW_START_JD);
181            let found = self
182                .eclipses_in_range(tdb(far), tdb(near), filter)?
183                .into_iter()
184                .rev()
185                .find(|e| e.greatest_eclipse.julian_day.days() < before_jd);
186            if found.is_some() || far <= WINDOW_START_JD {
187                return Ok(found);
188            }
189            near = far;
190            span *= 2.0;
191        }
192    }
193
194    /// Local (per-observer) circumstances for an already-found `eclipse`.
195    ///
196    /// Returns full circumstances even when the eclipse is not visible from
197    /// `observer` (all contacts below the horizon); inspect `any_phase_visible`
198    /// (via the returned variant) to test visibility. Solar contact instants are
199    /// observer-dependent (topocentric); lunar contact instants are global with
200    /// per-observer visibility.
201    ///
202    /// `atmosphere` is `pleiades_apparent::Atmosphere` from the
203    /// `pleiades-apparent` release this crate pins (0.7 and later); build it
204    /// from that same release.
205    pub fn local_circumstances(
206        &self,
207        eclipse: &Eclipse,
208        observer: &ObserverLocation,
209        atmosphere: Atmosphere,
210    ) -> Result<LocalCircumstances, EclipseError> {
211        observer
212            .validate()
213            .map_err(|e| EclipseError::InvalidObserver {
214                detail: e.to_string(),
215            })?;
216        check_atmosphere(atmosphere)?;
217        local_circumstances_for(&self.backend, eclipse, observer, atmosphere)
218    }
219
220    /// The next eclipse admitted by `filter`, strictly after `after`, that is
221    /// locally visible from `observer` (any phase above the horizon), paired with
222    /// its local circumstances. Walks the global `next_eclipse` sequence and
223    /// returns the first locally-visible one, so the result is a strict refinement
224    /// of the global engine.
225    pub fn next_local_eclipse(
226        &self,
227        after: Instant,
228        observer: &ObserverLocation,
229        filter: EclipseFilter,
230        atmosphere: Atmosphere,
231    ) -> Result<Option<(Eclipse, LocalCircumstances)>, EclipseError> {
232        observer
233            .validate()
234            .map_err(|e| EclipseError::InvalidObserver {
235                detail: e.to_string(),
236            })?;
237        check_atmosphere(atmosphere)?;
238        let mut cursor = after;
239        // Bounded walk: no more than MAX_LOCAL_SEARCH global eclipses inspected
240        // before giving up (backstop; a locally-visible eclipse always occurs well
241        // within the window). ~2 eclipses/year × 200 yr ≈ 1200 global eclipses max.
242        for _ in 0..MAX_LOCAL_SEARCH {
243            let Some(eclipse) = self.next_eclipse(cursor, filter)? else {
244                return Ok(None);
245            };
246            let local = local_circumstances_for(&self.backend, &eclipse, observer, atmosphere)?;
247            if is_locally_visible(&local) {
248                return Ok(Some((eclipse, local)));
249            }
250            cursor = eclipse.greatest_eclipse;
251        }
252        Ok(None)
253    }
254
255    /// The previous eclipse admitted by `filter`, strictly before `before`, that
256    /// is locally visible from `observer`, paired with its local circumstances.
257    pub fn previous_local_eclipse(
258        &self,
259        before: Instant,
260        observer: &ObserverLocation,
261        filter: EclipseFilter,
262        atmosphere: Atmosphere,
263    ) -> Result<Option<(Eclipse, LocalCircumstances)>, EclipseError> {
264        observer
265            .validate()
266            .map_err(|e| EclipseError::InvalidObserver {
267                detail: e.to_string(),
268            })?;
269        check_atmosphere(atmosphere)?;
270        let mut cursor = before;
271        for _ in 0..MAX_LOCAL_SEARCH {
272            let Some(eclipse) = self.previous_eclipse(cursor, filter)? else {
273                return Ok(None);
274            };
275            let local = local_circumstances_for(&self.backend, &eclipse, observer, atmosphere)?;
276            if is_locally_visible(&local) {
277                return Ok(Some((eclipse, local)));
278            }
279            cursor = eclipse.greatest_eclipse;
280        }
281        Ok(None)
282    }
283
284    fn check_window(&self, jd: f64) -> Result<(), EclipseError> {
285        if !(WINDOW_START_JD..=WINDOW_END_JD).contains(&jd) {
286            Err(EclipseError::OutOfWindow { julian_day: jd })
287        } else {
288            Ok(())
289        }
290    }
291
292    fn build(&self, syzygy: Syzygy, syzygy_jd: f64) -> Result<Option<Eclipse>, EclipseError> {
293        let greatest_jd = self.refine_greatest(syzygy, syzygy_jd)?;
294        let sample = sample_sun_moon(&self.backend, greatest_jd)?;
295        let greatest_eclipse = Instant::new(JulianDay::from_days(greatest_jd), TimeScale::Tdb);
296        // Compute the apparent geocentric solar longitude of date for eclipsed_longitude.
297        // Geometric sampling (separation, classification) uses the Mean frame — that is
298        // correct and unchanged. But eclipsed_longitude must be apparent-of-date per the
299        // spec (Task 10 gate: ≤1 arcsecond); mean would be ~20–25″ off.
300        let apparent_sun_lon = apparent_sun_longitude_deg(&self.backend, greatest_jd)?;
301        let eclipsed_longitude = match syzygy {
302            Syzygy::NewMoon => Longitude::from_degrees(apparent_sun_lon),
303            // For lunar eclipses the eclipsed body is the Moon, which is opposite the Sun;
304            // eclipsed_longitude is the apparent solar longitude + 180° (corpus MANIFEST).
305            Syzygy::FullMoon => Longitude::from_degrees(apparent_sun_lon + 180.0),
306        };
307        // Node: ascending (North) if the Moon's latitude is increasing through 0.
308        let later = sample_sun_moon(&self.backend, greatest_jd + 0.01)?;
309        let near_node = if later.moon_latitude_deg >= sample.moon_latitude_deg {
310            Node::North
311        } else {
312            Node::South
313        };
314
315        let eclipse = match syzygy {
316            Syzygy::NewMoon => {
317                let Some(c) = classify_solar(&sample) else {
318                    return Ok(None);
319                };
320                Eclipse {
321                    kind: EclipseKind::Solar,
322                    eclipse_type: EclipseType::Solar(c.eclipse_type),
323                    greatest_eclipse,
324                    magnitude: c.magnitude,
325                    gamma: c.gamma,
326                    saros_series: saros_series(EclipseKind::Solar, greatest_jd),
327                    eclipsed_longitude,
328                    near_node,
329                    greatest_eclipse_location: Some(sub_shadow_point(&sample, greatest_jd)),
330                }
331            }
332            Syzygy::FullMoon => {
333                let Some(c) = classify_lunar(&sample) else {
334                    return Ok(None);
335                };
336                Eclipse {
337                    kind: EclipseKind::Lunar,
338                    eclipse_type: EclipseType::Lunar(c.eclipse_type),
339                    greatest_eclipse,
340                    magnitude: c.magnitude,
341                    gamma: c.gamma,
342                    saros_series: saros_series(EclipseKind::Lunar, greatest_jd),
343                    eclipsed_longitude,
344                    near_node,
345                    greatest_eclipse_location: None,
346                }
347            }
348        };
349        Ok(Some(eclipse))
350    }
351
352    /// Golden-section minimize the Sun–Moon (or Moon–antisolar) separation in a
353    /// ±GREATEST_BRACKET_DAYS bracket around the syzygy to find greatest eclipse.
354    fn refine_greatest(&self, syzygy: Syzygy, syzygy_jd: f64) -> Result<f64, EclipseError> {
355        use crate::geometry::separation_for;
356        let phi = 0.618_033_988_75_f64;
357        let (mut a, mut b) = (
358            syzygy_jd - GREATEST_BRACKET_DAYS,
359            syzygy_jd + GREATEST_BRACKET_DAYS,
360        );
361        let mut c = b - (b - a) * phi;
362        let mut d = a + (b - a) * phi;
363        let mut fc = separation_for(syzygy, &sample_sun_moon(&self.backend, c)?);
364        let mut fd = separation_for(syzygy, &sample_sun_moon(&self.backend, d)?);
365        while (b - a) > 0.5 / 86_400.0 {
366            if fc < fd {
367                b = d;
368                d = c;
369                fd = fc;
370                c = b - (b - a) * phi;
371                fc = separation_for(syzygy, &sample_sun_moon(&self.backend, c)?);
372            } else {
373                a = c;
374                c = d;
375                fc = fd;
376                d = a + (b - a) * phi;
377                fd = separation_for(syzygy, &sample_sun_moon(&self.backend, d)?);
378            }
379        }
380        Ok(0.5 * (a + b))
381    }
382}
383
384fn tdb(jd: f64) -> Instant {
385    Instant::new(JulianDay::from_days(jd), TimeScale::Tdb)
386}
387
388fn check_atmosphere(atmos: Atmosphere) -> Result<(), EclipseError> {
389    if !atmos.pressure_mbar.is_finite() || !atmos.temperature_c.is_finite() {
390        return Err(EclipseError::InvalidAtmosphere {
391            detail: format!(
392                "pressure={} temp={}",
393                atmos.pressure_mbar, atmos.temperature_c
394            ),
395        });
396    }
397    Ok(())
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use pleiades_backend::test_backend::LinearSunMoon;
404    use pleiades_types::{Instant, JulianDay, TimeScale};
405
406    fn at(jd: f64) -> Instant {
407        Instant::new(JulianDay::from_days(jd), TimeScale::Tdb)
408    }
409
410    #[test]
411    fn out_of_window_start_fails_closed() {
412        let engine = EclipseEngine::new(LinearSunMoon::new_moon_at(2_451_550.0));
413        let err = engine
414            .eclipses_in_range(at(2_400_000.0), at(2_451_551.0), EclipseFilter::All)
415            .unwrap_err();
416        assert!(matches!(err, EclipseError::OutOfWindow { .. }));
417    }
418
419    #[test]
420    fn filter_excludes_lunar() {
421        // The on-node analytic backend yields a solar eclipse at every new moon
422        // and a lunar one at every full moon; SolarOnly must drop the lunar ones.
423        let engine =
424            EclipseEngine::new(LinearSunMoon::new_moon_at(2_451_550.0).with_moon_latitude(0.0));
425        let solar = engine
426            .eclipses_in_range(at(2_451_549.0), at(2_451_551.0), EclipseFilter::SolarOnly)
427            .unwrap();
428        assert!(solar.iter().all(|e| e.kind == EclipseKind::Solar));
429    }
430
431    /// Real eclipses always recur within the 400-day first span, so the
432    /// doubling path is exercised by starting from a 1-day span: reaching the
433    /// next/previous eclipse (months away) then takes many doubled spans, and
434    /// must give exactly the eclipse the default span gives in one. (The
435    /// default-span results are pinned against a single `eclipses_in_range`
436    /// scan in `tests/known_eclipses.rs`.)
437    #[test]
438    fn outward_search_result_does_not_depend_on_the_first_span() {
439        let engine = EclipseEngine::new(pleiades_data::packaged_backend());
440        // Near each edge: one direction walks to an eclipse months away, the
441        // other runs into the clamped window edge.
442        for t in [WINDOW_START_JD + 40.0, WINDOW_END_JD - 40.0] {
443            for filter in [
444                EclipseFilter::All,
445                EclipseFilter::SolarOnly,
446                EclipseFilter::LunarOnly,
447            ] {
448                assert_eq!(
449                    engine.search_forward(t, 1.0, filter).unwrap(),
450                    engine
451                        .search_forward(t, FIRST_SEARCH_SPAN_DAYS, filter)
452                        .unwrap(),
453                    "forward from JD {t}, {filter:?}"
454                );
455                assert_eq!(
456                    engine.search_backward(t, 1.0, filter).unwrap(),
457                    engine
458                        .search_backward(t, FIRST_SEARCH_SPAN_DAYS, filter)
459                        .unwrap(),
460                    "backward from JD {t}, {filter:?}"
461                );
462            }
463        }
464    }
465
466    #[test]
467    fn outward_search_with_no_eclipse_ends_at_the_window_edge() {
468        // Off-node Moon: no syzygy is an eclipse, so the search must walk its
469        // doubling spans out to the window edge and stop there with `None`.
470        let engine =
471            EclipseEngine::new(LinearSunMoon::new_moon_at(2_451_550.0).with_moon_latitude(5.0));
472        let t = WINDOW_END_JD - 30.0;
473        assert_eq!(
474            engine.search_forward(t, 1.0, EclipseFilter::All).unwrap(),
475            None
476        );
477        let t = WINDOW_START_JD + 30.0;
478        assert_eq!(
479            engine.search_backward(t, 1.0, EclipseFilter::All).unwrap(),
480            None
481        );
482    }
483
484    #[test]
485    fn next_and_previous_eclipse_fail_closed_outside_the_window() {
486        let engine = EclipseEngine::new(LinearSunMoon::new_moon_at(2_451_550.0));
487        for jd in [WINDOW_START_JD - 1.0, WINDOW_END_JD + 1.0, f64::NAN] {
488            assert!(matches!(
489                engine.next_eclipse(at(jd), EclipseFilter::All),
490                Err(EclipseError::OutOfWindow { .. })
491            ));
492            assert!(matches!(
493                engine.previous_eclipse(at(jd), EclipseFilter::All),
494                Err(EclipseError::OutOfWindow { .. })
495            ));
496        }
497    }
498
499    #[test]
500    fn local_circumstances_returns_solar_for_a_solar_eclipse() {
501        use pleiades_apparent::Atmosphere;
502        use pleiades_types::{Latitude, Longitude, ObserverLocation};
503        let engine =
504            EclipseEngine::new(LinearSunMoon::new_moon_at(2_451_550.0).with_moon_latitude(0.0));
505        let eclipse = engine
506            .next_eclipse(at(2_451_549.0), EclipseFilter::SolarOnly)
507            .unwrap()
508            .expect("a solar eclipse");
509        let observer = ObserverLocation::new(
510            Latitude::from_degrees(0.0),
511            Longitude::from_degrees(0.0),
512            Some(0.0),
513        );
514        let local = engine
515            .local_circumstances(&eclipse, &observer, Atmosphere::default())
516            .unwrap();
517        assert!(matches!(local, crate::LocalCircumstances::Solar(_)));
518    }
519}