"""Harness walidacyjno-porównawczy (LOG-25) i kontrakt parzystości (LOG-28). `compare_positions` zestawia wyniki dwóch silników z progami tolerancji per wielkość i flaguje rozbieżności — używane w testach regresyjnych (CI) i w trybie dual-run na żądanie (LOG-26). `check_engine_contract` to wspólny kontrakt, który MUSI spełnić każdy silnik — ten sam test uruchamiamy dla EngineA i EngineB (LOG-28). """ from __future__ import annotations from dataclasses import dataclass, field from app.engine.base import EphemerisEngine from app.engine.models import DEFAULT_OBJECTS, ChartMoment, ObjectPosition def angular_delta(a: float, b: float) -> float: """Najmniejsza różnica kątów w stopniach (z obsługą zawinięcia 0/360).""" return ((a - b + 180.0) % 360.0) - 180.0 @dataclass class ObjectDiff: name: str lon_a: float lon_b: float delta_arcsec: float # różnica długości w sekundach łuku lat_delta: float speed_sign_mismatch: bool over_tolerance: bool @dataclass class CompareReport: lon_tol_arcsec: float diffs: list[ObjectDiff] = field(default_factory=list) @property def ok(self) -> bool: return not any(d.over_tolerance or d.speed_sign_mismatch for d in self.diffs) @property def max_arcsec(self) -> float: return max((abs(d.delta_arcsec) for d in self.diffs), default=0.0) def summary(self) -> dict: return { "ok": self.ok, "objects": len(self.diffs), "max_arcsec": round(self.max_arcsec, 2), "tolerance_arcsec": self.lon_tol_arcsec, "flagged": [d.name for d in self.diffs if d.over_tolerance or d.speed_sign_mismatch], } def compare_positions( a: list[ObjectPosition], b: list[ObjectPosition], lon_tol_arcsec: float = 120.0, ) -> CompareReport: by_b = {p.name: p for p in b} report = CompareReport(lon_tol_arcsec=lon_tol_arcsec) for pa in a: pb = by_b.get(pa.name) if pb is None: continue d_arcsec = angular_delta(pa.longitude, pb.longitude) * 3600.0 report.diffs.append( ObjectDiff( name=pa.name, lon_a=pa.longitude, lon_b=pb.longitude, delta_arcsec=d_arcsec, lat_delta=pa.latitude - pb.latitude, speed_sign_mismatch=(pa.retrograde != pb.retrograde), over_tolerance=abs(d_arcsec) > lon_tol_arcsec, ) ) return report def compare_engines( engine_a: EphemerisEngine, engine_b: EphemerisEngine, moment: ChartMoment, lon_tol_arcsec: float = 120.0, ) -> CompareReport: return compare_positions( engine_a.positions(moment), engine_b.positions(moment), lon_tol_arcsec ) def check_engine_contract(engine: EphemerisEngine, moment: ChartMoment) -> None: """Kontrakt parzystości (LOG-28). Rzuca AssertionError przy naruszeniu.""" positions = engine.positions(moment) names = {p.name for p in positions} assert set(DEFAULT_OBJECTS) <= names, f"brakuje obiektów: {set(DEFAULT_OBJECTS) - names}" for p in positions: assert 0.0 <= p.longitude < 360.0, f"{p.name}: długość poza zakresem ({p.longitude})" assert -90.0 <= p.latitude <= 90.0, f"{p.name}: szerokość poza zakresem ({p.latitude})" assert p.retrograde in (True, False) d = p.as_dict() assert d["sign"] and d["in_sign"], f"{p.name}: brak formatów"