mirror of
https://github.com/migatu/astrololo.git
synced 2026-07-14 21:38:37 +00:00
Merge pull request #5 from migatu/feat/logic-engine
Silnik efemeryd: EngineProvider + SkyfieldEngine + harness porównawczy (LOG-24/01/25/27/28)
This commit is contained in:
@@ -10,6 +10,10 @@ services/data/.cache/
|
|||||||
*.parquet
|
*.parquet
|
||||||
*.db
|
*.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)
|
# Dane wejściowe (duże pliki Excela trzymane poza repo)
|
||||||
services/data/data_files/*.xlsx
|
services/data/data_files/*.xlsx
|
||||||
!services/data/data_files/.gitkeep
|
!services/data/data_files/.gitkeep
|
||||||
|
|||||||
@@ -16,11 +16,23 @@ services:
|
|||||||
build: ./services/logic
|
build: ./services/logic
|
||||||
environment:
|
environment:
|
||||||
DATA_URL: http://data:8002
|
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:
|
depends_on:
|
||||||
- data
|
- data
|
||||||
ports:
|
ports:
|
||||||
- "8001:8001"
|
- "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:
|
presentation:
|
||||||
build: ./services/presentation
|
build: ./services/presentation
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
@@ -6,8 +6,26 @@ i nie w bazie.
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
- `POST /api/query` → `QueryRequest` → `QueryResponse`
|
- `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ą)
|
- `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ół
|
## Zależności w dół
|
||||||
Zna wyłącznie `DATA_URL` (adres warstwy bazodanowej) i jej kontrakt `/search`.
|
Zna wyłącznie `DATA_URL` (adres warstwy bazodanowej) i jej kontrakt `/search`.
|
||||||
Nie wie, czy pod spodem jest Excel czy SQL.
|
Nie wie, czy pod spodem jest Excel czy SQL.
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -6,8 +6,11 @@ Nie serwuje HTML, nie czyta plików/baz — tylko reguły i pośrednictwo.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.clients.data_client import DataClient
|
from app.clients.data_client import DataClient
|
||||||
from app.models import QueryRequest, QueryResponse
|
from app.models import QueryRequest, QueryResponse
|
||||||
@@ -16,6 +19,25 @@ from app.service import QueryService
|
|||||||
app = FastAPI(title="astrololo · warstwa logiczna")
|
app = FastAPI(title="astrololo · warstwa logiczna")
|
||||||
service = QueryService()
|
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)
|
@app.post("/api/query", response_model=QueryResponse)
|
||||||
def query(req: QueryRequest) -> 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}")
|
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")
|
@app.get("/health")
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
info = {"status": "ok", "layer": "logic"}
|
info = {"status": "ok", "layer": "logic"}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest>=8.0
|
||||||
@@ -2,3 +2,5 @@ fastapi>=0.115
|
|||||||
uvicorn[standard]>=0.34
|
uvicorn[standard]>=0.34
|
||||||
httpx>=0.28
|
httpx>=0.28
|
||||||
pydantic>=2.10
|
pydantic>=2.10
|
||||||
|
# Silnik własny (ścieżka A, permisywny): Skyfield (MIT) + dane JPL (public domain)
|
||||||
|
skyfield>=1.49
|
||||||
|
|||||||
@@ -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}")
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user