mirror of
https://github.com/migatu/astrololo.git
synced 2026-07-17 15:12:09 +00:00
Silnik efemeryd: EngineProvider + SkyfieldEngine + harness porównawczy
Pierwszy increment implementacji warstwy logicznej (ścieżka A). LOG-24: interfejs EphemerisEngine z dwoma backendami — SkyfieldEngine (własny, permisywny: Skyfield MIT + dane JPL public domain) oraz RemoteEngine (klient izolowanej usługi swisseph). Fabryka + leniwa inicjalizacja; endpointy /chart/positions i /chart/compare. LOG-01: pozycje obiektów (długość/szerokość ekliptyczna, prędkość, kierunek, formaty: w znaku / absolutny / dziesiętny). LOG-25/28: harness porównawczy (compare.py) z progami tolerancji oraz wspólny kontrakt parzystości; pełen zestaw testów. LOG-27: services/engine-swisseph — osobna, opcjonalna usługa AGPL (pyswisseph, tryb Moshiera), licencjonowana osobno, w compose pod profilem "comparison"; nie wchodzi do zamkniętego produktu. Walidacja: SkyfieldEngine zgadza się ze Swiss Ephemeris co do ~1" dla wszystkich 10 obiektów na horoskopie referencyjnym (30.04.1984, Warszawa); 12 testów przechodzi (silnik B pomijany gdy nieskonfigurowany). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user