Files
astrololo/services/logic/tests/test_houses.py
T
gitea 21a00b0000
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m36s
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 24s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 10s
build-swisseph / build (push) Successful in 18s
build / build (push) Successful in 19s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m37s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 16s
Testy / Kontrola składni wszystkich warstw (push) Successful in 9s
feat(silnik B): endpoint /houses + nocny przemiał; ε PRAWDZIWE zamiast średniego
Etap 4, z jedną istotną zmianą planu i jednym znalezionym błędem.

/houses W SILNIKU B
Domyka kontrakt parzystości (LOG-28) po stronie domów — dotąd obejmował tylko
pozycje obiektów, więc błąd w podziale na domy przechodził przez porównanie
silników niezauważony. Nazwy systemów są NASZE (te same, co houses.SYSTEMS),
więc wołający nie musi znać liter swissepha; rozjazd tych dwóch list oznaczałby,
że parzystość przestała obejmować część systemów.

Poza dziedziną (Placidus/Koch za kołem podbiegunowym) zwracamy 422 z powodem,
a NIE podstawiamy po cichu innego systemu — cicha podmiana jest po stronie
wołającego niewykrywalna, a to on ma zdecydować, co z tym zrobić.

PRZEMIAŁ: NOCNE CI ZAMIAST CRONJOBA W KLASTRZE
Plan zakładał Job w k3s, bo „duży przemiał jest kosztowny". Pomiar tego nie
potwierdził: 500 000 przypadków × 13 systemów = 70 mln porównań w 64 sekundy,
skalowanie liniowe (20k→2,8 s, 100k→12,3 s, 500k→64 s). Osobny obraz w rejestrze,
manifest, CronJob i kopia harnessu poza repo byłyby infrastrukturą do problemu,
którego nie ma — a kopia harnessu poza repo to ryzyko cichego rozjazdu z kodem,
który ma testować. Workflow z harmonogramem daje to samo: co noc inne ziarno,
więc dziedzina przeczesuje się z czasem gęściej niż pojedynczym przebiegiem.

ε PRAWDZIWE — BŁĄD ZNALEZIONY PRZY OKAZJI
Silnik liczył RAMC z GAST (czas gwiazdowy POZORNY, mierzony od równonocy
PRAWDZIWEJ), ale parował go z ε ŚREDNIM, czyli bez nutacji. To nie wybór
konwencji, tylko pomieszanie dwóch układów odniesienia. Skutek: do 3,2″ na
cuspach domów oraz niespójne ε dla deklinacji i antyscji, liczonych z pozycji
POZORNYCH. Teraz ε pochodzi z serii IAU 2000A — z tego samego źródła, którego
Skyfield używa do GAST, więc oba są spójne z definicji.

Framework wyroczni tego NIE MÓGŁ wykryć: z założenia podaje to samo ε obu
stronom, żeby izolować samą funkcję domów. Błąd siedział w danych WEJŚCIOWYCH,
nie w testowanej funkcji — i cały czas świecił na zielono. Wejście ma więc teraz
własny sprawdzian, ze Skyfieldem jako niezależnym autorytetem (bez swissepha,
więc działa w każdym środowisku). Luka opisana wprost w tests/oracle/README.md,
bo poprzedni tekst twierdził, że ε jest testowane — nie było.

Reszta ~3″ przy porównaniu „cały horoskop nasz vs swissepha" to UT1 kontra UTC:
Skyfield konwertuje z tablic IERS, swisseph przyjmuje podany JD jako UT1 (dla
1984-04-30 różnica 0,181 s = 2,7″ RAMC — zgadza się co do trzeciego miejsca).
Podanie swissephowi JD w UT1 kasuje ją do 0,00065″. Nasza strona jest dokładniejsza;
niczego tu nie zmieniam.

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

297 lines
13 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
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