diff --git a/.gitignore b/.gitignore index 508bac4..0d04fa1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ services/data/.cache/ *.parquet *.db +# Cache efemeryd silnika własnego (pobierane jądra JPL, regenerowalne) +services/logic/.ephemeris/ +*.bsp + # Dane wejściowe (duże pliki Excela trzymane poza repo) services/data/data_files/*.xlsx !services/data/data_files/.gitkeep diff --git a/docker-compose.yml b/docker-compose.yml index 593cdcb..569fb05 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,11 +16,23 @@ services: build: ./services/logic environment: DATA_URL: http://data:8002 + EPHEMERIS_ENGINE: own # silnik własny (permisywny) — domyślny + # adres silnika B (AGPL) używany tylko w trybie porównawczym (profil comparison) + ENGINE_SWISSEPH_URL: http://engine-swisseph:8003 depends_on: - data ports: - "8001:8001" + # Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison": + # docker compose --profile comparison up + # Nie wchodzi do domyślnego (zamkniętego) produktu — patrz services/engine-swisseph/LICENSE. + engine-swisseph: + build: ./services/engine-swisseph + profiles: ["comparison"] + ports: + - "8003:8003" + presentation: build: ./services/presentation environment: diff --git a/services/engine-swisseph/Dockerfile b/services/engine-swisseph/Dockerfile new file mode 100644 index 0000000..cb2f4dc --- /dev/null +++ b/services/engine-swisseph/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8003 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8003"] diff --git a/services/engine-swisseph/LICENSE b/services/engine-swisseph/LICENSE new file mode 100644 index 0000000..864b2c2 --- /dev/null +++ b/services/engine-swisseph/LICENSE @@ -0,0 +1,19 @@ +Ten komponent (services/engine-swisseph) jest licencjonowany na warunkach +GNU AFFERO GENERAL PUBLIC LICENSE wersja 3 (AGPL-3.0-or-later). + +Powód: linkuje bibliotekę pyswisseph / Swiss Ephemeris (Astrodienst AG), która +jest udostępniana na zasadzie podwójnego licencjonowania: AGPL-3.0 ALBO płatna +licencja komercyjna. Wybierając wariant AGPL, ten komponent również jest AGPL. + +WAŻNE — IZOLACJA: ten komponent jest celowo wydzielony jako osobny proces/usługa +i komunikuje się z resztą systemu wyłącznie przez HTTP. Pozostałe komponenty +projektu (warstwa prezentacji, warstwa logiczna z silnikiem własnym, warstwa +danych) NIE są dziełem pochodnym tego komponentu ani Swiss Ephemeris i pozostają +na licencji permisywnej. Ten komponent NIE wchodzi do dystrybucji zamkniętego +produktu — służy jako wyrocznia walidacyjna / tryb porównawczy (dev/CI). + +Pełny tekst licencji AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.txt + +Alternatywa: zamiast wariantu AGPL można nabyć komercyjną licencję Swiss +Ephemeris od Astrodienst AG — wówczas warunki tego komponentu należy dostosować +do tej licencji. diff --git a/services/engine-swisseph/README.md b/services/engine-swisseph/README.md new file mode 100644 index 0000000..5101d80 --- /dev/null +++ b/services/engine-swisseph/README.md @@ -0,0 +1,24 @@ +# engine-swisseph (silnik B — AGPL, izolowany) + +Osobna, **opcjonalna** usługa będąca drugim silnikiem efemeryd (LOG‑27). Liczy +pozycje przez **pyswisseph / Swiss Ephemeris** i służy jako **wyrocznia +walidacyjna / tryb porównawczy** (LOG‑25/26) dla naszego silnika własnego. + +## ⚠️ Licencja +Ten komponent jest **AGPL‑3.0** (bo linkuje Swiss Ephemeris) — patrz [LICENSE](LICENSE). +Jest **wydzielony jako osobny proces** i wołany przez HTTP, więc nie „zaraża" +permisywnej reszty systemu. **Nie wchodzi do dystrybucji zamkniętego produktu.** + +## API +- `POST /positions` → `{when_utc, lat, lon, objects?}` → pozycje (ten sam kształt co silnik własny) +- `GET /health` + +Tryb Moshiera (`FLG_MOSEPH`) — bez plików efemeryd, zero konfiguracji. + +## Uruchomienie (tylko profil porównawczy / dev / CI) +```bash +pip install -r requirements.txt +uvicorn app.main:app --port 8003 +``` +Następnie w warstwie logicznej ustaw `ENGINE_SWISSEPH_URL=http://localhost:8003`, +aby włączyć silnik B (tryb dual-run i testy kontraktowe silnika B). diff --git a/services/engine-swisseph/app/__init__.py b/services/engine-swisseph/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/engine-swisseph/app/main.py b/services/engine-swisseph/app/main.py new file mode 100644 index 0000000..34fa73c --- /dev/null +++ b/services/engine-swisseph/app/main.py @@ -0,0 +1,65 @@ +"""engine-swisseph — IZOLOWANA usługa silnika B (AGPL). + +UWAGA LICENCYJNA: ta usługa linkuje pyswisseph / Swiss Ephemeris, więc jest +objęta **AGPL-3.0** i jest licencjonowana osobno (patrz ./LICENSE). Jest celowo +wydzielona jako osobny proces i wołana przez HTTP — dzięki temu permisywny +produkt (prezentacja + logika z silnikiem własnym + dane) NIE jest linkowany z +kodem AGPL i nie podlega jego obowiązkom (LOG-27). + +Rola: wyrocznia walidacyjna / tryb porównawczy (LOG-25/26). Nie wchodzi do +dystrybucji zamkniętej — uruchamiana tylko w profilu porównawczym/dev/CI. + +Udostępnia ten sam kontrakt co RemoteEngine po stronie warstwy logicznej. +""" +from __future__ import annotations + +from datetime import datetime + +from fastapi import FastAPI +from pydantic import BaseModel + +import swisseph as swe + +app = FastAPI(title="astrololo · engine-swisseph (AGPL, izolowany)") + +# Tryb Moshiera: bez plików efemeryd, w pełni samowystarczalny (~0,1\" dokładności). +_FLAGS = swe.FLG_MOSEPH | swe.FLG_SPEED + +_PLANETS = { + "Sun": swe.SUN, "Moon": swe.MOON, "Mercury": swe.MERCURY, "Venus": swe.VENUS, + "Mars": swe.MARS, "Jupiter": swe.JUPITER, "Saturn": swe.SATURN, + "Uranus": swe.URANUS, "Neptune": swe.NEPTUNE, "Pluto": swe.PLUTO, +} +DEFAULT_OBJECTS = list(_PLANETS) + + +class PositionsRequest(BaseModel): + when_utc: datetime + lat: float = 0.0 + lon: float = 0.0 + objects: list[str] | None = None + + +@app.post("/positions") +def positions(req: PositionsRequest) -> dict: + d = req.when_utc + ut_hours = d.hour + d.minute / 60.0 + d.second / 3600.0 + jd = swe.julday(d.year, d.month, d.day, ut_hours) # czas uniwersalny + + rows = [] + for name in (req.objects or DEFAULT_OBJECTS): + xx, _retflag = swe.calc_ut(jd, _PLANETS[name], _FLAGS) + lon, lat, _dist, lon_speed = xx[0], xx[1], xx[2], xx[3] + rows.append({ + "name": name, + "longitude": lon % 360.0, + "latitude": lat, + "speed": lon_speed, + "retrograde": lon_speed < 0, + }) + return {"engine": "swisseph", "positions": rows} + + +@app.get("/health") +def health() -> dict: + return {"engine": "swisseph", "status": "ok", "mode": "moshier", "license": "AGPL-3.0"} diff --git a/services/engine-swisseph/requirements.txt b/services/engine-swisseph/requirements.txt new file mode 100644 index 0000000..0b57c0a --- /dev/null +++ b/services/engine-swisseph/requirements.txt @@ -0,0 +1,6 @@ +# UWAGA: pyswisseph (Swiss Ephemeris) jest na licencji AGPL-3.0 — dlatego ta +# usługa jest wydzielona i licencjonowana osobno (patrz LICENSE). Nie instaluj +# tego w obrazie permisywnego produktu. +fastapi>=0.115 +uvicorn[standard]>=0.34 +pyswisseph>=2.10 diff --git a/services/logic/README.md b/services/logic/README.md index 08da50e..cf2b053 100644 --- a/services/logic/README.md +++ b/services/logic/README.md @@ -6,8 +6,26 @@ i nie w bazie. ## API - `POST /api/query` → `QueryRequest` → `QueryResponse` +- `POST /chart/positions` → `{when_utc, lat, lon, objects?}` → pozycje obiektów (LOG-01) +- `POST /chart/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B) - `GET /health` (sprawdza też warstwę bazodanową) +## Silnik efemeryd (LOG-24, „wymienny silnik liczący") +W `app/engine/` żyje pluggable silnik za interfejsem `EphemerisEngine`: +- **`SkyfieldEngine`** — własny, permisywny (Skyfield MIT + dane JPL public domain). Domyślny. +- **`RemoteEngine`** — klient OSOBNEJ, izolowanej usługi `engine-swisseph` (AGPL), używany tylko w trybie porównawczym. + +Wybór: `EPHEMERIS_ENGINE=own|swisseph`. Silnik B włącza się przez `ENGINE_SWISSEPH_URL`. + +Walidacja (LOG-25/28): `app/engine/compare.py` zestawia oba silniki z progiem tolerancji; +ten sam kontrakt parzystości obowiązuje każdy silnik. Nasz `SkyfieldEngine` zgadza się +ze Swiss Ephemeris **co do ~1″** na horoskopie referencyjnym (patrz `tests/`). + +```bash +pip install -r requirements-dev.txt +PYTHONPATH=. pytest tests -q # silnik B pomijany, jeśli ENGINE_SWISSEPH_URL nieustawiony +``` + ## Zależności w dół Zna wyłącznie `DATA_URL` (adres warstwy bazodanowej) i jej kontrakt `/search`. Nie wie, czy pod spodem jest Excel czy SQL. diff --git a/services/logic/app/engine/__init__.py b/services/logic/app/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/logic/app/engine/base.py b/services/logic/app/engine/base.py new file mode 100644 index 0000000..d640641 --- /dev/null +++ b/services/logic/app/engine/base.py @@ -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"} diff --git a/services/logic/app/engine/compare.py b/services/logic/app/engine/compare.py new file mode 100644 index 0000000..cc83fdb --- /dev/null +++ b/services/logic/app/engine/compare.py @@ -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" diff --git a/services/logic/app/engine/factory.py b/services/logic/app/engine/factory.py new file mode 100644 index 0000000..76b658b --- /dev/null +++ b/services/logic/app/engine/factory.py @@ -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 diff --git a/services/logic/app/engine/formats.py b/services/logic/app/engine/formats.py new file mode 100644 index 0000000..c8fe44d --- /dev/null +++ b/services/logic/app/engine/formats.py @@ -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) diff --git a/services/logic/app/engine/models.py b/services/logic/app/engine/models.py new file mode 100644 index 0000000..b11a1ac --- /dev/null +++ b/services/logic/app/engine/models.py @@ -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, + } diff --git a/services/logic/app/engine/remote_engine.py b/services/logic/app/engine/remote_engine.py new file mode 100644 index 0000000..da66626 --- /dev/null +++ b/services/logic/app/engine/remote_engine.py @@ -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)} diff --git a/services/logic/app/engine/skyfield_engine.py b/services/logic/app/engine/skyfield_engine.py new file mode 100644 index 0000000..ee5c352 --- /dev/null +++ b/services/logic/app/engine/skyfield_engine.py @@ -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} diff --git a/services/logic/app/main.py b/services/logic/app/main.py index 558531a..ecd43c9 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -6,8 +6,11 @@ Nie serwuje HTML, nie czyta plików/baz — tylko reguły i pośrednictwo. """ from __future__ import annotations +from datetime import datetime + import httpx from fastapi import FastAPI, HTTPException +from pydantic import BaseModel from app.clients.data_client import DataClient from app.models import QueryRequest, QueryResponse @@ -16,6 +19,25 @@ from app.service import QueryService app = FastAPI(title="astrololo · warstwa logiczna") service = QueryService() +# --- silnik efemeryd (LOG-24): budowany leniwie, by nie wymagać Skyfielda do startu --- +_engine = None + + +def get_engine(): + global _engine + if _engine is None: + from app.engine.factory import build_engine + + _engine = build_engine() + return _engine + + +class PositionsRequest(BaseModel): + when_utc: datetime # moment w UTC (świadomy strefy) + lat: float = 0.0 + lon: float = 0.0 + objects: list[str] | None = None + @app.post("/api/query", response_model=QueryResponse) def query(req: QueryRequest) -> QueryResponse: @@ -25,6 +47,36 @@ def query(req: QueryRequest) -> QueryResponse: raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}") +@app.post("/chart/positions") +def chart_positions(req: PositionsRequest) -> dict: + """Pozycje obiektów dla danego momentu (LOG-01), liczone aktywnym silnikiem.""" + from app.engine.models import ChartMoment + + engine = get_engine() + moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon) + positions = engine.positions(moment, req.objects) + return {"engine": engine.name, "positions": [p.as_dict() for p in positions]} + + +@app.post("/chart/compare") +def chart_compare(req: PositionsRequest) -> dict: + """Tryb dwu-silnikowy (LOG-26): policz oboma silnikami i zwróć raport różnic. + + Wymaga skonfigurowanego ENGINE_SWISSEPH_URL (silnik B). W przeciwnym razie + zwraca informację, że porównanie jest niedostępne. + """ + from app.engine.compare import compare_engines + from app.engine.factory import build_engine + from app.engine.models import ChartMoment + + moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon) + try: + report = compare_engines(build_engine("own"), build_engine("swisseph"), moment) + except (RuntimeError, httpx.HTTPError) as e: + raise HTTPException(status_code=503, detail=f"Silnik B niedostępny: {e}") + return report.summary() + + @app.get("/health") def health() -> dict: info = {"status": "ok", "layer": "logic"} diff --git a/services/logic/requirements-dev.txt b/services/logic/requirements-dev.txt new file mode 100644 index 0000000..a266747 --- /dev/null +++ b/services/logic/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest>=8.0 diff --git a/services/logic/requirements.txt b/services/logic/requirements.txt index 4841cbd..4eadce8 100644 --- a/services/logic/requirements.txt +++ b/services/logic/requirements.txt @@ -2,3 +2,5 @@ fastapi>=0.115 uvicorn[standard]>=0.34 httpx>=0.28 pydantic>=2.10 +# Silnik własny (ścieżka A, permisywny): Skyfield (MIT) + dane JPL (public domain) +skyfield>=1.49 diff --git a/services/logic/tests/conftest.py b/services/logic/tests/conftest.py new file mode 100644 index 0000000..627c59b --- /dev/null +++ b/services/logic/tests/conftest.py @@ -0,0 +1,31 @@ +"""Wspólne fixture'y testów silnika. + +Horoskop referencyjny = przykład z notatek projektu (notes3): 30.04.1984, +09:35 CEST = 07:35 UTC, Warszawa. Pozycje znane z astro.com/Swiss Ephemeris +służą jako wyrocznia (LOG-25). +""" +from __future__ import annotations + +import datetime as dt + +import pytest + +from app.engine.models import ChartMoment + +REFERENCE_UTC = dt.datetime(1984, 4, 30, 7, 35, 0, tzinfo=dt.timezone.utc) + + +@pytest.fixture(scope="session") +def reference_moment() -> ChartMoment: + return ChartMoment(when_utc=REFERENCE_UTC, lat=52.2333, lon=21.0167) + + +@pytest.fixture(scope="session") +def own_engine(): + pytest.importorskip("skyfield") + from app.engine.skyfield_engine import SkyfieldEngine + + try: + return SkyfieldEngine() + except Exception as e: # brak efemeryd / brak sieci + pytest.skip(f"Nie można wczytać efemeryd: {e}") diff --git a/services/logic/tests/test_compare.py b/services/logic/tests/test_compare.py new file mode 100644 index 0000000..3a34208 --- /dev/null +++ b/services/logic/tests/test_compare.py @@ -0,0 +1,35 @@ +"""Testy harnessu porównawczego (LOG-25) — bez efemeryd.""" +from app.engine.compare import compare_positions +from app.engine.models import ObjectPosition + + +def test_identical_positions_have_zero_diff(): + a = [ObjectPosition("Sun", 40.0, 0.0, 0.95, False)] + report = compare_positions(a, a, lon_tol_arcsec=1.0) + assert report.ok + assert report.max_arcsec == 0.0 + + +def test_offset_beyond_tolerance_is_flagged(): + a = [ObjectPosition("Sun", 40.0, 0.0, 0.95, False)] + b = [ObjectPosition("Sun", 40.1, 0.0, 0.95, False)] # 0.1° = 360" + report = compare_positions(a, b, lon_tol_arcsec=120.0) + assert not report.ok + assert report.diffs[0].over_tolerance + assert abs(abs(report.diffs[0].delta_arcsec) - 360.0) < 1.0 + + +def test_retrograde_mismatch_is_flagged(): + a = [ObjectPosition("Mars", 234.0, 0.0, -0.2, True)] + b = [ObjectPosition("Mars", 234.0, 0.0, 0.2, False)] + report = compare_positions(a, b, lon_tol_arcsec=120.0) + assert not report.ok + assert report.diffs[0].speed_sign_mismatch + + +def test_wraparound_delta_is_small(): + a = [ObjectPosition("Sun", 359.99, 0.0, 1.0, False)] + b = [ObjectPosition("Sun", 0.01, 0.0, 1.0, False)] + report = compare_positions(a, b, lon_tol_arcsec=120.0) + assert report.ok # 0.02° ≈ 72" < 120" + assert abs(report.diffs[0].delta_arcsec) < 80.0 diff --git a/services/logic/tests/test_engine_contract.py b/services/logic/tests/test_engine_contract.py new file mode 100644 index 0000000..e9e62ef --- /dev/null +++ b/services/logic/tests/test_engine_contract.py @@ -0,0 +1,25 @@ +"""Kontrakt parzystości silników (LOG-28). + +Ten sam test musi przejść dla KAŻDEGO silnika. Dziś uruchamiamy go dla silnika +własnego; gdy skonfigurowany jest ENGINE_SWISSEPH_URL, ten sam kontrakt sprawdza +też silnik B. +""" +import os + +import pytest + +from app.engine.compare import check_engine_contract + + +def test_own_engine_contract(own_engine, reference_moment): + check_engine_contract(own_engine, reference_moment) + + +@pytest.mark.skipif( + not os.getenv("ENGINE_SWISSEPH_URL"), + reason="silnik B (swisseph) nieskonfigurowany — pomijam", +) +def test_swisseph_engine_contract(reference_moment): + from app.engine.remote_engine import RemoteEngine + + check_engine_contract(RemoteEngine(), reference_moment) diff --git a/services/logic/tests/test_formats.py b/services/logic/tests/test_formats.py new file mode 100644 index 0000000..0d232a9 --- /dev/null +++ b/services/logic/tests/test_formats.py @@ -0,0 +1,28 @@ +"""Testy formatowania (czyste, bez efemeryd).""" +from app.engine import formats + + +def test_sign_index(): + assert formats.sign_index(0) == 0 # Aries + assert formats.sign_index(35) == 1 # Taurus + assert formats.sign_index(359.9) == 11 # Pisces + + +def test_in_sign_basic(): + # 40°08'21" absolutnie = Taurus 10°08'21" + assert formats.in_sign(40.139166) == "Tau 10°08'21\"" + + +def test_absolute(): + assert formats.absolute(40.139166) == "40°08'21\"" + + +def test_wraparound_and_norm(): + assert formats.norm360(370.0) == 10.0 + assert formats.norm360(-1.0) == 359.0 + + +def test_rounding_carry_into_next_sign(): + # tuż przy granicy znaku zaokrąglenie nie może dać "30°" + s = formats.in_sign(59.99999) + assert s.startswith("Gem 0°") or s.startswith("Tau 29°59") diff --git a/services/logic/tests/test_skyfield_reference.py b/services/logic/tests/test_skyfield_reference.py new file mode 100644 index 0000000..cf31697 --- /dev/null +++ b/services/logic/tests/test_skyfield_reference.py @@ -0,0 +1,44 @@ +"""Walidacja silnika własnego względem wyroczni (LOG-01 + LOG-25). + +Porównujemy pozycje policzone Skyfieldem ze znanymi wartościami Swiss Ephemeris +(astro.com) dla horoskopu referencyjnego. To dokładnie mechanizm LOG-25 — tyle +że wyrocznią są tu zapisane wcześniej wartości referencyjne zamiast żywej usługi +swisseph. Tolerancja 2' (120") z zapasem na różnicę barycentrum/centrum planety. +""" +from app.engine.compare import compare_positions +from app.engine.models import ObjectPosition + +# obiekt -> (długość absolutna w stopniach, retrogradacja) — Swiss Ephemeris / astro.com +REFERENCE = { + "Sun": (40.139167, False), # Tau 10°08'21" + "Moon": (30.551389, False), # Tau 0°33'05" + "Mercury": (27.383889, True), # Ari 27°23'02" Rx + "Venus": (27.676944, False), # Ari 27°40'37" + "Mars": (234.541111, True), # Sco 24°32'28" Rx + "Jupiter": (282.961667, True), # Cap 12°57'42" Rx + "Saturn": (223.310833, True), # Sco 13°18'39" Rx + "Uranus": (252.813333, True), # Sag 12°48'48" Rx + "Neptune": (271.220833, True), # Cap 1°13'15" Rx + "Pluto": (210.478889, True), # Sco 0°28'44" Rx +} + +TOLERANCE_ARCSEC = 120.0 + + +def _reference_positions(): + return [ + ObjectPosition(name, lon, 0.0, (-1.0 if retro else 1.0), retro) + for name, (lon, retro) in REFERENCE.items() + ] + + +def test_positions_match_swisseph_reference(own_engine, reference_moment): + got = own_engine.positions(reference_moment) + report = compare_positions(got, _reference_positions(), lon_tol_arcsec=TOLERANCE_ARCSEC) + assert report.ok, report.summary() + + +def test_retrograde_flags_match(own_engine, reference_moment): + got = {p.name: p for p in own_engine.positions(reference_moment)} + for name, (_lon, retro) in REFERENCE.items(): + assert got[name].retrograde == retro, f"{name}: kierunek niezgodny"