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
8 changes: 7 additions & 1 deletion chained_eclipse/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -484,14 +484,20 @@ def run_full(args: argparse.Namespace) -> dict[str, Any]:
outputs_dir=output,
assumptions=assumptions,
)
central_path_errors = [
item["coordinate_error_wgs84_km"]
for item in validation["results"]
# Timing-only (non-central) references publish no coordinates.
if item["coordinate_error_wgs84_km"] is not None
]
headline = _headline(fixed_events[0])
summary = (
headline
+ "\n\n"
+ f"Definitions: A={fixed_events[0].definition_a}; B={fixed_events[0].definition_b}; "
+ f"two separate totality intervals={fixed_events[0].two_totality_components}.\n"
+ f"Validation: max timing error {max(item['timing_error_tt_s'] for item in validation['results']):.3f} s; "
+ f"max path error {max(item['coordinate_error_wgs84_km'] for item in validation['results']):.3f} km.\n"
+ f"max path error {max(central_path_errors):.3f} km.\n"
+ f"Stability: {stability['stable']} for {stability['integrated_years']:.0f} years; "
+ f"minimum moon-moon distance {stability['min_moon_moon_distance_km']:.1f} km.\n"
+ f"Fixed-system qualifying events through 2100: {len(fixed_events)}.\n"
Expand Down
128 changes: 98 additions & 30 deletions chained_eclipse/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
The pages also publish a future ``UT`` prediction derived from an assumed
Delta-T. That value is retained in the output, but is not silently treated as
UTC: future UT1, leap seconds, and therefore UTC labels are not known exactly.

Non-central (partial) eclipses have no central line, so NASA publishes no
greatest-eclipse coordinates or central duration for them. Such references
store ``None`` for those fields and are validated on eclipse type and TT
timing only.
"""

from __future__ import annotations
Expand All @@ -25,16 +30,22 @@

@dataclass(frozen=True, slots=True)
class PublishedEclipseReference:
"""Published circumstances at NASA's point of greatest eclipse."""
"""Published circumstances at NASA's point of greatest eclipse.

``latitude_deg``, ``longitude_deg``, and ``central_duration_s`` are
``None`` for non-central (partial) eclipses: NASA publishes no
greatest-eclipse coordinates or central duration for those, so they are
validated on eclipse type and TT timing only.
"""

event_id: str
eclipse_type: str
published_greatest_ut: str
published_greatest_tdt: str
published_delta_t_s: float
latitude_deg: float
longitude_deg: float
central_duration_s: float
latitude_deg: float | None
longitude_deg: float | None
central_duration_s: float | None
source_url: str
source_ephemeris: str = "VSOP87/ELP2000-85"
coordinate_reference: str = "WGS84 geodetic, NASA central-path convention"
Expand All @@ -45,6 +56,36 @@ def to_dict(self) -> dict[str, object]:


NASA_GSFC_REFERENCES: tuple[PublishedEclipseReference, ...] = (
PublishedEclipseReference(
event_id="20240408_real",
eclipse_type="total",
published_greatest_ut="2024-04-08T18:17:18.300",
published_greatest_tdt="2024-04-08T18:18:29.000",
# NASA's page states Delta-T = 70.6 s while its own TDT/UT pair
# differs by 70.7 s; both values are quoted verbatim from the source.
published_delta_t_s=70.6,
latitude_deg=25.286666666667,
longitude_deg=-104.138333333333,
central_duration_s=268.1,
source_url=(
"https://eclipse.gsfc.nasa.gov/SEbeselm/SEbeselm2001/"
"SE2024Apr08Tbeselm.html"
),
),
PublishedEclipseReference(
event_id="20250329_real",
eclipse_type="partial",
published_greatest_ut="2025-03-29T10:47:24.700",
published_greatest_tdt="2025-03-29T10:48:35.700",
published_delta_t_s=71.0,
latitude_deg=None,
longitude_deg=None,
central_duration_s=None,
source_url=(
"https://eclipse.gsfc.nasa.gov/SEbeselm/SEbeselm2001/"
"SE2025Mar29Pbeselm.html"
),
),
PublishedEclipseReference(
event_id="20260812_real",
eclipse_type="total",
Expand Down Expand Up @@ -106,23 +147,28 @@ def to_dict(self) -> dict[str, object]:

@dataclass(frozen=True, slots=True)
class EclipseValidationResult:
"""One DE440s result compared with one published NASA circumstance."""
"""One DE440s result compared with one published NASA circumstance.

Coordinate and duration fields are ``None`` for timing-only (non-central)
references, and ``coordinate_within_target`` is ``None`` there; ``passed``
then rests on eclipse type and TT timing alone.
"""

reference: PublishedEclipseReference
model_eclipse_type: str
model_maximum_utc: str
model_maximum_tt: str
model_latitude_deg: float
model_longitude_deg: float
model_latitude_deg: float | None
model_longitude_deg: float | None
signed_timing_error_tt_s: float
timing_error_tt_s: float
coordinate_error_wgs84_km: float
model_central_duration_s: float
central_duration_error_s: float
coordinate_error_wgs84_km: float | None
model_central_duration_s: float | None
central_duration_error_s: float | None
nominal_utc_minus_published_ut_s: float
type_matches: bool
timing_within_target: bool
coordinate_within_target: bool
coordinate_within_target: bool | None
passed: bool
model_source: str

Expand Down Expand Up @@ -253,38 +299,55 @@ def _validate_one(
reference: PublishedEclipseReference,
event: RealEclipse,
) -> EclipseValidationResult:
if event.latitude_deg is None or event.longitude_deg is None:
timing_only = reference.latitude_deg is None or reference.longitude_deg is None
if not timing_only and (event.latitude_deg is None or event.longitude_deg is None):
raise ValueError(f"{event.event_id} was not classified as a central eclipse")

model_time = context.time_utc(event.maximum_utc)
published_tt = _tt_time(context, reference.published_greatest_tdt)
signed_timing_error = float(
(float(model_time.tt) - float(published_tt.tt)) * SECONDS_PER_DAY
)
coordinate_error = wgs84_geodesic_distance_km(
reference.latitude_deg,
reference.longitude_deg,
event.latitude_deg,
event.longitude_deg,
)
local = solve_local_circumstances(
context,
float(model_time.tt),
reference.latitude_deg,
reference.longitude_deg,
"real_moon",
bracket_step_seconds=30.0,
)
duration_error = local.central_duration_s - reference.central_duration_s
if timing_only:
coordinate_error = None
model_duration = None
duration_error = None
else:
coordinate_error = wgs84_geodesic_distance_km(
reference.latitude_deg,
reference.longitude_deg,
event.latitude_deg,
event.longitude_deg,
)
local = solve_local_circumstances(
context,
float(model_time.tt),
reference.latitude_deg,
reference.longitude_deg,
"real_moon",
bracket_step_seconds=30.0,
)
model_duration = local.central_duration_s
duration_error = (
model_duration - reference.central_duration_s
if reference.central_duration_s is not None
else None
)
model_utc = datetime.fromisoformat(event.maximum_utc.replace("Z", "+00:00"))
nominal_published_ut = _parse_published_time(reference.published_greatest_ut).replace(
tzinfo=timezone.utc
)
nominal_utc_minus_ut = (model_utc - nominal_published_ut).total_seconds()
type_matches = event.eclipse_type == reference.eclipse_type
timing_within_target = abs(signed_timing_error) < TIMING_TARGET_SECONDS
coordinate_within_target = coordinate_error < POSITION_TARGET_KM
passed = type_matches and timing_within_target and coordinate_within_target
coordinate_within_target = (
None if coordinate_error is None else coordinate_error < POSITION_TARGET_KM
)
passed = (
type_matches
and timing_within_target
and coordinate_within_target is not False
)
return EclipseValidationResult(
reference=reference,
model_eclipse_type=event.eclipse_type,
Expand All @@ -295,7 +358,7 @@ def _validate_one(
signed_timing_error_tt_s=signed_timing_error,
timing_error_tt_s=abs(signed_timing_error),
coordinate_error_wgs84_km=coordinate_error,
model_central_duration_s=local.central_duration_s,
model_central_duration_s=model_duration,
central_duration_error_s=duration_error,
nominal_utc_minus_published_ut_s=nominal_utc_minus_ut,
type_matches=type_matches,
Expand Down Expand Up @@ -347,6 +410,11 @@ def build_validation_report(
"NASA's published UT is a future UT1 prediction based on its listed Delta-T. "
"The model UTC label is reported but UTC-versus-UT is not the timing pass/fail basis."
),
"non_central_reference_note": (
"NASA publishes no greatest-eclipse coordinates or central duration for "
"non-central (partial) eclipses; those references are validated on "
"eclipse type and TT timing only."
),
"targets": {
"maximum_eclipse_timing_error_tt_s": TIMING_TARGET_SECONDS,
"central_path_coordinate_error_wgs84_km": POSITION_TARGET_KM,
Expand Down
32 changes: 31 additions & 1 deletion tests/test_known_eclipses.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,42 @@ def test_reference_fixtures_preserve_nasa_time_scales_and_sources() -> None:
for reference in NASA_GSFC_REFERENCES:
published_ut = datetime.fromisoformat(reference.published_greatest_ut)
published_tdt = datetime.fromisoformat(reference.published_greatest_tdt)
# NASA rounds Delta-T to 0.1 s; the 2024 Apr 08 page's stated 70.6 s
# differs from its own TDT/UT pair (70.7 s) by exactly one rounding
# step, so the tolerance allows one 0.1 s step plus label rounding.
assert (published_tdt - published_ut).total_seconds() == pytest.approx(
reference.published_delta_t_s,
abs=0.051,
abs=0.105,
)
assert reference.timing_reference == "TDT (equivalent to TT)"
assert reference.coordinate_reference.startswith("WGS84")
assert reference.source_url.startswith("https://eclipse.gsfc.nasa.gov/")


def test_references_are_chronologically_ordered() -> None:
"""Keeps beginning-inserts and end-appends from different PRs coherent."""

published = [
datetime.fromisoformat(item.published_greatest_tdt)
for item in NASA_GSFC_REFERENCES
]
assert published == sorted(published)


def test_non_central_references_are_explicitly_timing_only() -> None:
"""Partial references must not carry fabricated coordinates or durations."""

for reference in NASA_GSFC_REFERENCES:
if reference.eclipse_type == "partial":
assert reference.latitude_deg is None
assert reference.longitude_deg is None
assert reference.central_duration_s is None
else:
assert reference.latitude_deg is not None
assert reference.longitude_deg is not None
assert reference.central_duration_s is not None


@pytest.mark.parametrize("event_id", EVENT_IDS)
def test_de440s_greatest_eclipse_timing_in_tt(results_by_id, event_id: str) -> None:
"""Compare physical ephemeris time in TT, not future UT1 mislabeled as UTC."""
Expand All @@ -59,6 +86,8 @@ def test_de440s_greatest_eclipse_timing_in_tt(results_by_id, event_id: str) -> N
@pytest.mark.parametrize("event_id", EVENT_IDS)
def test_de440s_central_point_within_25_km(results_by_id, event_id: str) -> None:
result = results_by_id[event_id]
if result.reference.latitude_deg is None:
pytest.skip("non-central reference: NASA publishes no greatest-eclipse coordinates")
assert result.coordinate_error_wgs84_km < POSITION_TARGET_KM
assert result.coordinate_within_target

Expand All @@ -77,6 +106,7 @@ def test_structured_report_exposes_reference_provenance() -> None:
assert report["all_passed"] is True
assert "TT versus published TDT" in report["comparison_basis"]
assert "not the timing pass/fail basis" in report["published_ut_note"]
assert "timing only" in report["non_central_reference_note"]
for item in report["results"]:
reference = item["reference"]
assert reference["published_greatest_ut"]
Expand Down
88 changes: 88 additions & 0 deletions tests/test_validation_references.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Timing-only validation behavior for non-central (partial) NASA references."""

from __future__ import annotations

from pathlib import Path

import pytest

from chained_eclipse.ephemeris import load_ephemeris
from chained_eclipse.models import RealEclipse
from chained_eclipse.validation import NASA_GSFC_REFERENCES, _validate_one

PROJECT_ROOT = Path(__file__).resolve().parents[1]


@pytest.fixture(scope="module")
def context():
return load_ephemeris(PROJECT_ROOT / "data" / "ephemeris")


def _partial_reference():
matches = [item for item in NASA_GSFC_REFERENCES if item.eclipse_type == "partial"]
assert len(matches) == 1
return matches[0]


def test_partial_reference_identity() -> None:
reference = _partial_reference()
assert reference.event_id == "20250329_real"
assert reference.latitude_deg is None
assert reference.longitude_deg is None
assert reference.central_duration_s is None


def test_first_reference_is_pre_epoch_2024_total() -> None:
first = NASA_GSFC_REFERENCES[0]
assert first.event_id == "20240408_real"
assert first.eclipse_type == "total"
# Pre-epoch anchor: earlier than the model epoch (2026-07-10).
assert first.published_greatest_tdt.startswith("2024-")


def test_partial_event_validates_without_coordinates(context) -> None:
"""Fails on main: _validate_one raises for events without a central line."""

reference = _partial_reference()
event = RealEclipse(
event_id=reference.event_id,
maximum_utc="2025-03-29T10:47:24.700Z",
eclipse_type="partial",
latitude_deg=None,
longitude_deg=None,
axis_distance_km=7_000.0,
penumbra_margin_km=100.0,
core_margin_km=100.0,
solar_altitude_deg=None,
)
result = _validate_one(context, reference, event)
assert result.coordinate_error_wgs84_km is None
assert result.model_central_duration_s is None
assert result.central_duration_error_s is None
assert result.coordinate_within_target is None
assert result.type_matches
# The fabricated maximum reuses NASA's UT label as a UTC string, so its
# TT differs from the published TDT by roughly Delta-T minus the
# TT-minus-UTC offset (about -1.8 s here) -- well inside the 60 s target.
assert result.timing_within_target
assert result.passed == (result.type_matches and result.timing_within_target)
assert result.passed is True


def test_central_reference_still_requires_central_event(context) -> None:
"""A central reference matched to a partial event must still raise."""

central = NASA_GSFC_REFERENCES[0]
event = RealEclipse(
event_id=central.event_id,
maximum_utc="2024-04-08T18:17:18.300Z",
eclipse_type="partial",
latitude_deg=None,
longitude_deg=None,
axis_distance_km=7_000.0,
penumbra_margin_km=100.0,
core_margin_km=100.0,
solar_altitude_deg=None,
)
with pytest.raises(ValueError, match="not classified as a central eclipse"):
_validate_one(context, central, event)