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:
2026-06-27 19:48:31 +02:00
parent 9bf297d461
commit e69c0714b9
25 changed files with 805 additions and 0 deletions
@@ -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}