Files
astrololo/services/logic/tests/test_houses.py
T
gitea 8b6ecc727d
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m44s
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 20s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 8s
build / build (push) Successful in 23s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m38s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 17s
Testy / Kontrola składni wszystkich warstw (push) Successful in 9s
feat(domy): przypisanie do domu przez wyrocznię + poprawki układu strony
ETAP 5, część pierwsza: assign_house pod wyrocznią (swe_house_pos).
Osobny rodzaj błędu niż same cuspy — i od razu jeden znalazł.

BŁĄD: assign_house szło ZAWSZE do przodu. Przy dużych szerokościach systemy
dzielące koła wielkie mają kolejność domów ODWRÓCONĄ (przy φ=−84,3° cusp domu I
wypada na 174,2°, a domu II na 165,3°) — co nie jest usterką, bo wyrocznia zwraca
dokładnie te same wartości. Suma przeskoków „do przodu" wychodziła 3960° zamiast
360°, czyli każdy krok obchodził koło dookoła. Planety lądowały w złych domach
dla regiomontanusa, campanusa i topocentrica: 9,7-12,5% przypadków. Błąd cichy —
wykres wyglądał bez zarzutu. Kierunek bierzemy teraz z samych cuspów.

TOPOCENTRIC MA JEDNAK GRANICĘ DZIEDZINY — korekta tego, co pisałem wcześniej.
Cuspy są poprawne wszędzie (zgodne z wyrocznią co do zera), ale powyżej koła
podbiegunowego przestają DZIELIĆ OKRĄG: cusp VII (= I + 180°) wypada przed
cuspem VI i domy nachodzą na siebie. Przypisanie planety traci wtedy sens —
co potwierdza sama wyrocznia, której swe_house_pos przeczy tam własnym cuspom
(100% zgodności do 62°, 83,9% przy 66°, ok. 50% przy 72°; regiomontanus 100%
w tych samych punktach). Odmawiamy, z jawnym fallbackiem jak Placidus i Koch.

Próg jest WYPROWADZONY z warunku „dwanaście cuspów sumuje się do 360°", nie
dobrany pod wynik testu — i wypada na kole podbiegunowym (zmierzone: 100%
podziałów do 65°, 78% w pasie 66-67°). To inny rodzaj granicy niż u Placidusa
i Kocha: tam nie istnieją same cuspy, tu istnieją, tylko nie tworzą podziału.

Framework dostał pojęcie dziedziny WĘŻSZEJ niż wyroczni (NARROWER_THAN_ORACLE),
zamiast wyjątku „bo topocentric": skoro wyrocznia przeczy sama sobie, nie może
rozstrzygać, więc tam nie porównujemy — a nasze przypisanie jest w tym obszarze
sprawdzane testem samospójności z cuspami, bez swissepha.

UKŁAD STRONY — zmierzony na żywej stronie, nie na oko:
- tabela porównania przy 13 systemach miała 14 kolumn i 1863 px, a stała
  w rodzicu bez overflow-x, więc ROZPYCHAŁA CAŁY DOKUMENT: 1713 px przy oknie
  1280 px, poziomy pasek na body. Teraz ma własny kontener przewijany
  (dokument 1265 px, nie przewija się), a numer domu jest przyklejony do lewej,
  bo inaczej po przewinięciu nie wiadomo, który to wiersz.
- przypis „* nie działa za kołem podbiegunowym" siedział WEWNĄTRZ <label>
  selektora, łamał się na dwie linie i rozciągał wiersz siatki ze 66 do 108 px,
  rozjeżdżając go z sąsiednim polem. Wyjaśnienie stoi teraz raz, przy
  checkboxach z gwiazdkami; wiersz wrócił do 66 px.
- 13 checkboxów na flexie zawijało się w poszarpane wiersze — jest siatka
  o stałej szerokości kolumny (auto-fill, więc na wąskim ekranie kolumn mniej).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 13:55:13 +02:00

