Profekcje roczne (LOG-10) i Solar/Lunar Return (LOG-12)

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 11:12:49 +02:00
parent d14a77360a
commit b013831492
6 changed files with 278 additions and 0 deletions
+70
View File
@@ -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
+64
View File
@@ -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))