diff --git a/services/logic/README.md b/services/logic/README.md index 8dec6aa..9fa6a4b 100644 --- a/services/logic/README.md +++ b/services/logic/README.md @@ -6,9 +6,8 @@ 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/report` → `{when_utc, lat, lon, limit?}` → wynik obliczeń wyszukany w bazie: z pozycji + domów generuje sygnifikatory (fasety: planeta w znaku i w domu) i zwraca pasujące interpretacje z warstwy danych (zalążek LOG-16/18/19) -- `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/positions` → `{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty główne (LOG-06). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`. +- `POST /chart/report` → `{when_utc, lat, lon, limit?}` → wynik obliczeń wyszukany w bazie: fasety sygnifikatorów **w znaku / w domu / w aspekcie**, z rozwinięciem skrótów i odsiewaniem duplikatów (duplikat = ten sam sygnifikator i opis) — zalążek LOG-15/16/18/19 - `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/aspects.py b/services/logic/app/engine/aspects.py new file mode 100644 index 0000000..eeeccc9 --- /dev/null +++ b/services/logic/app/engine/aspects.py @@ -0,0 +1,59 @@ +"""Aspekty — kąty między obiektami (LOG-06, wersja: aspekty główne). + +Czysta matematyka na policzonych długościach ekliptycznych. Dla każdej pary +obiektów sprawdzamy, czy ich separacja kątowa mieści się w orbie któregoś z +aspektów głównych. Applying/separating (aplikacja/separacja) — na później. + +Tokeny bazy (z SIGNIFICATORS KEY): [conj, [sex, [sq, [tri, [opp. +""" +from __future__ import annotations + +MAJOR = { + "conjunction": 0.0, + "sextile": 60.0, + "square": 90.0, + "trine": 120.0, + "opposition": 180.0, +} +DB_TOKEN = { + "conjunction": "[conj", "sextile": "[sex", "square": "[sq", + "trine": "[tri", "opposition": "[opp", +} +PL_NAME = { + "conjunction": "koniunkcja", "sextile": "sekstyl", "square": "kwadratura", + "trine": "trygon", "opposition": "opozycja", +} +LUMINARIES = {"Sun", "Moon"} +DEFAULT_ORB = 8.0 +LUMINARY_BONUS = 2.0 + + +def separation(a: float, b: float) -> float: + """Najmniejsza separacja kątowa [0,180].""" + d = abs(a - b) % 360.0 + return min(d, 360.0 - d) + + +def find_aspects( + positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS +) -> list[dict]: + """positions: dicty z 'name' i 'decimal' (długość). Zwraca listę aspektów.""" + out: list[dict] = [] + n = len(positions) + for i in range(n): + for j in range(i + 1, n): + a, b = positions[i], positions[j] + la, lb = a.get("decimal"), b.get("decimal") + if la is None or lb is None: + continue + sep = separation(float(la), float(lb)) + allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0) + for asp, angle in MAJOR.items(): + dev = abs(sep - angle) + if dev <= allowed: + out.append({ + "obj1": a["name"], "obj2": b["name"], + "aspect": asp, "orb": round(dev, 2), + }) + break # jedna para = jeden aspekt + return out diff --git a/services/logic/app/engine/chart.py b/services/logic/app/engine/chart.py index cb8ea3f..f13c3b6 100644 --- a/services/logic/app/engine/chart.py +++ b/services/logic/app/engine/chart.py @@ -22,8 +22,11 @@ def _fmt(name: str, lon: float) -> dict: def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN) -> 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]} + result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06) if not hasattr(engine, "sidereal"): return result diff --git a/services/logic/app/main.py b/services/logic/app/main.py index 6cc6756..bd9e625 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -87,17 +87,20 @@ class ReportRequest(BaseModel): @app.post("/chart/report") def chart_report(req: ReportRequest) -> dict: - """Wynik obliczeń szukany w bazie: z pozycji + domów generuje sygnifikatory - (fasety znak/dom) i pyta warstwę danych o pasujące interpretacje.""" + """Wynik obliczeń szukany w bazie: z pozycji + domów + aspektów generuje + sygnifikatory (fasety znak/dom/aspekt) i pyta warstwę danych o interpretacje.""" from app.engine.chart import build_chart from app.engine.models import ChartMoment from app.significators import build_report engine = get_engine() moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon) - chart = build_chart(engine, moment) # pozycje z numerami domów + chart = build_chart(engine, moment) # pozycje z domami + aspekty try: - report = build_report(chart["positions"], DataClient(), per_object_limit=req.limit) + report = build_report( + chart["positions"], DataClient(), + aspects=chart.get("aspects"), per_object_limit=req.limit, + ) except httpx.HTTPError as e: return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"} return {"engine": engine.name, **report} diff --git a/services/logic/app/significators.py b/services/logic/app/significators.py index f916ff9..ada1fc5 100644 --- a/services/logic/app/significators.py +++ b/services/logic/app/significators.py @@ -57,27 +57,50 @@ def _ordinal(n: int) -> str: return f"{n}{suffix}" -def _facet_samples(rows: list[dict], token: str, limit: int = 4) -> list[dict]: - """Rekordy, których sygnifikator zawiera token — bez szumu.""" +def _norm(s: str) -> str: + """Normalizacja do porównań duplikatów: bez skrajnych spacji, jedna spacja, lower.""" + return " ".join(str(s).strip().lower().split()) + + +def _facet_samples(rows: list[dict], tokens: list[str]) -> list[dict]: + """Rekordy, których sygnifikator zawiera WSZYSTKIE tokeny — bez szumu i bez duplikatów. + + Duplikat = ten sam sygnifikator ORAZ ten sam opis (po normalizacji). Dedup + działa na zagregowanym wyniku, więc odsiewa też powtórki między wieloma bazami. + """ + toks = [t.lower() for t in tokens if t] out: list[dict] = [] - tok = token.lower() + seen: set[tuple[str, str]] = set() for r in rows: - sig = str(r.get("significator") or "") - if tok not in sig.lower(): + sig = str(r.get("significator") or "").strip() + low = sig.lower() + if not all(t in low for t in toks): continue eff = _effect(r) if _is_noise(sig, eff): continue - out.append({"significator": sig.strip(), "expanded": expand(sig.strip()), "effect": eff}) + key = (_norm(sig), _norm(eff)) + if key in seen: + continue + seen.add(key) + out.append({"significator": sig, "expanded": expand(sig), "effect": eff}) return out -def build_report(positions: list[dict], data: DataSource, per_object_limit: int = 5000) -> dict: +def build_report( + positions: list[dict], + data: DataSource, + aspects: list[dict] | None = None, + per_object_limit: int = 5000, +) -> dict: """positions: pozycje z build_chart (name, sign, direction, house). - Dla każdego obiektu: jedno zapytanie o token planety, potem faseta „w znaku" - i „w domu" (jeśli dom policzony). + Dla każdego obiektu fasety: „w znaku", „w domu" oraz „w aspekcie" (dla każdego + aspektu głównego z listy `aspects`, jeśli w bazie są dopasowania). Duplikaty + (ten sam sygnifikator i opis) są odsiewane wewnątrz każdej fasety. """ + from app.engine.aspects import DB_TOKEN as ASP_TOKEN, PL_NAME as ASP_NAME + items: list[dict] = [] provider = None for p in positions: @@ -98,7 +121,7 @@ def build_report(positions: list[dict], data: DataSource, per_object_limit: int facets: list[dict] = [] sign = p.get("sign") sign_tok = "[" + SIGN_TO_ABBR.get(sign, "") - sign_samples = _facet_samples(rows, sign_tok) + sign_samples = _facet_samples(rows, [sign_tok]) facets.append({ "type": "sign", "label": f"w znaku {sign}", "token": sign_tok, "count": len(sign_samples), "samples": sign_samples, @@ -107,12 +130,29 @@ def build_report(positions: list[dict], data: DataSource, per_object_limit: int house = p.get("house") if house: ordn = _ordinal(int(house)) - house_samples = _facet_samples(rows, f"{ordn} h") # matcuje '12th H.' + house_samples = _facet_samples(rows, [f"{ordn} h"]) # matcuje '12th H.' facets.append({ "type": "house", "label": f"w {ordn} domu", "token": f"{ordn} H.", "count": len(house_samples), "samples": house_samples, }) + for asp in (aspects or []): + if name not in (asp.get("obj1"), asp.get("obj2")): + continue + other = asp["obj2"] if asp["obj1"] == name else asp["obj1"] + asp_tok = ASP_TOKEN.get(asp["aspect"]) + if other not in PLANET_ABBR or not asp_tok: + continue + other_tok = "[" + PLANET_ABBR[other] + asp_samples = _facet_samples(rows, [asp_tok, other_tok]) + if not asp_samples: # pokazujemy tylko aspekty z trafieniami + continue + facets.append({ + "type": "aspect", "label": f"{ASP_NAME[asp['aspect']]} z {other}", + "token": f"{asp_tok} + {other_tok}", + "count": len(asp_samples), "samples": asp_samples, + }) + items.append({ "object": name, "sign": sign, diff --git a/services/logic/tests/test_aspects.py b/services/logic/tests/test_aspects.py new file mode 100644 index 0000000..00770a2 --- /dev/null +++ b/services/logic/tests/test_aspects.py @@ -0,0 +1,35 @@ +"""Testy aspektów (LOG-06) — czysta matematyka.""" +from app.engine.aspects import find_aspects, separation + + +def test_separation_wraparound(): + assert separation(10, 350) == 20 + assert separation(0, 180) == 180 + assert separation(0, 90) == 90 + + +def test_conjunction_and_opposition(): + pos = [ + {"name": "Sun", "decimal": 10.0}, + {"name": "Moon", "decimal": 12.0}, # 2° od Słońca -> koniunkcja + {"name": "Mars", "decimal": 190.0}, # 180° od Słońca -> opozycja + ] + pairs = {(a["obj1"], a["obj2"], a["aspect"]) for a in find_aspects(pos)} + assert ("Sun", "Moon", "conjunction") in pairs + assert ("Sun", "Mars", "opposition") in pairs + + +def test_orb_limit_excludes_wide(): + pos = [{"name": "Mercury", "decimal": 0.0}, {"name": "Venus", "decimal": 100.0}] + assert find_aspects(pos, orb=8.0, luminary_bonus=0.0) == [] + + +def test_luminary_bonus_widens_orb(): + # 99.5° -> 9.5° od kwadratury; z bonusem luminarza (8+2) mieści się + pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Saturn", "decimal": 99.5}] + assert any(a["aspect"] == "square" for a in find_aspects(pos)) + + +def test_one_aspect_per_pair(): + pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}] + assert len(find_aspects(pos)) == 1 diff --git a/services/logic/tests/test_significators.py b/services/logic/tests/test_significators.py index 6506261..bbf4fcb 100644 --- a/services/logic/tests/test_significators.py +++ b/services/logic/tests/test_significators.py @@ -38,6 +38,29 @@ def test_no_house_facet_when_house_missing(): assert "sign" in types and "house" not in types +def test_dedup_by_significator_and_effect(): + positions = [{"name": "Moon", "sign": "Taurus", "direction": "D", "house": 11}] + data = FakeData({"[Mo": [ + {"significator": "[Mo in 11th H.", "actioneffect": "efekt"}, + {"significator": "[Mo in 11th H.", "actioneffect": "efekt"}, # duplikat (sig+opis) + {"significator": "[Mo in 11th H.", "actioneffect": "inny efekt"}, # ten sam sig, inny opis + ]}) + facets = {f["type"]: f for f in build_report(positions, data)["objects"][0]["facets"]} + assert facets["house"]["count"] == 2 # duplikat odsiany, różny opis zostaje + + +def test_aspect_facet(): + positions = [ + {"name": "Sun", "sign": "Taurus", "direction": "D", "house": 11}, + {"name": "Moon", "sign": "Taurus", "direction": "D", "house": 11}, + ] + aspects = [{"obj1": "Sun", "obj2": "Moon", "aspect": "conjunction", "orb": 2.0}] + data = FakeData({"[Su": [{"significator": "[Su [conj [Mo", "actioneffect": "złączeni"}]}) + sun = build_report(positions, data, aspects=aspects)["objects"][0] + asp = [f for f in sun["facets"] if f["type"] == "aspect"] + assert asp and asp[0]["count"] == 1 and "Moon" in asp[0]["label"] + + def test_ordinal(): assert _ordinal(1) == "1st" and _ordinal(2) == "2nd" and _ordinal(3) == "3rd" assert _ordinal(4) == "4th" and _ordinal(11) == "11th" and _ordinal(12) == "12th" diff --git a/services/presentation/app/templates/chart.html b/services/presentation/app/templates/chart.html index f10dd48..953a41d 100644 --- a/services/presentation/app/templates/chart.html +++ b/services/presentation/app/templates/chart.html @@ -83,6 +83,18 @@ + {% if result.aspects %} +
+| Obiekt 1 | Aspekt | Obiekt 2 | Orb |
|---|---|---|---|
| {{ a.obj1 }} | {{ a.aspect }} | {{ a.obj2 }} | {{ '%.2f'|format(a.orb) }}° |