Aspekty (LOG-06) + faseta aspektu, dedup i dopieszczenie wyników

Aspekty:
- engine/aspects.py: aspekty główne (conj/sex/sq/tri/opp) z orbami
  (bonus dla luminarzy), separacja z obsługą zawinięcia. Applying/sep na później.
- build_chart zwraca listę aspektów; /chart/positions je udostępnia;
  widok Horoskop pokazuje tabelę aspektów.

Bogatsze sygnifikatory:
- trzecia faseta "w aspekcie": dla każdego aspektu głównego obiektu filtruje
  rekordy po tokenie aspektu + drugiej planety ([conj + [Mo). Cookbook
  komplet: znak + dom + aspekt.

Dopieszczenie wyników:
- ODSIEWANIE DUPLIKATÓW: duplikat = ten sam sygnifikator ORAZ ten sam opis
  (po normalizacji). Dedup wewnątrz fasety, działa też na wynikach z wielu baz.
- _facet_samples przyjmuje wiele tokenów (AND); dedup + istniejące odsiewanie szumu.

Zweryfikowano na realnym main_base.xlsx (30.04.1984): 16 aspektów zgodnych z
astro.com (Sun conj Moon 9.59°, Sun opp Saturn 3.17°); faseta aspektu daje
bogate trafienia (Sun koniunkcja z Moon 84, opozycja z Saturn 43); dedup obniżył
duplikaty (Sun w znaku 46->44). 36 testów przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 08:55:27 +02:00
parent 41d7ca491d
commit 166b438f83
8 changed files with 192 additions and 18 deletions
+59
View File
@@ -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
+3
View File
@@ -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
+7 -4
View File
@@ -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}
+51 -11
View File
@@ -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 dopasowania). Duplikaty
(ten sam sygnifikator i opis) 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,