1use crate::ephemeris::{sample_sun_moon, SunMoonSample};
6use crate::error::EclipseError;
7use crate::types::{Eclipse, EclipseKind, LunarEclipseType, SolarEclipseType};
8use pleiades_apparent::{
9 apparent_from_true, nutation::nutation, precession::precess_ecliptic_j2000_to_date,
10 sidereal_time, topocentric_position, true_obliquity_degrees, Atmosphere,
11};
12use pleiades_backend::EphemerisBackend;
13use pleiades_types::{
14 Angle, EclipticCoordinates, Instant, JulianDay, Latitude, Longitude, ObserverLocation,
15 TimeScale,
16};
17
18#[derive(Clone, Copy, Debug, PartialEq)]
22pub struct LocalContact {
23 pub instant: Instant,
25 pub altitude_degrees: f64,
27 pub azimuth_degrees: f64,
30 pub visible: bool,
32}
33
34#[derive(Clone, Copy, Debug, PartialEq)]
36pub struct LocalSolarCircumstances {
37 pub local_type: SolarEclipseType,
39 pub maximum: LocalContact,
41 pub magnitude: f64,
43 pub obscuration: f64,
45 pub first_contact: LocalContact,
47 pub second_contact: Option<LocalContact>,
49 pub third_contact: Option<LocalContact>,
51 pub fourth_contact: LocalContact,
53 pub any_phase_visible: bool,
55}
56
57#[derive(Clone, Copy, Debug, PartialEq)]
61pub struct LocalLunarCircumstances {
62 pub eclipse_type: LunarEclipseType,
64 pub maximum: LocalContact,
66 pub umbral_magnitude: f64,
68 pub penumbral_magnitude: f64,
70 pub penumbral_begin: LocalContact,
72 pub partial_begin: Option<LocalContact>,
74 pub total_begin: Option<LocalContact>,
76 pub total_end: Option<LocalContact>,
78 pub partial_end: Option<LocalContact>,
80 pub penumbral_end: LocalContact,
82 pub any_phase_visible: bool,
84}
85
86#[derive(Clone, Copy, Debug, PartialEq)]
88pub enum LocalCircumstances {
89 Solar(LocalSolarCircumstances),
91 Lunar(LocalLunarCircumstances),
93}
94
95#[derive(Clone, Copy, Debug)]
98pub(crate) struct TopoSunMoon {
99 pub sun_lon_deg: f64,
100 pub sun_lat_deg: f64,
101 pub sun_dist_au: f64,
102 pub moon_lon_deg: f64,
103 pub moon_lat_deg: f64,
104 pub moon_dist_au: f64,
105}
106
107pub(crate) fn topo_sun_moon<B: EphemerisBackend>(
110 backend: &B,
111 observer: &ObserverLocation,
112 jd: f64,
113) -> Result<TopoSunMoon, EclipseError> {
114 let sample: SunMoonSample = sample_sun_moon(backend, jd)?;
115 let eps = true_obliquity_degrees(jd)
116 .map_err(|e| EclipseError::Backend(format!("obliquity failed: {e}")))?;
117
118 let sid_jd = pleiades_time::ut1_jd_from_tt(jd).unwrap_or(jd);
127 let at = Instant::new(JulianDay::from_days(sid_jd), TimeScale::Tdb);
128 let lst = sidereal_time(at, observer.longitude).local_apparent_deg;
129
130 let delta_psi_deg = nutation(jd)
150 .map_err(|e| EclipseError::Backend(format!("nutation failed: {e}")))?
151 .delta_psi_arcsec
152 / 3600.0;
153
154 let apparent_of_date = |lon: f64, lat: f64, label: &str| -> Result<(f64, f64), EclipseError> {
155 let p = precess_ecliptic_j2000_to_date(lon, lat, jd)
156 .map_err(|e| EclipseError::Backend(format!("{label} precession failed: {e}")))?;
157 Ok((
158 (p.longitude_deg + delta_psi_deg).rem_euclid(360.0),
159 p.latitude_deg,
160 ))
161 };
162
163 let (sun_app_lon, sun_app_lat) =
164 apparent_of_date(sample.sun_longitude_deg, sample.sun_latitude_deg, "Sun")?;
165 let (moon_app_lon, moon_app_lat) =
166 apparent_of_date(sample.moon_longitude_deg, sample.moon_latitude_deg, "Moon")?;
167
168 let to_topo = |lon: f64, lat: f64, dist: f64| -> Result<(f64, f64, f64), EclipseError> {
169 let ecl = EclipticCoordinates::new(
170 Longitude::from_degrees(lon),
171 Latitude::from_degrees(lat),
172 Some(dist),
173 );
174 let topo = topocentric_position(ecl, observer, lst, eps)
175 .map_err(|e| EclipseError::Backend(format!("topocentric failed: {e}")))?;
176 Ok((
177 topo.ecliptic.longitude.degrees(),
178 topo.ecliptic.latitude.degrees(),
179 topo.ecliptic.distance_au.unwrap_or(dist),
180 ))
181 };
182
183 let (sun_lon_deg, sun_lat_deg, sun_dist_au) =
184 to_topo(sun_app_lon, sun_app_lat, sample.sun_distance_au)?;
185 let (moon_lon_deg, moon_lat_deg, moon_dist_au) =
186 to_topo(moon_app_lon, moon_app_lat, sample.moon_distance_au)?;
187 Ok(TopoSunMoon {
188 sun_lon_deg,
189 sun_lat_deg,
190 sun_dist_au,
191 moon_lon_deg,
192 moon_lat_deg,
193 moon_dist_au,
194 })
195}
196
197#[cfg(test)]
198mod topo_tests {
199 use super::*;
200 use pleiades_backend::test_backend::LinearSunMoon;
201
202 #[test]
203 fn moon_parallax_shifts_topocentric_longitude() {
204 let backend = LinearSunMoon::new_moon_at(2_451_550.0);
207 let observer = ObserverLocation::new(
208 Latitude::from_degrees(0.0),
209 Longitude::from_degrees(0.0),
210 Some(0.0),
211 );
212 let geo = sample_sun_moon(&backend, 2_451_550.0).unwrap();
213 let topo = topo_sun_moon(&backend, &observer, 2_451_550.0).unwrap();
214 let shift = (topo.moon_lon_deg - geo.moon_longitude_deg).abs();
215 assert!(
216 shift > 0.0,
217 "expected a nonzero parallax shift, got {shift}"
218 );
219 assert!(topo.moon_dist_au.is_finite());
220 }
221}
222
223mod solar_consts {
226 pub const R_SUN_KM: f64 = 696_000.0;
227 pub const R_MOON_KM: f64 = 1_737.4;
228 pub const AU_KM: f64 = 149_597_870.7;
229}
230
231#[derive(Clone, Copy, Debug)]
233pub(crate) struct SolarGeom {
234 pub sep_deg: f64,
236 pub s_sun_deg: f64,
238 pub s_moon_deg: f64,
240}
241
242fn angular_separation_deg(lon1: f64, lat1: f64, lon2: f64, lat2: f64) -> f64 {
244 let (l1, b1) = (lon1.to_radians(), lat1.to_radians());
245 let (l2, b2) = (lon2.to_radians(), lat2.to_radians());
246 let cos_sep = (b1.sin() * b2.sin() + b1.cos() * b2.cos() * (l1 - l2).cos()).clamp(-1.0, 1.0);
247 cos_sep.acos().to_degrees()
248}
249
250pub(crate) fn solar_geom(t: &TopoSunMoon) -> SolarGeom {
252 use solar_consts::*;
253 let sep_deg =
254 angular_separation_deg(t.sun_lon_deg, t.sun_lat_deg, t.moon_lon_deg, t.moon_lat_deg);
255 let s_sun_deg = (R_SUN_KM / (t.sun_dist_au * AU_KM)).asin().to_degrees();
256 let s_moon_deg = (R_MOON_KM / (t.moon_dist_au * AU_KM)).asin().to_degrees();
257 SolarGeom {
258 sep_deg,
259 s_sun_deg,
260 s_moon_deg,
261 }
262}
263
264pub(crate) fn covered_diameter_fraction(g: &SolarGeom) -> f64 {
266 ((g.s_sun_deg + g.s_moon_deg - g.sep_deg) / (2.0 * g.s_sun_deg)).max(0.0)
267}
268
269pub(crate) fn obscuration_fraction(g: &SolarGeom) -> f64 {
273 let (r_s, r_m, d) = (g.s_sun_deg, g.s_moon_deg, g.sep_deg);
274 if d >= r_s + r_m {
275 return 0.0; }
277 if d <= r_m - r_s {
278 return 1.0; }
280 if d <= (r_s - r_m).max(0.0) {
281 return ((r_m / r_s).powi(2)).clamp(0.0, 1.0);
283 }
284 let r_s2 = r_s * r_s;
285 let r_m2 = r_m * r_m;
286 let a_s = ((d * d + r_s2 - r_m2) / (2.0 * d * r_s))
287 .clamp(-1.0, 1.0)
288 .acos();
289 let a_m = ((d * d + r_m2 - r_s2) / (2.0 * d * r_m))
290 .clamp(-1.0, 1.0)
291 .acos();
292 let lens_area = r_s2 * a_s + r_m2 * a_m
293 - 0.5
294 * ((r_s + r_m + d) * (-r_s + r_m + d) * (r_s - r_m + d) * (r_s + r_m - d))
295 .max(0.0)
296 .sqrt();
297 (lens_area / (core::f64::consts::PI * r_s2)).clamp(0.0, 1.0)
298}
299
300#[cfg(test)]
301mod solar_geom_tests {
302 use super::*;
303
304 fn geom(sep: f64, s_sun: f64, s_moon: f64) -> SolarGeom {
305 SolarGeom {
306 sep_deg: sep,
307 s_sun_deg: s_sun,
308 s_moon_deg: s_moon,
309 }
310 }
311
312 #[test]
313 fn magnitude_is_one_when_centers_coincide_and_moon_larger() {
314 let g = geom(0.0, 0.26, 0.28);
315 let mag = covered_diameter_fraction(&g);
316 assert!(mag >= 1.0, "central total magnitude {mag}");
317 }
318
319 #[test]
320 fn magnitude_zero_outside_contact() {
321 let g = geom(1.0, 0.26, 0.26); assert_eq!(covered_diameter_fraction(&g), 0.0);
323 }
324
325 #[test]
326 fn obscuration_full_when_sun_fully_covered() {
327 let g = geom(0.0, 0.26, 0.30);
328 assert!((obscuration_fraction(&g) - 1.0).abs() < 1e-9);
329 }
330
331 #[test]
332 fn obscuration_zero_when_disjoint() {
333 let g = geom(1.0, 0.26, 0.26);
334 assert_eq!(obscuration_fraction(&g), 0.0);
335 }
336
337 #[test]
338 fn obscuration_between_zero_and_one_partial() {
339 let g = geom(0.30, 0.26, 0.26);
340 let o = obscuration_fraction(&g);
341 assert!(o > 0.0 && o < 1.0, "partial obscuration {o}");
342 }
343
344 #[test]
345 fn annular_obscuration_is_area_ratio() {
346 let g = geom(0.0, 0.28, 0.26);
348 let o = obscuration_fraction(&g);
349 let expected = (0.26_f64 / 0.28).powi(2);
350 assert!(
351 (o - expected).abs() < 1e-6,
352 "annular obscuration {o} vs {expected}"
353 );
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use pleiades_types::{JulianDay, TimeScale};
361
362 fn contact(jd: f64) -> LocalContact {
363 LocalContact {
364 instant: Instant::new(JulianDay::from_days(jd), TimeScale::Tdb),
365 altitude_degrees: 30.0,
366 azimuth_degrees: 180.0,
367 visible: true,
368 }
369 }
370
371 #[test]
372 fn local_circumstances_tags_solar_and_lunar() {
373 let solar = LocalCircumstances::Solar(LocalSolarCircumstances {
374 local_type: SolarEclipseType::Partial,
375 maximum: contact(2_451_545.0),
376 magnitude: 0.5,
377 obscuration: 0.4,
378 first_contact: contact(2_451_544.9),
379 second_contact: None,
380 third_contact: None,
381 fourth_contact: contact(2_451_545.1),
382 any_phase_visible: true,
383 });
384 assert!(matches!(solar, LocalCircumstances::Solar(_)));
385 }
386}
387
388const SOLAR_CONTACT_HALF_WINDOW_DAYS: f64 = 0.25;
392const REFINE_TOLERANCE_DAYS: f64 = 0.5 / 86_400.0;
395
396#[derive(Clone, Copy, Debug)]
397pub(crate) struct SolarContactsJd {
398 pub max_jd: f64,
399 pub c1_jd: f64,
400 pub c2_jd: f64,
401 pub c3_jd: f64,
402 pub c4_jd: f64,
403 pub min_sep_deg: f64,
404 pub s_sun_at_max: f64,
405 pub s_moon_at_max: f64,
406 pub c2_c3_present: bool,
407}
408
409fn solar_sep_deg<B: EphemerisBackend>(
411 backend: &B,
412 observer: &ObserverLocation,
413 jd: f64,
414) -> Result<f64, EclipseError> {
415 Ok(solar_geom(&topo_sun_moon(backend, observer, jd)?).sep_deg)
416}
417
418fn minimize_sep<B: EphemerisBackend>(
420 backend: &B,
421 observer: &ObserverLocation,
422 mut a: f64,
423 mut b: f64,
424) -> Result<f64, EclipseError> {
425 let phi = 0.618_033_988_75_f64;
426 let mut c = b - (b - a) * phi;
427 let mut d = a + (b - a) * phi;
428 let mut fc = solar_sep_deg(backend, observer, c)?;
429 let mut fd = solar_sep_deg(backend, observer, d)?;
430 while (b - a) > REFINE_TOLERANCE_DAYS {
431 if fc < fd {
432 b = d;
433 d = c;
434 fd = fc;
435 c = b - (b - a) * phi;
436 fc = solar_sep_deg(backend, observer, c)?;
437 } else {
438 a = c;
439 c = d;
440 fc = fd;
441 d = a + (b - a) * phi;
442 fd = solar_sep_deg(backend, observer, d)?;
443 }
444 }
445 Ok(0.5 * (a + b))
446}
447
448fn bisect_contact<B: EphemerisBackend>(
451 backend: &B,
452 observer: &ObserverLocation,
453 threshold: impl Fn(f64) -> f64, mut lo: f64,
455 mut hi: f64,
456) -> Result<Option<f64>, EclipseError> {
457 let f = |jd: f64,
458 s: &mut dyn FnMut(f64) -> Result<f64, EclipseError>|
459 -> Result<f64, EclipseError> { Ok(s(jd)? - threshold(jd)) };
460 let mut sep = |jd: f64| solar_sep_deg(backend, observer, jd);
461 let mut flo = f(lo, &mut sep)?;
462 let mut fhi = f(hi, &mut sep)?;
463 if flo.signum() == fhi.signum() {
464 return Ok(None);
465 }
466 while (hi - lo) > REFINE_TOLERANCE_DAYS {
467 let mid = 0.5 * (lo + hi);
468 let fmid = f(mid, &mut sep)?;
469 if fmid.signum() == flo.signum() {
470 lo = mid;
471 flo = fmid;
472 } else {
473 hi = mid;
474 fhi = fmid;
475 }
476 }
477 let _ = fhi;
478 Ok(Some(0.5 * (lo + hi)))
479}
480
481pub(crate) fn solar_contacts_jd<B: EphemerisBackend>(
483 backend: &B,
484 observer: &ObserverLocation,
485 greatest_jd: f64,
486) -> Result<Option<SolarContactsJd>, EclipseError> {
487 let max_jd = minimize_sep(
488 backend,
489 observer,
490 greatest_jd - SOLAR_CONTACT_HALF_WINDOW_DAYS,
491 greatest_jd + SOLAR_CONTACT_HALF_WINDOW_DAYS,
492 )?;
493 let g_max = solar_geom(&topo_sun_moon(backend, observer, max_jd)?);
494 let external = g_max.s_sun_deg + g_max.s_moon_deg;
495 if g_max.sep_deg >= external {
496 return Ok(None); }
498 let ext_threshold = move |_jd: f64| external;
501 let internal = (g_max.s_moon_deg - g_max.s_sun_deg).abs();
502 let int_threshold = move |_jd: f64| internal;
503
504 let lo = max_jd - SOLAR_CONTACT_HALF_WINDOW_DAYS;
505 let hi = max_jd + SOLAR_CONTACT_HALF_WINDOW_DAYS;
506 let c1 = bisect_contact(backend, observer, ext_threshold, lo, max_jd)?.unwrap_or(max_jd);
507 let c4 = bisect_contact(backend, observer, ext_threshold, max_jd, hi)?.unwrap_or(max_jd);
508
509 let c2_c3_present = g_max.sep_deg < internal;
510 let (c2, c3) = if c2_c3_present {
511 let c2 = bisect_contact(backend, observer, int_threshold, c1, max_jd)?.unwrap_or(max_jd);
512 let c3 = bisect_contact(backend, observer, int_threshold, max_jd, c4)?.unwrap_or(max_jd);
513 (c2, c3)
514 } else {
515 (max_jd, max_jd)
516 };
517
518 Ok(Some(SolarContactsJd {
519 max_jd,
520 c1_jd: c1,
521 c2_jd: c2,
522 c3_jd: c3,
523 c4_jd: c4,
524 min_sep_deg: g_max.sep_deg,
525 s_sun_at_max: g_max.s_sun_deg,
526 s_moon_at_max: g_max.s_moon_deg,
527 c2_c3_present,
528 }))
529}
530
531#[cfg(test)]
532mod solar_contact_tests {
533 use super::*;
534 use pleiades_backend::test_backend::LinearSunMoon;
535
536 #[test]
537 fn contacts_bracket_the_maximum() {
538 let backend = LinearSunMoon::new_moon_at(2_451_550.0).with_moon_latitude(0.0);
540 let observer = ObserverLocation::new(
541 Latitude::from_degrees(0.0),
542 Longitude::from_degrees(0.0),
543 Some(0.0),
544 );
545 let c = solar_contacts_jd(&backend, &observer, 2_451_550.0)
546 .unwrap()
547 .expect("a local eclipse");
548 assert!(
549 c.c1_jd <= c.max_jd + 1e-9 && c.max_jd <= c.c4_jd + 1e-9,
550 "C1<=max<=C4"
551 );
552 assert!(c.c1_jd < c.c4_jd, "C1 strictly before C4");
553 if c.c2_c3_present {
554 assert!(
555 c.c2_jd >= c.c1_jd - 1e-9 && c.c3_jd <= c.c4_jd + 1e-9,
556 "C2/C3 inside C1..C4"
557 );
558 assert!(
559 c.c2_jd <= c.max_jd + 1e-9 && c.max_jd <= c.c3_jd + 1e-9,
560 "C2<=max<=C3"
561 );
562 }
563 }
564}
565
566#[derive(Clone, Copy, Debug)]
568pub(crate) enum LocalBody {
569 Sun,
570 Moon,
571}
572
573pub(crate) fn body_horizontal<B: EphemerisBackend>(
576 backend: &B,
577 observer: &ObserverLocation,
578 atmos: Atmosphere,
579 jd: f64,
580 body: LocalBody,
581) -> Result<(f64, f64, bool), EclipseError> {
582 let t = topo_sun_moon(backend, observer, jd)?;
583 let (lon, lat, dist) = match body {
584 LocalBody::Sun => (t.sun_lon_deg, t.sun_lat_deg, t.sun_dist_au),
585 LocalBody::Moon => (t.moon_lon_deg, t.moon_lat_deg, t.moon_dist_au),
586 };
587 let at = Instant::new(JulianDay::from_days(jd), TimeScale::Tdb);
593 let eps = true_obliquity_degrees(jd)
594 .map_err(|e| EclipseError::Backend(format!("obliquity failed: {e}")))?;
595 let equ = EclipticCoordinates::new(
596 Longitude::from_degrees(lon),
597 Latitude::from_degrees(lat),
598 Some(dist),
599 )
600 .to_equatorial(Angle::from_degrees(eps));
601 let ra_deg = equ.right_ascension.degrees();
602 let dec_deg = equ.declination.degrees();
603 let lst = sidereal_time(at, observer.longitude).local_apparent_deg;
604 let ha = (lst - ra_deg).to_radians();
605 let dec = dec_deg.to_radians();
606 let phi = observer.latitude.degrees().to_radians();
607 let sin_alt = (phi.sin() * dec.sin() + phi.cos() * dec.cos() * ha.cos()).clamp(-1.0, 1.0);
609 let true_alt = sin_alt.asin().to_degrees();
610 let az = ha.sin().atan2(ha.cos() * phi.sin() - dec.tan() * phi.cos());
611 let apparent_alt = apparent_from_true(true_alt, atmos);
612 Ok((
613 az.to_degrees().rem_euclid(360.0),
614 apparent_alt,
615 apparent_alt > 0.0,
616 ))
617}
618
619pub(crate) fn contact_at<B: EphemerisBackend>(
621 backend: &B,
622 observer: &ObserverLocation,
623 atmos: Atmosphere,
624 jd: f64,
625 body: LocalBody,
626) -> Result<LocalContact, EclipseError> {
627 let (az, alt, visible) = body_horizontal(backend, observer, atmos, jd, body)?;
628 Ok(LocalContact {
629 instant: Instant::new(JulianDay::from_days(jd), TimeScale::Tdb),
630 altitude_degrees: alt,
631 azimuth_degrees: az,
632 visible,
633 })
634}
635
636fn classify_local_solar(c: &SolarContactsJd) -> SolarEclipseType {
643 let (sep, s_sun, s_moon) = (c.min_sep_deg, c.s_sun_at_max, c.s_moon_at_max);
644 if c.c2_c3_present && sep + s_sun <= s_moon + 1e-9 {
645 SolarEclipseType::Total
646 } else if c.c2_c3_present && sep + s_moon <= s_sun + 1e-9 {
647 SolarEclipseType::Annular
648 } else {
649 SolarEclipseType::Partial
650 }
651}
652
653fn solar_any_visible<B: EphemerisBackend>(
655 backend: &B,
656 observer: &ObserverLocation,
657 atmos: Atmosphere,
658 c1_jd: f64,
659 c4_jd: f64,
660) -> Result<bool, EclipseError> {
661 let step = 2.0 / 1440.0;
662 let mut jd = c1_jd;
663 while jd <= c4_jd + 1e-12 {
664 let (_, _, vis) = body_horizontal(backend, observer, atmos, jd, LocalBody::Sun)?;
665 if vis {
666 return Ok(true);
667 }
668 jd += step;
669 }
670 Ok(false)
671}
672
673pub(crate) fn solar_local<B: EphemerisBackend>(
675 backend: &B,
676 observer: &ObserverLocation,
677 atmos: Atmosphere,
678 greatest_jd: f64,
679) -> Result<LocalSolarCircumstances, EclipseError> {
680 let c = solar_contacts_jd(backend, observer, greatest_jd)?;
681 let sun = LocalBody::Sun;
682 match c {
683 None => {
684 let contact = contact_at(backend, observer, atmos, greatest_jd, sun)?;
692 let g = solar_geom(&topo_sun_moon(backend, observer, greatest_jd)?);
693 Ok(LocalSolarCircumstances {
694 local_type: SolarEclipseType::Partial,
695 maximum: contact,
696 magnitude: covered_diameter_fraction(&g), obscuration: obscuration_fraction(&g),
698 first_contact: contact,
699 second_contact: None,
700 third_contact: None,
701 fourth_contact: contact,
702 any_phase_visible: false,
703 })
704 }
705 Some(c) => {
706 let g_max = solar_geom(&topo_sun_moon(backend, observer, c.max_jd)?);
707 let local_type = classify_local_solar(&c);
708 let maximum = contact_at(backend, observer, atmos, c.max_jd, sun)?;
709 let first_contact = contact_at(backend, observer, atmos, c.c1_jd, sun)?;
710 let fourth_contact = contact_at(backend, observer, atmos, c.c4_jd, sun)?;
711 let (second_contact, third_contact) = if c.c2_c3_present {
712 (
713 Some(contact_at(backend, observer, atmos, c.c2_jd, sun)?),
714 Some(contact_at(backend, observer, atmos, c.c3_jd, sun)?),
715 )
716 } else {
717 (None, None)
718 };
719 let any_phase_visible = solar_any_visible(backend, observer, atmos, c.c1_jd, c.c4_jd)?;
720 Ok(LocalSolarCircumstances {
721 local_type,
722 maximum,
723 magnitude: covered_diameter_fraction(&g_max),
724 obscuration: obscuration_fraction(&g_max),
725 first_contact,
726 second_contact,
727 third_contact,
728 fourth_contact,
729 any_phase_visible,
730 })
731 }
732 }
733}
734
735#[cfg(test)]
736mod solar_local_tests {
737 use super::*;
738 use pleiades_backend::test_backend::LinearSunMoon;
739
740 #[test]
741 fn central_eclipse_has_full_magnitude_and_ordered_contacts() {
742 let backend = LinearSunMoon::new_moon_at(2_451_550.0).with_moon_latitude(0.0);
743 let observer = ObserverLocation::new(
744 Latitude::from_degrees(0.0),
745 Longitude::from_degrees(0.0),
746 Some(0.0),
747 );
748 let s = solar_local(&backend, &observer, Atmosphere::default(), 2_451_550.0).unwrap();
749 assert!(s.magnitude > 0.0, "central magnitude {}", s.magnitude);
750 assert!(s.obscuration >= 0.0 && s.obscuration <= 1.0);
751 assert!(
752 s.first_contact.instant.julian_day.days() <= s.maximum.instant.julian_day.days() + 1e-9
753 );
754 assert!(
755 s.maximum.instant.julian_day.days()
756 <= s.fourth_contact.instant.julian_day.days() + 1e-9
757 );
758 }
759
760 #[test]
768 fn no_eclipse_but_sun_up_reports_any_phase_visible_false() {
769 let jd = 2_451_550.0;
774 let backend = LinearSunMoon::new_moon_at(jd).with_moon_latitude(3.0);
775 let atmos = Atmosphere::default();
776
777 let mut found_sun_up = false;
781 for lon_step in 0..72 {
782 let lon_deg = -180.0 + 5.0 * lon_step as f64;
783 let observer = ObserverLocation::new(
784 Latitude::from_degrees(23.0),
785 Longitude::from_degrees(lon_deg),
786 Some(0.0),
787 );
788
789 let c = solar_contacts_jd(&backend, &observer, jd).unwrap();
792 assert!(c.is_none(), "expected no local eclipse at lon {lon_deg}");
793
794 let s = solar_local(&backend, &observer, atmos, jd).unwrap();
795 assert_eq!(
796 s.magnitude, 0.0,
797 "no eclipse -> magnitude 0 at lon {lon_deg}"
798 );
799 if s.maximum.visible {
800 found_sun_up = true;
801 assert!(
802 !s.any_phase_visible,
803 "Sun-up at greatest-eclipse instant (lon {lon_deg}) must not be \
804 reported as an eclipse phase being visible"
805 );
806 }
807 }
808 assert!(
809 found_sun_up,
810 "expected at least one observer longitude with the Sun above the horizon"
811 );
812 }
813}
814
815#[cfg(test)]
816mod horizontal_tests {
817 use super::*;
818 use pleiades_backend::test_backend::LinearSunMoon;
819
820 #[test]
821 fn altitude_is_finite_and_azimuth_in_range() {
822 let backend = LinearSunMoon::new_moon_at(2_451_550.0);
823 let observer = ObserverLocation::new(
824 Latitude::from_degrees(40.0),
825 Longitude::from_degrees(0.0),
826 Some(0.0),
827 );
828 let (az, alt, _vis) = body_horizontal(
829 &backend,
830 &observer,
831 Atmosphere::default(),
832 2_451_545.0,
833 LocalBody::Sun,
834 )
835 .unwrap();
836 assert!(alt.is_finite() && alt <= 90.0 + 1e-9, "alt {alt}");
837 assert!((0.0..360.0).contains(&az), "az {az}");
838 }
839}
840
841use crate::geometry::lunar_shadow;
842
843const LUNAR_CONTACT_HALF_WINDOW_DAYS: f64 = 0.25;
846
847#[derive(Clone, Copy)]
852enum LunarContactKind {
853 Penumbral,
855 UmbralPartial,
857 UmbralTotal,
859}
860
861fn lunar_residual<B: EphemerisBackend>(
862 backend: &B,
863 kind: LunarContactKind,
864 jd: f64,
865) -> Result<f64, EclipseError> {
866 let sh = lunar_shadow(&sample_sun_moon(backend, jd)?);
867 let target = match kind {
868 LunarContactKind::Penumbral => sh.p + sh.m_moon,
869 LunarContactKind::UmbralPartial => sh.u + sh.m_moon,
870 LunarContactKind::UmbralTotal => sh.u - sh.m_moon,
871 };
872 Ok(sh.sigma - target)
873}
874
875fn bisect_lunar<B: EphemerisBackend>(
877 backend: &B,
878 kind: LunarContactKind,
879 mut lo: f64,
880 mut hi: f64,
881) -> Result<Option<f64>, EclipseError> {
882 let mut flo = lunar_residual(backend, kind, lo)?;
883 let fhi = lunar_residual(backend, kind, hi)?;
884 if flo.signum() == fhi.signum() {
885 return Ok(None);
886 }
887 while (hi - lo) > REFINE_TOLERANCE_DAYS {
888 let mid = 0.5 * (lo + hi);
889 let fmid = lunar_residual(backend, kind, mid)?;
890 if fmid.signum() == flo.signum() {
891 lo = mid;
892 flo = fmid;
893 } else {
894 hi = mid;
895 }
896 }
897 Ok(Some(0.5 * (lo + hi)))
898}
899
900fn classify_lunar_public<B: EphemerisBackend>(
902 backend: &B,
903 greatest_jd: f64,
904) -> Result<(LunarEclipseType, f64, f64), EclipseError> {
905 let sample = sample_sun_moon(backend, greatest_jd)?;
906 let sh = lunar_shadow(&sample);
907 let umbral_magnitude = ((sh.u + sh.m_moon - sh.sigma) / (2.0 * sh.m_moon)).max(0.0);
908 let penumbral_magnitude = ((sh.p + sh.m_moon - sh.sigma) / (2.0 * sh.m_moon)).max(0.0);
909 let eclipse_type = if sh.sigma + sh.m_moon <= sh.u {
910 LunarEclipseType::Total
911 } else if sh.sigma - sh.m_moon < sh.u {
912 LunarEclipseType::Partial
913 } else {
914 LunarEclipseType::Penumbral
915 };
916 Ok((eclipse_type, umbral_magnitude, penumbral_magnitude))
917}
918
919pub(crate) fn lunar_local<B: EphemerisBackend>(
921 backend: &B,
922 observer: &ObserverLocation,
923 atmos: Atmosphere,
924 greatest_jd: f64,
925) -> Result<LocalLunarCircumstances, EclipseError> {
926 let moon = LocalBody::Moon;
927 let g = classify_lunar_public(backend, greatest_jd)?; let (eclipse_type, umbral_magnitude, penumbral_magnitude) = g;
929 let lo = greatest_jd - LUNAR_CONTACT_HALF_WINDOW_DAYS;
930 let hi = greatest_jd + LUNAR_CONTACT_HALF_WINDOW_DAYS;
931
932 let find = |kind: LunarContactKind, a: f64, b: f64| bisect_lunar(backend, kind, a, b);
933 let p1 = find(LunarContactKind::Penumbral, lo, greatest_jd)?.unwrap_or(greatest_jd);
935 let p4 = find(LunarContactKind::Penumbral, greatest_jd, hi)?.unwrap_or(greatest_jd);
936 let u1 = find(LunarContactKind::UmbralPartial, lo, greatest_jd)?;
937 let u4 = find(LunarContactKind::UmbralPartial, greatest_jd, hi)?;
938 let u2 = find(LunarContactKind::UmbralTotal, lo, greatest_jd)?;
939 let u3 = find(LunarContactKind::UmbralTotal, greatest_jd, hi)?;
940
941 let mk = |jd: f64| contact_at(backend, observer, atmos, jd, moon);
942 let opt = |o: Option<f64>| -> Result<Option<LocalContact>, EclipseError> {
943 match o {
944 Some(jd) => Ok(Some(mk(jd)?)),
945 None => Ok(None),
946 }
947 };
948
949 let any_phase_visible = {
951 let step = 5.0 / 1440.0;
952 let mut jd = p1;
953 let mut vis = false;
954 while jd <= p4 + 1e-12 {
955 let (_, _, v) = body_horizontal(backend, observer, atmos, jd, moon)?;
956 if v {
957 vis = true;
958 break;
959 }
960 jd += step;
961 }
962 vis
963 };
964
965 Ok(LocalLunarCircumstances {
966 eclipse_type,
967 maximum: mk(greatest_jd)?,
968 umbral_magnitude,
969 penumbral_magnitude,
970 penumbral_begin: mk(p1)?,
971 partial_begin: opt(u1)?,
972 total_begin: opt(u2)?,
973 total_end: opt(u3)?,
974 partial_end: opt(u4)?,
975 penumbral_end: mk(p4)?,
976 any_phase_visible,
977 })
978}
979
980#[cfg(test)]
981mod lunar_local_tests {
982 use super::*;
983 use pleiades_backend::test_backend::LinearSunMoon;
984
985 #[test]
986 fn lunar_contacts_are_ordered_and_penumbra_brackets_umbra() {
987 let backend = LinearSunMoon::full_moon_at(2_451_550.0).with_moon_latitude(0.0);
988 let observer = ObserverLocation::new(
989 Latitude::from_degrees(0.0),
990 Longitude::from_degrees(0.0),
991 Some(0.0),
992 );
993 let l = lunar_local(&backend, &observer, Atmosphere::default(), 2_451_550.0).unwrap();
994 let p1 = l.penumbral_begin.instant.julian_day.days();
995 let p4 = l.penumbral_end.instant.julian_day.days();
996 assert!(p1 <= p4, "P1 <= P4");
997 if let (Some(u1), Some(u4)) = (l.partial_begin, l.partial_end) {
998 assert!(p1 <= u1.instant.julian_day.days() + 1e-9);
999 assert!(u4.instant.julian_day.days() <= p4 + 1e-9);
1000 }
1001 }
1002}
1003
1004pub(crate) fn local_circumstances_for<B: EphemerisBackend>(
1006 backend: &B,
1007 eclipse: &Eclipse,
1008 observer: &ObserverLocation,
1009 atmos: Atmosphere,
1010) -> Result<LocalCircumstances, EclipseError> {
1011 let greatest_jd = eclipse.greatest_eclipse.julian_day.days();
1012 match eclipse.kind {
1013 EclipseKind::Solar => Ok(LocalCircumstances::Solar(solar_local(
1014 backend,
1015 observer,
1016 atmos,
1017 greatest_jd,
1018 )?)),
1019 EclipseKind::Lunar => Ok(LocalCircumstances::Lunar(lunar_local(
1020 backend,
1021 observer,
1022 atmos,
1023 greatest_jd,
1024 )?)),
1025 }
1026}
1027
1028pub(crate) fn is_locally_visible(local: &LocalCircumstances) -> bool {
1030 match local {
1031 LocalCircumstances::Solar(s) => s.any_phase_visible,
1032 LocalCircumstances::Lunar(l) => l.any_phase_visible,
1033 }
1034}