diff --git a/chained_eclipse/cli.py b/chained_eclipse/cli.py index e111ece..2848f3d 100644 --- a/chained_eclipse/cli.py +++ b/chained_eclipse/cli.py @@ -484,6 +484,12 @@ 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 @@ -491,7 +497,7 @@ def run_full(args: argparse.Namespace) -> dict[str, Any]: + 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" diff --git a/chained_eclipse/validation.py b/chained_eclipse/validation.py index 7f12219..58dc48e 100644 --- a/chained_eclipse/validation.py +++ b/chained_eclipse/validation.py @@ -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 @@ -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" @@ -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", @@ -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 @@ -253,7 +299,8 @@ 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) @@ -261,21 +308,31 @@ def _validate_one( 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 @@ -283,8 +340,14 @@ def _validate_one( 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, @@ -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, @@ -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, diff --git a/tests/test_known_eclipses.py b/tests/test_known_eclipses.py index 7db9b6c..04eb60d 100644 --- a/tests/test_known_eclipses.py +++ b/tests/test_known_eclipses.py @@ -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.""" @@ -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 @@ -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"] diff --git a/tests/test_validation_references.py b/tests/test_validation_references.py new file mode 100644 index 0000000..58094c8 --- /dev/null +++ b/tests/test_validation_references.py @@ -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)