diff --git a/services/logic/README.md b/services/logic/README.md index cf2b053..e5ccf82 100644 --- a/services/logic/README.md +++ b/services/logic/README.md @@ -6,7 +6,7 @@ i nie w bazie. ## API - `POST /api/query` → `QueryRequest` → `QueryResponse` -- `POST /chart/positions` → `{when_utc, lat, lon, objects?}` → pozycje obiektów (LOG-01) +- `POST /chart/positions` → `{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`. - `POST /chart/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B) - `GET /health` (sprawdza też warstwę bazodanową) diff --git a/services/logic/app/engine/chart.py b/services/logic/app/engine/chart.py new file mode 100644 index 0000000..cb8ea3f --- /dev/null +++ b/services/logic/app/engine/chart.py @@ -0,0 +1,50 @@ +"""Złożenie pełnego horoskopu: pozycje + osie + domy (LOG-01 + LOG-05). + +Silnik-agnostyczne: potrzebuje tylko `positions()` oraz (dla osi/domów) +`sidereal()`. Jeśli silnik nie umie policzyć czasu gwiazdowego, zwraca same +pozycje. +""" +from __future__ import annotations + +from app.engine import houses as H +from app.engine.base import EphemerisEngine +from app.engine.formats import SIGNS, in_sign, norm360, sign_index +from app.engine.models import ChartMoment + + +def _fmt(name: str, lon: float) -> dict: + return { + "name": name, + "sign": SIGNS[sign_index(lon)], + "in_sign": in_sign(lon), + "decimal": round(norm360(lon), 6), + } + + +def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN) -> dict: + positions = engine.positions(moment) + result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]} + + if not hasattr(engine, "sidereal"): + return result + + ramc, eps = engine.sidereal(moment) + 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) + + 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)), + } + result["cusps"] = [ + {"house": i + 1, "sign": SIGNS[sign_index(c)], "in_sign": in_sign(c)} + for i, c in enumerate(cusp_list) + ] + for pdict, obj in zip(result["positions"], positions): + pdict["house"] = H.assign_house(obj.longitude, cusp_list) + return result diff --git a/services/logic/app/engine/houses.py b/services/logic/app/engine/houses.py new file mode 100644 index 0000000..e5d12e0 --- /dev/null +++ b/services/logic/app/engine/houses.py @@ -0,0 +1,77 @@ +"""Osie i domy — czysta matematyka sferyczna (LOG-05). + +Bezstanowe funkcje: z lokalnego czasu gwiazdowego (RAMC), nachylenia ekliptyki +(ε) i szerokości geograficznej (φ) wyliczają Ascendent i MC, a stąd cusps domów +dla prostych systemów (Whole Sign, Equal, Porphyry). Niezależne od silnika — +silnik dostarcza tylko RAMC i ε. +""" +from __future__ import annotations + +import math + +from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401 + +WHOLE_SIGN = "whole_sign" +EQUAL = "equal" +PORPHYRY = "porphyry" +SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY) + + +def mean_obliquity(tt_jd: float) -> float: + """Średnie nachylenie ekliptyki [°] dla daty (Julian TT). Wystarcza do domów.""" + t = (tt_jd - 2451545.0) / 36525.0 + arcsec = 84381.448 - 46.8150 * t - 0.00059 * t * t + 0.001813 * t ** 3 + return arcsec / 3600.0 + + +def compute_mc(ramc_deg: float, eps_deg: float) -> float: + r, e = math.radians(ramc_deg), math.radians(eps_deg) + mc = math.atan2(math.sin(r), math.cos(r) * math.cos(e)) + return norm360(math.degrees(mc)) + + +def compute_asc(ramc_deg: float, eps_deg: float, lat_deg: float) -> float: + r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(lat_deg) + asc = math.atan2( + math.cos(r), + -(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)), + ) + return norm360(math.degrees(asc)) + + +def _trisect(a: float, b: float) -> tuple[float, float]: + """Dwa punkty dzielące łuk a→b (w kierunku zodiaku) na trzy równe części.""" + span = (b - a) % 360.0 + return norm360(a + span / 3.0), norm360(a + 2.0 * span / 3.0) + + +def cusps(asc: float, mc: float, system: str) -> list[float]: + """Zwraca 12 cusps (długości) domów 1..12.""" + if system == WHOLE_SIGN: + start = sign_index(asc) * 30.0 + return [norm360(start + 30.0 * i) for i in range(12)] + if system == EQUAL: + return [norm360(asc + 30.0 * i) for i in range(12)] + if system == PORPHYRY: + dsc, ic = norm360(asc + 180.0), norm360(mc + 180.0) + c = [0.0] * 12 + c[0], c[3], c[6], c[9] = asc, ic, dsc, mc + c[1], c[2] = _trisect(asc, ic) # domy 2,3 + c[4], c[5] = _trisect(ic, dsc) # domy 5,6 + c[7], c[8] = _trisect(dsc, mc) # domy 8,9 + c[10], c[11] = _trisect(mc, asc) # domy 11,12 + return c + raise ValueError(f"nieznany system domów: {system}") + + +def assign_house(lon: float, cusp_list: list[float]) -> int: + """Numer domu (1..12), w którym leży dana długość ekliptyczna.""" + lon = norm360(lon) + for i in range(12): + start = cusp_list[i] + end = cusp_list[(i + 1) % 12] + span = (end - start) % 360.0 + offset = (lon - start) % 360.0 + if offset < span: + return i + 1 + return 12 diff --git a/services/logic/app/engine/skyfield_engine.py b/services/logic/app/engine/skyfield_engine.py index ee5c352..7e00ba3 100644 --- a/services/logic/app/engine/skyfield_engine.py +++ b/services/logic/app/engine/skyfield_engine.py @@ -88,5 +88,17 @@ class SkyfieldEngine(EphemerisEngine): ) return out + def sidereal(self, moment: ChartMoment) -> tuple[float, float]: + """(RAMC, ε) w stopniach — lokalny apparent sidereal time i nachylenie ekliptyki. + + Materiał wejściowy do osi i domów (LOG-05). RAMC = GAST·15 + długość geo. + """ + from app.engine.houses import mean_obliquity + + t = self.ts.from_datetime(moment.when_utc) + ramc = norm360(t.gast * 15.0 + moment.lon) + eps = mean_obliquity(t.tt) + return ramc, eps + def health(self) -> dict: return {"engine": self.name, "status": "ok", "kernel": self.kernel} diff --git a/services/logic/app/main.py b/services/logic/app/main.py index ecd43c9..3904444 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -37,6 +37,7 @@ class PositionsRequest(BaseModel): lat: float = 0.0 lon: float = 0.0 objects: list[str] | None = None + house_system: str = "whole_sign" # whole_sign | equal | porphyry @app.post("/api/query", response_model=QueryResponse) @@ -49,13 +50,13 @@ def query(req: QueryRequest) -> QueryResponse: @app.post("/chart/positions") def chart_positions(req: PositionsRequest) -> dict: - """Pozycje obiektów dla danego momentu (LOG-01), liczone aktywnym silnikiem.""" + """Pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05), aktywnym silnikiem.""" + from app.engine.chart import build_chart from app.engine.models import ChartMoment engine = get_engine() moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon) - positions = engine.positions(moment, req.objects) - return {"engine": engine.name, "positions": [p.as_dict() for p in positions]} + return build_chart(engine, moment, req.house_system) @app.post("/chart/compare") diff --git a/services/logic/tests/test_chart.py b/services/logic/tests/test_chart.py new file mode 100644 index 0000000..bbc9f39 --- /dev/null +++ b/services/logic/tests/test_chart.py @@ -0,0 +1,27 @@ +"""Integracja: pełny horoskop (pozycje + osie + domy) przez silnik (LOG-01+LOG-05). + +Waliduje względem astro.com dla horoskopu referencyjnego. +""" +from app.engine.chart import build_chart + + +def test_chart_angles_match_reference(own_engine, reference_moment): + chart = build_chart(own_engine, reference_moment, "whole_sign") + assert chart["angles"]["Asc"]["sign"] == "Cancer" + assert chart["angles"]["MC"]["sign"] == "Pisces" + + +def test_chart_house_assignments_match_reference(own_engine, reference_moment): + chart = build_chart(own_engine, reference_moment, "whole_sign") + by = {p["name"]: p for p in chart["positions"]} + expected = {"Sun": 11, "Moon": 11, "Mercury": 10, "Venus": 10, "Mars": 5, + "Jupiter": 7, "Saturn": 5, "Uranus": 6, "Neptune": 7, "Pluto": 5} + for name, house in expected.items(): + assert by[name]["house"] == house, f"{name}: dom {by[name]['house']} != {house}" + + +def test_house_systems_available(own_engine, reference_moment): + for system in ("whole_sign", "equal", "porphyry"): + chart = build_chart(own_engine, reference_moment, system) + assert chart["house_system"] == system + assert len(chart["cusps"]) == 12 diff --git a/services/logic/tests/test_houses.py b/services/logic/tests/test_houses.py new file mode 100644 index 0000000..e8d502a --- /dev/null +++ b/services/logic/tests/test_houses.py @@ -0,0 +1,41 @@ +"""Testy osi i domów — czysta matematyka (LOG-05, bez efemeryd).""" +from app.engine import houses as H + +# RAMC i ε policzone Skyfieldem dla horoskopu referencyjnego (30.04.1984, Warszawa) +RAMC, EPS, LAT = 353.1968, 23.44133, 52.2333 + + +def _near(a, b, tol=0.05): + return abs(((a - b + 180) % 360) - 180) < tol + + +def test_asc_mc_match_reference(): + asc = H.compute_asc(RAMC, EPS, LAT) + mc = H.compute_mc(RAMC, EPS) + assert _near(asc, 112.18) # Cancer 22°10' (astro.com) + assert _near(mc, 352.59) # Pisces 22°35' + + +def test_whole_sign_starts_on_sign_boundary(): + cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN) + assert cusps[0] == 90.0 # dom 1 = 0° Raka + assert cusps[1] == 120.0 + + +def test_equal_cusps_are_30_apart_from_asc(): + cusps = H.cusps(112.18, 352.59, H.EQUAL) + assert abs(cusps[0] - 112.18) < 1e-9 + assert abs(cusps[1] - 142.18) < 1e-9 + + +def test_porphyry_angles_on_cusps(): + cusps = H.cusps(112.18, 352.59, H.PORPHYRY) + assert abs(cusps[0] - 112.18) < 1e-9 # Asc = dom 1 + assert abs(cusps[9] - 352.59) < 1e-9 # MC = dom 10 + assert abs(cusps[6] - (112.18 + 180) % 360) < 1e-9 # Dsc = dom 7 + + +def test_assign_house_whole_sign(): + cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN) # dom 1 = Rak (90–120°) + assert H.assign_house(100.0, cusps) == 1 # w Raku + assert H.assign_house(40.0, cusps) == 11 # Byk -> 11. dom diff --git a/services/presentation/app/clients/logic_client.py b/services/presentation/app/clients/logic_client.py index 0071e4a..67b503f 100644 --- a/services/presentation/app/clients/logic_client.py +++ b/services/presentation/app/clients/logic_client.py @@ -24,10 +24,21 @@ class LogicClient: return r.json() def positions( - self, when_utc_iso: str, lat: float, lon: float, objects: list[str] | None = None + self, + when_utc_iso: str, + lat: float, + lon: float, + objects: list[str] | None = None, + house_system: str = "whole_sign", ) -> dict[str, Any]: - """Pozycje obiektów dla danego momentu — woła logic /chart/positions.""" - payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "objects": objects} + """Pełny horoskop dla danego momentu — woła logic /chart/positions.""" + payload = { + "when_utc": when_utc_iso, + "lat": lat, + "lon": lon, + "objects": objects, + "house_system": house_system, + } with httpx.Client(timeout=settings.http_timeout) as client: r = client.post(f"{self.base_url}/chart/positions", json=payload) r.raise_for_status() diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index e7948ea..7496554 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -59,13 +59,17 @@ def chart_compute( tz_offset: float = Form(0.0), lat: float = Form(0.0), lon: float = Form(0.0), + house_system: str = Form("whole_sign"), ): - form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon} + form = {"date": date, "time": time, "tz_offset": tz_offset, + "lat": lat, "lon": lon, "house_system": house_system} 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) + ctx["result"] = logic.positions( + when_utc_iso=iso_utc, lat=lat, lon=lon, house_system=house_system + ) except (httpx.HTTPError,) as e: ctx["error"] = _logic_error(e) except ValueError as e: diff --git a/services/presentation/app/templates/chart.html b/services/presentation/app/templates/chart.html index 8084181..f10dd48 100644 --- a/services/presentation/app/templates/chart.html +++ b/services/presentation/app/templates/chart.html @@ -3,7 +3,7 @@ {% block nav_chart %}active{% endblock %} {% block content %} -

