"""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 _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). """ 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: 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