Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ace76183e5 | |||
| f1956a08ff | |||
| e8f868e907 |
Binary file not shown.
@@ -36,13 +36,39 @@ def _shift_pos(pdict: dict, off: float) -> None:
|
|||||||
|
|
||||||
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
|
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
|
||||||
lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict:
|
lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict:
|
||||||
|
from app.engine import glyphs as GL
|
||||||
from app.engine.aspects import find_aspects
|
from app.engine.aspects import find_aspects
|
||||||
|
from app.engine.houses import mean_obliquity
|
||||||
|
from app.engine.out_of_zodiac import (
|
||||||
|
declination,
|
||||||
|
find_antiscia,
|
||||||
|
find_declination_aspects,
|
||||||
|
is_out_of_bounds,
|
||||||
|
)
|
||||||
|
|
||||||
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]}
|
||||||
# aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze
|
# aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze
|
||||||
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
|
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
|
||||||
|
|
||||||
|
# Aspekty pozazodiakalne (LOG-07): deklinacja i antyscja liczone na
|
||||||
|
# współrzędnych TROPIKALNYCH of-date — deklinacja jest fizyczna (równikowa),
|
||||||
|
# a antyscja z definicji tropikalna. Dlatego PRZED przesunięciem na zodiak,
|
||||||
|
# z surowych długości/szerokości silnika.
|
||||||
|
eps = mean_obliquity(Z.julian_day(moment.when_utc))
|
||||||
|
result["obliquity"] = round(eps, 6)
|
||||||
|
for pdict, obj in zip(result["positions"], positions):
|
||||||
|
dec = declination(obj.longitude, obj.latitude, eps)
|
||||||
|
pdict["declination"] = round(dec, 4)
|
||||||
|
if is_out_of_bounds(dec, eps):
|
||||||
|
pdict["out_of_bounds"] = True
|
||||||
|
result["parallels"] = find_declination_aspects(result["positions"])
|
||||||
|
result["antiscia"] = find_antiscia(result["positions"])
|
||||||
|
|
||||||
|
# glify aspektów (LOG-22) — symbol aspektu nie zależy od zodiaku
|
||||||
|
for a in result["aspects"]:
|
||||||
|
a["glyph"] = GL.aspect_glyph(a["aspect"])
|
||||||
|
|
||||||
# offset zodiaku (LOG-04): syderyczny = ayanamsa, draconic = długość węzła
|
# offset zodiaku (LOG-04): syderyczny = ayanamsa, draconic = długość węzła
|
||||||
node_lon = next((p.longitude for p in positions if p.name == "North Node"), None)
|
node_lon = next((p.longitude for p in positions if p.name == "North Node"), None)
|
||||||
off = Z.offset(zodiac, Z.julian_day(moment.when_utc), node_lon)
|
off = Z.offset(zodiac, Z.julian_day(moment.when_utc), node_lon)
|
||||||
@@ -52,6 +78,13 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
|||||||
for pdict in result["positions"]:
|
for pdict in result["positions"]:
|
||||||
_shift_pos(pdict, off)
|
_shift_pos(pdict, off)
|
||||||
|
|
||||||
|
# glify obiektów (LOG-22): symbol planety jest niezmienniczy, symbol ZNAKU
|
||||||
|
# zależy od zodiaku, więc po przesunięciu. Pierścień 12 znaków pod kosmogram.
|
||||||
|
for pdict in result["positions"]:
|
||||||
|
pdict["glyph"] = GL.glyph_for(pdict["name"])
|
||||||
|
pdict["sign_glyph"] = GL.sign_glyph(pdict["sign"])
|
||||||
|
result["sign_glyphs"] = [{"sign": s, "glyph": GL.sign_glyph(s)} for s in SIGNS]
|
||||||
|
|
||||||
if not hasattr(engine, "sidereal"):
|
if not hasattr(engine, "sidereal"):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -68,9 +101,12 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
|||||||
"Dsc": _fmt("Dsc", norm360(asc + 180.0), off),
|
"Dsc": _fmt("Dsc", norm360(asc + 180.0), off),
|
||||||
"IC": _fmt("IC", norm360(mc + 180.0), off),
|
"IC": _fmt("IC", norm360(mc + 180.0), off),
|
||||||
}
|
}
|
||||||
|
for a in result["angles"].values(): # glif znaku osi (LOG-22)
|
||||||
|
a["sign_glyph"] = GL.sign_glyph(a["sign"])
|
||||||
result["cusps"] = [
|
result["cusps"] = [
|
||||||
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
|
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
|
||||||
"in_sign": in_sign(norm360(c - off))}
|
"in_sign": in_sign(norm360(c - off)),
|
||||||
|
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(c - off))])}
|
||||||
for i, c in enumerate(cusp_list)
|
for i, c in enumerate(cusp_list)
|
||||||
]
|
]
|
||||||
for pdict, obj in zip(result["positions"], positions):
|
for pdict, obj in zip(result["positions"], positions):
|
||||||
@@ -89,7 +125,9 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
|||||||
"longitude": decimal(norm360(lot["longitude"] - off)), # w wybranym zodiaku
|
"longitude": decimal(norm360(lot["longitude"] - off)), # w wybranym zodiaku
|
||||||
"sign": SIGNS[sign_index(norm360(lot["longitude"] - off))],
|
"sign": SIGNS[sign_index(norm360(lot["longitude"] - off))],
|
||||||
"in_sign": in_sign(norm360(lot["longitude"] - off)),
|
"in_sign": in_sign(norm360(lot["longitude"] - off)),
|
||||||
"house": H.assign_house(lot["longitude"], cusp_list)} # dom po długości tropikalnej
|
"house": H.assign_house(lot["longitude"], cusp_list), # dom po długości tropikalnej
|
||||||
|
"glyph": GL.glyph_for(lot["name"]), # ⊗ dla Fortuny; reszta None (LOG-22)
|
||||||
|
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(lot["longitude"] - off))])}
|
||||||
for lot in compute_lots(pts, day, lots_method)
|
for lot in compute_lots(pts, day, lots_method)
|
||||||
]
|
]
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Konwersja tekst ↔ symbol astrologiczny (LOG-22).
|
||||||
|
|
||||||
|
Litery i słowa (Sa Pis 26°08' conj Fortune) ↔ glify (♄ ♓ 26°08' ☌ ⊗). Potrzebne
|
||||||
|
pod rysowanie kosmogramu (PRE-12) — planety i znaki na kole rysujemy symbolami.
|
||||||
|
|
||||||
|
DWIE zasady z bazy wymagań, obie krytyczne:
|
||||||
|
* DAN-18: TYLKO tekstowy Unicode, NIGDY emoji. Część znaków (zodiak, ♀, ♂) ma
|
||||||
|
domyślnie prezentację emoji — kolorowy kwadrat zamiast czarno-białego glifu,
|
||||||
|
nieczytelny i niesterowalny przez CSS. Wymuszamy prezentację tekstową
|
||||||
|
selektorem wariantu U+FE0E (NIE U+FE0F, który robi odwrotnie).
|
||||||
|
* DAN-17: łańcuch musi znosić dowolny Unicode. Reverse (symbol→tekst)
|
||||||
|
normalizuje wejście, zdejmując selektory wariantu, żeby glif z FE0E i bez
|
||||||
|
dawał ten sam wynik.
|
||||||
|
|
||||||
|
Glify zapisane przez \\u — jednoznaczne code-pointy, odporne na zniekształcenia
|
||||||
|
edytorów i samodokumentujące (widać, który to znak Unicode).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# selektory wariantu prezentacji
|
||||||
|
_TEXT = "︎" # VS15 — wymusza glif tekstowy (czarno-biały)
|
||||||
|
_EMOJI = "️" # VS16 — prezentacja emoji; NIGDY nie emitujemy, zdejmujemy przy reverse
|
||||||
|
|
||||||
|
|
||||||
|
def _t(cp: str) -> str:
|
||||||
|
"""Znak z wymuszoną prezentacją tekstową (dokleja VS15)."""
|
||||||
|
return cp + _TEXT
|
||||||
|
|
||||||
|
|
||||||
|
# ── planety i światła ────────────────────────────────────────────────────
|
||||||
|
# ♀ i ♂ mają wariant emoji → wymuszamy tekst; reszta jest tekstowa domyślnie.
|
||||||
|
PLANET = {
|
||||||
|
"Sun": "☉", # ☉
|
||||||
|
"Moon": "☽", # ☽
|
||||||
|
"Mercury": "☿", # ☿
|
||||||
|
"Venus": _t("♀"), # ♀︎
|
||||||
|
"Mars": _t("♂"), # ♂︎
|
||||||
|
"Jupiter": "♃", # ♃
|
||||||
|
"Saturn": "♄", # ♄
|
||||||
|
"Uranus": "♅", # ♅
|
||||||
|
"Neptune": "♆", # ♆
|
||||||
|
"Pluto": "♇", # ♇
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── punkty wirtualne ─────────────────────────────────────────────────────
|
||||||
|
POINT = {
|
||||||
|
"North Node": "☊", # ☊
|
||||||
|
"South Node": "☋", # ☋
|
||||||
|
"Lilith": "⚸", # ⚸ (Black Moon Lilith)
|
||||||
|
"Chiron": "⚷", # ⚷
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Lots (punkty arabskie) ───────────────────────────────────────────────
|
||||||
|
# Standardowy glif ma tylko Fortuna (⊗). Reszta Lotów nie ma powszechnie
|
||||||
|
# wspieranego symbolu → zwracamy None i UI pokazuje nazwę.
|
||||||
|
LOT = {
|
||||||
|
"Fortune": "⊗", # ⊗
|
||||||
|
"Part of Fortune": "⊗",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── znaki zodiaku (wszystkie mają wariant emoji → wszystkie z VS15) ──────
|
||||||
|
_SIGN_CP = {
|
||||||
|
"Aries": "♈", "Taurus": "♉", "Gemini": "♊", "Cancer": "♋",
|
||||||
|
"Leo": "♌", "Virgo": "♍", "Libra": "♎", "Scorpio": "♏",
|
||||||
|
"Sagittarius": "♐", "Capricorn": "♑", "Aquarius": "♒", "Pisces": "♓",
|
||||||
|
}
|
||||||
|
SIGN = {name: _t(cp) for name, cp in _SIGN_CP.items()}
|
||||||
|
|
||||||
|
# ── aspekty (nazwy jak w aspects.py + minor z abbreviations) ─────────────
|
||||||
|
ASPECT = {
|
||||||
|
"conjunction": "☌", # ☌
|
||||||
|
"opposition": "☍", # ☍
|
||||||
|
"trine": "△", # △
|
||||||
|
"square": "□", # □
|
||||||
|
"sextile": "⚹", # ⚹
|
||||||
|
"semisextile": "⚺", # ⚺
|
||||||
|
"quincunx": "⚻", # ⚻
|
||||||
|
"semisquare": "∠", # ∠
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── ruch ─────────────────────────────────────────────────────────────────
|
||||||
|
RETROGRADE = "℞" # ℞
|
||||||
|
DIRECT = "D" # zwykłe „D" — brak dedykowanego glifu prostego ruchu
|
||||||
|
|
||||||
|
|
||||||
|
# ── forward: nazwa → glif ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def glyph_for(name: str) -> str | None:
|
||||||
|
"""Glif obiektu/punktu/Lota po nazwie (jak w silniku). None, gdy brak."""
|
||||||
|
return PLANET.get(name) or POINT.get(name) or LOT.get(name)
|
||||||
|
|
||||||
|
|
||||||
|
def sign_glyph(sign: str) -> str | None:
|
||||||
|
return SIGN.get(sign)
|
||||||
|
|
||||||
|
|
||||||
|
def aspect_glyph(aspect: str) -> str | None:
|
||||||
|
return ASPECT.get(aspect)
|
||||||
|
|
||||||
|
|
||||||
|
# ── reverse: glif → nazwa ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _strip_variants(g: str) -> str:
|
||||||
|
"""Zdejmuje selektory wariantu — glif z FE0E i bez daje ten sam klucz."""
|
||||||
|
return g.replace(_TEXT, "").replace(_EMOJI, "")
|
||||||
|
|
||||||
|
|
||||||
|
def _reverse(mapping: dict[str, str]) -> dict[str, str]:
|
||||||
|
# pierwsze wystąpienie wygrywa (Fortune vs Part of Fortune → 'Fortune')
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for name, g in mapping.items():
|
||||||
|
out.setdefault(_strip_variants(g), name)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_PLANET_POINT_REV = _reverse({**PLANET, **POINT, **{"Fortune": LOT["Fortune"]}})
|
||||||
|
_SIGN_REV = _reverse(SIGN)
|
||||||
|
_ASPECT_REV = _reverse(ASPECT)
|
||||||
|
|
||||||
|
|
||||||
|
def name_for_glyph(g: str) -> str | None:
|
||||||
|
"""Obiekt/punkt po glifie (znosi obecność lub brak selektora wariantu)."""
|
||||||
|
return _PLANET_POINT_REV.get(_strip_variants(g))
|
||||||
|
|
||||||
|
|
||||||
|
def sign_for_glyph(g: str) -> str | None:
|
||||||
|
return _SIGN_REV.get(_strip_variants(g))
|
||||||
|
|
||||||
|
|
||||||
|
def aspect_for_glyph(g: str) -> str | None:
|
||||||
|
return _ASPECT_REV.get(_strip_variants(g))
|
||||||
|
|
||||||
|
|
||||||
|
# ── glifikacja tekstu sygnifikatora (składnia [XX z bazy) ────────────────
|
||||||
|
# Nasze sygnifikatory zapisane są tokenami z prefiksem [ (np. „[Sa [conj [PF").
|
||||||
|
# glyphify zamienia znane tokeny na glify, resztę zostawia. To druga strona
|
||||||
|
# abbreviations.expand: tam token → słowo, tu token → symbol.
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
# token po nawiasie (np. „Su", „Tau", „conj", „PF") → glif
|
||||||
|
_TOKEN_GLYPH: dict[str, str] = {}
|
||||||
|
_ABBR_TO_NAME = {
|
||||||
|
"Su": "Sun", "Mo": "Moon", "Me": "Mercury", "Ve": "Venus", "Ma": "Mars",
|
||||||
|
"Ju": "Jupiter", "Sa": "Saturn", "Ur": "Uranus", "Ne": "Neptune", "Pl": "Pluto",
|
||||||
|
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
|
||||||
|
"PF": "Fortune", "Fortune": "Fortune",
|
||||||
|
}
|
||||||
|
for _abbr, _name in _ABBR_TO_NAME.items():
|
||||||
|
_g = glyph_for(_name)
|
||||||
|
if _g:
|
||||||
|
_TOKEN_GLYPH[_abbr] = _g
|
||||||
|
_SIGN_ABBR_NAME = {
|
||||||
|
"Ari": "Aries", "Tau": "Taurus", "Gem": "Gemini", "Can": "Cancer", "Leo": "Leo",
|
||||||
|
"Vir": "Virgo", "Lib": "Libra", "Sco": "Scorpio", "Sag": "Sagittarius",
|
||||||
|
"Cap": "Capricorn", "Aqu": "Aquarius", "Pis": "Pisces",
|
||||||
|
}
|
||||||
|
for _abbr, _name in _SIGN_ABBR_NAME.items():
|
||||||
|
_TOKEN_GLYPH[_abbr] = SIGN[_name]
|
||||||
|
_ASP_ABBR_NAME = {
|
||||||
|
"conj": "conjunction", "opp": "opposition", "tri": "trine", "sq": "square",
|
||||||
|
"sex": "sextile", "semisex": "semisextile", "quincunx": "quincunx", "semisq": "semisquare",
|
||||||
|
}
|
||||||
|
for _abbr, _name in _ASP_ABBR_NAME.items():
|
||||||
|
if _name in ASPECT:
|
||||||
|
_TOKEN_GLYPH[_abbr] = ASPECT[_name]
|
||||||
|
|
||||||
|
_TOKEN_RE = _re.compile(r"\[([A-Za-z]+)")
|
||||||
|
|
||||||
|
|
||||||
|
def glyphify(text: str) -> str:
|
||||||
|
"""Zamienia tokeny [XX na glify; nieznane zostawia bez zmiany. Dodatkowo
|
||||||
|
»Rx«/»R« → ℞. Nie parsuje stopni — te i tak są czytelne (26°08')."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
out = _TOKEN_RE.sub(lambda m: _TOKEN_GLYPH.get(m.group(1), m.group(0)), text)
|
||||||
|
out = _re.sub(r"\bR[x]?\b", RETROGRADE, out)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Aspekty pozazodiakalne (LOG-07): paralele deklinacji i antyscja.
|
||||||
|
|
||||||
|
Aspekty głowne (LOG-06) mierzą kąt wzdłuż EKLIPTYKI. Ale dwa ciała mogą być
|
||||||
|
powiązane też inaczej — a te powiązania klasyczna astrologia liczy naprawdę,
|
||||||
|
nie na oko:
|
||||||
|
|
||||||
|
* **Paralela / kontrparalela deklinacji.** Deklinacja to „szerokość" na równiku
|
||||||
|
niebieskim — jak daleko na północ/południe od równika stoi ciało. Dwa ciała na
|
||||||
|
tej samej deklinacji (parallel) działają jak koniunkcja, na przeciwnej
|
||||||
|
(kontrparalela) — jak opozycja, mimo że wzdłuż ekliptyki mogą być gdziekolwiek.
|
||||||
|
Baza interpretacyjna zna to zjawisko pod skrótem „P. Dec.".
|
||||||
|
|
||||||
|
* **Antyscja / kontrantyscja.** Odbicie punktu względem osi przesileń
|
||||||
|
(0° Raka – 0° Koziorożca). Dwa punkty w antyscji są równo odległe od tej osi —
|
||||||
|
„dzielą" tę samą długość dnia. Kontrantyscja to odbicie względem osi
|
||||||
|
równonocy (0° Barana – 0° Wagi).
|
||||||
|
|
||||||
|
Wszystko liczymy na współrzędnych TROPIKALNYCH of-date, bo:
|
||||||
|
- deklinacja jest wielkością fizyczną (równikową), niezależną od wyboru zodiaku;
|
||||||
|
- antyscja jest z definicji tropikalna — jej oś to punkty przesileń, czyli
|
||||||
|
kardynalne punkty zodiaku tropikalnego.
|
||||||
|
Dlatego moduł bierze surowe długości/szerokości z silnika, a nie etykiety po
|
||||||
|
przesunięciu na zodiak syderyczny/draconiczny.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.engine.aspects import LUMINARIES, RIGID_PAIRS, separation
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
from app.engine.zodiac import to_equatorial
|
||||||
|
|
||||||
|
# Orby — konfigurowalne (wymóg LOG-07). Paralele i antyscja są klasycznie CIASNE:
|
||||||
|
# to kontakty „punktowe", więc szeroki orb produkowałby fałszywe trafienia.
|
||||||
|
DECL_ORB = 1.0 # ° deklinacji dla paraleli/kontrparaleli
|
||||||
|
ANTISCIA_ORB = 1.0 # ° długości dla antyscji/kontrantyscji
|
||||||
|
LUMINARY_BONUS = 0.0 # świadomie 0 — te kontakty trzymamy ciasno; do podniesienia w API
|
||||||
|
|
||||||
|
|
||||||
|
def _is_rigid(name_a: str, name_b: str) -> bool:
|
||||||
|
return frozenset({name_a, name_b}) in RIGID_PAIRS
|
||||||
|
|
||||||
|
|
||||||
|
def declination(lon: float, lat: float, eps: float) -> float:
|
||||||
|
"""Deklinacja [−90, 90]° z długości i szerokości ekliptycznej (pełny wzór,
|
||||||
|
z szerokością — istotne dla Księżyca i planet, które schodzą z ekliptyki)."""
|
||||||
|
_, dec = to_equatorial(lon, lat, eps)
|
||||||
|
return dec
|
||||||
|
|
||||||
|
|
||||||
|
def is_out_of_bounds(dec: float, eps: float) -> bool:
|
||||||
|
"""Deklinacja poza zakresem Słońca (|dec| > nachylenie ekliptyki).
|
||||||
|
|
||||||
|
„Out of bounds" — ciało zaszło dalej na północ/południe, niż Słońce kiedykolwiek
|
||||||
|
potrafi. Astrologicznie czytane jako działanie „poza normą", stąd wart odnotowania."""
|
||||||
|
return abs(dec) > eps
|
||||||
|
|
||||||
|
|
||||||
|
def antiscion(lon: float) -> float:
|
||||||
|
"""Odbicie długości względem osi przesileń (0° Raka – 0° Koziorożca)."""
|
||||||
|
return norm360(180.0 - lon)
|
||||||
|
|
||||||
|
|
||||||
|
def contra_antiscion(lon: float) -> float:
|
||||||
|
"""Odbicie długości względem osi równonocy (0° Barana – 0° Wagi)."""
|
||||||
|
return norm360(-lon)
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed(orb: float, name_a: str, name_b: str, bonus: float) -> float:
|
||||||
|
if bonus and (name_a in LUMINARIES or name_b in LUMINARIES):
|
||||||
|
return orb + bonus
|
||||||
|
return orb
|
||||||
|
|
||||||
|
|
||||||
|
def find_declination_aspects(
|
||||||
|
bodies: list[dict], orb: float = DECL_ORB, luminary_bonus: float = LUMINARY_BONUS
|
||||||
|
) -> list[dict]:
|
||||||
|
"""bodies: dicty z 'name' i 'declination' (°).
|
||||||
|
|
||||||
|
Zwraca paralele (ta sama deklinacja) i kontrparalele (przeciwna). Pary z
|
||||||
|
RIGID_PAIRS pomijane — np. węzły są z definicji zawsze w kontrparaleli
|
||||||
|
(SN = NN+180 na ekliptyce → deklinacja przeciwna), co nie niesie informacji.
|
||||||
|
"""
|
||||||
|
out: list[dict] = []
|
||||||
|
n = len(bodies)
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(i + 1, n):
|
||||||
|
a, b = bodies[i], bodies[j]
|
||||||
|
if _is_rigid(a["name"], b["name"]):
|
||||||
|
continue
|
||||||
|
da, db = a.get("declination"), b.get("declination")
|
||||||
|
if da is None or db is None:
|
||||||
|
continue
|
||||||
|
da, db = float(da), float(db)
|
||||||
|
allowed = _allowed(orb, a["name"], b["name"], luminary_bonus)
|
||||||
|
parallel_dev = abs(da - db)
|
||||||
|
contra_dev = abs(da + db)
|
||||||
|
# ciało może wpaść tylko w jeden z dwóch — bierzemy ciaśniejszy
|
||||||
|
if parallel_dev <= allowed and parallel_dev <= contra_dev:
|
||||||
|
out.append(_row("parallel", a, b, parallel_dev, allowed, da, db))
|
||||||
|
elif contra_dev <= allowed:
|
||||||
|
out.append(_row("contraparallel", a, b, contra_dev, allowed, da, db))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def find_antiscia(
|
||||||
|
bodies: list[dict], orb: float = ANTISCIA_ORB, luminary_bonus: float = LUMINARY_BONUS
|
||||||
|
) -> list[dict]:
|
||||||
|
"""bodies: dicty z 'name' i 'decimal' (długość tropikalna of-date, °).
|
||||||
|
|
||||||
|
Zwraca antyscje (odbicie względem osi przesileń) i kontrantyscje (osi równonocy).
|
||||||
|
"""
|
||||||
|
out: list[dict] = []
|
||||||
|
n = len(bodies)
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(i + 1, n):
|
||||||
|
a, b = bodies[i], bodies[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
|
||||||
|
la, lb = float(la), float(lb)
|
||||||
|
allowed = _allowed(orb, a["name"], b["name"], luminary_bonus)
|
||||||
|
anti_dev = separation(la, antiscion(lb))
|
||||||
|
contra_dev = separation(la, contra_antiscion(lb))
|
||||||
|
if anti_dev <= allowed and anti_dev <= contra_dev:
|
||||||
|
out.append(_row("antiscion", a, b, anti_dev, allowed))
|
||||||
|
elif contra_dev <= allowed:
|
||||||
|
out.append(_row("contra_antiscion", a, b, contra_dev, allowed))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _row(kind: str, a: dict, b: dict, dev: float, allowed: float,
|
||||||
|
dec_a: float | None = None, dec_b: float | None = None) -> dict:
|
||||||
|
row = {
|
||||||
|
"obj1": a["name"], "obj2": b["name"],
|
||||||
|
"type": kind, "orb": round(dev, 3), "allowed": round(allowed, 3),
|
||||||
|
}
|
||||||
|
if dec_a is not None:
|
||||||
|
row["dec1"], row["dec2"] = round(dec_a, 3), round(dec_b, 3)
|
||||||
|
return row
|
||||||
@@ -124,7 +124,8 @@ def chart_report(req: ReportRequest) -> dict:
|
|||||||
try:
|
try:
|
||||||
report = build_report(
|
report = build_report(
|
||||||
chart["positions"], DataClient(),
|
chart["positions"], DataClient(),
|
||||||
aspects=chart.get("aspects"), per_object_limit=req.limit, group=req.group,
|
aspects=chart.get("aspects"), parallels=chart.get("parallels"),
|
||||||
|
per_object_limit=req.limit, group=req.group,
|
||||||
)
|
)
|
||||||
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}"}
|
||||||
@@ -188,7 +189,8 @@ def chart_prompt(req: PromptRequest) -> dict:
|
|||||||
try:
|
try:
|
||||||
report = build_report(
|
report = build_report(
|
||||||
chart["positions"], DataClient(),
|
chart["positions"], DataClient(),
|
||||||
aspects=chart.get("aspects"), per_object_limit=req.limit,
|
aspects=chart.get("aspects"), parallels=chart.get("parallels"),
|
||||||
|
per_object_limit=req.limit,
|
||||||
)
|
)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
data_error = f"Warstwa danych niedostępna: {e}"
|
data_error = f"Warstwa danych niedostępna: {e}"
|
||||||
|
|||||||
@@ -114,17 +114,24 @@ def build_report(
|
|||||||
positions: list[dict],
|
positions: list[dict],
|
||||||
data: DataSource,
|
data: DataSource,
|
||||||
aspects: list[dict] | None = None,
|
aspects: list[dict] | None = None,
|
||||||
|
parallels: list[dict] | None = None,
|
||||||
per_object_limit: int = 5000,
|
per_object_limit: int = 5000,
|
||||||
group: bool = False,
|
group: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""positions: pozycje z build_chart (name, sign, direction, house).
|
"""positions: pozycje z build_chart (name, sign, direction, house).
|
||||||
|
|
||||||
Dla każdego obiektu fasety: „w znaku", „w domu" oraz „w aspekcie" (dla każdego
|
Dla każdego obiektu fasety: „w znaku", „w domu", „w aspekcie" (dla każdego
|
||||||
aspektu głównego z listy `aspects`, jeśli w bazie są dopasowania). Duplikaty
|
aspektu głównego z listy `aspects`) oraz „paralela deklinacji" (dla paraleli
|
||||||
(ten sam sygnifikator i opis) są odsiewane wewnątrz każdej fasety.
|
z listy `parallels` — LOG-07). 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
|
from app.engine.aspects import DB_TOKEN as ASP_TOKEN, PL_NAME as ASP_NAME
|
||||||
|
|
||||||
|
# W bazie paralela deklinacji zapisana jest jako fraza „P. Dec." (nie token [).
|
||||||
|
# UWAGA: samo „Dec." w bazie to CZĘSTO dekanat („3rd Dec. of [Gem"), więc
|
||||||
|
# szukamy dokładnie „P. Dec.", inaczej sypnęłoby fałszywymi trafieniami.
|
||||||
|
PARALLEL_MARK = "P. Dec."
|
||||||
|
|
||||||
items: list[dict] = []
|
items: list[dict] = []
|
||||||
provider = None
|
provider = None
|
||||||
for p in positions:
|
for p in positions:
|
||||||
@@ -180,6 +187,25 @@ def build_report(
|
|||||||
"count": len(asp_samples), "samples": asp_samples,
|
"count": len(asp_samples), "samples": asp_samples,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# paralele deklinacji (LOG-07) — baza zna je jako „P. Dec.". Tylko `parallel`:
|
||||||
|
# kontrparaleli i antyscji baza nie opisuje osobnym znacznikiem.
|
||||||
|
for par in (parallels or []):
|
||||||
|
if par.get("type") != "parallel" or name not in (par.get("obj1"), par.get("obj2")):
|
||||||
|
continue
|
||||||
|
other = par["obj2"] if par["obj1"] == name else par["obj1"]
|
||||||
|
if other not in PLANET_ABBR:
|
||||||
|
continue
|
||||||
|
other_tok = "[" + PLANET_ABBR[other]
|
||||||
|
par_samples = _facet_samples(rows, [PARALLEL_MARK, other_tok])
|
||||||
|
if not par_samples: # pokazujemy tylko z trafieniami
|
||||||
|
continue
|
||||||
|
facets.append({
|
||||||
|
"type": "parallel", "label": f"paralela deklinacji z {other}",
|
||||||
|
"token": f"{PARALLEL_MARK} + {other_tok}",
|
||||||
|
"orb": par.get("orb"), "allowed": par.get("allowed"),
|
||||||
|
"count": len(par_samples), "samples": par_samples,
|
||||||
|
})
|
||||||
|
|
||||||
# punktacja siły (LOG-21), opcjonalne grupowanie po opisie, ranking faset
|
# punktacja siły (LOG-21), opcjonalne grupowanie po opisie, ranking faset
|
||||||
for f in facets:
|
for f in facets:
|
||||||
f["score"] = score_facet(f)
|
f["score"] = score_facet(f)
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Konwersja tekst ↔ symbol astrologiczny (LOG-22).
|
||||||
|
|
||||||
|
Sedno: glify muszą być KOMPLETNE (każdy obiekt/znak/aspekt, który liczymy, ma
|
||||||
|
symbol), DWUKIERUNKOWE (symbol → tekst z powrotem) i TEKSTOWE, nigdy emoji
|
||||||
|
(DAN-18) — inaczej na kosmogramie wyjdą kolorowe kwadraty zamiast czarno-białych
|
||||||
|
symboli.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine import glyphs as G
|
||||||
|
from app.engine.aspects import MAJOR
|
||||||
|
from app.engine.formats import SIGNS
|
||||||
|
from app.engine.models import DEFAULT_OBJECTS
|
||||||
|
|
||||||
|
VS15, VS16 = "︎", "️"
|
||||||
|
|
||||||
|
|
||||||
|
def _all_glyphs():
|
||||||
|
return (list(G.PLANET.values()) + list(G.POINT.values()) + list(G.LOT.values())
|
||||||
|
+ list(G.SIGN.values()) + list(G.ASPECT.values()) + [G.RETROGRADE])
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- kompletność
|
||||||
|
|
||||||
|
def test_every_default_object_has_a_glyph():
|
||||||
|
"""Każdy obiekt z kanonicznego zestawu musi mieć symbol — inaczej na kole
|
||||||
|
zostanie dziura."""
|
||||||
|
missing = [name for name in DEFAULT_OBJECTS if G.glyph_for(name) is None]
|
||||||
|
assert not missing, f"obiekty bez glifu: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_sign_has_a_glyph():
|
||||||
|
missing = [s for s in SIGNS if G.sign_glyph(s) is None]
|
||||||
|
assert not missing, f"znaki bez glifu: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_major_aspect_has_a_glyph():
|
||||||
|
missing = [a for a in MAJOR if G.aspect_glyph(a) is None]
|
||||||
|
assert not missing, f"aspekty główne bez glifu: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fortune_and_retrograde_from_requirement():
|
||||||
|
"""Wymóg wprost wymienia ⊗ (Fortuna) i ℞ (retrogradacja)."""
|
||||||
|
assert G.glyph_for("Fortune") == "⊗"
|
||||||
|
assert G.RETROGRADE == "℞"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------ DAN-18: tekst, nie emoji
|
||||||
|
|
||||||
|
def test_no_glyph_uses_emoji_variation_selector():
|
||||||
|
"""VS16 (U+FE0F) wymusiłby prezentację emoji — nie może go być NIGDZIE."""
|
||||||
|
offenders = [g for g in _all_glyphs() if VS16 in g]
|
||||||
|
assert not offenders, f"glify z wariantem emoji (FE0F): {offenders!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_signs_force_text_presentation():
|
||||||
|
"""Znaki zodiaku mają domyślnie wariant emoji → MUSZĄ nieść VS15."""
|
||||||
|
for s in SIGNS:
|
||||||
|
assert VS15 in G.sign_glyph(s), f"{s} bez wymuszenia tekstu (VS15)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_venus_mars_force_text_but_sun_does_not():
|
||||||
|
"""♀/♂ mają wariant emoji (VS15 konieczny); ☉ nie ma (VS15 zbędny)."""
|
||||||
|
assert VS15 in G.glyph_for("Venus")
|
||||||
|
assert VS15 in G.glyph_for("Mars")
|
||||||
|
assert VS15 not in G.glyph_for("Sun") # bez zaśmiecania niepotrzebnym selektorem
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- dwukierunkowość
|
||||||
|
|
||||||
|
def test_object_round_trip():
|
||||||
|
for name in DEFAULT_OBJECTS:
|
||||||
|
assert G.name_for_glyph(G.glyph_for(name)) == name
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_round_trip():
|
||||||
|
for s in SIGNS:
|
||||||
|
assert G.sign_for_glyph(G.sign_glyph(s)) == s
|
||||||
|
|
||||||
|
|
||||||
|
def test_aspect_round_trip():
|
||||||
|
for a in MAJOR:
|
||||||
|
assert G.aspect_for_glyph(G.aspect_glyph(a)) == a
|
||||||
|
|
||||||
|
|
||||||
|
def test_reverse_tolerates_missing_variant_selector():
|
||||||
|
"""Symbol wklejony bez VS15 też musi się rozpoznać — bo z zewnątrz przyjdzie
|
||||||
|
dowolna forma (DAN-17: łańcuch znosi każdy Unicode)."""
|
||||||
|
assert G.sign_for_glyph("♓") == "Pisces" # ♓ bez VS15
|
||||||
|
assert G.sign_for_glyph("♓" + VS15) == "Pisces" # ♓ z VS15
|
||||||
|
assert G.name_for_glyph("♀") == "Venus" # ♀ bez VS15
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------- glifikacja tekstu
|
||||||
|
|
||||||
|
def test_glyphify_matches_requirement_example():
|
||||||
|
"""Przykład z wymagań: [Sa [Pis 26°08' [conj [PF -> ♄ ♓ 26°08' ☌ ⊗."""
|
||||||
|
out = G.glyphify("[Sa [Pis 26°08' [conj [PF")
|
||||||
|
assert G.glyph_for("Saturn") in out
|
||||||
|
assert G.sign_glyph("Pisces") in out
|
||||||
|
assert G.aspect_glyph("conjunction") in out
|
||||||
|
assert G.glyph_for("Fortune") in out
|
||||||
|
assert "26°08'" in out # stopnie nietknięte
|
||||||
|
|
||||||
|
|
||||||
|
def test_glyphify_handles_retrograde_and_leaves_unknown():
|
||||||
|
out = G.glyphify("[Ma Rx w [Xyz")
|
||||||
|
assert G.glyph_for("Mars") in out
|
||||||
|
assert G.RETROGRADE in out
|
||||||
|
assert "[Xyz" in out # nieznany token bez zmian
|
||||||
|
|
||||||
|
|
||||||
|
def test_glyphify_empty_is_safe():
|
||||||
|
assert G.glyphify("") == ""
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Aspekty pozazodiakalne — paralele deklinacji i antyscja (LOG-07).
|
||||||
|
|
||||||
|
Walidacja wobec faktów NIEZALEŻNYCH od naszego kodu:
|
||||||
|
* deklinacja Słońca 30.04.1984 ≈ +14.9° (almanach; Słońce w ~10° Byka, koniec
|
||||||
|
kwietnia, deklinacja rośnie ku przesileniu letniemu),
|
||||||
|
* antyscja to czysta arytmetyka odbicia względem osi przesileń — pary znaków
|
||||||
|
(Rak↔Bliźnięta, Baran↔Panna) i inwolucja dają się sprawdzić na piechotę,
|
||||||
|
* węzły są z definicji w kontrparaleli (SN = NN+180° na ekliptyce → deklinacja
|
||||||
|
przeciwna), więc muszą być odsiane jako kontakt bez informacji.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.out_of_zodiac import (
|
||||||
|
antiscion,
|
||||||
|
contra_antiscion,
|
||||||
|
declination,
|
||||||
|
find_antiscia,
|
||||||
|
find_declination_aspects,
|
||||||
|
is_out_of_bounds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def krakow():
|
||||||
|
return ChartMoment(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc),
|
||||||
|
lat=50.0647, lon=19.9450)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def chart(own_engine, krakow):
|
||||||
|
return build_chart(own_engine, krakow)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- deklinacja
|
||||||
|
|
||||||
|
def test_sun_declination_matches_almanac(chart):
|
||||||
|
"""Deklinacja Słońca 30.04.1984 ≈ +14.9° (wartość z almanachu)."""
|
||||||
|
sun = next(p for p in chart["positions"] if p["name"] == "Sun")
|
||||||
|
assert 14.6 < sun["declination"] < 15.1
|
||||||
|
|
||||||
|
|
||||||
|
def test_declination_uses_latitude_not_only_longitude():
|
||||||
|
"""Pełny wzór: przy tej samej długości różna szerokość → różna deklinacja.
|
||||||
|
Gdyby liczyć z samej długości (β=0), obie wyszłyby identyczne."""
|
||||||
|
eps = 23.44
|
||||||
|
on_ecliptic = declination(60.0, 0.0, eps) # β = 0
|
||||||
|
above = declination(60.0, 5.0, eps) # 5° na północ od ekliptyki
|
||||||
|
assert abs(above - on_ecliptic) > 3.0 # szerokość realnie zmienia deklinację
|
||||||
|
|
||||||
|
|
||||||
|
def test_obliquity_is_reasonable_for_1984(chart):
|
||||||
|
assert 23.43 < chart["obliquity"] < 23.45
|
||||||
|
|
||||||
|
|
||||||
|
def test_out_of_bounds_flag():
|
||||||
|
eps = 23.44
|
||||||
|
assert is_out_of_bounds(24.0, eps) is True # poza zakresem Słońca
|
||||||
|
assert is_out_of_bounds(20.0, eps) is False
|
||||||
|
assert is_out_of_bounds(-24.5, eps) is True # także na południe
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- antyscja (wzór)
|
||||||
|
|
||||||
|
def test_antiscion_is_reflection_across_solstitial_axis():
|
||||||
|
assert antiscion(0.0) == pytest.approx(180.0) # 0° Barana → 0° Wagi
|
||||||
|
assert antiscion(105.0) == pytest.approx(75.0) # 15° Raka → 15° Bliźniąt
|
||||||
|
assert antiscion(90.0) == pytest.approx(90.0) # 0° Raka leży NA osi
|
||||||
|
|
||||||
|
|
||||||
|
def test_antiscion_is_an_involution():
|
||||||
|
for lon in (12.3, 88.0, 200.5, 359.9):
|
||||||
|
assert antiscion(antiscion(lon)) == pytest.approx(lon)
|
||||||
|
|
||||||
|
|
||||||
|
def test_contra_antiscion_across_equinoctial_axis():
|
||||||
|
assert contra_antiscion(0.0) == pytest.approx(0.0) # 0° Barana leży NA osi
|
||||||
|
assert contra_antiscion(30.0) == pytest.approx(330.0) # 0° Byka → 0° Ryb (odbicie)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_pairs_are_classic():
|
||||||
|
"""Antyscja parami znaków: Baran↔Panna, Byk↔Lew, Bliźnięta↔Rak."""
|
||||||
|
# 5° Barana (5°) → 175° = 25° Panny
|
||||||
|
assert antiscion(5.0) == pytest.approx(175.0)
|
||||||
|
# 10° Byka (40°) → 140° = 20° Lwa
|
||||||
|
assert antiscion(40.0) == pytest.approx(140.0)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------- wykrywanie paraleli/antyscji
|
||||||
|
|
||||||
|
def test_parallel_and_contraparallel_detection():
|
||||||
|
bodies = [
|
||||||
|
{"name": "A", "declination": 12.0},
|
||||||
|
{"name": "B", "declination": 12.4}, # paralela z A (0.4°)
|
||||||
|
{"name": "C", "declination": -11.6}, # kontrparalela z A (|12-11.6|=0.4)
|
||||||
|
{"name": "D", "declination": 5.0}, # nic (daleko)
|
||||||
|
]
|
||||||
|
res = {(r["obj1"], r["obj2"]): r for r in find_declination_aspects(bodies, orb=1.0)}
|
||||||
|
assert res[("A", "B")]["type"] == "parallel"
|
||||||
|
assert res[("A", "C")]["type"] == "contraparallel"
|
||||||
|
assert ("A", "D") not in res
|
||||||
|
|
||||||
|
|
||||||
|
def test_orb_is_configurable():
|
||||||
|
bodies = [{"name": "A", "declination": 10.0}, {"name": "B", "declination": 11.5}]
|
||||||
|
assert find_declination_aspects(bodies, orb=1.0) == [] # 1.5° > 1.0°
|
||||||
|
assert len(find_declination_aspects(bodies, orb=2.0)) == 1 # mieści się
|
||||||
|
|
||||||
|
|
||||||
|
def test_nodes_definitional_contraparallel_is_filtered(chart):
|
||||||
|
"""SN = NN+180° na ekliptyce → deklinacja przeciwna ZAWSZE. To kontakt bez
|
||||||
|
informacji, więc pary NN/SN nie może być w wynikach."""
|
||||||
|
decl = {p["name"]: p["declination"] for p in chart["positions"]}
|
||||||
|
# najpierw potwierdzamy, że deklinacje faktycznie są przeciwne
|
||||||
|
assert decl["North Node"] == pytest.approx(-decl["South Node"], abs=0.05)
|
||||||
|
# a mimo to para NN/SN nie pojawia się na liście paraleli
|
||||||
|
pairs = {frozenset((r["obj1"], r["obj2"])) for r in chart["parallels"]}
|
||||||
|
assert frozenset(("North Node", "South Node")) not in pairs
|
||||||
|
|
||||||
|
|
||||||
|
def test_antiscia_detection_with_constructed_longitudes():
|
||||||
|
# A na 40°, B na 138° → antyscja A leży na 140°, więc B jest 2° od niej
|
||||||
|
bodies = [{"name": "A", "decimal": 40.0}, {"name": "B", "decimal": 138.0}]
|
||||||
|
assert find_antiscia(bodies, orb=1.0) == [] # 2° > 1°
|
||||||
|
hit = find_antiscia(bodies, orb=3.0)
|
||||||
|
assert len(hit) == 1 and hit[0]["type"] == "antiscion"
|
||||||
|
|
||||||
|
|
||||||
|
def test_contra_antiscion_detection():
|
||||||
|
# A na 40°, kontrantyscja A = 320°; B na 320.5° → kontrantyscja w orbie
|
||||||
|
bodies = [{"name": "A", "decimal": 40.0}, {"name": "B", "decimal": 320.5}]
|
||||||
|
hit = find_antiscia(bodies, orb=1.0)
|
||||||
|
assert len(hit) == 1 and hit[0]["type"] == "contra_antiscion"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------ spójność z build_chart
|
||||||
|
|
||||||
|
def test_chart_exposes_parallels_and_antiscia(chart):
|
||||||
|
assert "parallels" in chart and "antiscia" in chart
|
||||||
|
assert "obliquity" in chart
|
||||||
|
# znany, ciasny kontakt z tego horoskopu: Uran ~paralela~ Neptun (mid-80s)
|
||||||
|
un = next((r for r in chart["parallels"]
|
||||||
|
if {r["obj1"], r["obj2"]} == {"Uranus", "Neptune"}), None)
|
||||||
|
assert un is not None and un["type"] == "parallel"
|
||||||
|
|
||||||
|
|
||||||
|
def test_declination_is_zodiac_independent(own_engine, krakow):
|
||||||
|
"""Deklinacja jest fizyczna — nie zmienia się z wyborem zodiaku."""
|
||||||
|
tropical = build_chart(own_engine, krakow, zodiac="tropical")
|
||||||
|
sidereal = build_chart(own_engine, krakow, zodiac="sidereal_lahiri")
|
||||||
|
dt = {p["name"]: p["declination"] for p in tropical["positions"]}
|
||||||
|
ds = {p["name"]: p["declination"] for p in sidereal["positions"]}
|
||||||
|
for name in dt:
|
||||||
|
assert dt[name] == pytest.approx(ds[name], abs=1e-6)
|
||||||
@@ -61,6 +61,42 @@ def test_aspect_facet():
|
|||||||
assert asp and asp[0]["count"] == 1 and "Moon" in asp[0]["label"]
|
assert asp and asp[0]["count"] == 1 and "Moon" in asp[0]["label"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parallel_facet_hits_db_and_avoids_decanate_trap():
|
||||||
|
"""Paralela deklinacji (LOG-07): baza zna ją jako „P. Dec.". Pułapka: samo
|
||||||
|
„Dec." to często DEKANAT („3rd Dec. of [Gem"), który NIE może wpaść do paraleli."""
|
||||||
|
positions = [
|
||||||
|
{"name": "Mercury", "sign": "Taurus", "direction": "D", "house": 3},
|
||||||
|
{"name": "Saturn", "sign": "Scorpio", "direction": "Rx", "house": 9},
|
||||||
|
]
|
||||||
|
parallels = [{"obj1": "Mercury", "obj2": "Saturn", "type": "parallel",
|
||||||
|
"orb": 0.3, "allowed": 1.0}]
|
||||||
|
data = FakeData({"[Me": [
|
||||||
|
{"significator": "[Me at B. in P. Dec. [Sa and [Ma", "actioneffect": "dyscyplina umysłu"},
|
||||||
|
{"significator": "3rd Dec. of [Gem on Asc.", "actioneffect": "TO DEKANAT, nie paralela"},
|
||||||
|
{"significator": "[Me [conj [Sa", "actioneffect": "inny aspekt, nie paralela"},
|
||||||
|
]})
|
||||||
|
par = [f for f in build_report(positions, data, parallels=parallels)["objects"][0]["facets"]
|
||||||
|
if f["type"] == "parallel"]
|
||||||
|
assert par and par[0]["count"] == 1
|
||||||
|
assert "Saturn" in par[0]["label"]
|
||||||
|
assert all("Dec. of" not in s["significator"] for s in par[0]["samples"]), "dekanat przeciekł"
|
||||||
|
|
||||||
|
|
||||||
|
def test_contraparallel_gets_no_db_facet():
|
||||||
|
"""Baza nie ma osobnego znacznika dla kontrparaleli — nie zmyślamy fasety."""
|
||||||
|
positions = [
|
||||||
|
{"name": "Mercury", "sign": "Taurus", "direction": "D", "house": 3},
|
||||||
|
{"name": "Saturn", "sign": "Scorpio", "direction": "Rx", "house": 9},
|
||||||
|
]
|
||||||
|
parallels = [{"obj1": "Mercury", "obj2": "Saturn", "type": "contraparallel",
|
||||||
|
"orb": 0.3, "allowed": 1.0}]
|
||||||
|
data = FakeData({"[Me": [
|
||||||
|
{"significator": "[Me at B. in P. Dec. [Sa", "actioneffect": "paralela, nie kontr-"},
|
||||||
|
]})
|
||||||
|
types = [f["type"] for f in build_report(positions, data, parallels=parallels)["objects"][0]["facets"]]
|
||||||
|
assert "parallel" not in types
|
||||||
|
|
||||||
|
|
||||||
def test_grouping_by_effect():
|
def test_grouping_by_effect():
|
||||||
positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx", "house": 5}]
|
positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx", "house": 5}]
|
||||||
data = FakeData({"[Ma": [
|
data = FakeData({"[Ma": [
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
|
|||||||
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||||
td.retro { color: #ff9b6a; font-weight: 600; }
|
td.retro { color: #ff9b6a; font-weight: 600; }
|
||||||
|
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
|
||||||
|
(DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
|
||||||
|
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
|
||||||
|
2) font-variant-emoji:text — nowsza własność CSS,
|
||||||
|
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
|
||||||
|
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
|
||||||
|
znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
|
||||||
|
„Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
|
||||||
|
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
|
||||||
|
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
|
||||||
|
"DejaVu Sans", sans-serif; }
|
||||||
|
td.glyph { color: var(--accent); white-space: nowrap; }
|
||||||
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
||||||
.row { display: flex; gap: .5rem; }
|
.row { display: flex; gap: .5rem; }
|
||||||
.row input[type=text] { flex: 1; }
|
.row input[type=text] { flex: 1; }
|
||||||
|
|||||||
@@ -86,18 +86,22 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th title="Symbol astrologiczny (LOG-22)">Sym.</th>
|
||||||
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
|
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
|
||||||
|
<th title="Deklinacja — odległość od równika niebieskiego. OOB = poza zakresem Słońca">Dekl.</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for p in result.positions %}
|
{% for p in result.positions %}
|
||||||
<tr>
|
<tr>
|
||||||
|
<td class="glyph">{{ p.glyph or '' }}{% if p.sign_glyph %} {{ p.sign_glyph }}{% endif %}</td>
|
||||||
<td>{{ p.name }}</td>
|
<td>{{ p.name }}</td>
|
||||||
<td>{{ p.sign }}</td>
|
<td>{{ p.sign }}</td>
|
||||||
<td class="mono">{{ p.in_sign }}</td>
|
<td class="mono">{{ p.in_sign }}</td>
|
||||||
<td>{{ p.house if p.house is defined else '—' }}</td>
|
<td>{{ p.house if p.house is defined else '—' }}</td>
|
||||||
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
|
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
|
||||||
<td class="mono">{{ '%+.4f' | format(p.speed) }}</td>
|
<td class="mono">{{ '%+.4f' | format(p.speed) }}</td>
|
||||||
|
<td class="mono">{% if p.declination is defined %}{{ '%+.2f°' | format(p.declination) }}{% if p.out_of_bounds %} <span class="badge" title="Out of bounds — deklinacja poza zakresem Słońca">OOB</span>{% endif %}{% else %}—{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -138,7 +142,43 @@
|
|||||||
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
|
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for a in result.aspects %}
|
{% 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><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
|
<tr><td>{{ a.obj1 }}</td><td>{% if a.glyph %}<span class="glyph">{{ a.glyph }}</span> {% endif %}{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Aspekty pozazodiakalne (LOG-07): paralele deklinacji i antyscja #}
|
||||||
|
{% if result.parallels %}
|
||||||
|
<div class="meta" title="Ciała na tej samej (paralela) lub przeciwnej (kontrparalela) deklinacji — działają jak koniunkcja / opozycja poza ekliptyką">Paralele deklinacji ({{ result.parallels | length }})</div>
|
||||||
|
<table class="angles">
|
||||||
|
<thead><tr><th>Obiekt 1</th><th>Rodzaj</th><th>Obiekt 2</th><th>Orb</th><th>Dekl.</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in result.parallels %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.obj1 }}</td>
|
||||||
|
<td>{{ 'paralela' if r.type == 'parallel' else 'kontrparalela' }}</td>
|
||||||
|
<td>{{ r.obj2 }}</td>
|
||||||
|
<td class="mono">{{ '%.2f'|format(r.orb) }}°</td>
|
||||||
|
<td class="mono">{{ '%+.2f'|format(r.dec1) }} / {{ '%+.2f'|format(r.dec2) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if result.antiscia %}
|
||||||
|
<div class="meta" title="Odbicie względem osi przesileń (antyscja) lub równonocy (kontrantyscja) — punkty „dzielące” tę samą długość dnia">Antyscja ({{ result.antiscia | length }})</div>
|
||||||
|
<table class="angles">
|
||||||
|
<thead><tr><th>Obiekt 1</th><th>Rodzaj</th><th>Obiekt 2</th><th>Orb</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in result.antiscia %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.obj1 }}</td>
|
||||||
|
<td>{{ 'antyscja' if r.type == 'antiscion' else 'kontrantyscja' }}</td>
|
||||||
|
<td>{{ r.obj2 }}</td>
|
||||||
|
<td class="mono">{{ '%.2f'|format(r.orb) }}°</td>
|
||||||
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Reference in New Issue
Block a user