From 7b435d426396df8967be1b6889680e26165c8039 Mon Sep 17 00:00:00 2001 From: migatu Date: Mon, 3 Aug 2026 20:55:49 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20synastria=20=E2=80=94=20aspekty=20mi?= =?UTF-8?q?=C4=99dzy=20dwoma=20horoskopami=20(PRE-04)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pierwsza technika relacyjna z pełnym UI (Returns były już w kalendarzu od LOG-12). Dwie osoby → aspekty MIĘDZY ich horoskopami (planeta osoby A do planety osoby B). Silnik: `find_cross_aspects(a, b, orb, luminary_bonus, minor)` — każdy obiekt A × każdy obiekt B. `obj1` = osoba A, `obj2` = osoba B. Statyczne (dwa natale, brak wspólnego czasu) → bez applying/separating. Par sztywnych (NN/SN) NIE wycinamy — między dwiema osobami to realny aspekt, nie artefakt definicji. Wspólny matcher `_first_aspect` (z find_aspects), więc orb/bonus/aspekty poboczne działają tak samo. Endpoint `POST /chart/synastry` (dwie osoby + zodiak + ustawienia aspektów) → pozycje obu + siatka aspektów z glifami. Prezentacja: zakładka „Synastria", formularz dwóch osób (pętla po a_/b_), tabela aspektów A · aspekt · B · orb. Weryfikacja na żywym API: 13+13 obiektów, 63 aspekty synastryczne z poprawnymi glifami i bonusem świateł (A.Sun ☌ B.Venus przy orbie 8.67 = 8+2). Testy: logika +4 (cross-aspekty, kolejność A/B, brak filtra par sztywnych, brak applying), prezentacja +7 (trasa, formularz dwóch osób, klient, tabela). Logika 277, prezentacja 228. Co-Authored-By: Claude Opus 4.8 --- services/logic/app/engine/aspects.py | 35 +++++++++ services/logic/app/main.py | 41 +++++++++++ services/logic/tests/test_aspects.py | 34 ++++++++- .../presentation/app/clients/logic_client.py | 12 +++ services/presentation/app/main.py | 43 +++++++++++ services/presentation/app/templates/base.html | 1 + .../presentation/app/templates/synastry.html | 73 +++++++++++++++++++ .../tests/test_aspect_settings.py | 4 +- services/presentation/tests/test_formsync.py | 7 +- services/presentation/tests/test_synastry.py | 60 +++++++++++++++ 10 files changed, 304 insertions(+), 6 deletions(-) create mode 100644 services/presentation/app/templates/synastry.html create mode 100644 services/presentation/tests/test_synastry.py diff --git a/services/logic/app/engine/aspects.py b/services/logic/app/engine/aspects.py index 1b8d389..c1b1e0b 100644 --- a/services/logic/app/engine/aspects.py +++ b/services/logic/app/engine/aspects.py @@ -71,6 +71,41 @@ def _is_applying(la: float, lb: float, sa: float, sb: float, angle: float, dt: f return dev_next < dev_now +def _first_aspect(sep: float, allowed: float, checks: dict) -> tuple[str, float] | None: + """Pierwszy aspekt z `checks`, w którego orbie mieści się separacja `sep`.""" + for asp, angle in checks.items(): + dev = abs(sep - angle) + if dev <= allowed: + return asp, round(dev, 2) + return None + + +def find_cross_aspects( + a_positions: list[dict], b_positions: list[dict], + orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS, minor: bool = False, +) -> list[dict]: + """Aspekty MIĘDZY dwoma horoskopami (synastria, PRE-04): każdy obiekt z A vs + każdy obiekt z B (`obj1` = osoba A, `obj2` = osoba B). Statyczne — dwa natale, + brak wspólnego czasu, więc bez applying/separating. RIGID_PAIRS nie dotyczy + (NN osoby A vs SN osoby B to realny aspekt, nie artefakt definicji).""" + checks = {**MAJOR, **MINOR} if minor else MAJOR + out: list[dict] = [] + for a in a_positions: + la = a.get("decimal") + if la is None: + continue + for b in b_positions: + lb = b.get("decimal") + if lb is None: + continue + allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0) + m = _first_aspect(separation(float(la), float(lb)), allowed, checks) + if m: + out.append({"obj1": a["name"], "obj2": b["name"], + "aspect": m[0], "orb": m[1], "allowed": round(allowed, 2)}) + return out + + def find_aspects( positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS, minor: bool = False, diff --git a/services/logic/app/main.py b/services/logic/app/main.py index 46b44c7..347be78 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -92,6 +92,47 @@ def chart_positions(req: PositionsRequest) -> dict: return chart +class PersonMoment(BaseModel): + when_utc: datetime + lat: float = 0.0 + lon: float = 0.0 + + +class SynastryRequest(BaseModel): + """Dwie osoby (PRE-04). Aspekty liczone MIĘDZY horoskopami, nie w środku.""" + person_a: PersonMoment + person_b: PersonMoment + zodiac: str = "tropical" + aspect_orb: float = 8.0 + aspect_luminary_bonus: float = 2.0 + aspect_minor: bool = False + + +@app.post("/chart/synastry") +def chart_synastry(req: SynastryRequest) -> dict: + """Synastria (PRE-04): dwa horoskopy natalne + aspekty MIĘDZY nimi (planeta + osoby A vs planeta osoby B). Bez interpretacji z bazy — sama siatka aspektów.""" + from app.engine import glyphs as GL + from app.engine.aspects import find_cross_aspects + from app.engine.chart import build_chart + from app.engine.models import ChartMoment + + engine = get_engine() + a = build_chart(engine, ChartMoment(when_utc=req.person_a.when_utc, + lat=req.person_a.lat, lon=req.person_a.lon), zodiac=req.zodiac) + b = build_chart(engine, ChartMoment(when_utc=req.person_b.when_utc, + lat=req.person_b.lat, lon=req.person_b.lon), zodiac=req.zodiac) + cross = find_cross_aspects( + a["positions"], b["positions"], orb=req.aspect_orb, + luminary_bonus=req.aspect_luminary_bonus, minor=req.aspect_minor) + for c in cross: + c["glyph"] = GL.aspect_glyph(c["aspect"]) # symbol aspektu (LOG-22) + return {"engine": engine.name, + "person_a": {"positions": a["positions"]}, + "person_b": {"positions": b["positions"]}, + "aspects": cross} + + @app.post("/chart/compare") def chart_compare(req: PositionsRequest) -> dict: """Tryb dwu-silnikowy (LOG-26): policz oboma silnikami i zwróć raport różnic. diff --git a/services/logic/tests/test_aspects.py b/services/logic/tests/test_aspects.py index 0b79d21..bd6fcb2 100644 --- a/services/logic/tests/test_aspects.py +++ b/services/logic/tests/test_aspects.py @@ -1,5 +1,37 @@ """Testy aspektów (LOG-06) — czysta matematyka.""" -from app.engine.aspects import RIGID_PAIRS, find_aspects, separation +from app.engine.aspects import RIGID_PAIRS, find_aspects, find_cross_aspects, separation + + +# ── synastria: aspekty MIĘDZY dwoma horoskopami (PRE-04) ── + +def test_cross_aspects_between_two_charts(): + a = [{"name": "Sun", "decimal": 10.0}, {"name": "Moon", "decimal": 100.0}] + b = [{"name": "Sun", "decimal": 12.0}, {"name": "Mars", "decimal": 70.0}] + pairs = {(c["obj1"], c["obj2"], c["aspect"]) for c in find_cross_aspects(a, b)} + assert ("Sun", "Sun", "conjunction") in pairs # A.Słońce ↔ B.Słońce (2°) + assert ("Sun", "Mars", "sextile") in pairs # A.Słońce(10) ↔ B.Mars(70) = 60° + + +def test_cross_aspect_obj1_is_person_a_obj2_person_b(): + a = [{"name": "Venus", "decimal": 10.0}] + b = [{"name": "Mars", "decimal": 12.0}] + c = find_cross_aspects(a, b)[0] + assert c["obj1"] == "Venus" and c["obj2"] == "Mars" + + +def test_cross_aspects_do_not_apply_rigid_pair_filter(): + """NN osoby A vs SN osoby B to REALNY aspekt między ludźmi — nie wycinamy go + jak pary sztywnej w jednym horoskopie.""" + a = [{"name": "North Node", "decimal": 10.0}] + b = [{"name": "South Node", "decimal": 12.0}] + assert find_cross_aspects(a, b) # niepuste + + +def test_cross_aspects_no_applying_flag(): + """Dwa natale — brak wspólnego czasu, więc bez applying/separating.""" + a = [{"name": "Sun", "decimal": 10.0, "speed": 1.0}] + b = [{"name": "Moon", "decimal": 12.0, "speed": 13.0}] + assert "as" not in find_cross_aspects(a, b)[0] # ── konfigurowalne aspekty i orby (PRE-06) ── diff --git a/services/presentation/app/clients/logic_client.py b/services/presentation/app/clients/logic_client.py index e3108de..79d37f3 100644 --- a/services/presentation/app/clients/logic_client.py +++ b/services/presentation/app/clients/logic_client.py @@ -78,6 +78,18 @@ class LogicClient: timeout = max(settings.http_timeout, 60.0) if (stations or tables) else settings.http_timeout return self._post("/chart/positions", payload, timeout) + def synastry( + self, person_a: dict, person_b: dict, zodiac: str = "tropical", + aspect_orb: float = 8.0, aspect_luminary_bonus: float = 2.0, aspect_minor: bool = False, + ) -> dict[str, Any]: + """Synastria dwóch osób (PRE-04) — aspekty między horoskopami.""" + payload = { + "person_a": person_a, "person_b": person_b, "zodiac": zodiac, + "aspect_orb": aspect_orb, "aspect_luminary_bonus": aspect_luminary_bonus, + "aspect_minor": aspect_minor, + } + return self._post("/chart/synastry", payload, settings.http_timeout) + def report( self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False ) -> dict[str, Any]: diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index 2a85e88..58b9c0e 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -277,6 +277,49 @@ def compile_pdf(payload: dict): headers={"Content-Disposition": 'attachment; filename="raport.pdf"'}) +# ---------------- Synastria (technika relacyjna) ---------------- +@app.get("/synastry", response_class=HTMLResponse) +def synastry_form(request: Request): + # domyślne liczby, żeby pola number nie były puste (puste → błąd przy wysyłce) + form = {"a_tz_offset": 0, "a_lat": 0, "a_lon": 0, "b_tz_offset": 0, "b_lat": 0, "b_lon": 0, + "aspect_orb": 8, "aspect_luminary_bonus": 2} + return templates.TemplateResponse(request, "synastry.html", {"result": None, "form": form}) + + +@app.post("/synastry", response_class=HTMLResponse) +def synastry_run( + request: Request, + a_person: str = Form(""), a_date: str = Form(...), a_time: str = Form(...), + a_tz_offset: float = Form(0.0), a_lat: float = Form(0.0), a_lon: float = Form(0.0), + b_person: str = Form(""), b_date: str = Form(...), b_time: str = Form(...), + b_tz_offset: float = Form(0.0), b_lat: float = Form(0.0), b_lon: float = Form(0.0), + zodiac: str = Form("tropical"), + aspect_orb: float = Form(8.0), aspect_luminary_bonus: float = Form(2.0), + aspect_minor: bool = Form(False), +): + """Synastria (PRE-04): dwie osoby → aspekty między ich horoskopami.""" + form = {"a_person": a_person, "a_date": a_date, "a_time": a_time, "a_tz_offset": a_tz_offset, + "a_lat": a_lat, "a_lon": a_lon, "b_person": b_person, "b_date": b_date, + "b_time": b_time, "b_tz_offset": b_tz_offset, "b_lat": b_lat, "b_lon": b_lon, + "zodiac": zodiac, "aspect_orb": aspect_orb, + "aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor} + ctx: dict = {"form": form, "result": None, "error": None} + try: + iso_a, _ = _build_utc(a_date, a_time, a_tz_offset) + iso_b, _ = _build_utc(b_date, b_time, b_tz_offset) + ctx["result"] = logic.synastry( + {"when_utc": iso_a, "lat": a_lat, "lon": a_lon}, + {"when_utc": iso_b, "lat": b_lat, "lon": b_lon}, + zodiac=zodiac, aspect_orb=aspect_orb, + aspect_luminary_bonus=aspect_luminary_bonus, aspect_minor=aspect_minor, + ) + except httpx.HTTPError as e: + ctx["error"] = _logic_error(e) + except ValueError as e: + ctx["error"] = f"Niepoprawne dane wejściowe: {e}" + return templates.TemplateResponse(request, "synastry.html", ctx) + + # ---------------- Sygnifikatory (wyszukiwarka w bazach) ---------------- @app.get("/significators", response_class=HTMLResponse) def significators_form(request: Request): diff --git a/services/presentation/app/templates/base.html b/services/presentation/app/templates/base.html index 43c99c9..80a9ec4 100644 --- a/services/presentation/app/templates/base.html +++ b/services/presentation/app/templates/base.html @@ -22,6 +22,7 @@ Horoskop Interpretacje Kalendarz + Synastria Sygnifikatory Skompiluj diff --git a/services/presentation/app/templates/synastry.html b/services/presentation/app/templates/synastry.html new file mode 100644 index 0000000..9562f97 --- /dev/null +++ b/services/presentation/app/templates/synastry.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% block title %}Synastria{% endblock %} +{% block nav_synastry %}active{% endblock %} + +{% block content %} +

