Skip to main content

pleiades_eclipse/
local.rs

1//! Per-observer (local) eclipse circumstances: contact times, magnitude,
2//! obscuration, horizontal position, and horizon visibility for a specific
3//! observer, extending the crate's global/geocentric eclipse data.
4
5use 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/// One observer-local contact event: its instant plus the eclipsed body's
19/// horizontal position and visibility there. A contact that occurs below the
20/// horizon is still timed (`instant` present) but flagged `visible == false`.
21#[derive(Clone, Copy, Debug, PartialEq)]
22pub struct LocalContact {
23    /// Instant of the contact (TDB).
24    pub instant: Instant,
25    /// Apparent (refracted) altitude of the eclipsed body, degrees.
26    pub altitude_degrees: f64,
27    /// Azimuth of the eclipsed body, measured from south increasing westward,
28    /// `[0,360)` degrees (matches `swe_azalt`).
29    pub azimuth_degrees: f64,
30    /// Whether the body is above the horizon at this instant.
31    pub visible: bool,
32}
33
34/// Local circumstances of a solar eclipse for one observer.
35#[derive(Clone, Copy, Debug, PartialEq)]
36pub struct LocalSolarCircumstances {
37    /// What THIS observer sees (may differ from the global classification).
38    pub local_type: SolarEclipseType,
39    /// Instant of local greatest eclipse.
40    pub maximum: LocalContact,
41    /// Covered fraction of the Sun's diameter at local maximum.
42    pub magnitude: f64,
43    /// Covered fraction of the Sun's area at local maximum.
44    pub obscuration: f64,
45    /// First contact (C1): partial phase begins.
46    pub first_contact: LocalContact,
47    /// Second contact (C2): total/annular phase begins (central path only).
48    pub second_contact: Option<LocalContact>,
49    /// Third contact (C3): total/annular phase ends (central path only).
50    pub third_contact: Option<LocalContact>,
51    /// Fourth contact (C4): partial phase ends.
52    pub fourth_contact: LocalContact,
53    /// Whether the Sun is above the horizon during any part of the eclipse.
54    pub any_phase_visible: bool,
55}
56
57/// Local circumstances of a lunar eclipse for one observer. Contact instants
58/// are global (shared by all observers); the local content is horizon
59/// visibility and the Moon's az/alt at each contact.
60#[derive(Clone, Copy, Debug, PartialEq)]
61pub struct LocalLunarCircumstances {
62    /// Umbral/penumbral classification (identical to the global type).
63    pub eclipse_type: LunarEclipseType,
64    /// Local greatest eclipse.
65    pub maximum: LocalContact,
66    /// Umbral magnitude at greatest eclipse.
67    pub umbral_magnitude: f64,
68    /// Penumbral magnitude at greatest eclipse.
69    pub penumbral_magnitude: f64,
70    /// P1: penumbral phase begins.
71    pub penumbral_begin: LocalContact,
72    /// U1: partial (umbral) phase begins; `None` for penumbral-only eclipses.
73    pub partial_begin: Option<LocalContact>,
74    /// U2: total phase begins; `None` unless total.
75    pub total_begin: Option<LocalContact>,
76    /// U3: total phase ends; `None` unless total.
77    pub total_end: Option<LocalContact>,
78    /// U4: partial (umbral) phase ends; `None` for penumbral-only eclipses.
79    pub partial_end: Option<LocalContact>,
80    /// P4: penumbral phase ends.
81    pub penumbral_end: LocalContact,
82    /// Whether the Moon is above the horizon during any part of the eclipse.
83    pub any_phase_visible: bool,
84}
85
86/// A tagged local result: either solar or lunar circumstances.
87#[derive(Clone, Copy, Debug, PartialEq)]
88pub enum LocalCircumstances {
89    /// Solar eclipse local circumstances.
90    Solar(LocalSolarCircumstances),
91    /// Lunar eclipse local circumstances.
92    Lunar(LocalLunarCircumstances),
93}
94
95/// Observer-relative (topocentric) Sun and Moon apparent ecliptic-of-date
96/// geometry at one instant: the input to the two-circle solar contact geometry.
97#[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
107/// Applies diurnal parallax to the geocentric Sun/Moon sample for `observer`,
108/// after carrying both bodies from Mean/J2000 to apparent ecliptic-of-date.
109pub(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    // Diurnal parallax rotates the observer's geocentric offset with the true
119    // Earth orientation, which is a function of **UT1**, not the dynamical scale
120    // `jd` is expressed in. Converting `jd` (TT/TDB) → UT1 via ΔT before taking
121    // sidereal time is what makes the topocentric geometry — and therefore the
122    // observer-local contact / greatest-eclipse instants found by minimizing this
123    // separation — agree with Swiss Ephemeris's UT1-based `swe_sol_eclipse_when_loc`
124    // (without it the parallax is rotated ~ΔT ≈ 69 s off, biasing the local
125    // maximum by tens of seconds). On a ΔT-table miss we fall back to `jd`.
126    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    // `sample_sun_moon` returns **Mean/J2000** geocentric ecliptic positions,
131    // already light-time retarded (see `ephemeris::sample_sun_moon`). The parallax
132    // step below (and the horizontal conversion downstream) works in the of-date
133    // frame — of-date true obliquity and of-date apparent sidereal time — so BOTH
134    // bodies must first be carried from Mean/J2000 to **apparent ecliptic-of-date**
135    // via precession (J2000→date) + nutation in longitude. The ~0.28°/20 yr
136    // precession offset it removes cancels in the Sun−Moon *separation* (so contact
137    // timing / type / magnitude were already correct) but otherwise corrupts the
138    // absolute az/alt. No light-time re-query is done here.
139    //
140    // Annual aberration is deliberately NOT applied: the Sun's ~499 s light-time
141    // retardation already IS its ~20.5″ annual-aberration displacement (the same
142    // Earth-reflex effect for the Sun — see `ephemeris::apparent_sun_longitude_deg`),
143    // and the Sun−Moon differential light-time already carried by `sample_sun_moon`
144    // is exactly the differential the apparent conjunction needs. Adding a separate
145    // ~20.5″ annual-aberration term to the Moon only would break the frame-shared
146    // cancellation, shifting the topocentric maximum by ~40 s (and it does not
147    // match SE's eclipse geometry). Precession + nutation are frame-common to both
148    // bodies, so the separation stays invariant.
149    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        // The analytic test backend places a new moon; an equatorial observer
205        // sees the Moon shifted from its geocentric longitude by parallax.
206        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
223/// Physical radii and unit conversion (mirrors `geometry::constants`; kept in
224/// sync deliberately — do not diverge these values).
225mod 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/// Instantaneous topocentric two-circle geometry of a solar eclipse.
232#[derive(Clone, Copy, Debug)]
233pub(crate) struct SolarGeom {
234    /// Center-to-center Sun–Moon separation, degrees.
235    pub sep_deg: f64,
236    /// Sun's topocentric angular semidiameter, degrees.
237    pub s_sun_deg: f64,
238    /// Moon's topocentric angular semidiameter, degrees.
239    pub s_moon_deg: f64,
240}
241
242/// Great-circle separation (degrees) between two ecliptic points.
243fn 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
250/// Topocentric two-circle geometry at one instant.
251pub(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
264/// Covered fraction of the Sun's diameter (the eclipse "magnitude"), clamped ≥ 0.
265pub(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
269/// Covered fraction of the Sun's disk AREA (obscuration), clamped to [0,1].
270/// Standard two-circle lens area with radii `r_sun`, `r_moon` and center
271/// distance `d` (all in the same angular units).
272pub(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; // disjoint
276    }
277    if d <= r_m - r_s {
278        return 1.0; // Sun fully covered (Moon disk envelops Sun disk)
279    }
280    if d <= (r_s - r_m).max(0.0) {
281        // Moon fully inside the Sun (annular): area ratio (r_m/r_s)^2.
282        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); // sep > s_sun + s_moon
322        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        // Moon fully inside Sun (annular): d + r_m <= r_s.
347        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
388/// Half-width of the solar contact search bracket around local maximum (days).
389/// A local solar eclipse's partial phase never exceeds ~3.5 h; 0.25 day (6 h)
390/// is a safe superset.
391const SOLAR_CONTACT_HALF_WINDOW_DAYS: f64 = 0.25;
392/// Root/extremum refinement tolerance: 0.5 s, matching the crate's global
393/// `refine_greatest` and the SP-2a root-finder.
394const 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
409/// Topocentric Sun–Moon separation (degrees) at `jd` for `observer`.
410fn 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
418/// Golden-section minimize `sep(t)` in `[a,b]` to `REFINE_TOLERANCE_DAYS`.
419fn 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
448/// Bisect `sep(t) - threshold` between `lo` and `hi` where it changes sign;
449/// returns `None` if it does not change sign across the bracket.
450fn bisect_contact<B: EphemerisBackend>(
451    backend: &B,
452    observer: &ObserverLocation,
453    threshold: impl Fn(f64) -> f64, // threshold as fn of jd (semidiameters vary slowly)
454    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
481/// Full set of solar contact instants for `observer` around `greatest_jd`.
482pub(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); // observer sees no eclipse at all
497    }
498    // Semidiameter sums vary slowly; freeze them at max for the threshold fn
499    // (sub-second contact error), matching the design's closed-form treatment.
500    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        // Analytic on-node backend → central solar eclipse for an equatorial observer.
539        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/// Which body a horizontal position is computed for.
567#[derive(Clone, Copy, Debug)]
568pub(crate) enum LocalBody {
569    Sun,
570    Moon,
571}
572
573/// Topocentric azimuth (from south, west, `[0,360)`), apparent (refracted)
574/// altitude, and above-horizon visibility of `body` at `jd` for `observer`.
575pub(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    // Horizontal-frame rotation uses local apparent sidereal time at the numeric
588    // instant (the engine's established convention, mirroring SP-2b
589    // rise/set/transit and the SE az/alt reference generation). The topocentric
590    // parallax that produced `t` already used the ΔT-corrected UT1 rotation (see
591    // `topo_sun_moon`), which is what pins the observer-local contact instants.
592    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    // Standard equatorial → horizontal (azimuth from south, increasing west).
608    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
619/// Packages a `jd` + a body's horizontal position into a `LocalContact`.
620pub(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
636/// Classifies what the observer sees at local maximum from the two-circle
637/// geometry: total when the Moon's disk fully covers the Sun's
638/// (`sep + s_sun <= s_moon`), annular when the Moon is fully inside
639/// (`sep + s_moon <= s_sun`), else partial. Hybrid is a global (path-level)
640/// distinction, not a single-observer one, so a single observer sees total or
641/// annular, never "hybrid".
642fn 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
653/// Whether the Sun is above the horizon anywhere in `[c1,c4]` (coarse 2-min scan).
654fn 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
673/// Full solar local circumstances for `observer` around `greatest_jd`.
674pub(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            // No eclipse for this observer: a degenerate all-at-greatest record
685            // with magnitude 0 and no partial phase at all, so `any_phase_visible`
686            // is unconditionally false (there is no eclipse phase to be visible,
687            // regardless of whether the Sun happens to be above the horizon at
688            // `greatest_jd`). `next_local_eclipse` filters these out correctly
689            // because of this; `local_circumstances` still returns the record so
690            // a caller can inspect "not visible here".
691            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), // 0.0 here
697                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    /// Regression for the SP-2c final-review fix: a magnitude-0 "no eclipse
761    /// here" record must never claim `any_phase_visible == true` just because
762    /// the Sun happens to be above the horizon at the greatest-eclipse instant.
763    /// Before the fix, `None`-arm `any_phase_visible` was wired to
764    /// `contact.visible` (Sun-up-ness), which is unrelated to whether any
765    /// eclipse phase occurred; this made `next_local_eclipse` surface
766    /// non-eclipses as "locally visible" whenever the Sun was up.
767    #[test]
768    fn no_eclipse_but_sun_up_reports_any_phase_visible_false() {
769        // The Moon 3 degrees off the ecliptic at conjunction puts the
770        // topocentric Sun-Moon separation at ~3 deg for every observer, far
771        // outside the ~0.5 deg sum of semidiameters, so `solar_contacts_jd`
772        // returns `None` (no eclipse anywhere) for every longitude tried below.
773        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        // Sweep observer longitude at a latitude near the Sun's declination at
778        // `jd` so at least one gives an above-horizon Sun at the instant of
779        // (non-)greatest-eclipse.
780        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            // Sanity: this scenario really does hit the `None` (no local
790            // eclipse) branch under test.
791            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
843/// Half-window (days) to search for lunar shadow contacts around greatest
844/// eclipse. A penumbral lunar eclipse lasts up to ~6 h; 0.25 day is safe.
845const LUNAR_CONTACT_HALF_WINDOW_DAYS: f64 = 0.25;
846
847/// Signed residual `dist(t) - radius` for a lunar contact, where `dist` is the
848/// Moon-to-shadow-axis separation `sigma` and `radius` is one of the shadow
849/// radii (umbral/penumbral), optionally offset by the Moon's semidiameter for
850/// the exterior/interior contacts. `kind` selects the residual.
851#[derive(Clone, Copy)]
852enum LunarContactKind {
853    /// P1/P4: sigma == p + m_moon (penumbra first touches / last touches disc).
854    Penumbral,
855    /// U1/U4: sigma == u + m_moon (umbra first touches / last touches disc).
856    UmbralPartial,
857    /// U2/U3: sigma == u - m_moon (disc fully enters / begins leaving umbra).
858    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
875/// Bisect a lunar contact residual between `lo` and `hi`; `None` if no sign change.
876fn 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
900/// Returns `(type, umbral_magnitude, penumbral_magnitude)` at greatest eclipse.
901fn 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
919/// Full lunar local circumstances. Contact instants are global; visibility is local.
920pub(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)?; // (type, umbral_mag, penumbral_mag)
928    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    // Penumbral contacts always exist for any lunar eclipse.
934    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    // Visibility across the widest phase present (P1..P4).
950    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
1004/// Computes local circumstances for an already-found `eclipse`.
1005pub(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
1028/// Whether a computed local result has any above-horizon phase.
1029pub(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}