Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion chained_eclipse/ephemeris.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 14 additions & 0 deletions chained_eclipse/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
),
),
)


Expand Down
50 changes: 50 additions & 0 deletions tests/test_hybrid_classification.py
Original file line number Diff line number Diff line change
@@ -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"