04b4b5dacb
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m40s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 29s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 22s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m55s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 35s
Testy / Kontrola składni wszystkich warstw (push) Successful in 21s
Jedyne wymaganie Must bez implementacji. Nowy zodiac.py + wpiecie w horoskop: - zodiac.py: ayanamsy (Lahiri, Fagan-Bradley, Krishnamurti) modelem ayan(jd)=ayan0+B*x+C*x^2 (wspolna precesja, rozna stala) — skalibrowanym do Swiss Ephemeris jako WYROCZNI: zgodnosc do ~0,02" w latach 1900-2100. Draconic = wzgledem wzla wznoszacego (wzel = 0 Barana). RA: konwersja ekliptyka->rownik (to_equatorial) na przyszly widok rownikowy. - chart.py: build_chart(..., zodiac): offset jednolicie przesuwa etykiety znakow/dlugosci obiektow, osi, cusps i Lots; DOMY licza sie po dlugosci tropikalnej (geometria niezmiennicza wzgledem obrotu -> numery domow bez zmian). Domyslnie tropical -> sciezka i wyniki bez zmian. - main.py: /chart/positions przyjmuje `zodiac`; bledny -> 422. - prezentacja: dropdown „Zodiak" + pokazanie ayanamshy w wynikach. Testy (14): ayanamsy vs wyrocznia swisseph (<0.1"), julian_day, draconic (wzel=0 Barana), niezmienniczosc domow, RA w punktach charakterystycznych, odrzucenie bledow. Cala logika: 99 passed, 1 skipped. Zweryfikowane e2e w przegladarce (horoskop syderyczny Lahiri: Slonce Aries 16°34', ayan 23.6382). RA jako osobny widok zodiaku (per-obiekt, z szerokoscia) — do osobnego PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
104 lines
3.9 KiB
Python
104 lines
3.9 KiB
Python
"""Systemy zodiaku (LOG-04): tropikalny, syderyczny (ayanamsy), draconic + RA.
|
||
|
||
Wszystkie pozycje silnika są liczone **tropikalnie of-date** (kontrakt LOG-28).
|
||
Zmiana zodiaku to — dla zodiaków ekliptycznych — jednolite przesunięcie długości:
|
||
|
||
długość_docelowa = (długość_tropikalna − offset) mod 360
|
||
|
||
gdzie offset to:
|
||
- **syderyczny**: ayanamsa (kąt między tropikalnym a syderycznym punktem Barana),
|
||
- **draconic**: długość wznoszącego węzła Księżyca (węzeł = 0° draconic),
|
||
- **tropikalny**: 0.
|
||
|
||
Ponieważ to stałe przesunięcie obiektu ORAZ cusps, **numery domów się nie zmieniają**
|
||
(geometria jest niezmiennicza względem obrotu) — przesuwamy tylko etykiety znaków.
|
||
|
||
Model ayanamsy: `ayan(jd) = ayan0 + B·x + C·x²`, gdzie `x = jd − J2000`. Prędkość
|
||
precesji (B, C) jest **wspólna** dla wszystkich ayanams; różni je tylko stała `ayan0`
|
||
(wybór syderycznego zera). Stałe skalibrowano do Swiss Ephemeris jako wyroczni —
|
||
zgodność do ~0,02" w latach 1900–2100 (patrz tests/test_zodiac.py).
|
||
|
||
RA (right ascension): konwersja ekliptyka→równik dla przyszłego widoku równikowego.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from datetime import datetime
|
||
|
||
from app.engine.formats import norm360
|
||
|
||
TROPICAL = "tropical"
|
||
DRACONIC = "draconic"
|
||
|
||
# stała ayanamsy w J2000.0 (°) — skalibrowana do swisseph (get_ayanamsa_ut)
|
||
_AYAN0 = {
|
||
"lahiri": 23.857092,
|
||
"fagan_bradley": 24.740300,
|
||
"krishnamurti": 23.760240,
|
||
}
|
||
_J2000 = 2451545.0
|
||
_B = 3.824459e-5 # °/dobę — liniowy człon precesji (wspólny)
|
||
_C = 2.304e-13 # °/dobę² — drobne przyspieszenie (wspólne)
|
||
|
||
# nazwy zodiaków akceptowane przez API
|
||
SIDEREAL = tuple(f"sidereal_{k}" for k in _AYAN0) # sidereal_lahiri, ...
|
||
SYSTEMS = (TROPICAL, *SIDEREAL, DRACONIC)
|
||
|
||
|
||
def julian_day(dt: datetime) -> float:
|
||
"""Julian Day (UT) z momentu UTC — algorytm Meeusa (kalendarz gregoriański)."""
|
||
y, m = dt.year, dt.month
|
||
day = dt.day + (dt.hour + dt.minute / 60.0 + dt.second / 3600.0
|
||
+ dt.microsecond / 3.6e9) / 24.0
|
||
if m <= 2:
|
||
y -= 1
|
||
m += 12
|
||
a = y // 100
|
||
b = 2 - a + a // 4
|
||
return math.floor(365.25 * (y + 4716)) + math.floor(30.6001 * (m + 1)) + day + b - 1524.5
|
||
|
||
|
||
def ayanamsha(name: str, jd: float) -> float:
|
||
"""Ayanamsa [°] danej szkoły dla Julian Day (UT)."""
|
||
key = name[len("sidereal_"):] if name.startswith("sidereal_") else name
|
||
if key not in _AYAN0:
|
||
raise ValueError(f"Nieznana ayanamsa: {name!r} (dostępne: {', '.join(_AYAN0)})")
|
||
x = jd - _J2000
|
||
return _AYAN0[key] + _B * x + _C * x * x
|
||
|
||
|
||
def offset(zodiac: str, jd: float, node_lon: float | None = None) -> float:
|
||
"""Ile odjąć od długości tropikalnej, by dostać wybrany zodiak.
|
||
|
||
`node_lon` (tropikalna długość węzła wznoszącego) wymagana tylko dla draconic.
|
||
"""
|
||
if zodiac == TROPICAL:
|
||
return 0.0
|
||
if zodiac == DRACONIC:
|
||
if node_lon is None:
|
||
raise ValueError("draconic wymaga długości węzła (node_lon)")
|
||
return norm360(node_lon)
|
||
if zodiac in SIDEREAL:
|
||
return ayanamsha(zodiac, jd)
|
||
raise ValueError(f"Nieznany zodiak: {zodiac!r} (dostępne: {', '.join(SYSTEMS)})")
|
||
|
||
|
||
def apply(lon: float, off: float) -> float:
|
||
"""Długość w docelowym zodiaku."""
|
||
return norm360(lon - off)
|
||
|
||
|
||
def to_equatorial(lon: float, lat: float, eps: float) -> tuple[float, float]:
|
||
"""Ekliptyka (λ, β) → równik: (RA, deklinacja) w stopniach. Wszystko w °.
|
||
|
||
RA rośnie 0–360°; deklinacja w [−90, 90].
|
||
"""
|
||
lam, bet, e = math.radians(lon), math.radians(lat), math.radians(eps)
|
||
sin_dec = math.sin(bet) * math.cos(e) + math.cos(bet) * math.sin(e) * math.sin(lam)
|
||
dec = math.asin(max(-1.0, min(1.0, sin_dec)))
|
||
ra = math.atan2(
|
||
math.sin(lam) * math.cos(e) - math.tan(bet) * math.sin(e),
|
||
math.cos(lam),
|
||
)
|
||
return norm360(math.degrees(ra)), math.degrees(dec)
|