feat(domy): topocentric zgodny co do zera — wybór gałęzi liczony, nie zgadywany
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m29s
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 9s
build-render / build (push) Successful in 5m34s
build / build (push) Successful in 39s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m46s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m31s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m27s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 19s
Testy / Kontrola składni wszystkich warstw (push) Successful in 11s

Topocentric był wycofany z powodu rozjazdu przy |φ| ≈ 89,9°. Ta diagnoza była
BŁĘDNA: opierała się wyłącznie na zestawie brzegowym, który próbkuje tylko wybrane
szerokości. Przemiał losowy pokazał prawdziwą skalę — błędy od ~70° w górę,
do 50% przypadków blisko biegunów, 6,5% całości. Nie dwa przypadki brzegowe.

Przyczyną nie była jednak konstrukcja, tylko wybór gałęzi: dwa koła wielkie
przecinają się w dwóch punktach antypodycznych. Kolejno zawiodły reguły „po której
stronie MC", „w łuku kwadrantu", „wschodnia połowa horyzontu" i śledzenie ciągłości
krokami (to ostatnie maskuje własną patologię — po korekcie do bliższej gałęzi
zmierzony skok ZAWSZE wychodzi ≤ 90°, więc detektor nigdy się nie zapala).

Wszystkie te reguły rozstrzygają lokalnie, a przy dużych szerokościach kolejność
domów potrafi się odwrócić: przy φ = −79,55° MC wypada na 306,8°, a dom 11 na
291,4°. To jest poprawne — wyrocznia zwraca to samo.

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 w wektorach, znak ustalamy raz — kotwicząc
rodzinę na MC górującym.

Wynik: 0,000000000° na 240 000 porównań, cała dziedzina, bez iteracji i bez
zawężania szerokości. Żadna granica dziedziny nie jest tu potrzebna, więc żadnej
nie udajemy — test pilnuje, że topocentric liczy się wszędzie i nie fallbackuje.

