diff --git a/chained_eclipse/ephemeris.py b/chained_eclipse/ephemeris.py index e11ee5c..ae22f73 100644 --- a/chained_eclipse/ephemeris.py +++ b/chained_eclipse/ephemeris.py @@ -324,6 +324,39 @@ def central_point_real( return lat, lon, height, float(signed_radius) +def _classify_central_eclipse( + context: EphemerisContext, + greatest_tt_jd: float, + near_core_radius_km: float, + *, + half_window_seconds: float = 10_800.0, + step_seconds: float = 120.0, +) -> str: + """Classify a central eclipse as ``total``, ``annular``, or ``hybrid``. + + The signed near-side core radius is sampled along the whole central track + (off-ellipsoid samples are skipped by ``iter_real_track``). If the radius + takes both signs anywhere on the track, the eclipse transitions between + annular and total and is labelled ``hybrid`` (annular-total), matching + NASA/GSFC catalog conventions. Otherwise the sign at greatest eclipse + decides between ``total`` and ``annular``, exactly as before. + """ + + saw_total = near_core_radius_km > 0.0 + saw_annular = near_core_radius_km < 0.0 + offsets = np.arange( + -half_window_seconds, half_window_seconds + step_seconds, step_seconds + ) + for _, _, _, core in iter_real_track(context, greatest_tt_jd, offsets): + if core > 0.0: + saw_total = True + elif core < 0.0: + saw_annular = True + if saw_total and saw_annular: + return "hybrid" + return "total" if near_core_radius_km > 0.0 else "annular" + + def enumerate_real_solar_eclipses( context: EphemerisContext, start: Time, @@ -393,7 +426,9 @@ def axis_miss(offset_days: float) -> float: ) else: lat, lon, altitude, near_core_radius = central - kind = "total" if near_core_radius > 0.0 else "annular" + kind = _classify_central_eclipse( + context, float(maximum.tt), near_core_radius + ) core_margin = abs(near_core_radius) event_id = maximum.utc_strftime("%Y%m%d") + "_real" events.append( diff --git a/chained_eclipse/validation.py b/chained_eclipse/validation.py index 7f12219..9ec1c45 100644 --- a/chained_eclipse/validation.py +++ b/chained_eclipse/validation.py @@ -101,6 +101,20 @@ def to_dict(self) -> dict[str, object]: "SE2028Jan26Abeselm.html" ), ), + PublishedEclipseReference( + event_id="20311114_real", + eclipse_type="hybrid", + published_greatest_ut="2031-11-14T21:06:12.300", + published_greatest_tdt="2031-11-14T21:07:30.200", + published_delta_t_s=77.9, + latitude_deg=-0.633333333333, + longitude_deg=-137.63, + central_duration_s=68.3, + source_url=( + "https://eclipse.gsfc.nasa.gov/SEbeselm/SEbeselm2001/" + "SE2031Nov14Hbeselm.html" + ), + ), ) diff --git a/tests/test_hybrid_classification.py b/tests/test_hybrid_classification.py new file mode 100644 index 0000000..8bbcfd0 --- /dev/null +++ b/tests/test_hybrid_classification.py @@ -0,0 +1,50 @@ +"""Regression tests for hybrid (annular-total) eclipse classification.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from chained_eclipse.ephemeris import enumerate_real_solar_eclipses, load_ephemeris + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +@pytest.fixture(scope="module") +def context(): + return load_ephemeris(PROJECT_ROOT / "data" / "ephemeris") + + +def _single_event(context, start_utc: str, end_utc: str): + events = enumerate_real_solar_eclipses( + context, context.time_utc(start_utc), context.time_utc(end_utc) + ) + assert len(events) == 1 + return events[0] + + +def test_2031_nov_14_is_classified_hybrid(context) -> None: + """NASA/GSFC lists 2031 Nov 14 as hybrid; sign-at-greatest alone says total. + + This test fails on main, where every central eclipse is labelled by the + sign of the core radius at greatest eclipse only. + """ + + event = _single_event(context, "2031-11-10T00:00:00Z", "2031-11-19T00:00:00Z") + assert event.event_id == "20311114_real" + assert event.eclipse_type == "hybrid" + assert event.latitude_deg is not None + assert event.longitude_deg is not None + + +def test_2026_aug_12_remains_total(context) -> None: + event = _single_event(context, "2026-08-08T00:00:00Z", "2026-08-17T00:00:00Z") + assert event.event_id == "20260812_real" + assert event.eclipse_type == "total" + + +def test_2027_feb_06_remains_annular(context) -> None: + event = _single_event(context, "2027-02-02T00:00:00Z", "2027-02-11T00:00:00Z") + assert event.event_id == "20270206_real" + assert event.eclipse_type == "annular"