feat(logic): systemy zodiaku — syderyczny, draconic (LOG-04)
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>
This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
"""Systemy zodiaku (LOG-04): ayanamsy, syderyczny, draconic, RA.
|
||||
|
||||
Wartości referencyjne ayanams pochodzą ze **Swiss Ephemeris** (get_ayanamsa_ut)
|
||||
użytego jako wyrocznia — silnika B nie importujemy tu (izolacja AGPL), więc stałe
|
||||
są wpięte tak jak referencje astro-seek w innych testach. Nasz model odtwarza je
|
||||
z dokładnością do ~0,02" w latach 1900–2100.
|
||||
"""
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine import zodiac as Z
|
||||
from app.engine.chart import build_chart
|
||||
from app.engine.formats import sign_index
|
||||
|
||||
# wyrocznia: swisseph get_ayanamsa_ut (JD UT -> ayanamsa °)
|
||||
ORACLE = {
|
||||
"sidereal_lahiri": {
|
||||
2451545.0: 23.857092, 2445820.88889: 23.638183,
|
||||
2415021.0: 22.460550, 2433283.0: 23.158744,
|
||||
2469808.0: 24.555632, 2488070.0: 25.254287,
|
||||
},
|
||||
"sidereal_fagan_bradley": {
|
||||
2451545.0: 24.740300, 2445820.88889: 24.521391,
|
||||
2415021.0: 23.343757, 2433283.0: 24.041952,
|
||||
2469808.0: 25.438840, 2488070.0: 26.137495,
|
||||
},
|
||||
"sidereal_krishnamurti": {
|
||||
2451545.0: 23.760240, 2415021.0: 22.363697,
|
||||
2469808.0: 24.458780, 2488070.0: 25.157435,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _arcsec(a, b):
|
||||
return abs(a - b) * 3600.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", list(ORACLE))
|
||||
def test_ayanamsha_matches_swisseph_oracle(name):
|
||||
for jd, ref in ORACLE[name].items():
|
||||
got = Z.ayanamsha(name, jd)
|
||||
assert _arcsec(got, ref) < 0.1, f"{name} @ JD{jd}: {got} vs {ref} ({_arcsec(got, ref):.3f}\")"
|
||||
|
||||
|
||||
def test_julian_day_j2000():
|
||||
from datetime import datetime, timezone
|
||||
jd = Z.julian_day(datetime(2000, 1, 1, 12, 0, tzinfo=timezone.utc))
|
||||
assert abs(jd - 2451545.0) < 1e-6
|
||||
|
||||
|
||||
def test_offset_tropical_is_zero():
|
||||
assert Z.offset("tropical", 2451545.0) == 0.0
|
||||
|
||||
|
||||
def test_offset_draconic_is_node_longitude():
|
||||
assert Z.offset("draconic", 2451545.0, node_lon=68.0) == 68.0
|
||||
# normalizacja
|
||||
assert Z.offset("draconic", 2451545.0, node_lon=428.0) == pytest.approx(68.0)
|
||||
|
||||
|
||||
def test_offset_draconic_requires_node():
|
||||
with pytest.raises(ValueError):
|
||||
Z.offset("draconic", 2451545.0)
|
||||
|
||||
|
||||
def test_unknown_zodiac_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
Z.offset("bzdura", 2451545.0)
|
||||
with pytest.raises(ValueError):
|
||||
Z.ayanamsha("sidereal_nieistnieje", 2451545.0)
|
||||
|
||||
|
||||
def test_apply_subtracts_and_normalizes():
|
||||
assert Z.apply(10.0, 20.0) == pytest.approx(350.0)
|
||||
assert Z.apply(40.0, 24.74) == pytest.approx(15.26)
|
||||
|
||||
|
||||
def test_to_equatorial_reference_points():
|
||||
# punkt równonocy: λ=0,β=0 -> RA=0, dec=0
|
||||
ra, dec = Z.to_equatorial(0.0, 0.0, 23.4392911)
|
||||
assert ra == pytest.approx(0.0, abs=1e-9) and dec == pytest.approx(0.0, abs=1e-9)
|
||||
# przesilenie letnie: λ=90,β=0 -> RA=90, dec=+ε
|
||||
ra, dec = Z.to_equatorial(90.0, 0.0, 23.4392911)
|
||||
assert ra == pytest.approx(90.0, abs=1e-6)
|
||||
assert dec == pytest.approx(23.4392911, abs=1e-6)
|
||||
|
||||
|
||||
# ---- integracja z pełnym horoskopem ----
|
||||
|
||||
def test_sidereal_chart_shifts_signs_back(own_engine, reference_moment):
|
||||
trop = build_chart(own_engine, reference_moment, "whole_sign")
|
||||
sid = build_chart(own_engine, reference_moment, "whole_sign", zodiac="sidereal_lahiri")
|
||||
assert sid["zodiac"] == "sidereal_lahiri"
|
||||
assert "ayanamsha" in sid and 23.0 < sid["ayanamsha"] < 24.5
|
||||
tp = {p["name"]: p for p in trop["positions"]}
|
||||
sp = {p["name"]: p for p in sid["positions"]}
|
||||
# syderyczna długość = tropikalna − ayanamsa (mod 360) dla każdego obiektu
|
||||
ay = sid["ayanamsha"]
|
||||
for name in tp:
|
||||
expect = (tp[name]["decimal"] - ay) % 360.0
|
||||
assert abs(((sp[name]["decimal"] - expect + 180) % 360) - 180) < 1e-4
|
||||
|
||||
|
||||
def test_sidereal_preserves_house_numbers(own_engine, reference_moment):
|
||||
# obrót zodiaku nie zmienia numerów domów (geometria niezmiennicza)
|
||||
trop = build_chart(own_engine, reference_moment, "whole_sign")
|
||||
sid = build_chart(own_engine, reference_moment, "whole_sign", zodiac="sidereal_lahiri")
|
||||
tp = {p["name"]: p["house"] for p in trop["positions"]}
|
||||
sp = {p["name"]: p["house"] for p in sid["positions"]}
|
||||
assert tp == sp
|
||||
|
||||
|
||||
def test_draconic_puts_node_at_zero_aries(own_engine, reference_moment):
|
||||
drac = build_chart(own_engine, reference_moment, "whole_sign", zodiac="draconic")
|
||||
assert drac["zodiac"] == "draconic"
|
||||
nn = next(p for p in drac["positions"] if p["name"] == "North Node")
|
||||
# węzeł wznoszący = 0° Barana w draconic
|
||||
assert sign_index(nn["decimal"]) == 0
|
||||
assert nn["decimal"] < 0.001 or nn["decimal"] > 359.999
|
||||
|
||||
|
||||
def test_tropical_default_unchanged(own_engine, reference_moment):
|
||||
# domyślnie tropikalny: brak ayanamshy, zodiac=tropical
|
||||
chart = build_chart(own_engine, reference_moment, "whole_sign")
|
||||
assert chart["zodiac"] == "tropical"
|
||||
assert "ayanamsha" not in chart
|
||||
Reference in New Issue
Block a user