Files
astrololo/services/logic/app/engine/aspects.py
T
gitea 73fd41b9d3
build / build (push) Successful in 53s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m44s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m55s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 26s
Testy / Kontrola składni wszystkich warstw (push) Successful in 23s
fix(logic): pomijaj trywialne aspekty par sztywnych (NN/SN)
Węzły księżycowe są z definicji w dokładnej opozycji (SN = NN + 180°),
więc wpis "North Node opposition South Node orb 0.00°" nie niósł żadnej
informacji astrologicznej — zaśmiecał /chart/positions i UI horoskopu,
a w generatorze promptów LLM zjadał budżet znaków i mógł zostać wzięty
przez model za realne świadectwo.

- aspects.py: RIGID_PAIRS + pomijanie takich par w find_aspects;
  struktura zbioru pozwala dopisać kolejne pary definicyjne,
- aspekty węzłów do pozostałych obiektów bez zmian,
- testy: jawne sprawdzenie braku pary NN/SN (jednostkowo i na
  horoskopie referencyjnym).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 18:33:16 +00:00

102 lines
3.8 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,
}
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 find_aspects(
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS
) -> list[dict]:
"""positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę).
Zwraca listę aspektów głównych; gdy znane są prędkości, każdy aspekt ma
applying (bool) i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny).
Pary z RIGID_PAIRS (np. NN/SN) są pomijane — ich kąt jest definicyjny.
"""
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 MAJOR.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