diff --git a/services/logic/app/engine/houses.py b/services/logic/app/engine/houses.py index 653b6fa..5701f8e 100644 --- a/services/logic/app/engine/houses.py +++ b/services/logic/app/engine/houses.py @@ -25,14 +25,11 @@ ALCABITUS = "alcabitus" TOPOCENTRIC = "topocentric" PLACIDUS = "placidus" KOCH = "koch" -# Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris co do zera -# (tests/oracle). TOPOCENTRIC celowo POZA listą: implementacja jest zgodna -# z wyrocznią wszędzie poza otoczeniem bieguna (|φ| ≈ 89,9° przy RAMC 90°/270°), -# gdzie kolejność domów się odwraca i konstrukcja traci sens — jego „biegun" -# atan(tan(89,9°)/3) to już 89,7°. Nie zawężamy dziedziny wyłącznie po to, by -# 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) +# 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, EQUAL, PORPHYRY, VEHLOW, MORINUS, REGIOMONTANUS, + CAMPANUS, ALCABITUS, TOPOCENTRIC, PLACIDUS, KOCH) 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 (Polich–Page). # 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, - 1.0, 2 / 3, 1 / 3, 0.0, 1 / 3, 2 / 3) +# 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. - -def _asc_under_pole_raw(ramc_deg: float, eps_deg: float, pole_deg: float) -> float: - """Surowy wzór na ascendent pod zadanym „biegunem", BEZ korekty gałęzi. - - 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))))) +# 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]: - """Polich–Page: dom pośredni to ASCENDENT policzony pod własnym „biegunem" - tan(P) = tan(φ)·k/3, dla RAMC przesuniętego o pozycję domu. + """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". - - Liczymy tylko domy 11, 12, 2, 3, a 5, 6, 8, 9 bierzemy jako ich OPOZYCJE — - to nie skrót, lecz własność tych systemów: przeciwległe domy leżą na tym samym - kole wielkim, więc ich cuspy są dokładnie antypodyczne.""" - 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. + 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 in (10, 11, 1, 2): # domy 11, 12, 2, 3 - pole = math.degrees(math.atan(tan_lat * _TOPO_POLE_FRACTION[i])) - lon = _asc_under_pole_raw(ramc + 30.0 * i, eps, pole) - if ((lon - culminating) % 360.0 < 180.0) != _INTERMEDIATE[i]: - lon = norm360(lon + 180.0) + 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) diff --git a/services/logic/tests/test_houses.py b/services/logic/tests/test_houses.py index e7b8347..598ba79 100644 --- a/services/logic/tests/test_houses.py +++ b/services/logic/tests/test_houses.py @@ -83,7 +83,8 @@ def test_polar_circle_moves_with_obliquity(): # Zgodność z wyrocznią sprawdza tests/oracle; tu pilnujemy niezmienników, które # 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) @@ -196,3 +197,36 @@ def test_polar_circle_boundary_moves_with_the_date(): with pytest.raises(H.HouseSystemUndefined): H.cusps_for(100.0, 23.4393, lat, "placidus") 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 diff --git a/services/presentation/app/house_systems.py b/services/presentation/app/house_systems.py index 8deafd1..0683a21 100644 --- a/services/presentation/app/house_systems.py +++ b/services/presentation/app/house_systems.py @@ -21,6 +21,7 @@ HOUSE_SYSTEMS: list[tuple[str, str]] = [ ("regiomontanus", "Regiomontanus"), ("campanus", "Campanus"), ("alcabitus", "Alcabitus"), + ("topocentric", "Topocentric"), ("morinus", "Morinus"), ("vehlow", "Vehlow"), ] diff --git a/tests/oracle/README.md b/tests/oracle/README.md index 2328614..48b5051 100644 --- a/tests/oracle/README.md +++ b/tests/oracle/README.md @@ -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° | | campanus | podział wertykału pierwszego | 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: @@ -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 kosmogram wygląda bezbłędnie, więc jest to jedyny sygnał dla astrologa. -**Topocentric (Polich–Page) jest zaimplementowany, ale NIE wypuszczony** — nie ma go -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 -oparta na łuku kwadrantu nie rozstrzyga wyboru gałęzi. Konstrukcja jest tam z natury -źle uwarunkowana — „biegun" `atan(tan(φ)·k/3)` dla φ = 89,9° wynosi już 89,7°. -Nie zawężamy dziedziny po to, żeby testy przeszły; system dołączy, gdy będzie -poprawny wszędzie. +### Topocentric — i czego nauczył + +Topocentric był początkowo **wycofany** z powodu rozjazdu przy |φ| ≈ 89,9°. +Ta diagnoza była **błędna**, bo opierała się wyłącznie na zestawie brzegowym, +który próbkuje tylko wybrane szerokości. Przemiał losowy pokazał prawdziwą skalę: +błędy zaczynały się już od ~70° i sięgały 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 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 zakres dat, nie poprawia niczego w obecnym. diff --git a/tests/oracle/run.py b/tests/oracle/run.py index cce7624..85d135c 100644 --- a/tests/oracle/run.py +++ b/tests/oracle/run.py @@ -28,7 +28,7 @@ from harness import ( # noqa: E402 # Systemy do sprawdzenia. Rośnie wraz z implementacją kolejnych (Etap 1 i 2) — # dopisanie nazwy tutaj wystarcza, żeby weszła do każdego builda. SYSTEMS = ["whole_sign", "equal", "porphyry", "vehlow", "morinus", - "regiomontanus", "campanus", "alcabitus", "placidus", "koch"] + "regiomontanus", "campanus", "alcabitus", "topocentric", "placidus", "koch"] def main() -> int: