"""Aspekty pozazodiakalne — paralele deklinacji i antyscja (LOG-07). Walidacja wobec faktów NIEZALEŻNYCH od naszego kodu: * deklinacja Słońca 30.04.1984 ≈ +14.9° (almanach; Słońce w ~10° Byka, koniec kwietnia, deklinacja rośnie ku przesileniu letniemu), * antyscja to czysta arytmetyka odbicia względem osi przesileń — pary znaków (Rak↔Bliźnięta, Baran↔Panna) i inwolucja dają się sprawdzić na piechotę, * węzły są z definicji w kontrparaleli (SN = NN+180° na ekliptyce → deklinacja przeciwna), więc muszą być odsiane jako kontakt bez informacji. """ from datetime import datetime, timezone import pytest from app.engine.chart import build_chart from app.engine.models import ChartMoment from app.engine.out_of_zodiac import ( antiscion, contra_antiscion, declination, find_antiscia, find_declination_aspects, is_out_of_bounds, ) @pytest.fixture(scope="module") def krakow(): return ChartMoment(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc), lat=50.0647, lon=19.9450) @pytest.fixture(scope="module") def chart(own_engine, krakow): return build_chart(own_engine, krakow) # ----------------------------------------------------------------- deklinacja def test_sun_declination_matches_almanac(chart): """Deklinacja Słońca 30.04.1984 ≈ +14.9° (wartość z almanachu).""" sun = next(p for p in chart["positions"] if p["name"] == "Sun") assert 14.6 < sun["declination"] < 15.1 def test_declination_uses_latitude_not_only_longitude(): """Pełny wzór: przy tej samej długości różna szerokość → różna deklinacja. Gdyby liczyć z samej długości (β=0), obie wyszłyby identyczne.""" eps = 23.44 on_ecliptic = declination(60.0, 0.0, eps) # β = 0 above = declination(60.0, 5.0, eps) # 5° na północ od ekliptyki assert abs(above - on_ecliptic) > 3.0 # szerokość realnie zmienia deklinację def test_obliquity_is_reasonable_for_1984(chart): assert 23.43 < chart["obliquity"] < 23.45 def test_out_of_bounds_flag(): eps = 23.44 assert is_out_of_bounds(24.0, eps) is True # poza zakresem Słońca assert is_out_of_bounds(20.0, eps) is False assert is_out_of_bounds(-24.5, eps) is True # także na południe # --------------------------------------------------------------- antyscja (wzór) def test_antiscion_is_reflection_across_solstitial_axis(): assert antiscion(0.0) == pytest.approx(180.0) # 0° Barana → 0° Wagi assert antiscion(105.0) == pytest.approx(75.0) # 15° Raka → 15° Bliźniąt assert antiscion(90.0) == pytest.approx(90.0) # 0° Raka leży NA osi def test_antiscion_is_an_involution(): for lon in (12.3, 88.0, 200.5, 359.9): assert antiscion(antiscion(lon)) == pytest.approx(lon) def test_contra_antiscion_across_equinoctial_axis(): assert contra_antiscion(0.0) == pytest.approx(0.0) # 0° Barana leży NA osi assert contra_antiscion(30.0) == pytest.approx(330.0) # 0° Byka → 0° Ryb (odbicie) def test_sign_pairs_are_classic(): """Antyscja parami znaków: Baran↔Panna, Byk↔Lew, Bliźnięta↔Rak.""" # 5° Barana (5°) → 175° = 25° Panny assert antiscion(5.0) == pytest.approx(175.0) # 10° Byka (40°) → 140° = 20° Lwa assert antiscion(40.0) == pytest.approx(140.0) # --------------------------------------------------- wykrywanie paraleli/antyscji def test_parallel_and_contraparallel_detection(): bodies = [ {"name": "A", "declination": 12.0}, {"name": "B", "declination": 12.4}, # paralela z A (0.4°) {"name": "C", "declination": -11.6}, # kontrparalela z A (|12-11.6|=0.4) {"name": "D", "declination": 5.0}, # nic (daleko) ] res = {(r["obj1"], r["obj2"]): r for r in find_declination_aspects(bodies, orb=1.0)} assert res[("A", "B")]["type"] == "parallel" assert res[("A", "C")]["type"] == "contraparallel" assert ("A", "D") not in res def test_orb_is_configurable(): bodies = [{"name": "A", "declination": 10.0}, {"name": "B", "declination": 11.5}] assert find_declination_aspects(bodies, orb=1.0) == [] # 1.5° > 1.0° assert len(find_declination_aspects(bodies, orb=2.0)) == 1 # mieści się def test_nodes_definitional_contraparallel_is_filtered(chart): """SN = NN+180° na ekliptyce → deklinacja przeciwna ZAWSZE. To kontakt bez informacji, więc pary NN/SN nie może być w wynikach.""" decl = {p["name"]: p["declination"] for p in chart["positions"]} # najpierw potwierdzamy, że deklinacje faktycznie są przeciwne assert decl["North Node"] == pytest.approx(-decl["South Node"], abs=0.05) # a mimo to para NN/SN nie pojawia się na liście paraleli pairs = {frozenset((r["obj1"], r["obj2"])) for r in chart["parallels"]} assert frozenset(("North Node", "South Node")) not in pairs def test_antiscia_detection_with_constructed_longitudes(): # A na 40°, B na 138° → antyscja A leży na 140°, więc B jest 2° od niej bodies = [{"name": "A", "decimal": 40.0}, {"name": "B", "decimal": 138.0}] assert find_antiscia(bodies, orb=1.0) == [] # 2° > 1° hit = find_antiscia(bodies, orb=3.0) assert len(hit) == 1 and hit[0]["type"] == "antiscion" def test_contra_antiscion_detection(): # A na 40°, kontrantyscja A = 320°; B na 320.5° → kontrantyscja w orbie bodies = [{"name": "A", "decimal": 40.0}, {"name": "B", "decimal": 320.5}] hit = find_antiscia(bodies, orb=1.0) assert len(hit) == 1 and hit[0]["type"] == "contra_antiscion" # ------------------------------------------------------ spójność z build_chart def test_chart_exposes_parallels_and_antiscia(chart): assert "parallels" in chart and "antiscia" in chart assert "obliquity" in chart # znany, ciasny kontakt z tego horoskopu: Uran ~paralela~ Neptun (mid-80s) un = next((r for r in chart["parallels"] if {r["obj1"], r["obj2"]} == {"Uranus", "Neptune"}), None) assert un is not None and un["type"] == "parallel" def test_declination_is_zodiac_independent(own_engine, krakow): """Deklinacja jest fizyczna — nie zmienia się z wyborem zodiaku.""" tropical = build_chart(own_engine, krakow, zodiac="tropical") sidereal = build_chart(own_engine, krakow, zodiac="sidereal_lahiri") dt = {p["name"]: p["declination"] for p in tropical["positions"]} ds = {p["name"]: p["declination"] for p in sidereal["positions"]} for name in dt: assert dt[name] == pytest.approx(ds[name], abs=1e-6)