feat(logic): aspekty pozazodiakalne — antyscja i paralele deklinacji (LOG-07)
build / build (push) Successful in 1m1s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m57s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 28s
Testy / Kontrola składni wszystkich warstw (push) Successful in 24s
build / build (push) Successful in 1m1s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m57s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 28s
Testy / Kontrola składni wszystkich warstw (push) Successful in 24s
Aspekty głowne (LOG-06) mierzą kąt wzdłuż ekliptyki. LOG-07 dokłada dwa rodzaje powiązań, które klasyczna astrologia liczy naprawdę, nie na oko: - PARALELA/KONTRPARALELA DEKLINACJI — dwa ciała na tej samej („parallel") lub przeciwnej („contraparallel") deklinacji działają jak koniunkcja/opozycja, mimo że wzdłuż ekliptyki mogą stać gdziekolwiek. Deklinacja liczona PEŁNYM wzorem (z szerokością ekliptyczną — istotne dla Księżyca i planet schodzących z ekliptyki), przez to_equatorial z LOG-04. Orb konfigurowalny (domyślnie 1°, ciasno — to kontakty punktowe). - ANTYSCJA/KONTRANTYSCJA — odbicie długości względem osi przesileń (0° Raka – 0° Koziorożca) albo równonocy (0° Barana – 0° Wagi). Liczone na współrzędnych TROPIKALNYCH of-date, bo deklinacja jest fizyczna (równikowa, niezależna od zodiaku), a antyscja z definicji tropikalna — jej oś to kardynalne punkty zodiaku tropikalnego. Dlatego PRZED przesunięciem na zodiak syderyczny/draconiczny. Dodatkowo: flaga „out of bounds" (|deklinacja| > nachylenie ekliptyki — ciało poza zakresem Słońca) i kolumna deklinacji przy każdej pozycji. Integracja z bazą: baza interpretacyjna ZNA paralele pod frazą „P. Dec.", więc generujemy fasetkę paraleli (trafia też do promptu LLM). PUŁAPKA: samo „Dec." w bazie to często DEKANAT („3rd Dec. of [Gem"), więc szukamy dokładnie „P. Dec." — inaczej sypnęłoby fałszywymi trafieniami. Kontrparaleli i antyscji baza nie opisuje osobnym znacznikiem, więc zostają obliczeniem display-only. Pary z RIGID_PAIRS (węzły) odsiane: SN = NN+180° na ekliptyce → deklinacja ZAWSZE przeciwna, czyli definicyjna kontrparalela bez informacji. Walidacja wobec faktów NIEZALEŻNYCH od kodu: - deklinacja Słońca 30.04.1984 = +14.88° wobec ~+14.9° z almanachu, - antyscja to arytmetyka odbicia: inwolucja i pary znaków (Rak↔Bliźnięta, Baran↔Panna) sprawdzone na piechotę, - węzły faktycznie mają przeciwną deklinację i są odsiane, - deklinacja niezależna od zodiaku (tropikalny == syderyczny co do 1e-6°), - realna baza: mechanizm paraleli znajduje wpisy „P. Dec." i odsiewa dekanaty (z 33 wpisów „P. Dec." 5 ma niepusty efekt — głównie „[conj or P. Dec."). UI: kolumna deklinacji (+znacznik OOB), tabela paraleli/kontrparaleli i tabela antyscji na ekranie Horoskop. Testy: 15 nowych (out_of_zodiac) + 2 (fasetka paraleli vs pułapka dekanatu). Całość: logika 251 passed / 1 skipped, prezentacja 25 passed. Render szablonu sprawdzony osobno (bez błędu Jinja, wszystkie sekcje obecne). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #23.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user