From b013831492d625db2b1d481dc20c9eb348f71277 Mon Sep 17 00:00:00 2001 From: migatu Date: Thu, 9 Jul 2026 11:12:49 +0200 Subject: [PATCH] Profekcje roczne (LOG-10) i Solar/Lunar Return (LOG-12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pierwsze techniki predykcyjne. Profekcje (LOG-10): - engine/profections.py: Whole Sign, wiek mod 12; profektowany Asc + Władca Roku (władca domicylowy) + profekcje MC/Su/Mo. Obsługa 29 lutego. - endpoint /chart/profections (zakres lat: start_age, count). Returns (LOG-12): - engine/returns.py: moment powrotu Słońca/Księżyca do długości natalnej; skan dobowy + bisekcja do ~sekundy, z pominięciem artefaktu zawinięcia 0/360. - endpoint /chart/return (kind solar|lunar, around) — zwraca pełny horoskop na znaleziony moment (oba warianty użycia po stronie technik wyżej). Walidacja: - profekcje zgodne co do joty z tabelą astro-seek z notes3 (wiek 0..42: Asc + Władca Roku + MC/Su/Mo). - Solar Return 2026: Słońce wraca do Tau 10°08'22" = natalny stopień; Lunar Return trafia natalny Księżyc <2'; samospójność potwierdzona. - 62 testy przechodzą (nowe: test_profections, test_returns). Co-Authored-By: Claude Opus 4.8 --- services/logic/README.md | 2 + services/logic/app/engine/profections.py | 70 ++++++++++++++++++++++++ services/logic/app/engine/returns.py | 64 ++++++++++++++++++++++ services/logic/app/main.py | 56 +++++++++++++++++++ services/logic/tests/test_profections.py | 46 ++++++++++++++++ services/logic/tests/test_returns.py | 40 ++++++++++++++ 6 files changed, 278 insertions(+) create mode 100644 services/logic/app/engine/profections.py create mode 100644 services/logic/app/engine/returns.py create mode 100644 services/logic/tests/test_profections.py create mode 100644 services/logic/tests/test_returns.py diff --git a/services/logic/README.md b/services/logic/README.md index 938a39c..9573385 100644 --- a/services/logic/README.md +++ b/services/logic/README.md @@ -8,6 +8,8 @@ i nie w bazie. - `POST /api/query` → `QueryRequest` → `QueryResponse` - `POST /chart/positions` → `{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty główne z applying/separating (LOG-06) + opcjonalnie stacje planet (`stations:true`, LOG-03). Obiekty: 10 planet + mean NN/SN/Lilith (LOG-02). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`. - `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/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/profections.py b/services/logic/app/engine/profections.py new file mode 100644 index 0000000..b50f948 --- /dev/null +++ b/services/logic/app/engine/profections.py @@ -0,0 +1,70 @@ +"""Profekcje roczne (LOG-10) — hellenistyczna technika time-lord. + +Zasada (Whole Sign): co każde urodziny profektowany Ascendent przeskakuje o jeden +znak do przodu (wiek mod 12). Władca Roku (Lord of Year) = władca domicylowy +znaku profektowanego Asc. Profektować można każdy punkt natalny (MC, Słońce…) — +wszystkie przeskakują o tyle samo znaków. + +Referencja: tabela profekcji w notes3 (astro-seek) dla horoskopu 30.04.1984 +(wiek 0: Can/Moon, 1: Leo/Sun, …, 42: Cap/Saturn). +""" +from __future__ import annotations + +from datetime import datetime + +from app.engine.formats import SIGNS, sign_index + +# władcy domicylowi (tradycyjni) — zgodni z tabelą referencyjną notes3 +DOMICILE_RULERS = { + "Aries": "Mars", "Taurus": "Venus", "Gemini": "Mercury", "Cancer": "Moon", + "Leo": "Sun", "Virgo": "Mercury", "Libra": "Venus", "Scorpio": "Mars", + "Sagittarius": "Jupiter", "Capricorn": "Saturn", "Aquarius": "Saturn", + "Pisces": "Jupiter", +} + + +def age_at(birth_utc: datetime, when_utc: datetime) -> int: + """Pełne lata między urodzeniem a danym momentem (wiek profekcyjny).""" + age = when_utc.year - birth_utc.year + if (when_utc.month, when_utc.day) < (birth_utc.month, birth_utc.day): + age -= 1 + return max(age, 0) + + +def profected_sign(natal_lon: float, age: int) -> str: + """Znak, do którego profektował punkt natalny po `age` latach.""" + return SIGNS[(sign_index(natal_lon) + age) % 12] + + +def profection_rows( + natal_points: dict[str, float], + birth_utc: datetime, + start_age: int, + count: int, +) -> list[dict]: + """Tabela profekcji dla zakresu lat życia. + + natal_points: nazwa -> natalna długość ekliptyczna (musi zawierać 'Asc'). + Każdy wiersz: wiek, data początku roku profekcyjnego (urodziny), znak + profektowanego Asc, Władca Roku oraz profekcje pozostałych punktów. + """ + def _birthday(year: int) -> datetime: + try: + return birth_utc.replace(year=year) + except ValueError: # 29 lutego w roku nieprzestępnym + return birth_utc.replace(year=year, day=28) + + rows: list[dict] = [] + for age in range(start_age, start_age + count): + asc_sign = profected_sign(natal_points["Asc"], age) + row = { + "age": age, + "from": _birthday(birth_utc.year + age).strftime("%Y-%m-%d"), + "profected_asc": asc_sign, + "lord_of_year": DOMICILE_RULERS[asc_sign], + } + for name, lon in natal_points.items(): + if name != "Asc": + row[name] = profected_sign(lon, age) + rows.append(row) + return rows diff --git a/services/logic/app/engine/returns.py b/services/logic/app/engine/returns.py new file mode 100644 index 0000000..5ceda6b --- /dev/null +++ b/services/logic/app/engine/returns.py @@ -0,0 +1,64 @@ +"""Solar / Lunar Return (LOG-12) — moment powrotu do pozycji natalnej. + +Solar Return (solariusz): moment, w którym Słońce wraca dokładnie do natalnej +długości ekliptycznej (raz na rok, w okolicy urodzin). Lunar Return: to samo +dla Księżyca (raz na ~27,3 dnia). Dwa warianty użycia (osobny horoskop vs +tranzyt do natalu) obsługujemy zwracając pełny horoskop na znaleziony moment — +interpretacja pozostaje po stronie technik wyżej. + +Metoda: podpisana różnica długości Δ = lon − natal (zawinięta do ±180°) rośnie +monotonicznie i przechodzi przez 0 dokładnie w momencie powrotu. Skan dobowy +wykrywa przejście −→+ (skok +180→−180 to artefakt zawinięcia — pomijany, +warunek d_hi − d_lo < 180), potem bisekcja do ~sekundy. +""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from app.engine.models import ChartMoment + +# szerokość okna skanu wokół `around` [dni]: solar kotwiczymy przy urodzinach, +# lunar musi objąć cały okres syderyczny Księżyca (27,3 d) +SCAN_WINDOW = {"solar": 6.0, "lunar": 15.0} + + +def _lon_delta(engine, body: str, natal_lon: float, when: datetime) -> float: + m = ChartMoment(when_utc=when) + lon = engine.positions(m, [body])[0].longitude + return ((lon - natal_lon + 180.0) % 360.0) - 180.0 + + +def find_return( + engine, kind: str, natal_moment: ChartMoment, around: datetime +) -> datetime | None: + """Moment powrotu (kind: 'solar'/'lunar') najbliższy dacie `around`.""" + body = "Sun" if kind == "solar" else "Moon" + natal_lon = engine.positions(natal_moment, [body])[0].longitude + if around.tzinfo is None: + around = around.replace(tzinfo=timezone.utc) + + window = SCAN_WINDOW[kind] + step = timedelta(days=1.0) + t = around - timedelta(days=window) + end = around + timedelta(days=window) + + candidates: list[datetime] = [] + d_prev = _lon_delta(engine, body, natal_lon, t) + while t < end: + t_next = t + step + d_next = _lon_delta(engine, body, natal_lon, t_next) + # prawdziwe przejście przez zero: − -> + bez skoku zawinięcia + if d_prev < 0 <= d_next and (d_next - d_prev) < 180.0: + lo, hi, d_lo = t, t_next, d_prev + for _ in range(40): # bisekcja do ułamka sekundy + mid = lo + (hi - lo) / 2 + if (_lon_delta(engine, body, natal_lon, mid) < 0) == (d_lo < 0): + lo = mid + else: + hi = mid + candidates.append(lo + (hi - lo) / 2) + t, d_prev = t_next, d_next + + if not candidates: + return None + return min(candidates, key=lambda c: abs(c - around)) diff --git a/services/logic/app/main.py b/services/logic/app/main.py index aa4fb7a..7d4bf2e 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -117,6 +117,62 @@ def chart_report(req: ReportRequest) -> dict: return {"engine": engine.name, **report} +class ProfectionsRequest(BaseModel): + when_utc: datetime # moment urodzenia (UTC) + lat: float = 0.0 + lon: float = 0.0 + start_age: int = 0 + count: int = 13 # domyślnie pełny cykl 12 lat + rok startowy + + +@app.post("/chart/profections") +def chart_profections(req: ProfectionsRequest) -> dict: + """Profekcje roczne (LOG-10): wiek, profektowany Asc, Władca Roku (+MC/Su/Mo).""" + from app.engine import houses as H + from app.engine.models import ChartMoment + from app.engine.profections import profection_rows + + 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, ["Sun", "Moon"]): + points[p.name] = p.longitude + rows = profection_rows(points, req.when_utc, req.start_age, req.count) + return {"engine": engine.name, "rows": rows} + + +class ReturnRequest(BaseModel): + when_utc: datetime # moment urodzenia (UTC) + lat: float = 0.0 + lon: float = 0.0 + kind: str = "solar" # solar | lunar + around: datetime | None = None # data, wokół której szukać powrotu + + +@app.post("/chart/return") +def chart_return(req: ReturnRequest) -> dict: + """Solar/Lunar Return (LOG-12): moment powrotu + pełny horoskop na ten moment.""" + from app.engine.chart import build_chart + from app.engine.models import ChartMoment + from app.engine.returns import find_return + + if req.kind not in ("solar", "lunar"): + raise HTTPException(status_code=422, detail="kind: solar albo lunar") + engine = get_engine() + natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon) + around = req.around or req.when_utc + hit = find_return(engine, req.kind, natal, around) + if hit is None: + raise HTTPException(status_code=404, detail="nie znaleziono powrotu w oknie skanu") + chart = build_chart(engine, ChartMoment(when_utc=hit, lat=req.lat, lon=req.lon)) + return {"engine": engine.name, "kind": req.kind, + "return_utc": hit.isoformat(), **chart} + + @app.get("/health") def health() -> dict: info = {"status": "ok", "layer": "logic"} diff --git a/services/logic/tests/test_profections.py b/services/logic/tests/test_profections.py new file mode 100644 index 0000000..90373aa --- /dev/null +++ b/services/logic/tests/test_profections.py @@ -0,0 +1,46 @@ +"""Profekcje (LOG-10) — walidacja względem tabeli astro-seek z notes3.""" +import datetime as dt + +from app.engine.profections import DOMICILE_RULERS, age_at, profected_sign, profection_rows + +BIRTH = dt.datetime(1984, 4, 30, 7, 35, tzinfo=dt.timezone.utc) +NATAL = {"Asc": 112.18, "MC": 352.59, "Sun": 40.14, "Moon": 30.55} + +# wiek -> (profektowany Asc, Władca Roku) — tabela referencyjna notes3 +REFERENCE = { + 0: ("Cancer", "Moon"), 1: ("Leo", "Sun"), 2: ("Virgo", "Mercury"), + 3: ("Libra", "Venus"), 4: ("Scorpio", "Mars"), 5: ("Sagittarius", "Jupiter"), + 6: ("Capricorn", "Saturn"), 7: ("Aquarius", "Saturn"), 8: ("Pisces", "Jupiter"), + 9: ("Aries", "Mars"), 10: ("Taurus", "Venus"), 11: ("Gemini", "Mercury"), + 12: ("Cancer", "Moon"), 40: ("Scorpio", "Mars"), 41: ("Sagittarius", "Jupiter"), + 42: ("Capricorn", "Saturn"), +} + + +def test_profections_match_astroseek_table(): + rows = {r["age"]: r for r in profection_rows(NATAL, BIRTH, 0, 43)} + for age, (asc, lord) in REFERENCE.items(): + assert rows[age]["profected_asc"] == asc, f"wiek {age}" + assert rows[age]["lord_of_year"] == lord, f"wiek {age}" + + +def test_profected_secondary_points_match_reference(): + rows = {r["age"]: r for r in profection_rows(NATAL, BIRTH, 0, 3)} + # notes3: wiek 0 -> MC Pis, Sun Tau, Moon Tau; wiek 1 -> MC Ari, Sun Gem + assert rows[0]["MC"] == "Pisces" and rows[0]["Sun"] == "Taurus" + assert rows[1]["MC"] == "Aries" and rows[1]["Sun"] == "Gemini" + + +def test_from_dates_are_birthdays(): + rows = profection_rows(NATAL, BIRTH, 40, 3) + assert [r["from"] for r in rows] == ["2024-04-30", "2025-04-30", "2026-04-30"] + + +def test_age_at_boundaries(): + assert age_at(BIRTH, dt.datetime(2026, 4, 29, tzinfo=dt.timezone.utc)) == 41 + assert age_at(BIRTH, dt.datetime(2026, 4, 30, tzinfo=dt.timezone.utc)) == 42 + + +def test_rulers_cover_all_signs(): + assert len(DOMICILE_RULERS) == 12 + assert profected_sign(112.18, 12) == "Cancer" # pełny cykl wraca diff --git a/services/logic/tests/test_returns.py b/services/logic/tests/test_returns.py new file mode 100644 index 0000000..80b6f27 --- /dev/null +++ b/services/logic/tests/test_returns.py @@ -0,0 +1,40 @@ +"""Solar / Lunar Return (LOG-12) — samospójność i sensowność dat.""" +import datetime as dt + +import pytest + +from app.engine.models import ChartMoment +from app.engine.returns import find_return + + +def _lon(engine, body, when): + return engine.positions(ChartMoment(when_utc=when), [body])[0].longitude + + +def _delta_arcmin(a, b): + return abs(((a - b + 180.0) % 360.0) - 180.0) * 60.0 + + +def test_solar_return_hits_natal_sun(own_engine, reference_moment): + natal_sun = _lon(own_engine, "Sun", reference_moment.when_utc) + # solariusz na 42. urodziny (2026) + hit = find_return(own_engine, "solar", reference_moment, + dt.datetime(2026, 4, 30, tzinfo=dt.timezone.utc)) + assert hit is not None + assert _delta_arcmin(_lon(own_engine, "Sun", hit), natal_sun) < 0.5 + assert hit.month == 4 and hit.year == 2026 # w okolicy urodzin + + +def test_lunar_return_hits_natal_moon(own_engine, reference_moment): + natal_moon = _lon(own_engine, "Moon", reference_moment.when_utc) + hit = find_return(own_engine, "lunar", reference_moment, + dt.datetime(1984, 5, 27, tzinfo=dt.timezone.utc)) + assert hit is not None + assert _delta_arcmin(_lon(own_engine, "Moon", hit), natal_moon) < 2.0 + + +def test_solar_return_near_birth_is_close_to_birth(own_engine, reference_moment): + # powrót szukany wokół samych urodzin = ~moment urodzenia + hit = find_return(own_engine, "solar", reference_moment, reference_moment.when_utc) + assert hit is not None + assert abs(hit - reference_moment.when_utc) < dt.timedelta(days=1)