From 012e4f39f603553aa5496305350a2a1fa8e16bc4 Mon Sep 17 00:00:00 2001 From: migatu Date: Thu, 23 Jul 2026 20:20:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(logic):=20aspekty=20pozazodiakalne=20?= =?UTF-8?q?=E2=80=94=20antyscja=20i=20paralele=20deklinacji=20(LOG-07)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/astrololo_wymagania.xlsx | Bin 34092 -> 34091 bytes services/logic/app/engine/chart.py | 21 +++ services/logic/app/engine/out_of_zodiac.py | 140 ++++++++++++++++ services/logic/app/main.py | 6 +- services/logic/app/significators.py | 32 +++- services/logic/tests/test_out_of_zodiac.py | 157 ++++++++++++++++++ services/logic/tests/test_significators.py | 36 ++++ .../presentation/app/templates/chart.html | 38 +++++ 8 files changed, 425 insertions(+), 5 deletions(-) create mode 100644 services/logic/app/engine/out_of_zodiac.py create mode 100644 services/logic/tests/test_out_of_zodiac.py diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx index 684281f3625cc4f2dc3905eff1cd4da593345a82..c28c810ab2bc986dc96579dfb626060fb6207638 100644 GIT binary patch delta 10566 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 diff --git a/services/logic/app/engine/chart.py b/services/logic/app/engine/chart.py index 42e0029..eccdc74 100644 --- a/services/logic/app/engine/chart.py +++ b/services/logic/app/engine/chart.py @@ -37,12 +37,33 @@ def _shift_pos(pdict: dict, off: float) -> None: def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN, lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict: from app.engine.aspects import find_aspects + from app.engine.houses import mean_obliquity + from app.engine.out_of_zodiac import ( + declination, + find_antiscia, + find_declination_aspects, + is_out_of_bounds, + ) positions = engine.positions(moment) result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]} # aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06) + # Aspekty pozazodiakalne (LOG-07): deklinacja i antyscja liczone na + # współrzędnych TROPIKALNYCH of-date — deklinacja jest fizyczna (równikowa), + # a antyscja z definicji tropikalna. Dlatego PRZED przesunięciem na zodiak, + # z surowych długości/szerokości silnika. + eps = mean_obliquity(Z.julian_day(moment.when_utc)) + result["obliquity"] = round(eps, 6) + for pdict, obj in zip(result["positions"], positions): + dec = declination(obj.longitude, obj.latitude, eps) + pdict["declination"] = round(dec, 4) + if is_out_of_bounds(dec, eps): + pdict["out_of_bounds"] = True + result["parallels"] = find_declination_aspects(result["positions"]) + result["antiscia"] = find_antiscia(result["positions"]) + # offset zodiaku (LOG-04): syderyczny = ayanamsa, draconic = długość węzła node_lon = next((p.longitude for p in positions if p.name == "North Node"), None) off = Z.offset(zodiac, Z.julian_day(moment.when_utc), node_lon) diff --git a/services/logic/app/engine/out_of_zodiac.py b/services/logic/app/engine/out_of_zodiac.py new file mode 100644 index 0000000..50d753a --- /dev/null +++ b/services/logic/app/engine/out_of_zodiac.py @@ -0,0 +1,140 @@ +"""Aspekty pozazodiakalne (LOG-07): paralele deklinacji i antyscja. + +Aspekty głowne (LOG-06) mierzą kąt wzdłuż EKLIPTYKI. Ale dwa ciała mogą być +powiązane też inaczej — a te powiązania klasyczna astrologia liczy naprawdę, +nie na oko: + +* **Paralela / kontrparalela deklinacji.** Deklinacja to „szerokość" na równiku + niebieskim — jak daleko na północ/południe od równika stoi ciało. Dwa ciała na + tej samej deklinacji (parallel) działają jak koniunkcja, na przeciwnej + (kontrparalela) — jak opozycja, mimo że wzdłuż ekliptyki mogą być gdziekolwiek. + Baza interpretacyjna zna to zjawisko pod skrótem „P. Dec.". + +* **Antyscja / kontrantyscja.** Odbicie punktu względem osi przesileń + (0° Raka – 0° Koziorożca). Dwa punkty w antyscji są równo odległe od tej osi — + „dzielą" tę samą długość dnia. Kontrantyscja to odbicie względem osi + równonocy (0° Barana – 0° Wagi). + +Wszystko liczymy na współrzędnych TROPIKALNYCH of-date, bo: + - deklinacja jest wielkością fizyczną (równikową), niezależną od wyboru zodiaku; + - antyscja jest z definicji tropikalna — jej oś to punkty przesileń, czyli + kardynalne punkty zodiaku tropikalnego. +Dlatego moduł bierze surowe długości/szerokości z silnika, a nie etykiety po +przesunięciu na zodiak syderyczny/draconiczny. +""" +from __future__ import annotations + +from app.engine.aspects import LUMINARIES, RIGID_PAIRS, separation +from app.engine.formats import norm360 +from app.engine.zodiac import to_equatorial + +# Orby — konfigurowalne (wymóg LOG-07). Paralele i antyscja są klasycznie CIASNE: +# to kontakty „punktowe", więc szeroki orb produkowałby fałszywe trafienia. +DECL_ORB = 1.0 # ° deklinacji dla paraleli/kontrparaleli +ANTISCIA_ORB = 1.0 # ° długości dla antyscji/kontrantyscji +LUMINARY_BONUS = 0.0 # świadomie 0 — te kontakty trzymamy ciasno; do podniesienia w API + + +def _is_rigid(name_a: str, name_b: str) -> bool: + return frozenset({name_a, name_b}) in RIGID_PAIRS + + +def declination(lon: float, lat: float, eps: float) -> float: + """Deklinacja [−90, 90]° z długości i szerokości ekliptycznej (pełny wzór, + z szerokością — istotne dla Księżyca i planet, które schodzą z ekliptyki).""" + _, dec = to_equatorial(lon, lat, eps) + return dec + + +def is_out_of_bounds(dec: float, eps: float) -> bool: + """Deklinacja poza zakresem Słońca (|dec| > nachylenie ekliptyki). + + „Out of bounds" — ciało zaszło dalej na północ/południe, niż Słońce kiedykolwiek + potrafi. Astrologicznie czytane jako działanie „poza normą", stąd wart odnotowania.""" + return abs(dec) > eps + + +def antiscion(lon: float) -> float: + """Odbicie długości względem osi przesileń (0° Raka – 0° Koziorożca).""" + return norm360(180.0 - lon) + + +def contra_antiscion(lon: float) -> float: + """Odbicie długości względem osi równonocy (0° Barana – 0° Wagi).""" + return norm360(-lon) + + +def _allowed(orb: float, name_a: str, name_b: str, bonus: float) -> float: + if bonus and (name_a in LUMINARIES or name_b in LUMINARIES): + return orb + bonus + return orb + + +def find_declination_aspects( + bodies: list[dict], orb: float = DECL_ORB, luminary_bonus: float = LUMINARY_BONUS +) -> list[dict]: + """bodies: dicty z 'name' i 'declination' (°). + + Zwraca paralele (ta sama deklinacja) i kontrparalele (przeciwna). Pary z + RIGID_PAIRS pomijane — np. węzły są z definicji zawsze w kontrparaleli + (SN = NN+180 na ekliptyce → deklinacja przeciwna), co nie niesie informacji. + """ + out: list[dict] = [] + n = len(bodies) + for i in range(n): + for j in range(i + 1, n): + a, b = bodies[i], bodies[j] + if _is_rigid(a["name"], b["name"]): + continue + da, db = a.get("declination"), b.get("declination") + if da is None or db is None: + continue + da, db = float(da), float(db) + allowed = _allowed(orb, a["name"], b["name"], luminary_bonus) + parallel_dev = abs(da - db) + contra_dev = abs(da + db) + # ciało może wpaść tylko w jeden z dwóch — bierzemy ciaśniejszy + if parallel_dev <= allowed and parallel_dev <= contra_dev: + out.append(_row("parallel", a, b, parallel_dev, allowed, da, db)) + elif contra_dev <= allowed: + out.append(_row("contraparallel", a, b, contra_dev, allowed, da, db)) + return out + + +def find_antiscia( + bodies: list[dict], orb: float = ANTISCIA_ORB, luminary_bonus: float = LUMINARY_BONUS +) -> list[dict]: + """bodies: dicty z 'name' i 'decimal' (długość tropikalna of-date, °). + + Zwraca antyscje (odbicie względem osi przesileń) i kontrantyscje (osi równonocy). + """ + out: list[dict] = [] + n = len(bodies) + for i in range(n): + for j in range(i + 1, n): + a, b = bodies[i], bodies[j] + if _is_rigid(a["name"], b["name"]): + continue + la, lb = a.get("decimal"), b.get("decimal") + if la is None or lb is None: + continue + la, lb = float(la), float(lb) + allowed = _allowed(orb, a["name"], b["name"], luminary_bonus) + anti_dev = separation(la, antiscion(lb)) + contra_dev = separation(la, contra_antiscion(lb)) + if anti_dev <= allowed and anti_dev <= contra_dev: + out.append(_row("antiscion", a, b, anti_dev, allowed)) + elif contra_dev <= allowed: + out.append(_row("contra_antiscion", a, b, contra_dev, allowed)) + return out + + +def _row(kind: str, a: dict, b: dict, dev: float, allowed: float, + dec_a: float | None = None, dec_b: float | None = None) -> dict: + row = { + "obj1": a["name"], "obj2": b["name"], + "type": kind, "orb": round(dev, 3), "allowed": round(allowed, 3), + } + if dec_a is not None: + row["dec1"], row["dec2"] = round(dec_a, 3), round(dec_b, 3) + return row diff --git a/services/logic/app/main.py b/services/logic/app/main.py index a1e30a4..131e46d 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -124,7 +124,8 @@ def chart_report(req: ReportRequest) -> dict: try: report = build_report( chart["positions"], DataClient(), - aspects=chart.get("aspects"), per_object_limit=req.limit, group=req.group, + aspects=chart.get("aspects"), parallels=chart.get("parallels"), + per_object_limit=req.limit, group=req.group, ) except httpx.HTTPError as e: return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"} @@ -188,7 +189,8 @@ def chart_prompt(req: PromptRequest) -> dict: try: report = build_report( chart["positions"], DataClient(), - aspects=chart.get("aspects"), per_object_limit=req.limit, + aspects=chart.get("aspects"), parallels=chart.get("parallels"), + per_object_limit=req.limit, ) except httpx.HTTPError as e: data_error = f"Warstwa danych niedostępna: {e}" diff --git a/services/logic/app/significators.py b/services/logic/app/significators.py index a029807..d5722f9 100644 --- a/services/logic/app/significators.py +++ b/services/logic/app/significators.py @@ -114,17 +114,24 @@ def build_report( positions: list[dict], data: DataSource, aspects: list[dict] | None = None, + parallels: list[dict] | None = None, per_object_limit: int = 5000, group: bool = False, ) -> dict: """positions: pozycje z build_chart (name, sign, direction, house). - Dla każdego obiektu fasety: „w znaku", „w domu" oraz „w aspekcie" (dla każdego - aspektu głównego z listy `aspects`, jeśli w bazie są dopasowania). Duplikaty - (ten sam sygnifikator i opis) są odsiewane wewnątrz każdej fasety. + Dla każdego obiektu fasety: „w znaku", „w domu", „w aspekcie" (dla każdego + aspektu głównego z listy `aspects`) oraz „paralela deklinacji" (dla paraleli + z listy `parallels` — LOG-07). Duplikaty (ten sam sygnifikator i opis) są + odsiewane wewnątrz każdej fasety. """ from app.engine.aspects import DB_TOKEN as ASP_TOKEN, PL_NAME as ASP_NAME + # W bazie paralela deklinacji zapisana jest jako fraza „P. Dec." (nie token [). + # UWAGA: 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. + PARALLEL_MARK = "P. Dec." + items: list[dict] = [] provider = None for p in positions: @@ -180,6 +187,25 @@ def build_report( "count": len(asp_samples), "samples": asp_samples, }) + # paralele deklinacji (LOG-07) — baza zna je jako „P. Dec.". Tylko `parallel`: + # kontrparaleli i antyscji baza nie opisuje osobnym znacznikiem. + for par in (parallels or []): + if par.get("type") != "parallel" or name not in (par.get("obj1"), par.get("obj2")): + continue + other = par["obj2"] if par["obj1"] == name else par["obj1"] + if other not in PLANET_ABBR: + continue + other_tok = "[" + PLANET_ABBR[other] + par_samples = _facet_samples(rows, [PARALLEL_MARK, other_tok]) + if not par_samples: # pokazujemy tylko z trafieniami + continue + facets.append({ + "type": "parallel", "label": f"paralela deklinacji z {other}", + "token": f"{PARALLEL_MARK} + {other_tok}", + "orb": par.get("orb"), "allowed": par.get("allowed"), + "count": len(par_samples), "samples": par_samples, + }) + # punktacja siły (LOG-21), opcjonalne grupowanie po opisie, ranking faset for f in facets: f["score"] = score_facet(f) diff --git a/services/logic/tests/test_out_of_zodiac.py b/services/logic/tests/test_out_of_zodiac.py new file mode 100644 index 0000000..3036c0f --- /dev/null +++ b/services/logic/tests/test_out_of_zodiac.py @@ -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) diff --git a/services/logic/tests/test_significators.py b/services/logic/tests/test_significators.py index 7924005..d323e90 100644 --- a/services/logic/tests/test_significators.py +++ b/services/logic/tests/test_significators.py @@ -61,6 +61,42 @@ def test_aspect_facet(): assert asp and asp[0]["count"] == 1 and "Moon" in asp[0]["label"] +def test_parallel_facet_hits_db_and_avoids_decanate_trap(): + """Paralela deklinacji (LOG-07): baza zna ją jako „P. Dec.". Pułapka: samo + „Dec." to często DEKANAT („3rd Dec. of [Gem"), który NIE może wpaść do paraleli.""" + positions = [ + {"name": "Mercury", "sign": "Taurus", "direction": "D", "house": 3}, + {"name": "Saturn", "sign": "Scorpio", "direction": "Rx", "house": 9}, + ] + parallels = [{"obj1": "Mercury", "obj2": "Saturn", "type": "parallel", + "orb": 0.3, "allowed": 1.0}] + data = FakeData({"[Me": [ + {"significator": "[Me at B. in P. Dec. [Sa and [Ma", "actioneffect": "dyscyplina umysłu"}, + {"significator": "3rd Dec. of [Gem on Asc.", "actioneffect": "TO DEKANAT, nie paralela"}, + {"significator": "[Me [conj [Sa", "actioneffect": "inny aspekt, nie paralela"}, + ]}) + par = [f for f in build_report(positions, data, parallels=parallels)["objects"][0]["facets"] + if f["type"] == "parallel"] + assert par and par[0]["count"] == 1 + assert "Saturn" in par[0]["label"] + assert all("Dec. of" not in s["significator"] for s in par[0]["samples"]), "dekanat przeciekł" + + +def test_contraparallel_gets_no_db_facet(): + """Baza nie ma osobnego znacznika dla kontrparaleli — nie zmyślamy fasety.""" + positions = [ + {"name": "Mercury", "sign": "Taurus", "direction": "D", "house": 3}, + {"name": "Saturn", "sign": "Scorpio", "direction": "Rx", "house": 9}, + ] + parallels = [{"obj1": "Mercury", "obj2": "Saturn", "type": "contraparallel", + "orb": 0.3, "allowed": 1.0}] + data = FakeData({"[Me": [ + {"significator": "[Me at B. in P. Dec. [Sa", "actioneffect": "paralela, nie kontr-"}, + ]}) + types = [f["type"] for f in build_report(positions, data, parallels=parallels)["objects"][0]["facets"]] + assert "parallel" not in types + + def test_grouping_by_effect(): positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx", "house": 5}] data = FakeData({"[Ma": [ diff --git a/services/presentation/app/templates/chart.html b/services/presentation/app/templates/chart.html index d73c80f..6eedb7f 100644 --- a/services/presentation/app/templates/chart.html +++ b/services/presentation/app/templates/chart.html @@ -87,6 +87,7 @@ ObiektZnakW znakuDomKier.Prędkość °/d + Dekl. @@ -98,6 +99,7 @@ {{ p.house if p.house is defined else '—' }} {{ p.direction }} {{ '%+.4f' | format(p.speed) }} + {% if p.declination is defined %}{{ '%+.2f°' | format(p.declination) }}{% if p.out_of_bounds %} OOB{% endif %}{% else %}—{% endif %} {% endfor %} @@ -144,6 +146,42 @@ {% endif %} + {# Aspekty pozazodiakalne (LOG-07): paralele deklinacji i antyscja #} + {% if result.parallels %} +
Paralele deklinacji ({{ result.parallels | length }})
+ + + + {% for r in result.parallels %} + + + + + + + + {% endfor %} + +
Obiekt 1RodzajObiekt 2OrbDekl.
{{ r.obj1 }}{{ 'paralela' if r.type == 'parallel' else 'kontrparalela' }}{{ r.obj2 }}{{ '%.2f'|format(r.orb) }}°{{ '%+.2f'|format(r.dec1) }} / {{ '%+.2f'|format(r.dec2) }}
+ {% endif %} + + {% if result.antiscia %} +
Antyscja ({{ result.antiscia | length }})
+ + + + {% for r in result.antiscia %} + + + + + + + {% endfor %} + +
Obiekt 1RodzajObiekt 2Orb
{{ r.obj1 }}{{ 'antyscja' if r.type == 'antiscion' else 'kontrantyscja' }}{{ r.obj2 }}{{ '%.2f'|format(r.orb) }}°
+ {% endif %} + {% if result.tables %} {% set tb = result.tables %}
Tabele dodatkowe (LOG-23)