mirror of
https://github.com/migatu/astrololo.git
synced 2026-07-14 21:38:37 +00:00
Merge pull request #13 from migatu/feat/aspects
Aspekty (LOG-06) + faseta aspektu, dedup i dopieszczenie wynikow
This commit is contained in:
@@ -6,9 +6,8 @@ i nie w bazie.
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
- `POST /api/query` → `QueryRequest` → `QueryResponse`
|
- `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) + 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: 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/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/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)
|
- `POST /chart/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B)
|
||||||
- `GET /health` (sprawdza też warstwę bazodanową)
|
- `GET /health` (sprawdza też warstwę bazodanową)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
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)
|
positions = engine.positions(moment)
|
||||||
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
|
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"):
|
if not hasattr(engine, "sidereal"):
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -87,17 +87,20 @@ class ReportRequest(BaseModel):
|
|||||||
|
|
||||||
@app.post("/chart/report")
|
@app.post("/chart/report")
|
||||||
def chart_report(req: ReportRequest) -> dict:
|
def chart_report(req: ReportRequest) -> dict:
|
||||||
"""Wynik obliczeń szukany w bazie: z pozycji + domów generuje sygnifikatory
|
"""Wynik obliczeń szukany w bazie: z pozycji + domów + aspektów generuje
|
||||||
(fasety znak/dom) i pyta warstwę danych o pasujące interpretacje."""
|
sygnifikatory (fasety znak/dom/aspekt) i pyta warstwę danych o interpretacje."""
|
||||||
from app.engine.chart import build_chart
|
from app.engine.chart import build_chart
|
||||||
from app.engine.models import ChartMoment
|
from app.engine.models import ChartMoment
|
||||||
from app.significators import build_report
|
from app.significators import build_report
|
||||||
|
|
||||||
engine = get_engine()
|
engine = get_engine()
|
||||||
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
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:
|
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:
|
except httpx.HTTPError as e:
|
||||||
return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"}
|
return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"}
|
||||||
return {"engine": engine.name, **report}
|
return {"engine": engine.name, **report}
|
||||||
|
|||||||
@@ -57,27 +57,50 @@ def _ordinal(n: int) -> str:
|
|||||||
return f"{n}{suffix}"
|
return f"{n}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
def _facet_samples(rows: list[dict], token: str, limit: int = 4) -> list[dict]:
|
def _norm(s: str) -> str:
|
||||||
"""Rekordy, których sygnifikator zawiera token — bez szumu."""
|
"""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] = []
|
out: list[dict] = []
|
||||||
tok = token.lower()
|
seen: set[tuple[str, str]] = set()
|
||||||
for r in rows:
|
for r in rows:
|
||||||
sig = str(r.get("significator") or "")
|
sig = str(r.get("significator") or "").strip()
|
||||||
if tok not in sig.lower():
|
low = sig.lower()
|
||||||
|
if not all(t in low for t in toks):
|
||||||
continue
|
continue
|
||||||
eff = _effect(r)
|
eff = _effect(r)
|
||||||
if _is_noise(sig, eff):
|
if _is_noise(sig, eff):
|
||||||
continue
|
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
|
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).
|
"""positions: pozycje z build_chart (name, sign, direction, house).
|
||||||
|
|
||||||
Dla każdego obiektu: jedno zapytanie o token planety, potem faseta „w znaku"
|
Dla każdego obiektu fasety: „w znaku", „w domu" oraz „w aspekcie" (dla każdego
|
||||||
i „w domu" (jeśli dom policzony).
|
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] = []
|
items: list[dict] = []
|
||||||
provider = None
|
provider = None
|
||||||
for p in positions:
|
for p in positions:
|
||||||
@@ -98,7 +121,7 @@ def build_report(positions: list[dict], data: DataSource, per_object_limit: int
|
|||||||
facets: list[dict] = []
|
facets: list[dict] = []
|
||||||
sign = p.get("sign")
|
sign = p.get("sign")
|
||||||
sign_tok = "[" + SIGN_TO_ABBR.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({
|
facets.append({
|
||||||
"type": "sign", "label": f"w znaku {sign}", "token": sign_tok,
|
"type": "sign", "label": f"w znaku {sign}", "token": sign_tok,
|
||||||
"count": len(sign_samples), "samples": sign_samples,
|
"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")
|
house = p.get("house")
|
||||||
if house:
|
if house:
|
||||||
ordn = _ordinal(int(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({
|
facets.append({
|
||||||
"type": "house", "label": f"w {ordn} domu", "token": f"{ordn} H.",
|
"type": "house", "label": f"w {ordn} domu", "token": f"{ordn} H.",
|
||||||
"count": len(house_samples), "samples": house_samples,
|
"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({
|
items.append({
|
||||||
"object": name,
|
"object": name,
|
||||||
"sign": sign,
|
"sign": sign,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -38,6 +38,29 @@ def test_no_house_facet_when_house_missing():
|
|||||||
assert "sign" in types and "house" not in types
|
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():
|
def test_ordinal():
|
||||||
assert _ordinal(1) == "1st" and _ordinal(2) == "2nd" and _ordinal(3) == "3rd"
|
assert _ordinal(1) == "1st" and _ordinal(2) == "2nd" and _ordinal(3) == "3rd"
|
||||||
assert _ordinal(4) == "4th" and _ordinal(11) == "11th" and _ordinal(12) == "12th"
|
assert _ordinal(4) == "4th" and _ordinal(11) == "11th" and _ordinal(12) == "12th"
|
||||||
|
|||||||
@@ -83,6 +83,18 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{% if result.aspects %}
|
||||||
|
<div class="meta">Aspekty główne ({{ result.aspects | length }})</div>
|
||||||
|
<table class="angles">
|
||||||
|
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in result.aspects %}
|
||||||
|
<tr><td>{{ a.obj1 }}</td><td>{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if result.cusps %}
|
{% if result.cusps %}
|
||||||
<details class="loc">
|
<details class="loc">
|
||||||
<summary>Cusps domów ({{ result.house_system }})</summary>
|
<summary>Cusps domów ({{ result.house_system }})</summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user