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(
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 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 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 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 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 Syzygy::FullMoon => Longitude::from_degrees(apparent_sun_lon + 180.0),
218 };
219 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 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 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}