diff --git a/services/logic/README.md b/services/logic/README.md index 9573385..6c19cf7 100644 --- a/services/logic/README.md +++ b/services/logic/README.md @@ -10,6 +10,7 @@ i nie w bazie. - `POST /chart/report` → `{when_utc, lat, lon, limit?}` → wynik obliczeń wyszukany w bazie: fasety sygnifikatorów **w znaku / w domu / w aspekcie**, z rozwinięciem skrótów, odsiewaniem duplikatów (ten sam sygnifikator i opis), rankingiem siły (LOG-21) oraz opcją group (grupowanie identycznych opisów) - `POST /chart/profections` → `{when_utc, lat, lon, start_age?, count?}` → profekcje roczne: wiek, profektowany Asc, Władca Roku (+MC/Su/Mo) (LOG-10) - `POST /chart/return` → `{when_utc, lat, lon, kind, around?}` → Solar/Lunar Return: moment powrotu + pełny horoskop na ten moment (LOG-12) +- `POST /chart/timeline` → `{when_utc, lat, lon, from_date, to_date, techniques?}` → zbiorcza oś czasu: profekcje + Solar Return + dyrekcje solar-arc, posortowane (technique | significator | start | exact | end) (LOG-14) - `POST /chart/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B) - `GET /health` (sprawdza też warstwę bazodanową) diff --git a/services/logic/app/engine/timeline.py b/services/logic/app/engine/timeline.py new file mode 100644 index 0000000..1e2983b --- /dev/null +++ b/services/logic/app/engine/timeline.py @@ -0,0 +1,131 @@ +"""Zbiorcza tabela dat z technik (LOG-14). + +Spina w jedną, posortowaną oś czasu daty z kilku technik: +- profekcje roczne (LOG-10) — rok życia, +- Solar Return (LOG-12) — moment powrotu Słońca, +- dyrekcje solar-arc — daty dokładnych aspektów kierowanych planet do punktów + natalnych (wzorzec z notes3: „Profection planet | Aspect | Birth planet | + Exact Date"). Klucz łuku konfigurowalny; domyślnie Naiboda (0°59'08"/rok). + +Każdy wiersz ma kształt z notes2: technique | significator | start | exact | end. +""" +from __future__ import annotations + +from datetime import date, datetime, timedelta, timezone + +from app.engine.aspects import MAJOR, PL_NAME +from app.engine.profections import DOMICILE_RULERS, profected_sign +from app.engine.returns import find_return + +NAIBOD_KEY = 0.9856472 # °/rok (0°59'08") — domyślny klucz solar-arc +DAYS_PER_YEAR = 365.2422 +DIRECTED = ["Sun", "Moon", "Mercury", "Venus", "Mars", + "Jupiter", "Saturn", "Uranus", "Neptune", "Pluto"] + + +def _add_years(birth: datetime, years: float) -> datetime: + return birth + timedelta(days=years * DAYS_PER_YEAR) + + +def _row(technique, significator, start, exact, end) -> dict: + def iso(x): + return x.date().isoformat() if isinstance(x, datetime) else x + return {"technique": technique, "significator": significator, + "start": iso(start), "exact": iso(exact), "end": iso(end)} + + +def solar_arc_directions( + natal: dict[str, float], birth: datetime, lo: datetime, hi: datetime, + key: float = NAIBOD_KEY, orb_years: float = 1.0, +) -> list[dict]: + """Daty dyrekcji solar-arc w oknie [lo, hi]. + + natal: nazwa punktu -> długość natalna (planety + Asc/MC). Kierowane są planety + (DIRECTED), celem każdy punkt natalny. Aspekt dokładny gdy łuk = odległość + kątowa (mod 360). Wiek = łuk/klucz; data = urodziny + wiek. + """ + out: list[dict] = [] + lo_age = (lo - birth).days / DAYS_PER_YEAR - orb_years + hi_age = (hi - birth).days / DAYS_PER_YEAR + orb_years + for p in DIRECTED: + if p not in natal: + continue + for q, q_lon in natal.items(): + for asp, angle in MAJOR.items(): + for target in ({angle, (360.0 - angle) % 360.0}): + arc = (q_lon + target - natal[p]) % 360.0 + age = arc / key + if not (lo_age <= age <= hi_age) or (p == q and arc < 1e-6): + continue + exact = _add_years(birth, age) + out.append(_row( + "solar_arc", + f"dyr. {p} {PL_NAME[asp]} {q}", + _add_years(birth, age - orb_years), + exact, + _add_years(birth, age + orb_years), + )) + return out + + +def profection_events(natal_asc: float, birth: datetime, lo: datetime, hi: datetime) -> list[dict]: + """Lata profekcyjne (LOG-10) nachodzące na okno.""" + out: list[dict] = [] + for age in range((lo.year - birth.year) - 1, (hi.year - birth.year) + 1): + if age < 0: + continue + try: + start = birth.replace(year=birth.year + age) + end = birth.replace(year=birth.year + age + 1) + except ValueError: # 29 lutego + start = birth.replace(year=birth.year + age, day=28) + end = birth.replace(year=birth.year + age + 1, day=28) + if end < lo or start > hi: + continue + sign = profected_sign(natal_asc, age) + out.append(_row( + "profection", f"Władca Roku: {DOMICILE_RULERS[sign]} (Asc {sign}, wiek {age})", + start, start, end, + )) + return out + + +def solar_return_events(engine, natal_moment, birth: datetime, lo: datetime, hi: datetime) -> list[dict]: + """Solariusze w oknie (LOG-12) — jeden na rok.""" + out: list[dict] = [] + for year in range(lo.year, hi.year + 1): + try: + around = birth.replace(year=year) + except ValueError: + around = birth.replace(year=year, day=28) + hit = find_return(engine, "solar", natal_moment, around) + if hit and lo <= hit <= hi: + out.append(_row("solar_return", "Solar Return", hit, hit, _add_years(hit, 1))) + return out + + +def _as_dt(d) -> datetime: + if isinstance(d, datetime): + return d if d.tzinfo else d.replace(tzinfo=timezone.utc) + if isinstance(d, date): + return datetime(d.year, d.month, d.day, tzinfo=timezone.utc) + return datetime.fromisoformat(str(d)).replace(tzinfo=timezone.utc) + + +def build_timeline( + engine, natal_moment, natal_points: dict[str, float], + from_d, to_d, techniques: list[str] | None = None, +) -> list[dict]: + """Scala wybrane techniki w jedną oś czasu, posortowaną po dacie dokładnej.""" + lo, hi = _as_dt(from_d), _as_dt(to_d) + birth = natal_moment.when_utc + want = set(techniques or ["profection", "solar_return", "solar_arc"]) + events: list[dict] = [] + if "profection" in want: + events += profection_events(natal_points["Asc"], birth, lo, hi) + if "solar_return" in want: + events += solar_return_events(engine, natal_moment, birth, lo, hi) + if "solar_arc" in want: + events += solar_arc_directions(natal_points, birth, lo, hi) + events.sort(key=lambda e: e["exact"]) + return events diff --git a/services/logic/app/main.py b/services/logic/app/main.py index 7d4bf2e..665d939 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -173,6 +173,33 @@ def chart_return(req: ReturnRequest) -> dict: "return_utc": hit.isoformat(), **chart} +class TimelineRequest(BaseModel): + when_utc: datetime # moment urodzenia (UTC) + lat: float = 0.0 + lon: float = 0.0 + from_date: str # zakres: YYYY-MM-DD + to_date: str + techniques: list[str] | None = None # profection | solar_return | solar_arc + + +@app.post("/chart/timeline") +def chart_timeline(req: TimelineRequest) -> dict: + """Zbiorcza oś czasu z technik (LOG-14): technique | significator | start | exact | end.""" + from app.engine import houses as H + from app.engine.models import ChartMoment + from app.engine.timeline import build_timeline + + engine = get_engine() + natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon) + ramc, eps = engine.sidereal(natal) + points = {"Asc": H.compute_asc(ramc, eps, natal.lat), "MC": H.compute_mc(ramc, eps)} + for p in engine.positions(natal): + points[p.name] = p.longitude + events = build_timeline(engine, natal, points, req.from_date, req.to_date, req.techniques) + return {"engine": engine.name, "from": req.from_date, "to": req.to_date, + "count": len(events), "events": events} + + @app.get("/health") def health() -> dict: info = {"status": "ok", "layer": "logic"} diff --git a/services/logic/tests/test_timeline.py b/services/logic/tests/test_timeline.py new file mode 100644 index 0000000..356c141 --- /dev/null +++ b/services/logic/tests/test_timeline.py @@ -0,0 +1,71 @@ +"""Zbiorcza oś czasu z technik (LOG-14).""" +import datetime as dt + +from app.engine.timeline import ( + NAIBOD_KEY, + build_timeline, + profection_events, + solar_arc_directions, +) + +BIRTH = dt.datetime(1984, 4, 30, 7, 35, tzinfo=dt.timezone.utc) +# natalne długości (z horoskopu referencyjnego) +NATAL = { + "Asc": 112.18, "MC": 352.59, "Sun": 40.14, "Moon": 30.55, "Mercury": 27.38, + "Venus": 27.68, "Mars": 234.54, "Jupiter": 282.96, "Saturn": 223.31, + "Uranus": 252.81, "Neptune": 271.22, "Pluto": 210.48, +} + + +def _win(y0, y1): + return (dt.datetime(y0, 1, 1, tzinfo=dt.timezone.utc), + dt.datetime(y1, 12, 31, tzinfo=dt.timezone.utc)) + + +def test_profection_events_in_window(): + lo, hi = _win(2024, 2026) + rows = profection_events(NATAL["Asc"], BIRTH, lo, hi) + # rok profekcyjny wiek 42 zaczyna się 30.04.2026 -> Asc Capricorn + ages = [r["significator"] for r in rows] + assert any("Asc Capricorn" in a and "wiek 42" in a for a in ages) + + +def test_solar_arc_exact_matches_arc_over_key(): + lo, hi = _win(2020, 2030) + rows = solar_arc_directions(NATAL, BIRTH, lo, hi, key=NAIBOD_KEY) + assert rows, "brak dyrekcji w oknie" + # dla każdej dyrekcji: łuk = (wiek * klucz), a data = urodziny + wiek -> spójne + for r in rows[:5]: + exact = dt.date.fromisoformat(r["exact"]) + age_years = (dt.datetime(exact.year, exact.month, exact.day, tzinfo=dt.timezone.utc) + - BIRTH).days / 365.2422 + assert 36 <= age_years <= 47 # okno 2020-2030 = wiek ~36-46 + assert r["technique"] == "solar_arc" and "dyr." in r["significator"] + + +def test_directed_sun_conjunct_natal_mc_date(): + # Sun natal 40.14 -> MC natal 352.59: łuk koniunkcji = (352.59-40.14)%360 = 312.45 + # to > lifespan przy Naibod (~317 lat) -> NIE powinno być w oknie życia + lo, hi = _win(1984, 2084) + rows = solar_arc_directions(NATAL, BIRTH, lo, hi) + sun_mc = [r for r in rows if r["significator"] == "dyr. Sun koniunkcja MC"] + assert not sun_mc # łuk 312° = poza życiem + + +def test_build_timeline_sorted_and_merged(): + events = build_timeline(_FakeEngine(), _FakeNatal(), NATAL, + "2025-01-01", "2027-01-01", + techniques=["profection", "solar_arc"]) + assert events + dates = [e["exact"] for e in events] + assert dates == sorted(dates) # posortowane po dacie dokładnej + techs = {e["technique"] for e in events} + assert "profection" in techs and "solar_arc" in techs + + +class _FakeNatal: + when_utc = BIRTH + + +class _FakeEngine: + """Silnik-atrapa — build_timeline z solar_return by go użył, tu go pomijamy."""