"""Tabele pomocnicze horoskopu (LOG-23). Zbiór wyliczeń, które astrolog czyta „obok" pozycji: bilans żywiołów i jakości, faza Księżyca, stopnie krytyczne, dzień i godziny planetarne, syzygia prenatalna oraz podziały (dwunastniki i nawamsa). Dwie rzeczy wymagają prawdziwego liczenia, nie tabelki: * **godziny planetarne** — są NIERÓWNE: dzień od wschodu do zachodu Słońca dzieli się na 12 części, noc osobno. Bez faktycznego wschodu/zachodu wynik byłby zmyślony, więc szukamy ich numerycznie (przejście wysokości Słońca przez −0°50′); * **syzygia prenatalna** — ostatni nów albo pełnia PRZED urodzeniem; szukamy wstecz momentu, w którym elongacja Księżyca przechodzi przez 0° lub 180°. Moduł jest silnik-agnostyczny: potrzebuje tylko `positions()` i `sidereal()`. """ from __future__ import annotations import math from datetime import datetime, timedelta from app.engine.formats import SIGNS, in_sign, norm360, sign_index from app.engine.models import ChartMoment from app.engine.zodiac import to_equatorial # --- żywioły i jakości ------------------------------------------------------ ELEMENTS = ["Fire", "Earth", "Air", "Water"] QUALITIES = ["Cardinal", "Fixed", "Mutable"] ELEMENT_PL = {"Fire": "Ogień", "Earth": "Ziemia", "Air": "Powietrze", "Water": "Woda"} QUALITY_PL = {"Cardinal": "Kardynalny", "Fixed": "Stały", "Mutable": "Zmienny"} CLASSICAL = ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn"] MODERN = CLASSICAL + ["Uranus", "Neptune", "Pluto"] # --- dzień i godziny planetarne -------------------------------------------- # Kolejność chaldejska: od najwolniejszej do najszybszej planety CHALDEAN = ["Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury", "Moon"] # Władca dnia wg dnia tygodnia (0 = poniedziałek, jak w datetime.weekday()) WEEKDAY_RULER = ["Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Sun"] # wysokość środka tarczy Słońca przy wschodzie/zachodzie (refrakcja + promień tarczy) SUNRISE_ALTITUDE = -0.833 def element_of(sign: str) -> str: return ELEMENTS[SIGNS.index(sign) % 4] def quality_of(sign: str) -> str: return QUALITIES[SIGNS.index(sign) % 3] def tally(positions: list[dict], asc_sign: str | None = None, modern: bool = True) -> dict: """Bilans żywiołów i jakości (LOG-23). Liczymy w dwóch wariantach naraz, bo szkoły się różnią: 7 planet klasycznych i 10 z nowożytnymi. Ascendent doliczany osobno — bywa traktowany jak punkt równorzędny planetom. """ wanted = MODERN if modern else CLASSICAL by_name = {p.get("name"): p for p in positions} def count(names: list[str], with_asc: bool) -> dict: elements = dict.fromkeys(ELEMENTS, 0) qualities = dict.fromkeys(QUALITIES, 0) used = [] for name in names: p = by_name.get(name) if not p or not p.get("sign"): continue elements[element_of(p["sign"])] += 1 qualities[quality_of(p["sign"])] += 1 used.append(name) if with_asc and asc_sign: elements[element_of(asc_sign)] += 1 qualities[quality_of(asc_sign)] += 1 used.append("Asc") return {"elements": elements, "qualities": qualities, "counted": used, "total": len(used)} classical = count(CLASSICAL, False) result = { "classical_7": classical, "with_modern_10": count(wanted, False), "classical_7_plus_asc": count(CLASSICAL, True), "with_modern_10_plus_asc": count(wanted, True), } # brakujące żywioły — klasyczne „no air" itd., podstawa pod scoring (LOG-21) base = result["with_modern_10_plus_asc"] result["missing_elements"] = [e for e, n in base["elements"].items() if n == 0] result["missing_qualities"] = [q for q, n in base["qualities"].items() if n == 0] result["labels"] = {"elements": ELEMENT_PL, "qualities": QUALITY_PL} return result # --- faza Księżyca ---------------------------------------------------------- _PHASES = [ (0.0, "New Moon", "Nów"), (45.0, "Waxing Crescent", "Sierp przybywający"), (90.0, "First Quarter", "Pierwsza kwadra"), (135.0, "Waxing Gibbous", "Garb przybywający"), (180.0, "Full Moon", "Pełnia"), (225.0, "Waning Gibbous", "Garb ubywający"), (270.0, "Last Quarter", "Ostatnia kwadra"), (315.0, "Waning Crescent", "Sierp ubywający"), ] def moon_phase(sun_lon: float, moon_lon: float) -> dict: """Faza Księżyca z elongacji (Księżyc − Słońce).""" angle = norm360(moon_lon - sun_lon) idx = int(((angle + 22.5) % 360.0) // 45.0) _, name, name_pl = _PHASES[idx] illumination = (1.0 - math.cos(math.radians(angle))) / 2.0 return { "angle": round(angle, 4), "phase": name, "phase_pl": name_pl, "illumination": round(illumination, 4), "waxing": angle < 180.0, } # --- stopnie krytyczne ------------------------------------------------------ # klasyczne stopnie krytyczne zależą od jakości znaku _CRITICAL = {"Cardinal": (0, 13, 26), "Fixed": (8, 21), "Mutable": (4, 17)} CRITICAL_ORB = 1.0 def critical_degrees(positions: list[dict]) -> list[dict]: """Obiekty stojące na stopniach krytycznych, 0° albo 29° (anaretycznym).""" out = [] for p in positions: lon = p.get("decimal") sign = p.get("sign") if lon is None or not sign: continue deg = norm360(lon) - sign_index(lon) * 30.0 flags = [] for critical in _CRITICAL[quality_of(sign)]: if abs(deg - critical) <= CRITICAL_ORB: flags.append(f"stopień krytyczny {critical}° ({QUALITY_PL[quality_of(sign)].lower()})") if deg >= 29.0: flags.append("29° — stopień anaretyczny (koniec znaku)") elif deg < 1.0: flags.append("0° — wejście w znak") if flags: out.append({"name": p.get("name"), "sign": sign, "in_sign": p.get("in_sign"), "flags": flags}) return out # --- podziały: dwunastnik i nawamsa ---------------------------------------- def dwadasamsa(lon: float) -> float: """12. część (dwadasamsa): znak dzielony na 12 po 2°30′, licząc od siebie.""" lon = norm360(lon) start = sign_index(lon) * 30.0 return norm360(start + (lon - start) * 12.0) def navamsa(lon: float) -> float: """9. część (nawamsa): 108 podziałów po 3°20′ liczonych od 0° Barana.""" lon = norm360(lon) part = int(lon // (30.0 / 9.0)) return norm360((part % 12) * 30.0 + (lon % (30.0 / 9.0)) * 9.0) def divisional(positions: list[dict]) -> list[dict]: """Pozycje w podziałach 12. i 9. — obie tabele naraz.""" out = [] for p in positions: lon = p.get("decimal") if lon is None: continue d12, d9 = dwadasamsa(lon), navamsa(lon) out.append({ "name": p.get("name"), "d12_sign": SIGNS[sign_index(d12)], "d12_in_sign": in_sign(d12), "d9_sign": SIGNS[sign_index(d9)], "d9_in_sign": in_sign(d9), }) return out # --- wschód/zachód Słońca i godziny planetarne ------------------------------ def sun_altitude(engine, moment: ChartMoment) -> float: """Wysokość Słońca nad horyzontem [°] dla momentu i miejsca.""" ramc, eps = engine.sidereal(moment) sun = engine.positions(moment, ["Sun"])[0] ra, dec = to_equatorial(sun.longitude, sun.latitude, eps) hour_angle = math.radians(norm360(ramc - ra)) phi, d = math.radians(moment.lat), math.radians(dec) sin_alt = math.sin(d) * math.sin(phi) + math.cos(d) * math.cos(phi) * math.cos(hour_angle) return math.degrees(math.asin(max(-1.0, min(1.0, sin_alt)))) def _at(moment: ChartMoment, when: datetime) -> ChartMoment: return ChartMoment(when_utc=when, lat=moment.lat, lon=moment.lon) def _crossings(engine, moment: ChartMoment, start: datetime, end: datetime, step_minutes: int = 20) -> list[tuple[datetime, str]]: """Momenty przejścia Słońca przez horyzont w oknie [start, end]. Skan zgrubny + bisekcja — ten sam wzorzec co przy stacjach planet (LOG-03). """ out: list[tuple[datetime, str]] = [] step = timedelta(minutes=step_minutes) t0 = start f0 = sun_altitude(engine, _at(moment, t0)) - SUNRISE_ALTITUDE while t0 < end: t1 = min(t0 + step, end) f1 = sun_altitude(engine, _at(moment, t1)) - SUNRISE_ALTITUDE if f0 == 0.0 or (f0 < 0.0) != (f1 < 0.0): lo, hi, flo = t0, t1, f0 for _ in range(40): # ~sekundowa dokładność mid = lo + (hi - lo) / 2 fmid = sun_altitude(engine, _at(moment, mid)) - SUNRISE_ALTITUDE if (flo < 0.0) != (fmid < 0.0): hi = mid else: lo, flo = mid, fmid out.append((lo + (hi - lo) / 2, "sunrise" if f1 > f0 else "sunset")) t0, f0 = t1, f1 return out def planetary_hours(engine, moment: ChartMoment) -> dict | None: """Dzień i godziny planetarne w porządku chaldejskim (LOG-23). Godziny są NIERÓWNE: dzień (wschód→zachód) i noc (zachód→wschód) dzielą się na 12 części każde. Doba planetarna zaczyna się o WSCHODZIE, nie o północy — dlatego władcę dnia bierzemy z dnia tygodnia tego wschodu, który otworzył bieżący okres. Zwraca None dla dnia polarnego/nocy polarnej, gdzie wschód nie występuje. """ now = moment.when_utc events = _crossings(engine, moment, now - timedelta(hours=30), now + timedelta(hours=30)) if not events: return None # brak wschodu/zachodu w oknie before = [e for e in events if e[0] <= now] after = [e for e in events if e[0] > now] if not before or not after: return None last_time, last_kind = before[-1] next_time, _ = after[0] daytime = last_kind == "sunrise" period_start, period_end = last_time, next_time # doba planetarna startuje o wschodzie: w nocy to wschód POPRZEDZAJĄCY zachód day_start = last_time if daytime else next((t for t, k in reversed(before) if k == "sunrise"), last_time) length = (period_end - period_start) / 12 index = int((now - period_start) / length) index = max(0, min(11, index)) day_ruler = WEEKDAY_RULER[day_start.weekday()] hour_number = index if daytime else index + 12 # 0..23 od wschodu ruler = CHALDEAN[(CHALDEAN.index(day_ruler) + hour_number) % 7] hours = [] for i in range(12): start = period_start + length * i hours.append({ "index": i + 1, "ruler": CHALDEAN[(CHALDEAN.index(day_ruler) + (i if daytime else i + 12)) % 7], "start": start.isoformat(timespec="seconds"), "end": (start + length).isoformat(timespec="seconds"), "current": i == index, }) return { "day_ruler": day_ruler, "hour_ruler": ruler, "hour_number": hour_number + 1, "daytime": daytime, "period": "dzień" if daytime else "noc", "hour_length_minutes": round(length.total_seconds() / 60.0, 2), "period_start": period_start.isoformat(timespec="seconds"), "period_end": period_end.isoformat(timespec="seconds"), "hours": hours, } # --- syzygia prenatalna ----------------------------------------------------- def prenatal_syzygy(engine, moment: ChartMoment, max_days: float = 32.0) -> dict | None: """Ostatni nów albo pełnia PRZED podanym momentem (LOG-23). Szukamy wstecz przejścia elongacji przez 0° (nów) lub 180° (pełnia); bierzemy to, które wypadło później. Cykl trwa ~29,5 dnia, więc okno 32 dni wystarcza. """ def elongation(when: datetime) -> float: pts = {p.name: p.longitude for p in engine.positions(_at(moment, when), ["Sun", "Moon"])} return norm360(pts["Moon"] - pts["Sun"]) def signed(when: datetime, target: float) -> float: """Odległość od celu w [−180, 180] — zeruje się dokładnie w syzygii.""" return ((elongation(when) - target + 180.0) % 360.0) - 180.0 best: tuple[datetime, str] | None = None for target, kind in ((0.0, "new_moon"), (180.0, "full_moon")): step = timedelta(hours=6) t1 = moment.when_utc f1 = signed(t1, target) scanned = timedelta() while scanned < timedelta(days=max_days): t0 = t1 - step f0 = signed(t0, target) if (f0 < 0.0) != (f1 < 0.0) and abs(f0 - f1) < 180.0: lo, hi, flo = t0, t1, f0 for _ in range(40): mid = lo + (hi - lo) / 2 fmid = signed(mid, target) if (flo < 0.0) != (fmid < 0.0): hi = mid else: lo, flo = mid, fmid found = lo + (hi - lo) / 2 if best is None or found > best[0]: best = (found, kind) break t1, f1 = t0, f0 scanned += step if best is None: return None when, kind = best pts = {p.name: p.longitude for p in engine.positions(_at(moment, when), ["Sun", "Moon"])} lon = pts["Sun"] if kind == "new_moon" else pts["Moon"] return { "type": kind, "type_pl": "nów" if kind == "new_moon" else "pełnia", "when_utc": when.isoformat(timespec="seconds"), "days_before_birth": round((moment.when_utc - when).total_seconds() / 86400.0, 3), "sign": SIGNS[sign_index(lon)], "in_sign": in_sign(lon), "decimal": round(norm360(lon), 6), } # --- złożenie wszystkiego --------------------------------------------------- def build_tables(engine, moment: ChartMoment, chart: dict, heavy: bool = True) -> dict: """Komplet tabel dla policzonego horoskopu. `heavy=False` pomija wyliczenia wymagające szukania numerycznego (godziny planetarne, syzygia) — przydatne, gdy liczy się czas odpowiedzi. """ positions = chart.get("positions") or [] by_name = {p.get("name"): p for p in positions} asc_sign = (chart.get("angles") or {}).get("Asc", {}).get("sign") out: dict = { "tally": tally(positions, asc_sign), "critical_degrees": critical_degrees(positions), "divisional": divisional(positions), } if "Sun" in by_name and "Moon" in by_name: out["moon_phase"] = moon_phase(by_name["Sun"]["decimal"], by_name["Moon"]["decimal"]) if heavy: out["planetary_hours"] = planetary_hours(engine, moment) out["prenatal_syzygy"] = prenatal_syzygy(engine, moment) return out