349 lines
16 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 (-66.0, -50.0, -20.0, 0.0, 20.0, 50.06, 66.0):
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_refuses_where_its_houses_stop_partitioning_the_circle():
"""Topocentric ma granicę INNEGO rodzaju niż Placidus i Koch.
Same cuspy są poprawne wszędzie (zgodne z wyrocznią co do zera), ale powyżej
koła podbiegunowego przestają dzielić okrąg — cusp VII (= I + 180°) wypada
przed cuspem VI i domy nachodzą na siebie. Przypisanie planety do domu traci
wtedy sens, więc odmawiamy zamiast zwracać liczbę bez znaczenia.
Próg jest WYPROWADZONY z warunku „dwanaście cuspów sumuje się do 360°",
a nie dobrany pod wynik testu — i wypada na kole podbiegunowym."""
for lat in (0.0, 50.06, 66.0):
assert len(H.cusps_for(100.0, 23.4393, lat, "topocentric")) == 12
for lat in (69.65, 89.9, -89.9):
with pytest.raises(H.HouseSystemUndefined):
H.cusps_for(100.0, 23.4393, lat, "topocentric")
cs = H.cusps_detailed(100.0, 23.4393, 89.9, "topocentric")
assert cs.is_fallback and cs.notice and "topocentric" in cs.notice
def test_whole_sign_aries_ignores_the_ascendant():
"""Wariant „od Barana": dom I zaczyna się na 0° Barana niezależnie od tego,
co wschodzi. To odróżnia go od zwykłego whole sign."""
for lat in (0.0, 50.06, -33.9):
c = H.cusps_for(100.0, 23.4393, lat, "whole_sign_aries")
assert c == [pytest.approx(30.0 * i) for i in range(12)]
def test_equal_mc_starts_the_tenth_house_exactly_on_the_midheaven():
"""W `equal` MC leży GDZIEŚ w domu X; w `equal_mc` zaczyna go dokładnie."""
ramc, eps, lat = 100.0, 23.4393, 50.0
mc = H.compute_mc(ramc, eps)
c = H.cusps_for(ramc, eps, lat, "equal_mc")
assert c[9] == pytest.approx(mc, abs=1e-9) # dom X rusza na MC
assert c[0] == pytest.approx(H.norm360(mc + 90.0), abs=1e-9)
for i in range(12): # nadal równe 30°
assert H.norm360(c[(i + 1) % 12] - c[i]) == pytest.approx(30.0, abs=1e-9)
# ── nachylenie ekliptyki: prawdziwe, nie średnie ─────────────────────────
def test_sidereal_returns_true_obliquity_not_mean():
"""RAMC liczymy z GAST — czasu gwiazdowego POZORNEGO, mierzonego od
równonocy PRAWDZIWEJ. Ekliptyka odniesiona do tej samej równonocy ma ε
Z NUTACJĄ. Sparowanie GAST z ε średnim mieszało dwa układy odniesienia
i przesuwało cuspy domów o ~3″.
Framework wyroczni tego NIE wykryje: z założenia podaje to samo ε obu
stronom, żeby izolować samą funkcję domów. Dlatego sprawdzian jest tutaj,
ze Skyfieldem jako niezależnym autorytetem."""
from datetime import datetime, timezone
from skyfield.api import load
from skyfield.nutationlib import mean_obliquity
from app.engine.models import ChartMoment
from app.engine.skyfield_engine import SkyfieldEngine, true_obliquity
when = datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc)
t = load.timescale().from_datetime(when)
_, eps = SkyfieldEngine().sidereal(ChartMoment(when_utc=when, lat=50.06, lon=19.94))
assert eps == pytest.approx(true_obliquity(t), abs=1e-12)
eps_mean = float(mean_obliquity(t.tdb)) / 3600.0
assert eps != pytest.approx(eps_mean, abs=1e-9), "ε jest średnie — nutacja zgubiona"
# nutacja w nachyleniu nie przekracza ~9,2″; większa różnica to nie nutacja
assert abs(eps - eps_mean) * 3600.0 < 9.3
def test_true_obliquity_stays_in_a_sane_band_across_the_date_range():
"""ε maleje ~0,013°/wiek. Na całym zakresie programu (370 p.n.e. 2270 n.e.)
musi zostać w widełkach 23,223,8° — wyjście oznacza błąd jednostek."""
from datetime import datetime, timezone
from skyfield.api import load
from app.engine.skyfield_engine import true_obliquity
ts = load.timescale()
for year in (1600, 1900, 2000, 2100):
t = ts.from_datetime(datetime(year, 6, 1, tzinfo=timezone.utc))
assert 23.2 < true_obliquity(t) < 23.8, year
# ── przypisanie obiektu do domu ──────────────────────────────────────────
REVERSED = (108.456, 23.4393, -84.324) # tu domy biegną WSTECZ
def test_cusps_can_run_backwards_at_extreme_latitudes():
"""Nie jest to błąd, tylko własność tych systemów — wyrocznia zwraca to samo.
Test istnieje po to, żeby nikt tego „nie naprawił"."""
ramc, eps, lat = REVERSED
c = H.cusps_for(ramc, eps, lat, "regiomontanus")
assert c[1] < c[0] and c[2] < c[1], "kolejność miała być odwrócona"
assert not H._runs_forward(c)
assert H._runs_forward(H.cusps_for(ramc, eps, 50.0, "regiomontanus"))
@pytest.mark.parametrize("system", H.SYSTEMS)
def test_every_point_lands_in_exactly_one_house(system):
"""Cuspy dzielą okrąg bez luk i bez zakładek, więc każdy punkt ma dokładnie
jeden dom, a cusp trafia do domu, który zaczyna. Sprawdzian SAMOSPÓJNOŚCI —
działa bez swissepha, także tam, gdzie sama wyrocznia przeczy sobie
(topocentric powyżej koła podbiegunowego)."""
for ramc, eps, lat in (REVERSED, (100.0, 23.4393, 50.06), (210.33, 23.4393, 72.675)):
try:
cusps = H.cusps_for(ramc, eps, lat, system)
except H.HouseSystemUndefined:
continue
for i, cusp in enumerate(cusps):
assert H.assign_house(cusp, cusps) == i + 1, f"{system}: cusp {i+1}"
seen = {H.assign_house(lon / 4.0, cusps) for lon in range(0, 1440)}
assert seen == set(range(1, 13)), f"{system}: puste albo zdublowane domy {sorted(seen)}"
def test_assign_house_follows_the_cusps_not_a_fixed_direction():
"""Regresja: zaszyte „zawsze do przodu" dawało przy |φ| za kołem podbiegunowym
złe domy dla systemów dzielących koła wielkie — mimo cuspów zgodnych
z wyrocznią co do zera. Błąd był cichy: wykres wyglądał poprawnie."""
ramc, eps, lat = REVERSED
cusps = H.cusps_for(ramc, eps, lat, "regiomontanus")
# 1,7822° leży między cuspem 6 (4,458°) a 7 (354,228°) — patrząc WSTECZ
assert H.assign_house(1.7822, cusps) == 6