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    pub fn local_circumstances(
114        &self,
115        eclipse: &Eclipse,
116        observer: &ObserverLocation,
117        atmosphere: Atmosphere,
118    ) -> Result<LocalCircumstances, EclipseError> {
119        observer
120            .validate()
121            .map_err(|e| EclipseError::InvalidObserver {
122                detail: e.to_string(),
123            })?;
124        check_atmosphere(atmosphere)?;
125        local_circumstances_for(&self.backend, eclipse, observer, atmosphere)
126    }
127
128    /// The next eclipse admitted by `filter`, strictly after `after`, that is
129    /// locally visible from `observer` (any phase above the horizon), paired with
130    /// its local circumstances. Walks the global `next_eclipse` sequence and
131    /// returns the first locally-visible one, so the result is a strict refinement
132    /// of the global engine.
133    pub fn next_local_eclipse(
134        &self,
135        after: Instant,
136        observer: &ObserverLocation,
137        filter: EclipseFilter,
138        atmosphere: Atmosphere,
139    ) -> Result<Option<(Eclipse, LocalCircumstances)>, EclipseError> {
140        observer
141            .validate()
142            .map_err(|e| EclipseError::InvalidObserver {
143                detail: e.to_string(),
144            })?;
145        check_atmosphere(atmosphere)?;
146        let mut cursor = after;
147        // Bounded walk: no more than MAX_LOCAL_SEARCH global eclipses inspected
148        // before giving up (backstop; a locally-visible eclipse always occurs well
149        // within the window). ~2 eclipses/year × 200 yr ≈ 1200 global eclipses max.
150        for _ in 0..MAX_LOCAL_SEARCH {
151            let Some(eclipse) = self.next_eclipse(cursor, filter)? else {
152                return Ok(None);
153            };
154            let local = local_circumstances_for(&self.backend, &eclipse, observer, atmosphere)?;
155            if is_locally_visible(&local) {
156                return Ok(Some((eclipse, local)));
157            }
158            cursor = eclipse.greatest_eclipse;
159        }
160        Ok(None)
161    }
162
163    /// The previous eclipse admitted by `filter`, strictly before `before`, that
164    /// is locally visible from `observer`, paired with its local circumstances.
165    pub fn previous_local_eclipse(
166        &self,
167        before: Instant,
168        observer: &ObserverLocation,
169        filter: EclipseFilter,
170        atmosphere: Atmosphere,
171    ) -> Result<Option<(Eclipse, LocalCircumstances)>, EclipseError> {
172        observer
173            .validate()
174            .map_err(|e| EclipseError::InvalidObserver {
175                detail: e.to_string(),
176            })?;
177        check_atmosphere(atmosphere)?;
178        let mut cursor = before;
179        for _ in 0..MAX_LOCAL_SEARCH {
180            let Some(eclipse) = self.previous_eclipse(cursor, filter)? else {
181                return Ok(None);
182            };
183            let local = local_circumstances_for(&self.backend, &eclipse, observer, atmosphere)?;
184            if is_locally_visible(&local) {
185                return Ok(Some((eclipse, local)));
186            }
187            cursor = eclipse.greatest_eclipse;
188        }
189        Ok(None)
190    }
191
192    fn check_window(&self, jd: f64) -> Result<(), EclipseError> {
193        if !(WINDOW_START_JD..=WINDOW_END_JD).contains(&jd) {
194            Err(EclipseError::OutOfWindow { julian_day: jd })
195        } else {
196            Ok(())
197        }
198    }
199
200    fn build(&self, syzygy: Syzygy, syzygy_jd: f64) -> Result<Option<Eclipse>, EclipseError> {
201        let greatest_jd = self.refine_greatest(syzygy, syzygy_jd)?;
202        let sample = sample_sun_moon(&self.backend, greatest_jd)?;
203        let greatest_eclipse = Instant::new(JulianDay::from_days(greatest_jd), TimeScale::Tdb);
204        // Compute the apparent geocentric solar longitude of date for eclipsed_longitude.
205        // Geometric sampling (separation, classification) uses the Mean frame — that is
206        // correct and unchanged. But eclipsed_longitude must be apparent-of-date per the
207        // spec (Task 10 gate: ≤1 arcsecond); mean would be ~20–25″ off.
208        let apparent_sun_lon = apparent_sun_longitude_deg(&self.backend, greatest_jd)?;
209        let eclipsed_longitude = match syzygy {
210            Syzygy::NewMoon => Longitude::from_degrees(apparent_sun_lon),
211            // For lunar eclipses the eclipsed body is the Moon, which is opposite the Sun;
212            // eclipsed_longitude is the apparent solar longitude + 180° (corpus MANIFEST).
213            Syzygy::FullMoon => Longitude::from_degrees(apparent_sun_lon + 180.0),
214        };
215        // Node: ascending (North) if the Moon's latitude is increasing through 0.
216        let later = sample_sun_moon(&self.backend, greatest_jd + 0.01)?;
217        let near_node = if later.moon_latitude_deg >= sample.moon_latitude_deg {
218            Node::North
219        } else {
220            Node::South
221        };
222
223        let eclipse = match syzygy {
224            Syzygy::NewMoon => {
225                let Some(c) = classify_solar(&sample) else {
226                    return Ok(None);
227                };
228                Eclipse {
229                    kind: EclipseKind::Solar,
230                    eclipse_type: EclipseType::Solar(c.eclipse_type),
231                    greatest_eclipse,
232                    magnitude: c.magnitude,
233                    gamma: c.gamma,
234                    saros_series: saros_series(EclipseKind::Solar, greatest_jd),
235                    eclipsed_longitude,
236                    near_node,
237                    greatest_eclipse_location: Some(sub_shadow_point(&sample, greatest_jd)),
238                }
239            }
240            Syzygy::FullMoon => {
241                let Some(c) = classify_lunar(&sample) else {
242                    return Ok(None);
243                };
244                Eclipse {
245                    kind: EclipseKind::Lunar,
246                    eclipse_type: EclipseType::Lunar(c.eclipse_type),
247                    greatest_eclipse,
248                    magnitude: c.magnitude,
249                    gamma: c.gamma,
250                    saros_series: saros_series(EclipseKind::Lunar, greatest_jd),
251                    eclipsed_longitude,
252                    near_node,
253                    greatest_eclipse_location: None,
254                }
255            }
256        };
257        Ok(Some(eclipse))
258    }
259
260    /// Golden-section minimize the Sun–Moon (or Moon–antisolar) separation in a
261    /// ±0.25-day bracket around the syzygy to find greatest eclipse.
262    fn refine_greatest(&self, syzygy: Syzygy, syzygy_jd: f64) -> Result<f64, EclipseError> {
263        use crate::geometry::separation_for;
264        let phi = 0.618_033_988_75_f64;
265        let (mut a, mut b) = (syzygy_jd - 0.25, syzygy_jd + 0.25);
266        let mut c = b - (b - a) * phi;
267        let mut d = a + (b - a) * phi;
268        let mut fc = separation_for(syzygy, &sample_sun_moon(&self.backend, c)?);
269        let mut fd = separation_for(syzygy, &sample_sun_moon(&self.backend, d)?);
270        while (b - a) > 0.5 / 86_400.0 {
271            if fc < fd {
272                b = d;
273                d = c;
274                fd = fc;
275                c = b - (b - a) * phi;
276                fc = separation_for(syzygy, &sample_sun_moon(&self.backend, c)?);
277            } else {
278                a = c;
279                c = d;
280                fc = fd;
281                d = a + (b - a) * phi;
282                fd = separation_for(syzygy, &sample_sun_moon(&self.backend, d)?);
283            }
284        }
285        Ok(0.5 * (a + b))
286    }
287}
288
289fn check_atmosphere(atmos: Atmosphere) -> Result<(), EclipseError> {
290    if !atmos.pressure_mbar.is_finite() || !atmos.temperature_c.is_finite() {
291        return Err(EclipseError::InvalidAtmosphere {
292            detail: format!(
293                "pressure={} temp={}",
294                atmos.pressure_mbar, atmos.temperature_c
295            ),
296        });
297    }
298    Ok(())
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use pleiades_backend::test_backend::LinearSunMoon;
305    use pleiades_types::{Instant, JulianDay, TimeScale};
306
307    fn at(jd: f64) -> Instant {
308        Instant::new(JulianDay::from_days(jd), TimeScale::Tdb)
309    }
310
311    #[test]
312    fn out_of_window_start_fails_closed() {
313        let engine = EclipseEngine::new(LinearSunMoon::new_moon_at(2_451_550.0));
314        let err = engine
315            .eclipses_in_range(at(2_400_000.0), at(2_451_551.0), EclipseFilter::All)
316            .unwrap_err();
317        assert!(matches!(err, EclipseError::OutOfWindow { .. }));
318    }
319
320    #[test]
321    fn filter_excludes_lunar() {
322        // The on-node analytic backend yields a solar eclipse at every new moon
323        // and a lunar one at every full moon; SolarOnly must drop the lunar ones.
324        let engine =
325            EclipseEngine::new(LinearSunMoon::new_moon_at(2_451_550.0).with_moon_latitude(0.0));
326        let solar = engine
327            .eclipses_in_range(at(2_451_549.0), at(2_451_551.0), EclipseFilter::SolarOnly)
328            .unwrap();
329        assert!(solar.iter().all(|e| e.kind == EclipseKind::Solar));
330    }
331
332    #[test]
333    fn local_circumstances_returns_solar_for_a_solar_eclipse() {
334        use pleiades_apparent::Atmosphere;
335        use pleiades_types::{Latitude, Longitude, ObserverLocation};
336        let engine =
337            EclipseEngine::new(LinearSunMoon::new_moon_at(2_451_550.0).with_moon_latitude(0.0));
338        let eclipse = engine
339            .next_eclipse(at(2_451_549.0), EclipseFilter::SolarOnly)
340            .unwrap()
341            .expect("a solar eclipse");
342        let observer = ObserverLocation::new(
343            Latitude::from_degrees(0.0),
344            Longitude::from_degrees(0.0),
345            Some(0.0),
346        );
347        let local = engine
348            .local_circumstances(&eclipse, &observer, Atmosphere::default())
349            .unwrap();
350        assert!(matches!(local, crate::LocalCircumstances::Solar(_)));
351    }
352}