mirror of
https://github.com/migatu/astrololo.git
synced 2026-07-14 13:34:38 +00:00
e69c0714b9
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>
105 lines
3.4 KiB
Python
105 lines
3.4 KiB
Python
"""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"
|