diff --git a/services/logic/app/engine/chart.py b/services/logic/app/engine/chart.py index 2c2f2c9..42e0029 100644 --- a/services/logic/app/engine/chart.py +++ b/services/logic/app/engine/chart.py @@ -7,28 +7,51 @@ pozycje. from __future__ import annotations from app.engine import houses as H +from app.engine import zodiac as Z from app.engine.base import EphemerisEngine -from app.engine.formats import SIGNS, in_sign, norm360, sign_index +from app.engine.formats import SIGNS, absolute, decimal, in_sign, norm360, sign_index from app.engine.models import ChartMoment -def _fmt(name: str, lon: float) -> dict: +def _fmt(name: str, lon: float, off: float = 0.0) -> dict: + lon = norm360(lon - off) return { "name": name, "sign": SIGNS[sign_index(lon)], "in_sign": in_sign(lon), - "decimal": round(norm360(lon), 6), + "decimal": round(lon, 6), } +def _shift_pos(pdict: dict, off: float) -> None: + """Przelicza etykiety pozycji na wybrany zodiak (in-place). off=0 → bez zmian.""" + if not off: + return + lon = norm360(pdict["decimal"] - off) + pdict["sign"] = SIGNS[sign_index(lon)] + pdict["in_sign"] = in_sign(lon) + pdict["absolute"] = absolute(lon) + pdict["decimal"] = decimal(lon) + + def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN, - lots_method: str = "degree") -> dict: + lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict: from app.engine.aspects import find_aspects positions = engine.positions(moment) result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]} + # aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06) + # offset zodiaku (LOG-04): syderyczny = ayanamsa, draconic = długość węzła + node_lon = next((p.longitude for p in positions if p.name == "North Node"), None) + off = Z.offset(zodiac, Z.julian_day(moment.when_utc), node_lon) + result["zodiac"] = zodiac + if zodiac in Z.SIDEREAL: + result["ayanamsha"] = round(off, 6) + for pdict in result["positions"]: + _shift_pos(pdict, off) + if not hasattr(engine, "sidereal"): return result @@ -36,21 +59,22 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str asc = H.compute_asc(ramc, eps, moment.lat) mc = H.compute_mc(ramc, eps) system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN - cusp_list = H.cusps(asc, mc, system) + cusp_list = H.cusps(asc, mc, system) # tropikalne — geometria domów jest niezmiennicza result["house_system"] = system result["angles"] = { - "Asc": _fmt("Asc", asc), - "MC": _fmt("MC", mc), - "Dsc": _fmt("Dsc", norm360(asc + 180.0)), - "IC": _fmt("IC", norm360(mc + 180.0)), + "Asc": _fmt("Asc", asc, off), + "MC": _fmt("MC", mc, off), + "Dsc": _fmt("Dsc", norm360(asc + 180.0), off), + "IC": _fmt("IC", norm360(mc + 180.0), off), } result["cusps"] = [ - {"house": i + 1, "sign": SIGNS[sign_index(c)], "in_sign": in_sign(c)} + {"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))], + "in_sign": in_sign(norm360(c - off))} for i, c in enumerate(cusp_list) ] for pdict, obj in zip(result["positions"], positions): - pdict["house"] = H.assign_house(obj.longitude, cusp_list) + pdict["house"] = H.assign_house(obj.longitude, cusp_list) # dom po długości tropikalnej # Lots (LOG-08) — wymagają Asc i sekty (dzień/noc) from app.engine.firdaria import is_day_birth @@ -62,9 +86,10 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str result["sect"] = "day" if day else "night" result["lots"] = [ {**lot, - "sign": SIGNS[sign_index(lot["longitude"])], - "in_sign": in_sign(lot["longitude"]), - "house": H.assign_house(lot["longitude"], cusp_list)} + "longitude": decimal(norm360(lot["longitude"] - off)), # w wybranym zodiaku + "sign": SIGNS[sign_index(norm360(lot["longitude"] - off))], + "in_sign": in_sign(norm360(lot["longitude"] - off)), + "house": H.assign_house(lot["longitude"], cusp_list)} # dom po długości tropikalnej for lot in compute_lots(pts, day, lots_method) ] return result diff --git a/services/logic/app/engine/zodiac.py b/services/logic/app/engine/zodiac.py new file mode 100644 index 0000000..687a118 --- /dev/null +++ b/services/logic/app/engine/zodiac.py @@ -0,0 +1,103 @@ +"""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) diff --git a/services/logic/app/main.py b/services/logic/app/main.py index 572b526..8b14535 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -39,6 +39,7 @@ class PositionsRequest(BaseModel): objects: list[str] | None = None house_system: str = "whole_sign" # whole_sign | equal | porphyry stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy) + zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic @app.post("/api/query", response_model=QueryResponse) @@ -58,7 +59,10 @@ def chart_positions(req: PositionsRequest) -> dict: engine = get_engine() moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon) - chart = build_chart(engine, moment, req.house_system) + try: + chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) if req.stations: from app.engine.stations import find_stations diff --git a/services/logic/tests/test_zodiac.py b/services/logic/tests/test_zodiac.py new file mode 100644 index 0000000..8df8707 --- /dev/null +++ b/services/logic/tests/test_zodiac.py @@ -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 diff --git a/services/presentation/app/clients/logic_client.py b/services/presentation/app/clients/logic_client.py index 80c40cd..76abcd3 100644 --- a/services/presentation/app/clients/logic_client.py +++ b/services/presentation/app/clients/logic_client.py @@ -31,6 +31,7 @@ class LogicClient: objects: list[str] | None = None, house_system: str = "whole_sign", stations: bool = False, + zodiac: str = "tropical", ) -> dict[str, Any]: """Pełny horoskop dla danego momentu — woła logic /chart/positions.""" payload = { @@ -40,6 +41,7 @@ class LogicClient: "objects": objects, "house_system": house_system, "stations": stations, + "zodiac": zodiac, } # stacje wymagają root-findów — dłuższy timeout with httpx.Client(timeout=max(settings.http_timeout, 60.0) if stations else settings.http_timeout) as client: diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index 78381cd..36681e7 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -65,16 +65,18 @@ def chart_compute( lon: float = Form(0.0), house_system: str = Form("whole_sign"), stations: bool = Form(False), + zodiac: str = Form("tropical"), ): form = {"date": date, "time": time, "tz_offset": tz_offset, - "lat": lat, "lon": lon, "house_system": house_system, "stations": stations} + "lat": lat, "lon": lon, "house_system": house_system, "stations": stations, + "zodiac": zodiac} ctx: dict = {"form": form, "result": None, "error": None, "moment": None} try: iso_utc, label = _build_utc(date, time, tz_offset) ctx["moment"] = label ctx["result"] = logic.positions( when_utc_iso=iso_utc, lat=lat, lon=lon, - house_system=house_system, stations=stations, + house_system=house_system, stations=stations, zodiac=zodiac, ) except (httpx.HTTPError,) as e: ctx["error"] = _logic_error(e) diff --git a/services/presentation/app/templates/chart.html b/services/presentation/app/templates/chart.html index 6f58644..9e808a5 100644 --- a/services/presentation/app/templates/chart.html +++ b/services/presentation/app/templates/chart.html @@ -32,6 +32,16 @@ +