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
19pub struct EclipseEngine<B> {
25 backend: B,
26}
27
28impl<B: EphemerisBackend> EclipseEngine<B> {
29 pub fn new(backend: B) -> Self {
31 Self { backend }
32 }
33
34 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 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 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 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 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 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 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 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 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 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 Syzygy::FullMoon => Longitude::from_degrees(apparent_sun_lon + 180.0),
214 };
215 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 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 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}