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
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>
297 lines
13 KiB
Python
297 lines
13 KiB
Python
"""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 (-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,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
|