Morał do frameworka: zestaw brzegowy mówi, CZY system się psuje; dopiero przemiał
losowy mówi JAK BARDZO. Wniosek o skali wyciągnięty z samych brzegów był tu
zaniżony o trzy rzędy wielkości. Odnotowane w tests/oracle/README.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit was merged in pull request #63.
This commit is contained in:
2026-08-05 22:24:40 +02:00
parent 7266f671a4
commit aec3f84331
5 changed files with 106 additions and 45 deletions
+43 -36
View File
@@ -25,14 +25,11 @@ ALCABITUS = "alcabitus"
TOPOCENTRIC = "topocentric" TOPOCENTRIC = "topocentric"
PLACIDUS = "placidus" PLACIDUS = "placidus"
KOCH = "koch" KOCH = "koch"
# Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris co do zera # Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris
# (tests/oracle). TOPOCENTRIC celowo POZA listą: implementacja jest zgodna # (tests/oracle). Placidus i Koch jako jedyne mają granicę dziedziny:
# z wyrocznią wszędzie poza otoczeniem bieguna (|φ| ≈ 89,9° przy RAMC 90°/270°), # powyżej koła podbiegunowego nie istnieją i podlegają jawnemu fallbackowi.
# gdzie kolejność domów się odwraca i konstrukcja traci sens — jego „biegun" SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY, VEHLOW, MORINUS, REGIOMONTANUS,
# atan(tan(89,9°)/3) to już 89,7°. Nie zawężamy dziedziny wyłącznie po to, by CAMPANUS, ALCABITUS, TOPOCENTRIC, PLACIDUS, KOCH)
# testy przeszły; system dołączy, gdy będzie poprawny na całej dziedzinie.
SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY, VEHLOW, MORINUS,
REGIOMONTANUS, CAMPANUS, ALCABITUS, PLACIDUS, KOCH)
def mean_obliquity(tt_jd: float) -> float: def mean_obliquity(tt_jd: float) -> float:
@@ -289,44 +286,54 @@ def _cusps_alcabitus(ramc: float, eps: float, asc: float) -> list[float]:
# Ułamek szerokości geograficznej użyty jako „biegun" koła domu (PolichPage). # Ułamek szerokości geograficznej użyty jako „biegun" koła domu (PolichPage).
# Domy na południku (10 i 4) mają biegun 0 — ich koło to sam południk. # Domy na południku (10 i 4) mają biegun 0 — ich koło to sam południk.
_TOPO_POLE_FRACTION = (1.0, 2 / 3, 1 / 3, 0.0, 1 / 3, 2 / 3, # PolichPage: dom pośredni to Ascendent policzony pod własnym „biegunem"
1.0, 2 / 3, 1 / 3, 0.0, 1 / 3, 2 / 3) # 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 611% 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).
def _asc_under_pole_raw(ramc_deg: float, eps_deg: float, pole_deg: float) -> float: _TOPO_STEP = {10: (-60.0, 1 / 3), 11: (-30.0, 2 / 3), # domy 11, 12
"""Surowy wzór na ascendent pod zadanym „biegunem", BEZ korekty gałęzi. 1: (30.0, 2 / 3), 2: (60.0, 1 / 3)} # domy 2, 3
Korektę stosuje wywołujący — względem PRAWDZIWEGO MC horoskopu. `compute_asc`
poprawia gałąź względem MC dla PRZESUNIĘTEGO RAMC, co dla cuspu domu jest złym
punktem odniesienia i w okolicach biegunów dawało obrót o 180°."""
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(pole_deg)
return norm360(math.degrees(math.atan2(
math.cos(r), -(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)))))
def _cusps_topocentric(ramc: float, eps: float, lat: float, def _cusps_topocentric(ramc: float, eps: float, lat: float,
asc: float, mc: float) -> list[float]: asc: float, mc: float) -> list[float]:
"""PolichPage: dom pośredni to ASCENDENT policzony pod własnym „biegunem" """Ascendenty pod biegunami tan(P) = tan(φ)·k/3, liczone wektorowo.
tan(P) = tan(φ)·k/3, dla RAMC przesuniętego o pozycję domu.
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 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 bliski, ale nie równy (kilka sekund łuku); wyrocznia rozstrzygnęła na rzecz
konstrukcji „ascendent pod biegunem". konstrukcji „ascendent pod biegunem"."""
er, tan_lat = math.radians(eps), math.tan(math.radians(lat))
Liczymy tylko domy 11, 12, 2, 3, a 5, 6, 8, 9 bierzemy jako ich OPOZYCJE — epole = _ecliptic_pole(er)
to nie skrót, lecz własność tych systemów: przeciwległe domy leżą na tym samym # Gdy MC górujące rozjeżdża się z matematycznym (za kołem podbiegunowym),
kole wielkim, więc ich cuspy są dokładnie antypodyczne.""" # cała rodzina obraca się razem z dziesiątym domem — stąd zwrot iloczynu.
tan_lat = math.tan(math.radians(lat))
# Gałąź liczymy względem MC GÓRUJĄCEGO, nie matematycznego: gdy za kołem
# podbiegunowym te dwa się rozjeżdżają, cała czwórka domów pośrednich musi
# obrócić się razem z dziesiątym domem.
culminating = _culminating_mc(mc, eps, lat) 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 out = [0.0] * 12
for i in (10, 11, 1, 2): # domy 11, 12, 2, 3 for i, (offset, fraction) in _TOPO_STEP.items():
pole = math.degrees(math.atan(tan_lat * _TOPO_POLE_FRACTION[i])) th = math.radians(ramc + offset)
lon = _asc_under_pole_raw(ramc + 30.0 * i, eps, pole) pole = math.atan(tan_lat * fraction)
if ((lon - culminating) % 360.0 < 180.0) != _INTERMEDIATE[i]: zenith = (math.cos(pole) * math.cos(th),
lon = norm360(lon + 180.0) 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] = lon
out[(i + 6) % 12] = norm360(lon + 180.0) out[(i + 6) % 12] = norm360(lon + 180.0)
out[0], out[3] = asc, norm360(culminating + 180.0) out[0], out[3] = asc, norm360(culminating + 180.0)
+35 -1
View File
@@ -83,7 +83,8 @@ def test_polar_circle_moves_with_obliquity():
# Zgodność z wyrocznią sprawdza tests/oracle; tu pilnujemy niezmienników, które # Zgodność z wyrocznią sprawdza tests/oracle; tu pilnujemy niezmienników, które
# muszą zachodzić także bez swissepha (czyli w każdym środowisku). # muszą zachodzić także bez swissepha (czyli w każdym środowisku).
EXOTIC = ("vehlow", "morinus", "regiomontanus", "campanus", "alcabitus") EXOTIC = ("vehlow", "morinus", "regiomontanus", "campanus", "alcabitus",
"topocentric")
@pytest.mark.parametrize("system", EXOTIC) @pytest.mark.parametrize("system", EXOTIC)
@@ -196,3 +197,36 @@ def test_polar_circle_boundary_moves_with_the_date():
with pytest.raises(H.HouseSystemUndefined): with pytest.raises(H.HouseSystemUndefined):
H.cusps_for(100.0, 23.4393, lat, "placidus") H.cusps_for(100.0, 23.4393, lat, "placidus")
assert len(H.cusps_for(100.0, 23.3528, lat, "placidus")) == 12 assert len(H.cusps_for(100.0, 23.3528, lat, "placidus")) == 12
def test_topocentric_cusps_stay_in_order_between_mc_and_ascendant():
"""Regresja na to, co topocentrica psuło najdłużej — wybór gałęzi.
Cuspy 11 i 12 to punkty CIĄGŁEJ rodziny biegnącej od MC do Ascendentu, więc
czwórka MC → 11 → 12 → Asc leży na okręgu w kolejności, bez wyprzedzania się.
Zły wybór gałęzi przerzuca któryś punkt o 180° i kolejność pęka.
Kierunek celowo NIE jest ustalony: przy dużych szerokościach domy biegną
wstecz (przy φ=79,55° MC=306,8°, a dom 11 to 291,4°) i to jest poprawne —
wyrocznia zwraca tam dokładnie to samo. Sprawdzamy więc, że kolejność trzyma
się w JEDNĄ ze stron, a nie że rośnie."""
def d(a, b):
return H.norm360(b - a)
for lat in (-89.9, -79.55, -67.18, 0.0, 50.06, 73.69, 80.66, 89.9):
c = H.cusps_for(129.2387, 23.4393, lat, "topocentric")
mc, c11, c12, asc = c[9], c[10], c[11], c[0]
forward = d(mc, c11) + d(c11, c12) + d(c12, asc)
backward = d(c11, mc) + d(c12, c11) + d(asc, c12)
assert (abs(forward - d(mc, asc)) < 1e-9
or abs(backward - d(asc, mc)) < 1e-9), \
f"φ={lat}: MC={mc:.3f} 11={c11:.3f} 12={c12:.3f} Asc={asc:.3f} nie po kolei"
def test_topocentric_needs_no_domain_limit():
"""W przeciwieństwie do Placidusa/Kocha topocentric liczy się WSZĘDZIE —
nie ma tu granicy dziedziny do wyprowadzenia, więc nie wolno jej udawać."""
for lat in (0.0, 66.5, 69.65, 89.9, -89.9):
assert len(H.cusps_for(100.0, 23.4393, lat, "topocentric")) == 12
cs = H.cusps_detailed(100.0, 23.4393, 89.9, "topocentric")
assert not cs.is_fallback and cs.notice is None
@@ -21,6 +21,7 @@ HOUSE_SYSTEMS: list[tuple[str, str]] = [
("regiomontanus", "Regiomontanus"), ("regiomontanus", "Regiomontanus"),
("campanus", "Campanus"), ("campanus", "Campanus"),
("alcabitus", "Alcabitus"), ("alcabitus", "Alcabitus"),
("topocentric", "Topocentric"),
("morinus", "Morinus"), ("morinus", "Morinus"),
("vehlow", "Vehlow"), ("vehlow", "Vehlow"),
] ]
+26 -7
View File
@@ -77,6 +77,7 @@ zarówno zestaw brzegowy (2520 porównań), jak i losowy przemiał 20 000 przypa
| regiomontanus | podział równika, koła przez punkty N/S horyzontu | 0,000000000° | | regiomontanus | podział równika, koła przez punkty N/S horyzontu | 0,000000000° |
| campanus | podział wertykału pierwszego | 0,000000000° | | campanus | podział wertykału pierwszego | 0,000000000° |
| alcabitus | podział łuków dobowych po równiku | 0,000000000° | | alcabitus | podział łuków dobowych po równiku | 0,000000000° |
| topocentric | ascendenty pod biegunami tan(P) = tan(φ)·k/3 | 0,000000000° |
Etap 2 — systemy **łuku dobowego**, jedyne z realną granicą dziedziny: Etap 2 — systemy **łuku dobowego**, jedyne z realną granicą dziedziny:
@@ -97,13 +98,31 @@ Warstwa aplikacyjna łapie tę odmowę i podstawia Porphyry'ego z **głośnym
komunikatem**, który idzie na ekran, w prompt do modelu i do PDF-a: po podmianie komunikatem**, który idzie na ekran, w prompt do modelu i do PDF-a: po podmianie
kosmogram wygląda bezbłędnie, więc jest to jedyny sygnał dla astrologa. kosmogram wygląda bezbłędnie, więc jest to jedyny sygnał dla astrologa.
**Topocentric (PolichPage) jest zaimplementowany, ale NIE wypuszczony** — nie ma go ### Topocentric — i czego nauczył
w `houses.SYSTEMS`. Zgadza się z wyrocznią na całej dziedzinie poza otoczeniem
bieguna: przy |φ| ≈ 89,9° i RAMC 90°/270° kolejność domów się odwraca i żadna reguła Topocentric był początkowo **wycofany** z powodu rozjazdu przy |φ| ≈ 89,9°.
oparta na łuku kwadrantu nie rozstrzyga wyboru gałęzi. Konstrukcja jest tam z natury Ta diagnoza była **błędna**, bo opierała się wyłącznie na zestawie brzegowym,
źle uwarunkowana — „biegun" `atan(tan(φ)·k/3)` dla φ = 89,9° wynosi już 89,7°. który próbkuje tylko wybrane szerokości. Przemiał losowy pokazał prawdziwą skalę:
Nie zawężamy dziedziny po to, żeby testy przeszły; system dołączy, gdy będzie błędy zaczynały się już od ~70° i sięgały 50% przypadków blisko biegunów —
poprawny wszędzie. **6,5% całości**, nie dwa przypadki brzegowe.
Przyczyną nie była jednak konstrukcja, tylko wybór gałęzi. Dwa koła wielkie
przecinają się w dwóch punktach antypodycznych i trzeba wskazać właściwy.
Kolejno zawiodły: „po której stronie MC", „w łuku kwadrantu", „wschodnia połowa
horyzontu" oraz śledzenie ciągłości krokami. Każda z nich rozstrzyga **lokalnie**,
a przy dużych szerokościach kolejność domów potrafi się odwrócić — przy φ = 79,55°
MC wypada na 306,8°, a dom 11 na 291,4°, czyli domy biegną wstecz. To jest poprawne
i wyrocznia zwraca dokładnie to samo.
Rozwiązaniem było **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`. Zostając w wektorach i kotwicząc rodzinę
raz — na MC górującym — dostajemy zgodność **co do zera na całej dziedzinie**,
bez iteracji i bez zawężania szerokości.
Morał praktyczny: zestaw brzegowy mówi, **czy** system się psuje; dopiero przemiał
losowy mówi **jak bardzo**. Wniosek o skali wyciągnięty z samych brzegów był tu
zaniżony o trzy rzędy wielkości.
Większe jądro efemeryd (de441) — **odłożone, „nice to have"**: rozszerza tylko Większe jądro efemeryd (de441) — **odłożone, „nice to have"**: rozszerza tylko
zakres dat, nie poprawia niczego w obecnym. zakres dat, nie poprawia niczego w obecnym.
+1 -1
View File
@@ -28,7 +28,7 @@ from harness import ( # noqa: E402
# Systemy do sprawdzenia. Rośnie wraz z implementacją kolejnych (Etap 1 i 2) — # Systemy do sprawdzenia. Rośnie wraz z implementacją kolejnych (Etap 1 i 2) —
# dopisanie nazwy tutaj wystarcza, żeby weszła do każdego builda. # dopisanie nazwy tutaj wystarcza, żeby weszła do każdego builda.
SYSTEMS = ["whole_sign", "equal", "porphyry", "vehlow", "morinus", SYSTEMS = ["whole_sign", "equal", "porphyry", "vehlow", "morinus",
"regiomontanus", "campanus", "alcabitus", "placidus", "koch"] "regiomontanus", "campanus", "alcabitus", "topocentric", "placidus", "koch"]
def main() -> int: def main() -> int: