"""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