feat(logic): systemy zodiaku — syderyczny, draconic (LOG-04)
build / build (push) Successful in 1m6s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m51s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 25s
Testy / Kontrola składni wszystkich warstw (push) Successful in 20s

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:
2026-07-21 03:47:45 +02:00
committed by gitea
parent a2aabd37a5
commit 6f87b2b323
7 changed files with 291 additions and 17 deletions
+127
View File
@@ -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 19002100.
"""
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