Files
astrololo/services/logic/tests/test_houses.py
T
gitea aec3f84331
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
feat(domy): topocentric zgodny co do zera — wybór gałęzi liczony, nie zgadywany
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>
2026-08-05 22:24:40 +02:00

233 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Testy osi i domów — czysta matematyka (LOG-05, bez efemeryd)."""
import math
import pytest
from app.engine import houses as H
# RAMC i ε policzone Skyfieldem dla horoskopu referencyjnego (30.04.1984, Warszawa)
RAMC, EPS, LAT = 353.1968, 23.44133, 52.2333
def _near(a, b, tol=0.05):
return abs(((a - b + 180) % 360) - 180) < tol
def test_asc_mc_match_reference():
asc = H.compute_asc(RAMC, EPS, LAT)
mc = H.compute_mc(RAMC, EPS)
assert _near(asc, 112.18) # Cancer 22°10' (astro.com)
assert _near(mc, 352.59) # Pisces 22°35'
def test_whole_sign_starts_on_sign_boundary():
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN)
assert cusps[0] == 90.0 # dom 1 = 0° Raka
assert cusps[1] == 120.0
def test_equal_cusps_are_30_apart_from_asc():
cusps = H.cusps(112.18, 352.59, H.EQUAL)
assert abs(cusps[0] - 112.18) < 1e-9
assert abs(cusps[1] - 142.18) < 1e-9
def test_porphyry_angles_on_cusps():
cusps = H.cusps(112.18, 352.59, H.PORPHYRY)
assert abs(cusps[0] - 112.18) < 1e-9 # Asc = dom 1
assert abs(cusps[9] - 352.59) < 1e-9 # MC = dom 10
assert abs(cusps[6] - (112.18 + 180) % 360) < 1e-9 # Dsc = dom 7
def test_assign_house_whole_sign():
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN) # dom 1 = Rak (90120°)
assert H.assign_house(100.0, cusps) == 1 # w Raku
assert H.assign_house(40.0, cusps) == 11 # Byk -> 11. dom
# ── regresje wykryte przez porównanie z wyrocznią (tests/oracle) ──────────
def test_ascendant_branch_beyond_polar_circle():
"""REGRESJA: powyżej koła podbiegunowego `atan2` wybierał NIEWŁAŚCIWY punkt
przecięcia ekliptyki z horyzontem — zwracaliśmy Descendent zamiast Ascendenta,
czyli błąd o 180°. Planety lądowały w PRZECIWNYCH domach dla całej północnej
Skandynawii. Rozstrzyga położenie względem MC: punkt wschodzący leży zawsze
w półkolu (0°,180°) na wschód od MC."""
eps = 23.4393
for lat in (67.0, 70.0, 85.0, -70.0):
for ramc in (60.0, 100.0, 200.0, 270.0, 300.0):
asc = H.compute_asc(ramc, eps, lat)
mc = H.compute_mc(ramc, eps)
offset = (asc - mc) % 360.0
assert 0.0 <= offset <= 180.0, (
f"Asc poza półkolem wschodzącym: lat={lat} ramc={ramc} offset={offset:.4f}")
def test_whole_sign_is_deterministic_at_sign_boundary():
"""REGRESJA: Ascendent o włos od granicy znaku (359,999999999976 vs 1e-10 —
ta sama wartość, różne strony) przerzucał cały dom I o 30°. Ten sam horoskop
na innej maszynie dawał inny wynik."""
below = H.cusps(359.999999999976, 270.0, H.WHOLE_SIGN)
above = H.cusps(1e-10, 270.0, H.WHOLE_SIGN)
assert below[0] == above[0] == 0.0, (below[0], above[0])
def test_polar_circle_moves_with_obliquity():
"""Granica dziedziny Placidusa/Kocha NIE jest stałą 66,56° — zależy od ε,
a ε zmienia się z datą. Testy brzegowe muszą ją liczyć per data."""
assert H.polar_circle(23.4393) == pytest.approx(66.5607, abs=1e-4) # dziś
assert H.polar_circle(23.747) == pytest.approx(66.253, abs=1e-4) # 370 p.n.e.
# ── systemy egzotyczne o zamkniętym wzorze (Etap 1) ──────────────────────
# 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",
"topocentric")
@pytest.mark.parametrize("system", EXOTIC)
def test_exotic_returns_twelve_cusps_in_range(system):
out = H.cusps_for(100.0, 23.4393, 50.0, system)
assert len(out) == 12
assert all(0.0 <= c < 360.0 for c in out)
@pytest.mark.parametrize("system", EXOTIC)
def test_opposite_houses_are_antipodal(system):
"""Domy przeciwległe leżą na tym samym kole wielkim, więc ich cuspy są
dokładnie antypodyczne. Naruszenie tego oznacza błąd w wyborze gałęzi."""
out = H.cusps_for(137.0, 23.4393, 42.0, system)
for i in range(6):
assert abs(((out[i + 6] - out[i]) % 360.0) - 180.0) < 1e-9, f"domy {i+1}/{i+7}"
@pytest.mark.parametrize("system", ("regiomontanus", "campanus", "alcabitus"))
def test_quadrant_systems_anchor_on_ascendant(system):
"""Systemy kwadrantowe zaczynają dom I na Ascendencie."""
ramc, eps, lat = 100.0, 23.4393, 50.0
assert H.cusps_for(ramc, eps, lat, system)[0] == pytest.approx(
H.compute_asc(ramc, eps, lat), abs=1e-9)
def test_vehlow_puts_ascendant_in_the_middle_of_house_one():
ramc, eps, lat = 100.0, 23.4393, 50.0
asc = H.compute_asc(ramc, eps, lat)
assert H.cusps_for(ramc, eps, lat, "vehlow")[0] == pytest.approx(
H.norm360(asc - 15.0), abs=1e-9)
def test_morinus_ignores_latitude():
"""Morinus rzutuje równik wprost na ekliptykę, bez horyzontu — jako jedyny
nie zależy od szerokości geograficznej."""
a = H.cusps_for(100.0, 23.4393, 20.0, "morinus")
b = H.cusps_for(100.0, 23.4393, 65.0, "morinus")
assert a == pytest.approx(b, abs=1e-12)
def test_unknown_system_is_rejected():
with pytest.raises(ValueError):
H.cusps_for(100.0, 23.4393, 50.0, "nie-ma-takiego")
# ── systemy łuku dobowego: Placidus i Koch (Etap 2) ──────────────────────
DIURNAL = ("placidus", "koch")
TROMSO_LAT = 69.65 # za kołem podbiegunowym — tam te systemy nie istnieją
KRAKOW_LAT = 50.06
@pytest.mark.parametrize("system", DIURNAL)
def test_diurnal_systems_refuse_beyond_the_polar_circle(system):
"""Odmowa, nie liczba. Cicha podmiana systemu jest niewykrywalna z wykresu."""
with pytest.raises(H.HouseSystemUndefined):
H.cusps_for(100.0, 23.4393, TROMSO_LAT, system)
@pytest.mark.parametrize("system", DIURNAL)
def test_fallback_says_what_it_did(system):
cs = H.cusps_detailed(100.0, 23.4393, TROMSO_LAT, system)
assert cs.is_fallback and cs.system == H.FALLBACK_SYSTEM and cs.requested == system
assert len(cs.cusps) == 12
assert cs.notice and system in cs.notice and H.FALLBACK_SYSTEM in cs.notice
@pytest.mark.parametrize("system", DIURNAL)
def test_no_fallback_and_no_notice_where_the_system_exists(system):
cs = H.cusps_detailed(100.0, 23.4393, KRAKOW_LAT, system)
assert not cs.is_fallback and cs.notice is None
assert cs.cusps == H.cusps_for(100.0, 23.4393, KRAKOW_LAT, system)
def test_placidus_cusps_satisfy_their_own_definition():
"""Sprawdzian NIEZALEŻNY od wyroczni: czy znaleziony punkt naprawdę przebył
zadany ułamek swojego półłuku. Wyrocznia mówi „zgadza się z swissephem",
to mówi „zgadza się z definicją Placidusa" — i działa bez swissepha."""
ramc, eps, lat = 212.9986, 23.4393, 66.3144 # tuż pod kołem, najtrudniej
eps_rad = math.radians(eps)
c = H.cusps_for(ramc, eps, lat, "placidus")
for house, (frac, nocturnal) in ((11, (1 / 3, False)), (12, (2 / 3, False)),
(2, (2 / 3, True)), (3, (1 / 3, True))):
lon = c[house - 1]
dec = H._declination_of_ecliptic_lon(lon, eps_rad)
ad = H._ascensional_difference(dec, lat)
ra = math.degrees(math.atan2(math.sin(math.radians(lon)) * math.cos(eps_rad),
math.cos(math.radians(lon))))
want = (ramc + 180.0 - frac * (90.0 - ad) if nocturnal
else ramc + frac * (90.0 + ad))
assert abs((ra - want + 180.0) % 360.0 - 180.0) < 1e-9, f"dom {house}"
def test_koch_reduces_to_equal_spacing_on_the_equator():
"""Na równiku półłuk każdego stopnia to dokładnie 90°, więc trójdzielenie
czasu daje Ascendenty co 30° czasu gwiazdowego — dobry test zdrowia wzoru."""
ramc, eps = 100.0, 23.4393
c = H.cusps_for(ramc, eps, 0.0, "koch")
for house, offset in ((11, -60.0), (12, -30.0), (2, 30.0), (3, 60.0)):
assert c[house - 1] == pytest.approx(
H.compute_asc(ramc + offset, eps, 0.0), abs=1e-9), f"dom {house}"
def test_polar_circle_boundary_moves_with_the_date():
"""Granica dziedziny NIE jest stałą 66,56° — zależy od ε, czyli od daty."""
lat = 66.60
assert lat > H.polar_circle(23.4393) # dziś: poza dziedziną
assert lat < H.polar_circle(23.3528) # rok 2270: już w dziedzinie
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