7b435d4263
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m32s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 11s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 9s
build / build (push) Successful in 25s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m18s
Testy / Kontrola składni wszystkich warstw (push) Successful in 31s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 13m6s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 13m25s
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 <noreply@anthropic.com>
150 lines
6.0 KiB
Python
150 lines
6.0 KiB
Python
"""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,
|
|
}
|
|
# Aspekty POBOCZNE (opcjonalne, PRE-06). Tylko te trzy — bo mają już glify i barwy
|
|
# w warstwie prezentacji (chartwheel) oraz w engine/glyphs.py, więc dokładają się
|
|
# bez ruszania czegokolwiek poza silnikiem. Bazy interpretacyjne zwykle ich nie
|
|
# opisują (brak tokenu w DB_TOKEN), więc trafiają na kosmogram i do tabeli, ale
|
|
# NIE generują faset sygnifikatorów — most po cichu je pomija (significators.py).
|
|
MINOR = {
|
|
"semisextile": 30.0,
|
|
"semisquare": 45.0,
|
|
"quincunx": 150.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
|
|
|
|
# Pary sztywno powiązane definicyjnie — kąt między nimi wynika z samej definicji
|
|
# punktu, nie z układu nieba (SN = NN + 180°). Aspekt taki zawsze wychodzi
|
|
# dokładny (orb 0,00°) i nie niesie żadnej informacji astrologicznej, więc
|
|
# wycinamy go z wyników: zaśmieca listę w UI i zjada budżet promptu do LLM.
|
|
RIGID_PAIRS = frozenset({
|
|
frozenset({"North Node", "South Node"}),
|
|
})
|
|
|
|
|
|
def _is_rigid(name_a: str, name_b: str) -> bool:
|
|
return frozenset({name_a, name_b}) in RIGID_PAIRS
|
|
|
|
|
|
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 _is_applying(la: float, lb: float, sa: float, sb: float, angle: float, dt: float = 0.01) -> bool | None:
|
|
"""Czy aspekt aplikuje (dokładność 0° dopiero nastąpi)?
|
|
|
|
Porównujemy odchyłkę od dokładnego kąta teraz i po małym kroku czasu
|
|
(pozycje przesunięte o prędkość·dt). Malejąca odchyłka = applying.
|
|
dt celowo małe (0,01 doby), by szybki Księżyc nie „przeskoczył" dokładności.
|
|
Zwraca None, gdy brak prędkości (nie da się rozstrzygnąć).
|
|
"""
|
|
if sa is None or sb is None:
|
|
return None
|
|
dev_now = abs(separation(la, lb) - angle)
|
|
dev_next = abs(separation(la + sa * dt, lb + sb * dt) - angle)
|
|
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,
|
|
) -> list[dict]:
|
|
"""positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę).
|
|
|
|
Zwraca listę aspektów; gdy znane są prędkości, każdy aspekt ma applying (bool)
|
|
i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny). Orb i bonus dla świateł są
|
|
KONFIGUROWALNE (PRE-06); `minor=True` dokłada aspekty poboczne (30/45/150°).
|
|
|
|
Pary z RIGID_PAIRS (np. NN/SN) są pomijane — ich kąt jest definicyjny.
|
|
"""
|
|
checks = {**MAJOR, **MINOR} if minor else MAJOR
|
|
out: list[dict] = []
|
|
n = len(positions)
|
|
for i in range(n):
|
|
for j in range(i + 1, n):
|
|
a, b = positions[i], positions[j]
|
|
if _is_rigid(a["name"], b["name"]):
|
|
continue
|
|
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 checks.items():
|
|
dev = abs(sep - angle)
|
|
if dev <= allowed:
|
|
applying = _is_applying(
|
|
float(la), float(lb), a.get("speed"), b.get("speed"), angle
|
|
)
|
|
row = {
|
|
"obj1": a["name"], "obj2": b["name"],
|
|
"aspect": asp, "orb": round(dev, 2), "allowed": round(allowed, 2),
|
|
}
|
|
if applying is not None:
|
|
row["applying"] = applying
|
|
row["as"] = "A" if applying else "S"
|
|
out.append(row)
|
|
break # jedna para = jeden aspekt
|
|
return out
|