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)