Dwie osoby → aspekty między ich horoskopami (planeta jednej osoby do planety drugiej). Bez interpretacji z bazy — sama siatka aspektów.

+ +
+
+ {% for p in [{'k': 'a', 'label': 'Osoba A'}, {'k': 'b', 'label': 'Osoba B'}] %} +
+ {{ p.label }} + +
+ + +
+
+ + + +
+
+ {% endfor %} +
+ +
+ + + + +
+
+ +
+
+ +{% if error %}
{{ error }}
{% endif %} + +{% if result %} + {% set an = form.a_person or 'Osoba A' %} + {% set bn = form.b_person or 'Osoba B' %} +
Aspekty synastryczne ({{ result.aspects | length }}) · {{ an }}{{ bn }} · silnik {{ result.engine }}
+ {% if result.aspects %} + + + + {% for a in result.aspects %} + + + + + + + {% endfor %} + +
{{ an }}Aspekt{{ bn }}Orb
{{ a.obj1 }}{% if a.glyph %}{{ a.glyph }} {% endif %}{{ a.aspect }}{{ a.obj2 }}{{ '%.2f'|format(a.orb) }}°
+ {% else %} +

Brak aspektów w zadanym orbie. Spróbuj szerszego orbu albo aspektów pobocznych.

+ {% endif %} +{% endif %} +{% endblock %} diff --git a/services/presentation/tests/test_aspect_settings.py b/services/presentation/tests/test_aspect_settings.py index c00d2be..a163fc2 100644 --- a/services/presentation/tests/test_aspect_settings.py +++ b/services/presentation/tests/test_aspect_settings.py @@ -28,8 +28,8 @@ def test_both_forms_have_aspect_settings(): def test_handlers_accept_and_pass_aspect_settings(): - # oba wywołania logic.positions (chart + compile) dostają orb - assert MAIN.count("aspect_orb=aspect_orb") == 2 + # co najmniej Horoskop i Skompiluj przekazują orb do logiki (synastria też — 3) + assert MAIN.count("aspect_orb=aspect_orb") >= 2 assert "aspect_minor: bool = Form(False)" in MAIN diff --git a/services/presentation/tests/test_formsync.py b/services/presentation/tests/test_formsync.py index 49c2665..042d4a5 100644 --- a/services/presentation/tests/test_formsync.py +++ b/services/presentation/tests/test_formsync.py @@ -50,10 +50,11 @@ def test_person_value_survives_submit(name): def test_handlers_accept_and_echo_person(): - """Każdy handler renderujący formularz przyjmuje `person` i wkłada go + """Każdy handler renderujący WSPÓLNY formularz przyjmuje `person` i wkłada go z powrotem do `form` — inaczej imię znikałoby po przeliczeniu. - Cztery: Horoskop, Interpretacje, Kalendarz, Skompiluj (PRE-23).""" - assert MAIN_PY.count("person: str = Form") == 4 + Cztery: Horoskop, Interpretacje, Kalendarz, Skompiluj (PRE-23). Synastria ma + własne `a_person`/`b_person` (dwie osoby), więc echo `"person": person` = 4.""" + assert MAIN_PY.count(" person: str = Form") == 4 # spacja z przodu wyklucza a_/b_person assert MAIN_PY.count('"person": person') == 4 diff --git a/services/presentation/tests/test_synastry.py b/services/presentation/tests/test_synastry.py new file mode 100644 index 0000000..6876ab4 --- /dev/null +++ b/services/presentation/tests/test_synastry.py @@ -0,0 +1,60 @@ +"""Synastria — technika relacyjna (PRE-04).""" +import pathlib + +APP = pathlib.Path(__file__).resolve().parents[1] / "app" +TPL = (APP / "templates" / "synastry.html").read_text(encoding="utf-8") +BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8") +MAIN = (APP / "main.py").read_text(encoding="utf-8") +CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8") + + +def _client(): + import os + + os.environ.pop("APP_PASSWORD", None) + from starlette.testclient import TestClient + + from app.main import app + + return TestClient(app) + + +def test_tab_is_in_the_menu(): + assert 'href="/synastry"' in BASE and "Synastria" in BASE + + +def test_routes_exist(): + assert '@app.get("/synastry"' in MAIN and '@app.post("/synastry"' in MAIN + + +def test_get_renders_two_person_form(): + r = _client().get("/synastry") + assert r.status_code == 200 + # dwie osoby: pola z prefiksami a_ i b_ + for pfx in ("a_", "b_"): + assert f'name="{pfx}date"' in r.text and f'name="{pfx}time"' in r.text + + +def test_form_has_both_people_and_shared_aspect_settings(): + """Pola dwóch osób generuje pętla — sprawdzamy WYRENDEROWANY formularz.""" + html = _client().get("/synastry").text + for pfx in ("a_", "b_"): + assert f'name="{pfx}person"' in html + assert f'name="{pfx}lat"' in html + assert 'name="aspect_orb"' in html and 'name="aspect_minor"' in html + + +def test_handler_calls_synastry_with_both_moments(): + assert "logic.synastry(" in MAIN + assert '"when_utc": iso_a' in MAIN and '"when_utc": iso_b' in MAIN + + +def test_client_posts_to_synastry_endpoint(): + assert 'self._post("/chart/synastry"' in CLIENT + assert '"person_a": person_a' in CLIENT and '"person_b": person_b' in CLIENT + + +def test_result_table_shows_cross_aspects(): + """Wynik: kolumny osoba A · aspekt · osoba B · orb.""" + assert "Aspekty synastryczne" in TPL + assert "a.obj1" in TPL and "a.obj2" in TPL and "a.glyph" in TPL