mirror of
https://github.com/migatu/astrololo.git
synced 2026-07-14 21:38:37 +00:00
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:
@@ -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ą)
|
||||
|
||||
|
||||
@@ -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:
|
||||
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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@@ -83,6 +83,18 @@
|
||||
</tbody>
|
||||
</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 %}
|
||||
<details class="loc">
|
||||
<summary>Cusps domów ({{ result.house_system }})</summary>
|
||||
|
||||
Reference in New Issue
Block a user