mirror of
https://github.com/migatu/astrololo.git
synced 2026-07-14 21:38:37 +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,24 @@
|
||||
"""Interfejs silnika efemeryd (LOG-24).
|
||||
|
||||
To jest „wymienny silnik liczący". Dziś realizują go: SkyfieldEngine (własny,
|
||||
permisywny, in-process) i RemoteEngine (klient izolowanej usługi swisseph, AGPL).
|
||||
Warstwa logiczna woła tylko ten interfejs — nie wie, który silnik liczy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from app.engine.models import ChartMoment, ObjectPosition
|
||||
|
||||
|
||||
class EphemerisEngine(ABC):
|
||||
name: str = "base"
|
||||
|
||||
@abstractmethod
|
||||
def positions(
|
||||
self, moment: ChartMoment, objects: list[str] | None = None
|
||||
) -> list[ObjectPosition]:
|
||||
"""Pozycje obiektów dla danego momentu (LOG-01). None = zestaw domyślny."""
|
||||
|
||||
def health(self) -> dict:
|
||||
return {"engine": self.name, "status": "ok"}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Fabryka silników (LOG-24) — jedyne miejsce znające konkretne implementacje.
|
||||
|
||||
EPHEMERIS_ENGINE = own (domyślnie, permisywny Skyfield) | swisseph (zdalny AGPL).
|
||||
`available_engines()` zwraca to, co da się dziś uruchomić — używane przez
|
||||
harness porównawczy i testy parzystości.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from app.engine.base import EphemerisEngine
|
||||
|
||||
|
||||
def build_engine(name: str | None = None) -> EphemerisEngine:
|
||||
name = (name or os.getenv("EPHEMERIS_ENGINE", "own")).lower()
|
||||
if name in ("swisseph", "remote", "b"):
|
||||
from app.engine.remote_engine import RemoteEngine
|
||||
|
||||
return RemoteEngine()
|
||||
from app.engine.skyfield_engine import SkyfieldEngine
|
||||
|
||||
return SkyfieldEngine()
|
||||
|
||||
|
||||
def available_engines() -> dict[str, EphemerisEngine]:
|
||||
"""Silniki gotowe do użycia teraz (own zawsze; swisseph jeśli skonfigurowany)."""
|
||||
engines: dict[str, EphemerisEngine] = {"own": build_engine("own")}
|
||||
if os.getenv("ENGINE_SWISSEPH_URL"):
|
||||
engines["swisseph"] = build_engine("swisseph")
|
||||
return engines
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Formatowanie długości ekliptycznej (LOG-01: kilka zapisów).
|
||||
|
||||
Astrolog myśli w stopniach/minutach/sekundach w znaku, Excel woli dziesiętne,
|
||||
a część technik używa pozycji absolutnej 0–360°. Tu są czyste, bezstanowe
|
||||
funkcje konwersji — bez zależności od jakiegokolwiek silnika.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
SIGNS = [
|
||||
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
||||
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
|
||||
]
|
||||
SIGN_ABBR = ["Ari", "Tau", "Gem", "Can", "Leo", "Vir",
|
||||
"Lib", "Sco", "Sag", "Cap", "Aqu", "Pis"]
|
||||
|
||||
|
||||
def norm360(lon: float) -> float:
|
||||
return lon % 360.0
|
||||
|
||||
|
||||
def sign_index(lon: float) -> int:
|
||||
"""0 = Aries … 11 = Pisces."""
|
||||
return int(norm360(lon) // 30)
|
||||
|
||||
|
||||
def _dms(deg: float) -> tuple[int, int, int]:
|
||||
"""Rozkład stopni (>=0) na (°, ', ") z poprawnym przeniesieniem zaokrąglenia."""
|
||||
total = round(deg * 3600)
|
||||
d, rem = divmod(total, 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
return d, m, s
|
||||
|
||||
|
||||
def in_sign(lon: float) -> str:
|
||||
"""Np. 'Tau 28°12'57\"' — pozycja w znaku."""
|
||||
lon = norm360(lon)
|
||||
idx = sign_index(lon)
|
||||
d, m, s = _dms(lon - idx * 30)
|
||||
if d >= 30: # zaokrąglenie przekroczyło granicę znaku
|
||||
idx = (idx + 1) % 12
|
||||
d -= 30
|
||||
return f"{SIGN_ABBR[idx]} {d}°{m:02d}'{s:02d}\""
|
||||
|
||||
|
||||
def absolute(lon: float) -> str:
|
||||
"""Np. '58°12'57\"' — pozycja absolutna 0–360°."""
|
||||
d, m, s = _dms(norm360(lon))
|
||||
return f"{d}°{m:02d}'{s:02d}\""
|
||||
|
||||
|
||||
def decimal(lon: float, places: int = 6) -> float:
|
||||
return round(norm360(lon), places)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Modele domenowe silnika efemeryd — wspólny kontrakt dla KAŻDEGO silnika.
|
||||
|
||||
To jest część LOG-28 (parzystość): każdy silnik (własny Skyfield czy zdalny
|
||||
swisseph) przyjmuje `ChartMoment` i zwraca listę `ObjectPosition` w identycznym
|
||||
kształcie i jednostkach. Dzięki temu wyniki są bezpośrednio porównywalne, a
|
||||
prezentacja/dane nie wiedzą, który silnik liczył.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from app.engine import formats
|
||||
|
||||
# kanoniczny zestaw i kolejność obiektów (LOG-02: światła + 7 klasycznych + 3 nowożytne)
|
||||
DEFAULT_OBJECTS = [
|
||||
"Sun", "Moon", "Mercury", "Venus", "Mars",
|
||||
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChartMoment:
|
||||
"""Wejście silnika: moment w UTC + lokalizacja geograficzna.
|
||||
|
||||
Pozycje obiektów zależą tylko od czasu (geocentrycznie); szerokość/długość
|
||||
geograficzna będą potrzebne dopiero przy osiach i domach (LOG-05).
|
||||
"""
|
||||
|
||||
when_utc: datetime # musi być świadome strefy (UTC)
|
||||
lat: float = 0.0 # szerokość geograficzna, + na północ
|
||||
lon: float = 0.0 # długość geograficzna, + na wschód
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ObjectPosition:
|
||||
"""Wynik dla jednego obiektu — surowe wartości w jednostkach SI astrologii."""
|
||||
|
||||
name: str
|
||||
longitude: float # długość ekliptyczna 0–360° (tropikalna, of-date)
|
||||
latitude: float # szerokość ekliptyczna (°)
|
||||
speed: float # prędkość w długości (°/dobę)
|
||||
retrograde: bool
|
||||
|
||||
@property
|
||||
def sign(self) -> str:
|
||||
return formats.SIGNS[formats.sign_index(self.longitude)]
|
||||
|
||||
@property
|
||||
def direction(self) -> str:
|
||||
return "Rx" if self.retrograde else "D"
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"sign": self.sign,
|
||||
"in_sign": formats.in_sign(self.longitude),
|
||||
"absolute": formats.absolute(self.longitude),
|
||||
"decimal": formats.decimal(self.longitude),
|
||||
"latitude": round(self.latitude, 6),
|
||||
"speed": round(self.speed, 6),
|
||||
"direction": self.direction,
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"""RemoteEngine — klient izolowanej usługi silnika (LOG-24 backend nr 2, LOG-27).
|
||||
|
||||
Realizuje ten sam interfejs co SkyfieldEngine, ale liczenie deleguje przez HTTP
|
||||
do OSOBNEJ usługi `engine-swisseph` (AGPL). Dzięki granicy sieciowej kod AGPL
|
||||
nigdy nie jest linkowany do permisywnego produktu — patrz services/engine-swisseph.
|
||||
|
||||
Używany tylko, gdy skonfigurowano ENGINE_SWISSEPH_URL (tryb porównawczy/dev/CI).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
from app.engine.base import EphemerisEngine
|
||||
from app.engine.formats import norm360
|
||||
from app.engine.models import DEFAULT_OBJECTS, ChartMoment, ObjectPosition
|
||||
|
||||
|
||||
class RemoteEngine(EphemerisEngine):
|
||||
name = "swisseph"
|
||||
|
||||
def __init__(self, base_url: str | None = None, timeout: float = 15.0) -> None:
|
||||
self.base_url = (base_url or os.getenv("ENGINE_SWISSEPH_URL", "")).rstrip("/")
|
||||
self.timeout = timeout
|
||||
|
||||
def positions(
|
||||
self, moment: ChartMoment, objects: list[str] | None = None
|
||||
) -> list[ObjectPosition]:
|
||||
if not self.base_url:
|
||||
raise RuntimeError("ENGINE_SWISSEPH_URL nie ustawiony — silnik B niedostępny")
|
||||
payload = {
|
||||
"when_utc": moment.when_utc.isoformat(),
|
||||
"lat": moment.lat,
|
||||
"lon": moment.lon,
|
||||
"objects": objects or DEFAULT_OBJECTS,
|
||||
}
|
||||
with httpx.Client(timeout=self.timeout) as client:
|
||||
r = client.post(f"{self.base_url}/positions", json=payload)
|
||||
r.raise_for_status()
|
||||
rows = r.json()["positions"]
|
||||
return [
|
||||
ObjectPosition(
|
||||
name=row["name"],
|
||||
longitude=norm360(row["longitude"]),
|
||||
latitude=row["latitude"],
|
||||
speed=row["speed"],
|
||||
retrograde=row["retrograde"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def health(self) -> dict:
|
||||
if not self.base_url:
|
||||
return {"engine": self.name, "status": "disabled"}
|
||||
try:
|
||||
with httpx.Client(timeout=self.timeout) as client:
|
||||
r = client.get(f"{self.base_url}/health")
|
||||
r.raise_for_status()
|
||||
return {"engine": self.name, "status": "ok", "remote": r.json()}
|
||||
except httpx.HTTPError as e:
|
||||
return {"engine": self.name, "status": "down", "error": str(e)}
|
||||
@@ -0,0 +1,92 @@
|
||||
"""SkyfieldEngine — własny, permisywny silnik (LOG-01).
|
||||
|
||||
Ścieżka A: Skyfield (MIT) + efemerydy JPL (public domain). Liczy geocentryczne
|
||||
pozycje pozorne (apparent) i rzutuje je na ekliptykę daty → długość tropikalna,
|
||||
szerokość, prędkość i kierunek. Brak zależności AGPL.
|
||||
|
||||
Prędkość liczymy numerycznie (różnica długości po małym kroku czasu) — wystarcza
|
||||
do kierunku (D/Rx) i do wykrywania stacji w LOG-03.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from functools import lru_cache
|
||||
|
||||
from app.engine.base import EphemerisEngine
|
||||
from app.engine.formats import norm360
|
||||
from app.engine.models import DEFAULT_OBJECTS, ChartMoment, ObjectPosition
|
||||
|
||||
# nazwa obiektu -> cel w jądrze efemeryd (de421 ma centra Merkurego/Wenus,
|
||||
# dla pozostałych planet używamy barycentrów — różnica nieistotna astrologicznie)
|
||||
_TARGETS = {
|
||||
"Sun": "sun",
|
||||
"Moon": "moon",
|
||||
"Mercury": "mercury",
|
||||
"Venus": "venus",
|
||||
"Mars": "mars barycenter",
|
||||
"Jupiter": "jupiter barycenter",
|
||||
"Saturn": "saturn barycenter",
|
||||
"Uranus": "uranus barycenter",
|
||||
"Neptune": "neptune barycenter",
|
||||
"Pluto": "pluto barycenter",
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _load(kernel: str, data_dir: str):
|
||||
"""Wczytuje skalę czasu i jądro efemeryd raz (kosztowne) i cache'uje."""
|
||||
from skyfield.api import Loader
|
||||
|
||||
load = Loader(data_dir)
|
||||
ts = load.timescale()
|
||||
eph = load(kernel)
|
||||
return ts, eph, eph["earth"]
|
||||
|
||||
|
||||
class SkyfieldEngine(EphemerisEngine):
|
||||
name = "skyfield"
|
||||
|
||||
def __init__(self, kernel: str | None = None, data_dir: str | None = None) -> None:
|
||||
self.kernel = kernel or os.getenv("EPHEMERIS_KERNEL", "de421.bsp")
|
||||
self.data_dir = data_dir or os.getenv(
|
||||
"EPHEMERIS_DIR", os.path.join(os.path.dirname(__file__), "..", "..", ".ephemeris")
|
||||
)
|
||||
os.makedirs(self.data_dir, exist_ok=True)
|
||||
self.ts, self.eph, self.earth = _load(self.kernel, os.path.abspath(self.data_dir))
|
||||
|
||||
def _ecliptic_lon_lat(self, target, t):
|
||||
astrometric = self.earth.at(t).observe(target).apparent()
|
||||
lat, lon, _dist = astrometric.ecliptic_latlon(epoch="date")
|
||||
return lon.degrees, lat.degrees
|
||||
|
||||
def positions(
|
||||
self, moment: ChartMoment, objects: list[str] | None = None
|
||||
) -> list[ObjectPosition]:
|
||||
names = objects or DEFAULT_OBJECTS
|
||||
t = self.ts.from_datetime(moment.when_utc)
|
||||
dt = timedelta(hours=1)
|
||||
t2 = self.ts.from_datetime(moment.when_utc + dt)
|
||||
|
||||
out: list[ObjectPosition] = []
|
||||
for name in names:
|
||||
target = self.eph[_TARGETS[name]]
|
||||
lon, lat = self._ecliptic_lon_lat(target, t)
|
||||
lon2, _ = self._ecliptic_lon_lat(target, t2)
|
||||
# prędkość °/dobę z poprawką na przejście przez 0°/360°
|
||||
step = ((lon2 - lon + 180.0) % 360.0) - 180.0
|
||||
speed = step * 24.0
|
||||
# rzutowanie na czysty float — Skyfield zwraca numpy.float64
|
||||
out.append(
|
||||
ObjectPosition(
|
||||
name=name,
|
||||
longitude=float(norm360(lon)),
|
||||
latitude=float(lat),
|
||||
speed=float(speed),
|
||||
retrograde=bool(speed < 0),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
def health(self) -> dict:
|
||||
return {"engine": self.name, "status": "ok", "kernel": self.kernel}
|
||||
Reference in New Issue
Block a user