4d1daab35a
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m34s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 18s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 10s
build / build (push) Successful in 26s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m37s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m27s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 17s
Testy / Kontrola składni wszystkich warstw (push) Successful in 10s
Ostatnie trzy pozycje z treści LOG-05, których wcześniej nie było: - whole_sign_aries — znaki jako domy, ale dom I ZAWSZE na 0° Barana, niezależnie od Ascendentu (tradycja indyjska i część szkół hellenistycznych), - equal_mc — równe domy zakotwiczone na MC: dom X zaczyna się dokładnie na południku, a nie ma go gdzieś w środku, - obrót kosmogramu: Ascendent albo 0° Barana po lewej stronie koła. Zmienia WYŁĄCZNIE rysunek, żadna liczba nie jest przeliczana. Wariant „od Barana" unieruchamia koło względem zodiaku, więc dwa horoskopy da się porównać na oko. Oba nowe systemy zgodne z wyrocznią co do zera od pierwszego uruchomienia. Razem 13 systemów: 2 833 424 porównania w przemiale, zero przekroczeń. PRZY OKAZJI — DWA BŁĘDY, KTÓRE SAM WPROWADZIŁEM I KTÓRYCH TESTY NIE WIDZIAŁY: - linia zbierająca ostrzeżenia o fallbacku trafiła do handlera strony głównej zamiast do compile_pdf: odwoływała się do nieistniejącej zmiennej, czyli 500 na stronie głównej, a do PDF-a ostrzeżenia nie docierały wcale, - compile_build używał parametru formularza, którego nie miał w sygnaturze. Oba przeszły przez komplet zielonych testów, bo żaden nie wywoływał POST-a — testy prezentacji sprawdzały teksty w szablonach i w main.py. Doszły więc testy uderzające w prawdziwe trasy (POST / i POST /compile ze stubowaną logiką), które łapią tę klasę błędu. Z tego samego powodu przepisane dwa testy PDF-a: greppowały z main.py dokładny kształt wywołania render(chart, theme="print") i pękały przy dopisaniu argumentu, mimo poprawnego zachowania. Teraz wołają trasę i sprawdzają, że KAŻDY z czterech rysunków dostaje motyw druku. LOG-05 i PRE-05 → Zrobione w docs/astrololo_wymagania.xlsx. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
548 lines
26 KiB
Python
548 lines
26 KiB
Python
"""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 dataclasses import dataclass
|
||
|
||
from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401
|
||
|
||
WHOLE_SIGN = "whole_sign"
|
||
EQUAL = "equal"
|
||
EQUAL_MC = "equal_mc" # równe domy zakotwiczone na MC, nie na Asc
|
||
WHOLE_SIGN_ARIES = "whole_sign_aries" # znaki jako domy, ale dom I to ZAWSZE Baran
|
||
PORPHYRY = "porphyry"
|
||
# Systemy o ZAMKNIĘTYM wzorze (bez iteracji). Placidus i Koch wymagają rozwiązania
|
||
# iteracyjnego i dochodzą osobno.
|
||
VEHLOW = "vehlow"
|
||
MORINUS = "morinus"
|
||
REGIOMONTANUS = "regiomontanus"
|
||
CAMPANUS = "campanus"
|
||
ALCABITUS = "alcabitus"
|
||
TOPOCENTRIC = "topocentric"
|
||
PLACIDUS = "placidus"
|
||
KOCH = "koch"
|
||
# Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris
|
||
# (tests/oracle). Placidus i Koch jako jedyne mają granicę dziedziny:
|
||
# powyżej koła podbiegunowego nie istnieją i podlegają jawnemu fallbackowi.
|
||
SYSTEMS = (WHOLE_SIGN, WHOLE_SIGN_ARIES, EQUAL, EQUAL_MC, PORPHYRY, VEHLOW,
|
||
MORINUS, REGIOMONTANUS, CAMPANUS, ALCABITUS, TOPOCENTRIC,
|
||
PLACIDUS, KOCH)
|
||
|
||
|
||
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:
|
||
"""Ascendent — punkt ekliptyki wschodzący na horyzoncie.
|
||
|
||
KOREKTA GAŁĘZI (błąd wykryty przez porównanie z wyrocznią, tests/oracle):
|
||
ekliptyka przecina horyzont w DWÓCH punktach — wschodzącym (Asc) i zachodzącym
|
||
(Dsc). `atan2` wybiera jeden z nich, ale powyżej koła podbiegunowego potrafi
|
||
wskazać ten NIEWŁAŚCIWY: dla szerokości 67°+ i szerokiego zakresu RAMC
|
||
zwracaliśmy Descendent, czyli Ascendent przesunięty o 180°. Skutek nie był
|
||
subtelny — planety lądowały w PRZECIWNYCH domach dla całej Skandynawii
|
||
północnej (Tromsø, Rovaniemi, Murmańsk).
|
||
|
||
Rozstrzyga położenie względem MC: punkt wschodzący leży zawsze w półkolu
|
||
(0°, 180°) na wschód od MC. Reguła zweryfikowana na 46 800 przypadkach wobec
|
||
Swiss Ephemeris — zero rozbieżności.
|
||
"""
|
||
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(lat_deg)
|
||
asc = norm360(math.degrees(math.atan2(
|
||
math.cos(r),
|
||
-(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)),
|
||
)))
|
||
mc = compute_mc(ramc_deg, eps_deg)
|
||
return norm360(asc + 180.0) if (asc - mc) % 360.0 > 180.0 else 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)
|
||
|
||
|
||
# Poniżej tej odległości od granicy znaku traktujemy Ascendent jak leżący DOKŁADNIE
|
||
# na niej. 1e-9° to 3,6 mikrosekundy łuku — o rzędy wielkości poniżej jakiejkolwiek
|
||
# realnej dokładności danych urodzeniowych, więc nie zmienia to żadnego horoskopu.
|
||
_SIGN_SNAP_DEG = 1e-9
|
||
|
||
|
||
def _snap_to_sign_boundary(lon: float) -> float:
|
||
"""Przyciąga długość do granicy znaku, gdy jest od niej o włos.
|
||
|
||
Whole sign jest NIECIĄGŁY na granicach znaków: różnica 10⁻¹¹° w Ascendencie
|
||
przerzuca cały dom I o 30°. Bez tego przyciągania ten sam horoskop policzony
|
||
na innej maszynie mógłby dać inny wynik (wykryte przez porównanie z wyrocznią:
|
||
nasz Asc = 359,999999999976, swissepha = 1e-10 — ta sama wartość po dwóch
|
||
stronach granicy Ryby/Baran). Determinizm jest tu ważniejszy niż dosłowność
|
||
zmiennoprzecinkowa."""
|
||
nearest = round(lon / 30.0) * 30.0
|
||
return norm360(nearest) if abs(lon - nearest) < _SIGN_SNAP_DEG else lon
|
||
|
||
|
||
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(_snap_to_sign_boundary(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 polar_circle(eps_deg: float) -> float:
|
||
"""Szerokość koła podbiegunowego [°] dla danego nachylenia ekliptyki.
|
||
|
||
NIE jest to stała 66,56°: ε zmienia się z datą (ok. 23,71° w 370 p.n.e.,
|
||
23,44° dziś), więc granica przesuwa się o ~0,3° w zakresie dat programu.
|
||
Powyżej niej stopnie ekliptyki bywają okołobiegunowe — nie wschodzą ani nie
|
||
zachodzą — przez co systemy oparte na łuku dobowym (Placidus, Koch) tracą
|
||
definicję."""
|
||
return 90.0 - abs(eps_deg)
|
||
|
||
|
||
# ── geometria wektorowa dla systemów dzielących koła wielkie ─────────────
|
||
# Wzory na te systemy krążą w literaturze w kilku wariantach i łatwo o pomyłkę
|
||
# w gałęzi albo znaku. Liczymy więc WPROST z geometrii: budujemy wektory kierunkowe
|
||
# w układzie równikowym, przecinamy płaszczyzny i dopiero wynik zamieniamy na
|
||
# długość ekliptyczną. Jest to dłuższe, ale jednoznaczne i sprawdzalne.
|
||
|
||
def _cross(a, b):
|
||
return (a[1] * b[2] - a[2] * b[1],
|
||
a[2] * b[0] - a[0] * b[2],
|
||
a[0] * b[1] - a[1] * b[0])
|
||
|
||
|
||
def _dot(a, b):
|
||
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||
|
||
|
||
def _equatorial_to_lon(v, eps_rad: float) -> float:
|
||
"""Wektor w układzie RÓWNIKOWYM → długość ekliptyczna [°]."""
|
||
x, y, z = v
|
||
y_ecl = y * math.cos(eps_rad) + z * math.sin(eps_rad)
|
||
return norm360(math.degrees(math.atan2(y_ecl, x)))
|
||
|
||
|
||
def _ecliptic_pole(eps_rad: float):
|
||
"""Biegun ekliptyki (normalna płaszczyzny ekliptyki) w układzie równikowym."""
|
||
return (0.0, -math.sin(eps_rad), math.cos(eps_rad))
|
||
|
||
|
||
def _horizon_north(ramc_rad: float, lat_rad: float):
|
||
"""Punkt północny horyzontu: RA = RAMC+180°, deklinacja = 90°−φ."""
|
||
return (-math.sin(lat_rad) * math.cos(ramc_rad),
|
||
-math.sin(lat_rad) * math.sin(ramc_rad),
|
||
math.cos(lat_rad))
|
||
|
||
|
||
# Domy POŚREDNIE (bez osi) i to, po której stronie MC leżą. Domy 11, 12, 2, 3
|
||
# są na wschód od MC (przesunięcie 0–180°), domy 5, 6, 8, 9 — na zachód.
|
||
_INTERMEDIATE = {1: True, 2: True, 4: False, 5: False,
|
||
7: False, 8: False, 10: True, 11: True}
|
||
|
||
|
||
def _house_circle_cusp(north, q, eps_rad: float, mc: float, east_of_mc: bool) -> float:
|
||
"""Cusp = przecięcie ekliptyki z kołem domu.
|
||
|
||
Koło domu przechodzi przez punkty N/S horyzontu oraz przez punkt podziału `q`
|
||
(na równiku dla Regiomontanusa, na pierwszym wertykale dla Campanusa).
|
||
Przecięcie dwóch płaszczyzn daje PROSTĄ, czyli DWA antypodyczne kierunki;
|
||
wybieramy ten po właściwej stronie południka.
|
||
|
||
Używane WYŁĄCZNIE dla domów pośrednich. Osie (1, 4, 7, 10) znamy dokładnie
|
||
z Asc i MC — liczenie ich tą drogą było błędem, bo leżą dokładnie na granicy
|
||
„wschód/zachód" (przesunięcie 0° i 180°), gdzie porównanie zmiennoprzecinkowe
|
||
się chwieje i potrafi wybrać przeciwny punkt nieba."""
|
||
normal = _cross(north, q) # normalna płaszczyzny koła domu
|
||
line = _cross(normal, _ecliptic_pole(eps_rad))
|
||
lon = _equatorial_to_lon(line, eps_rad)
|
||
return lon if ((lon - mc) % 360.0 < 180.0) == east_of_mc else norm360(lon + 180.0)
|
||
|
||
|
||
def _culminating_mc(mc: float, eps: float, lat: float) -> float:
|
||
"""Punkt południka, który dla tej szerokości leży NAD horyzontem.
|
||
|
||
Systemy oparte na horyzoncie (Regiomontanus, Campanus, Topocentric) biorą jako
|
||
dziesiąty dom punkt GÓRUJĄCY, a nie matematyczne MC — a za kołem podbiegunowym
|
||
to nie zawsze to samo. Punkt południka o deklinacji δ ma wysokość 90−|φ−δ|,
|
||
więc jest nad horyzontem dokładnie wtedy, gdy |φ−δ| < 90.
|
||
|
||
Systemy dzielące samą ekliptykę (porphyry, equal, alcabitus, whole sign) tego
|
||
nie robią — i tak samo zachowuje się wyrocznia."""
|
||
dec = math.degrees(math.asin(math.sin(math.radians(mc)) * math.sin(math.radians(eps))))
|
||
return norm360(mc + 180.0) if abs(lat - dec) > 90.0 else mc
|
||
|
||
|
||
def _with_exact_angles(intermediate, asc: float, mc: float) -> list[float]:
|
||
"""Składa 12 cuspów: osie wstawione dokładnie, reszta z geometrii."""
|
||
out = [0.0] * 12
|
||
out[0], out[3] = asc, norm360(mc + 180.0) # Asc, IC
|
||
out[6], out[9] = norm360(asc + 180.0), mc # Dsc, MC
|
||
for i, value in intermediate.items():
|
||
out[i] = value
|
||
return out
|
||
|
||
|
||
def _ra_to_ecliptic_lon(ra_deg: float, eps_rad: float) -> float:
|
||
"""Punkt ekliptyki o zadanej rektascensji (koło godzinne → ekliptyka)."""
|
||
r = math.radians(ra_deg)
|
||
return norm360(math.degrees(math.atan2(math.sin(r), math.cos(r) * math.cos(eps_rad))))
|
||
|
||
|
||
def _equator_point(ra_deg: float):
|
||
"""Kierunek punktu na równiku niebieskim o danej rektascensji."""
|
||
r = math.radians(ra_deg)
|
||
return (math.cos(r), math.sin(r), 0.0)
|
||
|
||
|
||
def _prime_vertical_point(ramc_rad: float, lat_rad: float, angle_deg: float):
|
||
"""Punkt pierwszego wertykału, `angle_deg` od punktu wschodu w stronę nadiru.
|
||
|
||
Pierwszy wertykał to koło przez wschód, zenit, zachód i nadir — Campanus dzieli
|
||
właśnie je."""
|
||
east = (-math.sin(ramc_rad), math.cos(ramc_rad), 0.0)
|
||
zenith = (math.cos(lat_rad) * math.cos(ramc_rad),
|
||
math.cos(lat_rad) * math.sin(ramc_rad),
|
||
math.sin(lat_rad))
|
||
a = math.radians(angle_deg)
|
||
return tuple(east[i] * math.cos(a) - zenith[i] * math.sin(a) for i in range(3))
|
||
|
||
|
||
def _cusps_regiomontanus(ramc: float, eps: float, lat: float,
|
||
asc: float, mc: float) -> list[float]:
|
||
"""Równik niebieski dzielony na 12 równych łuków, rzut kołami przez N/S horyzontu."""
|
||
er, rr, lr = math.radians(eps), math.radians(ramc), math.radians(lat)
|
||
north = _horizon_north(rr, lr)
|
||
mid = {i: _house_circle_cusp(north, _equator_point(ramc + 90.0 + 30.0 * i), er, mc, e)
|
||
for i, e in _INTERMEDIATE.items()}
|
||
return _with_exact_angles(mid, asc, _culminating_mc(mc, eps, lat))
|
||
|
||
|
||
def _cusps_campanus(ramc: float, eps: float, lat: float,
|
||
asc: float, mc: float) -> list[float]:
|
||
"""Pierwszy wertykał dzielony na 12 równych łuków, rzut tak samo jak wyżej."""
|
||
er, rr, lr = math.radians(eps), math.radians(ramc), math.radians(lat)
|
||
north = _horizon_north(rr, lr)
|
||
mid = {i: _house_circle_cusp(north, _prime_vertical_point(rr, lr, 30.0 * i), er, mc, e)
|
||
for i, e in _INTERMEDIATE.items()}
|
||
return _with_exact_angles(mid, asc, _culminating_mc(mc, eps, lat))
|
||
|
||
|
||
def _cusps_morinus(ramc: float, eps: float) -> list[float]:
|
||
"""Równik dzielony od RAMC i rzutowany WPROST na ekliptykę — bez horyzontu.
|
||
|
||
Dlatego Morinus jako jedyny nie zależy od szerokości geograficznej, a jego
|
||
dom I nie pokrywa się z Ascendentem. Uwaga: to ZAMIANA WSPÓŁRZĘDNYCH punktu
|
||
równika (RA, dec=0) na ekliptyczne, a nie rzut kołem godzinnym — te dwie
|
||
operacje dają różne wyniki i pomylenie ich kosztowało tu do 5°."""
|
||
er = math.radians(eps)
|
||
return [_equatorial_to_lon(_equator_point(ramc + 90.0 + 30.0 * i), er)
|
||
for i in range(12)]
|
||
|
||
|
||
def _cusps_alcabitus(ramc: float, eps: float, asc: float) -> list[float]:
|
||
"""Łuki równika MC→Asc i Asc→IC dzielone na trzy; rzut kołami godzinnymi."""
|
||
er = math.radians(eps)
|
||
a = math.radians(asc)
|
||
ra_asc = norm360(math.degrees(math.atan2(math.sin(a) * math.cos(er), math.cos(a))))
|
||
day = (ra_asc - ramc) % 360.0 # łuk MC → Asc (domy 11, 12)
|
||
night = (ramc + 180.0 - ra_asc) % 360.0 # łuk Asc → IC (domy 2, 3)
|
||
|
||
ra = [0.0] * 12
|
||
ra[9] = ramc # dom 10 = MC
|
||
ra[10] = ramc + day / 3.0 # dom 11
|
||
ra[11] = ramc + 2.0 * day / 3.0 # dom 12
|
||
ra[0] = ra_asc # dom 1 = Asc
|
||
ra[1] = ra_asc + night / 3.0 # dom 2
|
||
ra[2] = ra_asc + 2.0 * night / 3.0 # dom 3
|
||
for i in range(6): # domy 4–9 naprzeciw 10–3
|
||
ra[i + 3] = ra[(i + 9) % 12] + 180.0
|
||
return [_ra_to_ecliptic_lon(x, er) for x in ra]
|
||
|
||
|
||
# Ułamek szerokości geograficznej użyty jako „biegun" koła domu (Polich–Page).
|
||
# Domy na południku (10 i 4) mają biegun 0 — ich koło to sam południk.
|
||
# Polich–Page: dom pośredni to Ascendent policzony pod własnym „biegunem"
|
||
# tan(P) = tan(φ)·k/3, dla RAMC przesuniętego o pozycję domu. Rodzina jest CIĄGŁA:
|
||
# przy k=0 (biegun 0, przesunięcie −90°) daje MC, przy k=3 (biegun φ, przesunięcie 0)
|
||
# Ascendent, a domy 11 i 12 leżą po drodze.
|
||
#
|
||
# Cała trudność tego systemu siedziała w wyborze gałęzi — dwa koła wielkie
|
||
# przecinają się w dwóch punktach antypodycznych. Heurystyki („po której stronie
|
||
# MC", „w łuku kwadrantu", „wschodnia połowa horyzontu", śledzenie ciągłości
|
||
# krokami) myliły się na 6–11% przypadków powyżej ~70°, bo każda z nich rozstrzyga
|
||
# LOKALNIE, a przy dużych szerokościach kolejność domów potrafi się odwrócić.
|
||
#
|
||
# Rozwiązanie: nie wybierać w ogóle. Iloczyn wektorowy zenitu z biegunem ekliptyki
|
||
# jest ciągłą funkcją parametru rodziny i sam niesie właściwy zwrot — dwuznaczność
|
||
# wprowadza dopiero atan2. Zostajemy więc w wektorach, a znak ustalamy RAZ, kotwicząc
|
||
# rodzinę na MC górującym. Stąd zgodność co do zera na całej dziedzinie, bez iteracji
|
||
# i bez zawężania szerokości.
|
||
|
||
# Dom → (przesunięcie RAMC [°], ułamek bieguna k/3).
|
||
_TOPO_STEP = {10: (-60.0, 1 / 3), 11: (-30.0, 2 / 3), # domy 11, 12
|
||
1: (30.0, 2 / 3), 2: (60.0, 1 / 3)} # domy 2, 3
|
||
|
||
|
||
def _cusps_topocentric(ramc: float, eps: float, lat: float,
|
||
asc: float, mc: float) -> list[float]:
|
||
"""Ascendenty pod biegunami tan(P) = tan(φ)·k/3, liczone wektorowo.
|
||
|
||
Domy 5, 6, 8, 9 bierzemy jako OPOZYCJE domów 11, 12, 2, 3 — to nie skrót,
|
||
lecz własność konstrukcji: przeciwległe domy leżą na tym samym kole wielkim.
|
||
|
||
Kusi, by liczyć to jak Regiomontanusa z podmienioną szerokością — daje wynik
|
||
bliski, ale nie równy (kilka sekund łuku); wyrocznia rozstrzygnęła na rzecz
|
||
konstrukcji „ascendent pod biegunem"."""
|
||
er, tan_lat = math.radians(eps), math.tan(math.radians(lat))
|
||
epole = _ecliptic_pole(er)
|
||
# Gdy MC górujące rozjeżdża się z matematycznym (za kołem podbiegunowym),
|
||
# cała rodzina obraca się razem z dziesiątym domem — stąd zwrot iloczynu.
|
||
culminating = _culminating_mc(mc, eps, lat)
|
||
sign = 1.0 if abs(((culminating - mc + 180.0) % 360.0) - 180.0) > 90.0 else -1.0
|
||
|
||
out = [0.0] * 12
|
||
for i, (offset, fraction) in _TOPO_STEP.items():
|
||
th = math.radians(ramc + offset)
|
||
pole = math.atan(tan_lat * fraction)
|
||
zenith = (math.cos(pole) * math.cos(th),
|
||
math.cos(pole) * math.sin(th),
|
||
math.sin(pole))
|
||
v = _cross(zenith, epole)
|
||
lon = _equatorial_to_lon(tuple(sign * x for x in v), er)
|
||
out[i] = lon
|
||
out[(i + 6) % 12] = norm360(lon + 180.0)
|
||
out[0], out[3] = asc, norm360(culminating + 180.0)
|
||
out[6], out[9] = norm360(asc + 180.0), culminating
|
||
return out
|
||
|
||
|
||
# ── systemy łuku dobowego (Placidus, Koch) ───────────────────────────────
|
||
# Różnią się od wszystkich poprzednich tym, że NIE MAJĄ wzoru zamkniętego: cusp
|
||
# jest zdefiniowany warunkiem na samego siebie („punkt, który przebył 1/3 swojego
|
||
# półłuku"), więc trzeba go znaleźć iteracyjnie. Mają też jako jedyne REALNĄ
|
||
# granicę dziedziny — powyżej koła podbiegunowego stopnie ekliptyki bywają
|
||
# okołobiegunowe, nie wschodzą ani nie zachodzą, i półłuk po prostu nie istnieje.
|
||
|
||
|
||
class HouseSystemUndefined(ValueError):
|
||
"""System domów nie ma definicji dla podanych parametrów (nie: błąd liczenia).
|
||
|
||
Podnoszone zamiast zwrócenia liczby, bo cicha podmiana systemu jest gorsza
|
||
niż błąd: wykres wygląda poprawnie, a planety siedzą w innych domach, niż
|
||
astrolog zamawiał. Warstwa aplikacyjna łapie to w cusps_detailed() i robi
|
||
JAWNY fallback."""
|
||
|
||
|
||
_ITER_MAX = 100
|
||
_ITER_TOL_DEG = 1e-11
|
||
|
||
|
||
def _declination_of_ecliptic_lon(lon_deg: float, eps_rad: float) -> float:
|
||
"""Deklinacja punktu LEŻĄCEGO NA EKLIPTYCE o danej długości."""
|
||
return math.degrees(math.asin(math.sin(eps_rad) * math.sin(math.radians(lon_deg))))
|
||
|
||
|
||
def _ascensional_difference(dec_deg: float, lat_deg: float) -> float:
|
||
"""Różnica wschodnia: o ile półłuk dobowy odbiega od 90°.
|
||
|
||
sin(AD) = tan(φ)·tan(δ). Gdy |tan(φ)·tan(δ)| ≥ 1, punkt jest okołobiegunowy
|
||
(nigdy nie wschodzi albo nigdy nie zachodzi) i półłuk nie istnieje."""
|
||
v = math.tan(math.radians(lat_deg)) * math.tan(math.radians(dec_deg))
|
||
if abs(v) >= 1.0:
|
||
raise HouseSystemUndefined(
|
||
f"punkt okołobiegunowy (tan φ·tan δ = {v:.6f}): półłuk dobowy nie istnieje")
|
||
return math.degrees(math.asin(v))
|
||
|
||
|
||
# Dom → (ułamek półłuku, czy łuk NOCNY). Domy 11 i 12 dzielą łuk dzienny licząc
|
||
# od MC; domy 2 i 3 — łuk nocny, licząc WSTECZ od IC.
|
||
_PLACIDUS_STEP = {10: (1 / 3, False), 11: (2 / 3, False),
|
||
1: (2 / 3, True), 2: (1 / 3, True)}
|
||
|
||
|
||
def _placidus_cusp(ramc: float, eps: float, lat: float,
|
||
fraction: float, nocturnal: bool) -> float:
|
||
"""Punkt ekliptyki, który przebył `fraction` swojego półłuku.
|
||
|
||
Warunek jest uwikłany: półłuk zależy od deklinacji, deklinacja od długości,
|
||
a długość od położenia — więc iterujemy po punkcie stałym. Zbieżność jest
|
||
szybka z dala od koła podbiegunowego i psuje się przy nim, dlatego brak
|
||
zbieżności traktujemy jako wyjście poza dziedzinę, a nie jako wynik."""
|
||
eps_rad = math.radians(eps)
|
||
# Start od podziału równomiernego — to Porphyry na równiku, czyli dokładnie
|
||
# ten przypadek, w którym Placidus się do niego sprowadza.
|
||
ra = ramc + 180.0 - 90.0 * fraction if nocturnal else ramc + 90.0 * fraction
|
||
for _ in range(_ITER_MAX):
|
||
dec = _declination_of_ecliptic_lon(_ra_to_ecliptic_lon(ra, eps_rad), eps_rad)
|
||
ad = _ascensional_difference(dec, lat)
|
||
nxt = (ramc + 180.0 - fraction * (90.0 - ad) if nocturnal
|
||
else ramc + fraction * (90.0 + ad))
|
||
if abs(nxt - ra) < _ITER_TOL_DEG:
|
||
return _ra_to_ecliptic_lon(nxt, eps_rad)
|
||
ra = nxt
|
||
raise HouseSystemUndefined(
|
||
f"brak zbieżności po {_ITER_MAX} krokach (φ={lat:.4f}, RAMC={ramc:.4f})")
|
||
|
||
|
||
def _cusps_placidus(ramc: float, eps: float, lat: float,
|
||
asc: float, mc: float) -> list[float]:
|
||
"""Półłuki dobowe i nocne dzielone na trzy — każdy punkt swoim własnym łukiem."""
|
||
if abs(lat) >= polar_circle(eps):
|
||
raise HouseSystemUndefined(
|
||
f"φ={lat:.4f}° poza kołem podbiegunowym ({polar_circle(eps):.4f}° dla ε={eps:.4f}°)")
|
||
inter = {i: _placidus_cusp(ramc, eps, lat, f, noct)
|
||
for i, (f, noct) in _PLACIDUS_STEP.items()}
|
||
inter.update({(i + 6) % 12: norm360(v + 180.0) for i, v in list(inter.items())})
|
||
return _with_exact_angles(inter, asc, mc)
|
||
|
||
|
||
# Koch dzieli CZAS, nie łuk na niebie. Kryterium: ile czasu minęło od wschodu
|
||
# tego stopnia zodiaku, który stoi na MC. Ten odcinek (półłuk dobowy stopnia MC)
|
||
# dzielimy na trzy i dla punktów podziału liczymy ZWYKŁY Ascendent — stąd nazwa
|
||
# „system miejsca urodzenia". Zgodne z definicją Astrodienst (astro.com/astrowiki).
|
||
#
|
||
# W przeciwieństwie do Placidusa NIE wymaga iteracji: półłuk zależy od deklinacji
|
||
# stopnia MC, którą znamy wprost. Granicę dziedziny dzieli natomiast z Placidusem —
|
||
# gdy stopień MC jest okołobiegunowy, „moment jego wschodu" nie istnieje.
|
||
_KOCH_OFFSET = {10: -2 / 3, 11: -1 / 3, 1: 1 / 3, 2: 2 / 3}
|
||
|
||
|
||
def _cusps_koch(ramc: float, eps: float, lat: float,
|
||
asc: float, mc: float) -> list[float]:
|
||
"""Ascendenty dla chwil trójdzielących drogę stopnia MC od wschodu do górowania."""
|
||
if abs(lat) >= polar_circle(eps):
|
||
raise HouseSystemUndefined(
|
||
f"φ={lat:.4f}° poza kołem podbiegunowym ({polar_circle(eps):.4f}° dla ε={eps:.4f}°)")
|
||
dec_mc = _declination_of_ecliptic_lon(mc, math.radians(eps))
|
||
half_arc = 90.0 + _ascensional_difference(dec_mc, lat)
|
||
inter = {i: compute_asc(ramc + f * half_arc, eps, lat)
|
||
for i, f in _KOCH_OFFSET.items()}
|
||
inter.update({(i + 6) % 12: norm360(v + 180.0) for i, v in list(inter.items())})
|
||
return _with_exact_angles(inter, asc, mc)
|
||
|
||
|
||
def cusps_for(ramc: float, eps: float, lat: float, system: str) -> list[float]:
|
||
"""Kanoniczne wejście: (RAMC, ε, φ) → 12 cusps.
|
||
|
||
Systemy proste (whole sign / equal / porphyry) potrzebują tylko Asc i MC,
|
||
ale systemy egzotyczne dzielą inne koła wielkie i wymagają pełnego zestawu
|
||
(RAMC, ε, φ). Ta funkcja jest wspólnym punktem wejścia dla obu rodzajów —
|
||
i to ją porównuje z wyrocznią framework testowy (tests/oracle).
|
||
"""
|
||
asc = compute_asc(ramc, eps, lat)
|
||
mc = compute_mc(ramc, eps)
|
||
if system in (WHOLE_SIGN, EQUAL, PORPHYRY):
|
||
return cusps(asc, mc, system)
|
||
if system == WHOLE_SIGN_ARIES:
|
||
# Znaki jako domy, ale numeracja rusza od Barana niezależnie od Ascendentu.
|
||
# Wariant spotykany w tradycji indyjskiej i w części szkół hellenistycznych.
|
||
return [norm360(30.0 * i) for i in range(12)]
|
||
if system == EQUAL_MC:
|
||
# Równe domy jak `equal`, ale zakotwiczone na MC: dom X zaczyna się
|
||
# DOKŁADNIE na MC, więc oś południka wypada na granicy domu, a nie w środku.
|
||
return [norm360(mc + 90.0 + 30.0 * i) for i in range(12)]
|
||
if system == VEHLOW:
|
||
# equal, ale Ascendent leży w ŚRODKU domu I, nie na jego początku
|
||
return [norm360(asc - 15.0 + 30.0 * i) for i in range(12)]
|
||
if system == MORINUS:
|
||
return _cusps_morinus(ramc, eps)
|
||
if system == REGIOMONTANUS:
|
||
return _cusps_regiomontanus(ramc, eps, lat, asc, mc)
|
||
if system == CAMPANUS:
|
||
return _cusps_campanus(ramc, eps, lat, asc, mc)
|
||
if system == ALCABITUS:
|
||
return _cusps_alcabitus(ramc, eps, asc)
|
||
if system == TOPOCENTRIC:
|
||
return _cusps_topocentric(ramc, eps, lat, asc, mc)
|
||
if system == PLACIDUS:
|
||
return _cusps_placidus(ramc, eps, lat, asc, mc)
|
||
if system == KOCH:
|
||
return _cusps_koch(ramc, eps, lat, asc, mc)
|
||
raise ValueError(f"nieznany system domów: {system}")
|
||
|
||
|
||
# ── jawny fallback poza dziedziną ────────────────────────────────────────
|
||
# Placidus i Koch jako jedyne mają miejsca, w których po prostu NIE ISTNIEJĄ.
|
||
# Astrolog z Tromsø ma dostać wynik, ale musi wiedzieć, że dostał inny system —
|
||
# cicha podmiana jest gorsza niż brak wyniku, bo jest nie do wykrycia z wykresu.
|
||
|
||
FALLBACK_SYSTEM = PORPHYRY
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class CuspSet:
|
||
"""12 cuspów + uczciwa informacja, czym naprawdę zostały policzone."""
|
||
|
||
cusps: list[float]
|
||
system: str # system FAKTYCZNIE użyty
|
||
requested: str # o który poproszono
|
||
reason: str | None = None # dlaczego nie dało się użyć żądanego
|
||
|
||
@property
|
||
def is_fallback(self) -> bool:
|
||
return self.system != self.requested
|
||
|
||
@property
|
||
def notice(self) -> str | None:
|
||
"""Komunikat dla człowieka. Ma trafić na ekran, do raportu i do PDF-a."""
|
||
if not self.is_fallback:
|
||
return None
|
||
return (f"UWAGA: system domów \u201e{self.requested}\u201d nie ma definicji "
|
||
f"dla tego miejsca i czasu \u2014 {self.reason}. Domy policzono "
|
||
f"systemem \u201e{self.system}\u201d. To NIE jest ten sam podzia\u0142: "
|
||
f"pozycje planet s\u0105 poprawne, ale przypisanie ich do dom\u00f3w "
|
||
f"pochodzi z innego systemu.")
|
||
|
||
|
||
def cusps_detailed(ramc: float, eps: float, lat: float, system: str) -> CuspSet:
|
||
"""Jak cusps_for, ale zamiast wyjątku poza dziedziną robi JAWNY fallback.
|
||
|
||
cusps_for zostaje funkcją czystą i nieustępliwą (to ją porównuje wyrocznia);
|
||
ustępstwo wobec rzeczywistości jest tutaj — i zawsze zostawia ślad."""
|
||
try:
|
||
return CuspSet(cusps_for(ramc, eps, lat, system), system, system)
|
||
except HouseSystemUndefined as e:
|
||
return CuspSet(cusps_for(ramc, eps, lat, FALLBACK_SYSTEM),
|
||
FALLBACK_SYSTEM, system, str(e))
|
||
|
||
|
||
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
|