1use 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
14const MAX_LOCAL_SEARCH: usize = 4000;
18
19const GREATEST_BRACKET_DAYS: f64 = 0.25;
24
25const FIRST_SEARCH_SPAN_DAYS: f64 = 400.0;
30
31pub struct EclipseEngine<B> {
37 backend: B,
38}
39
40impl<B: EphemerisBackend> EclipseEngine<B> {
41 pub fn new(backend: B) -> Self {
43 Self { backend }
44 }
45
46 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 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 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 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 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 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 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 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 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 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 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 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 Syzygy::FullMoon => Longitude::from_degrees(apparent_sun_lon + 180.0),
306 };
307 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 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 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 #[test]
438 fn outward_search_result_does_not_depend_on_the_first_span() {
439 let engine = EclipseEngine::new(pleiades_data::packaged_backend());
440 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 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}