"""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 (90–120°) 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,2–23,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