mirror of
https://github.com/migatu/astrololo.git
synced 2026-07-14 21:38:37 +00:00
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:
@@ -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
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user