Files
astrololo/services/logic/app/main.py
T
gitea 2dbd3410e9 Zbiorcza tabela dat z technik (LOG-14)
engine/timeline.py: scala w jedną posortowaną oś czasu (technique | significator |
start | exact | end):
- profekcje roczne (LOG-10) — Władca Roku per rok życia,
- Solar Return (LOG-12) — moment powrotu Słońca,
- dyrekcje solar-arc — daty dokładnych aspektów kierowanych planet do punktów
  natalnych (klucz Naiboda 0°59'08"/rok, konfigurowalny; okno orbowe ±1 rok).
Endpoint /chart/timeline (zakres from_date..to_date, wybór technik).

Walidacja:
- profekcje spójne z tabelą notes3; dyrekcja Sun koniunkcja MC (łuk 312°) słusznie
  poza życiem; oś posortowana po dacie dokładnej; wiek = łuk/klucz spójny z datą.
- E2E: dla 2025-2026 zwraca 19 zdarzeń (m.in. Władca Roku 42 Saturn 30.04.2026,
  Solar Return, dyr. Venus koniunkcja North Node 27.05.2025). 66 testów przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:33:15 +02:00

211 lines
7.5 KiB
Python

"""Warstwa LOGICZNA — usługa HTTP.
W górę: udostępnia API dla warstwy prezentacji.
W dół: woła warstwę bazodanową (DataClient).
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
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
house_system: str = "whole_sign" # whole_sign | equal | porphyry
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
@app.post("/api/query", response_model=QueryResponse)
def query(req: QueryRequest) -> QueryResponse:
try:
return service.handle(req)
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
@app.post("/chart/positions")
def chart_positions(req: PositionsRequest) -> dict:
"""Pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty (LOG-06);
opcjonalnie stacje planet (LOG-03, stations=true)."""
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
engine = get_engine()
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
chart = build_chart(engine, moment, req.house_system)
if req.stations:
from app.engine.stations import find_stations
for p in chart["positions"]:
st = find_stations(engine, moment, p["name"])
if st:
p["stations"] = st
return chart
@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()
class ReportRequest(BaseModel):
when_utc: datetime
lat: float = 0.0
lon: float = 0.0
limit: int = 5000
group: bool = False # grupowanie identycznych opisów
@app.post("/chart/report")
def chart_report(req: ReportRequest) -> dict:
"""Wynik obliczeń szukany w bazie: z pozycji + domów + aspektów generuje
sygnifikatory (fasety znak/dom/aspekt) i pyta warstwę danych o interpretacje."""
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
from app.significators import build_report
engine = get_engine()
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
chart = build_chart(engine, moment) # pozycje z domami + aspekty
try:
report = build_report(
chart["positions"], DataClient(),
aspects=chart.get("aspects"), per_object_limit=req.limit, group=req.group,
)
except httpx.HTTPError as e:
return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"}
return {"engine": engine.name, **report}
class ProfectionsRequest(BaseModel):
when_utc: datetime # moment urodzenia (UTC)
lat: float = 0.0
lon: float = 0.0
start_age: int = 0
count: int = 13 # domyślnie pełny cykl 12 lat + rok startowy
@app.post("/chart/profections")
def chart_profections(req: ProfectionsRequest) -> dict:
"""Profekcje roczne (LOG-10): wiek, profektowany Asc, Władca Roku (+MC/Su/Mo)."""
from app.engine import houses as H
from app.engine.models import ChartMoment
from app.engine.profections import profection_rows
engine = get_engine()
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
ramc, eps = engine.sidereal(natal)
points = {
"Asc": H.compute_asc(ramc, eps, natal.lat),
"MC": H.compute_mc(ramc, eps),
}
for p in engine.positions(natal, ["Sun", "Moon"]):
points[p.name] = p.longitude
rows = profection_rows(points, req.when_utc, req.start_age, req.count)
return {"engine": engine.name, "rows": rows}
class ReturnRequest(BaseModel):
when_utc: datetime # moment urodzenia (UTC)
lat: float = 0.0
lon: float = 0.0
kind: str = "solar" # solar | lunar
around: datetime | None = None # data, wokół której szukać powrotu
@app.post("/chart/return")
def chart_return(req: ReturnRequest) -> dict:
"""Solar/Lunar Return (LOG-12): moment powrotu + pełny horoskop na ten moment."""
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
from app.engine.returns import find_return
if req.kind not in ("solar", "lunar"):
raise HTTPException(status_code=422, detail="kind: solar albo lunar")
engine = get_engine()
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
around = req.around or req.when_utc
hit = find_return(engine, req.kind, natal, around)
if hit is None:
raise HTTPException(status_code=404, detail="nie znaleziono powrotu w oknie skanu")
chart = build_chart(engine, ChartMoment(when_utc=hit, lat=req.lat, lon=req.lon))
return {"engine": engine.name, "kind": req.kind,
"return_utc": hit.isoformat(), **chart}
class TimelineRequest(BaseModel):
when_utc: datetime # moment urodzenia (UTC)
lat: float = 0.0
lon: float = 0.0
from_date: str # zakres: YYYY-MM-DD
to_date: str
techniques: list[str] | None = None # profection | solar_return | solar_arc
@app.post("/chart/timeline")
def chart_timeline(req: TimelineRequest) -> dict:
"""Zbiorcza oś czasu z technik (LOG-14): technique | significator | start | exact | end."""
from app.engine import houses as H
from app.engine.models import ChartMoment
from app.engine.timeline import build_timeline
engine = get_engine()
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
ramc, eps = engine.sidereal(natal)
points = {"Asc": H.compute_asc(ramc, eps, natal.lat), "MC": H.compute_mc(ramc, eps)}
for p in engine.positions(natal):
points[p.name] = p.longitude
events = build_timeline(engine, natal, points, req.from_date, req.to_date, req.techniques)
return {"engine": engine.name, "from": req.from_date, "to": req.to_date,
"count": len(events), "events": events}
@app.get("/health")
def health() -> dict:
info = {"status": "ok", "layer": "logic"}
try:
info["data_layer"] = DataClient().health()
except httpx.HTTPError as e:
info["data_layer"] = {"status": "down", "error": str(e)}
return info