Silnik: osie (Asc/MC) i systemy domów (LOG-05)

Kontynuacja silnika efemeryd o osie i domy.

- engine/houses.py: czysta matematyka sferyczna — Asc, MC (z RAMC + ε + φ),
  cusps dla Whole Sign / Equal / Porphyry, przypisanie obiektu do domu.
- SkyfieldEngine.sidereal(): RAMC (lokalny apparent ST) + średnie nachylenie
  ekliptyki ze Skyfielda.
- engine/chart.py: build_chart() składa pełny horoskop (pozycje + osie + domy).
- Endpoint /chart/positions rozszerzony o house_system i zwraca angles + cusps
  + numer domu per obiekt.
- Prezentacja: lokalizacja i wybór systemu domów w formularzu, tabela osi,
  kolumna Dom, rozwijane cusps.

Walidacja względem astro.com (30.04.1984, Warszawa): Asc Can 22°10'43",
MC Pis 22°35'29" (~1' od referencji); wszystkie przypisania domów Whole Sign
zgodne (Sun 11, Mercury 10, Mars 5, ...). 20 testów przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 14:06:13 +02:00
parent e4f6a16c9c
commit 94c3023d3a
10 changed files with 280 additions and 24 deletions
+50
View File
@@ -0,0 +1,50 @@
"""Złożenie pełnego horoskopu: pozycje + osie + domy (LOG-01 + LOG-05).
Silnik-agnostyczne: potrzebuje tylko `positions()` oraz (dla osi/domów)
`sidereal()`. Jeśli silnik nie umie policzyć czasu gwiazdowego, zwraca same
pozycje.
"""
from __future__ import annotations
from app.engine import houses as H
from app.engine.base import EphemerisEngine
from app.engine.formats import SIGNS, in_sign, norm360, sign_index
from app.engine.models import ChartMoment
def _fmt(name: str, lon: float) -> dict:
return {
"name": name,
"sign": SIGNS[sign_index(lon)],
"in_sign": in_sign(lon),
"decimal": round(norm360(lon), 6),
}
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN) -> dict:
positions = engine.positions(moment)
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
if not hasattr(engine, "sidereal"):
return result
ramc, eps = engine.sidereal(moment)
asc = H.compute_asc(ramc, eps, moment.lat)
mc = H.compute_mc(ramc, eps)
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
cusp_list = H.cusps(asc, mc, system)
result["house_system"] = system
result["angles"] = {
"Asc": _fmt("Asc", asc),
"MC": _fmt("MC", mc),
"Dsc": _fmt("Dsc", norm360(asc + 180.0)),
"IC": _fmt("IC", norm360(mc + 180.0)),
}
result["cusps"] = [
{"house": i + 1, "sign": SIGNS[sign_index(c)], "in_sign": in_sign(c)}
for i, c in enumerate(cusp_list)
]
for pdict, obj in zip(result["positions"], positions):
pdict["house"] = H.assign_house(obj.longitude, cusp_list)
return result
+77
View File
@@ -0,0 +1,77 @@
"""Osie i domy — czysta matematyka sferyczna (LOG-05).
Bezstanowe funkcje: z lokalnego czasu gwiazdowego (RAMC), nachylenia ekliptyki
(ε) i szerokości geograficznej (φ) wyliczają Ascendent i MC, a stąd cusps domów
dla prostych systemów (Whole Sign, Equal, Porphyry). Niezależne od silnika —
silnik dostarcza tylko RAMC i ε.
"""
from __future__ import annotations
import math
from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401
WHOLE_SIGN = "whole_sign"
EQUAL = "equal"
PORPHYRY = "porphyry"
SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY)
def mean_obliquity(tt_jd: float) -> float:
"""Średnie nachylenie ekliptyki [°] dla daty (Julian TT). Wystarcza do domów."""
t = (tt_jd - 2451545.0) / 36525.0
arcsec = 84381.448 - 46.8150 * t - 0.00059 * t * t + 0.001813 * t ** 3
return arcsec / 3600.0
def compute_mc(ramc_deg: float, eps_deg: float) -> float:
r, e = math.radians(ramc_deg), math.radians(eps_deg)
mc = math.atan2(math.sin(r), math.cos(r) * math.cos(e))
return norm360(math.degrees(mc))
def compute_asc(ramc_deg: float, eps_deg: float, lat_deg: float) -> float:
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(lat_deg)
asc = math.atan2(
math.cos(r),
-(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)),
)
return norm360(math.degrees(asc))
def _trisect(a: float, b: float) -> tuple[float, float]:
"""Dwa punkty dzielące łuk a→b (w kierunku zodiaku) na trzy równe części."""
span = (b - a) % 360.0
return norm360(a + span / 3.0), norm360(a + 2.0 * span / 3.0)
def cusps(asc: float, mc: float, system: str) -> list[float]:
"""Zwraca 12 cusps (długości) domów 1..12."""
if system == WHOLE_SIGN:
start = sign_index(asc) * 30.0
return [norm360(start + 30.0 * i) for i in range(12)]
if system == EQUAL:
return [norm360(asc + 30.0 * i) for i in range(12)]
if system == PORPHYRY:
dsc, ic = norm360(asc + 180.0), norm360(mc + 180.0)
c = [0.0] * 12
c[0], c[3], c[6], c[9] = asc, ic, dsc, mc
c[1], c[2] = _trisect(asc, ic) # domy 2,3
c[4], c[5] = _trisect(ic, dsc) # domy 5,6
c[7], c[8] = _trisect(dsc, mc) # domy 8,9
c[10], c[11] = _trisect(mc, asc) # domy 11,12
return c
raise ValueError(f"nieznany system domów: {system}")
def assign_house(lon: float, cusp_list: list[float]) -> int:
"""Numer domu (1..12), w którym leży dana długość ekliptyczna."""
lon = norm360(lon)
for i in range(12):
start = cusp_list[i]
end = cusp_list[(i + 1) % 12]
span = (end - start) % 360.0
offset = (lon - start) % 360.0
if offset < span:
return i + 1
return 12
@@ -88,5 +88,17 @@ class SkyfieldEngine(EphemerisEngine):
)
return out
def sidereal(self, moment: ChartMoment) -> tuple[float, float]:
"""(RAMC, ε) w stopniach — lokalny apparent sidereal time i nachylenie ekliptyki.
Materiał wejściowy do osi i domów (LOG-05). RAMC = GAST·15 + długość geo.
"""
from app.engine.houses import mean_obliquity
t = self.ts.from_datetime(moment.when_utc)
ramc = norm360(t.gast * 15.0 + moment.lon)
eps = mean_obliquity(t.tt)
return ramc, eps
def health(self) -> dict:
return {"engine": self.name, "status": "ok", "kernel": self.kernel}