Wpisz podstawowe dane momentu — program policzy pozycje obiektów (silnik efemeryd warstwy logicznej).

+

Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).

@@ -17,20 +17,25 @@
-
- Lokalizacja (opcjonalnie — przyda się później do osi i domów) -
- - -
-
+
+ + + +
- +
@@ -42,12 +47,26 @@
Silnik: {{ result.engine }} · obiektów: {{ result.positions | length }} + {% if result.house_system %}· domy: {{ result.house_system }}{% endif %} {% if moment %}· moment: {{ moment }}{% endif %}
+ + {% if result.angles %} + + + + {% for key in ["Asc", "MC", "Dsc", "IC"] %} + {% set a = result.angles[key] %} + + {% endfor %} + +
ZnakW znaku
{{ a.name }}{{ a.sign }}{{ a.in_sign }}
+ {% endif %} + - + @@ -56,13 +75,27 @@ - + {% endfor %}
ObiektZnakW znakuAbsolutnaKier.Prędkość °/dObiektZnakW znakuDomKier.Prędkość °/d
{{ p.name }} {{ p.sign }} {{ p.in_sign }}{{ p.absolute }}{{ p.house if p.house is defined else '—' }} {{ p.direction }} {{ '%+.4f' | format(p.speed) }}
+ + {% if result.cusps %} +
+ Cusps domów ({{ result.house_system }}) + + + + {% for c in result.cusps %} + + {% endfor %} + +
DomZnakCusp
{{ c.house }}{{ c.sign }}{{ c.in_sign }}
+
+ {% endif %} {% endif %}