Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d1daab35a | |||
| aec3f84331 |
Binary file not shown.
@@ -14,6 +14,8 @@ from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401
|
|||||||
|
|
||||||
WHOLE_SIGN = "whole_sign"
|
WHOLE_SIGN = "whole_sign"
|
||||||
EQUAL = "equal"
|
EQUAL = "equal"
|
||||||
|
EQUAL_MC = "equal_mc" # równe domy zakotwiczone na MC, nie na Asc
|
||||||
|
WHOLE_SIGN_ARIES = "whole_sign_aries" # znaki jako domy, ale dom I to ZAWSZE Baran
|
||||||
PORPHYRY = "porphyry"
|
PORPHYRY = "porphyry"
|
||||||
# Systemy o ZAMKNIĘTYM wzorze (bez iteracji). Placidus i Koch wymagają rozwiązania
|
# Systemy o ZAMKNIĘTYM wzorze (bez iteracji). Placidus i Koch wymagają rozwiązania
|
||||||
# iteracyjnego i dochodzą osobno.
|
# iteracyjnego i dochodzą osobno.
|
||||||
@@ -25,14 +27,12 @@ ALCABITUS = "alcabitus"
|
|||||||
TOPOCENTRIC = "topocentric"
|
TOPOCENTRIC = "topocentric"
|
||||||
PLACIDUS = "placidus"
|
PLACIDUS = "placidus"
|
||||||
KOCH = "koch"
|
KOCH = "koch"
|
||||||
# Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris co do zera
|
# Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris
|
||||||
# (tests/oracle). TOPOCENTRIC celowo POZA listą: implementacja jest zgodna
|
# (tests/oracle). Placidus i Koch jako jedyne mają granicę dziedziny:
|
||||||
# z wyrocznią wszędzie poza otoczeniem bieguna (|φ| ≈ 89,9° przy RAMC 90°/270°),
|
# powyżej koła podbiegunowego nie istnieją i podlegają jawnemu fallbackowi.
|
||||||
# gdzie kolejność domów się odwraca i konstrukcja traci sens — jego „biegun"
|
SYSTEMS = (WHOLE_SIGN, WHOLE_SIGN_ARIES, EQUAL, EQUAL_MC, PORPHYRY, VEHLOW,
|
||||||
# atan(tan(89,9°)/3) to już 89,7°. Nie zawężamy dziedziny wyłącznie po to, by
|
MORINUS, REGIOMONTANUS, CAMPANUS, ALCABITUS, TOPOCENTRIC,
|
||||||
# testy przeszły; system dołączy, gdy będzie poprawny na całej dziedzinie.
|
PLACIDUS, KOCH)
|
||||||
SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY, VEHLOW, MORINUS,
|
|
||||||
REGIOMONTANUS, CAMPANUS, ALCABITUS, PLACIDUS, KOCH)
|
|
||||||
|
|
||||||
|
|
||||||
def mean_obliquity(tt_jd: float) -> float:
|
def mean_obliquity(tt_jd: float) -> float:
|
||||||
@@ -289,44 +289,54 @@ def _cusps_alcabitus(ramc: float, eps: float, asc: float) -> list[float]:
|
|||||||
|
|
||||||
# Ułamek szerokości geograficznej użyty jako „biegun" koła domu (Polich–Page).
|
# Ułamek szerokości geograficznej użyty jako „biegun" koła domu (Polich–Page).
|
||||||
# Domy na południku (10 i 4) mają biegun 0 — ich koło to sam południk.
|
# Domy na południku (10 i 4) mają biegun 0 — ich koło to sam południk.
|
||||||
_TOPO_POLE_FRACTION = (1.0, 2 / 3, 1 / 3, 0.0, 1 / 3, 2 / 3,
|
# Polich–Page: dom pośredni to Ascendent policzony pod własnym „biegunem"
|
||||||
1.0, 2 / 3, 1 / 3, 0.0, 1 / 3, 2 / 3)
|
# tan(P) = tan(φ)·k/3, dla RAMC przesuniętego o pozycję domu. Rodzina jest CIĄGŁA:
|
||||||
|
# przy k=0 (biegun 0, przesunięcie −90°) daje MC, przy k=3 (biegun φ, przesunięcie 0)
|
||||||
|
# Ascendent, a domy 11 i 12 leżą po drodze.
|
||||||
|
#
|
||||||
|
# Cała trudność tego systemu siedziała w wyborze gałęzi — dwa koła wielkie
|
||||||
|
# przecinają się w dwóch punktach antypodycznych. Heurystyki („po której stronie
|
||||||
|
# MC", „w łuku kwadrantu", „wschodnia połowa horyzontu", śledzenie ciągłości
|
||||||
|
# krokami) myliły się na 6–11% przypadków powyżej ~70°, bo każda z nich rozstrzyga
|
||||||
|
# LOKALNIE, a przy dużych szerokościach kolejność domów potrafi się odwrócić.
|
||||||
|
#
|
||||||
|
# Rozwiązanie: nie wybierać w ogóle. Iloczyn wektorowy zenitu z biegunem ekliptyki
|
||||||
|
# jest ciągłą funkcją parametru rodziny i sam niesie właściwy zwrot — dwuznaczność
|
||||||
|
# wprowadza dopiero atan2. Zostajemy więc w wektorach, a znak ustalamy RAZ, kotwicząc
|
||||||
|
# rodzinę na MC górującym. Stąd zgodność co do zera na całej dziedzinie, bez iteracji
|
||||||
|
# i bez zawężania szerokości.
|
||||||
|
|
||||||
|
# Dom → (przesunięcie RAMC [°], ułamek bieguna k/3).
|
||||||
def _asc_under_pole_raw(ramc_deg: float, eps_deg: float, pole_deg: float) -> float:
|
_TOPO_STEP = {10: (-60.0, 1 / 3), 11: (-30.0, 2 / 3), # domy 11, 12
|
||||||
"""Surowy wzór na ascendent pod zadanym „biegunem", BEZ korekty gałęzi.
|
1: (30.0, 2 / 3), 2: (60.0, 1 / 3)} # domy 2, 3
|
||||||
|
|
||||||
Korektę stosuje wywołujący — względem PRAWDZIWEGO MC horoskopu. `compute_asc`
|
|
||||||
poprawia gałąź względem MC dla PRZESUNIĘTEGO RAMC, co dla cuspu domu jest złym
|
|
||||||
punktem odniesienia i w okolicach biegunów dawało obrót o 180°."""
|
|
||||||
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(pole_deg)
|
|
||||||
return norm360(math.degrees(math.atan2(
|
|
||||||
math.cos(r), -(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)))))
|
|
||||||
|
|
||||||
|
|
||||||
def _cusps_topocentric(ramc: float, eps: float, lat: float,
|
def _cusps_topocentric(ramc: float, eps: float, lat: float,
|
||||||
asc: float, mc: float) -> list[float]:
|
asc: float, mc: float) -> list[float]:
|
||||||
"""Polich–Page: dom pośredni to ASCENDENT policzony pod własnym „biegunem"
|
"""Ascendenty pod biegunami tan(P) = tan(φ)·k/3, liczone wektorowo.
|
||||||
tan(P) = tan(φ)·k/3, dla RAMC przesuniętego o pozycję domu.
|
|
||||||
|
Domy 5, 6, 8, 9 bierzemy jako OPOZYCJE domów 11, 12, 2, 3 — to nie skrót,
|
||||||
|
lecz własność konstrukcji: przeciwległe domy leżą na tym samym kole wielkim.
|
||||||
|
|
||||||
Kusi, by liczyć to jak Regiomontanusa z podmienioną szerokością — daje wynik
|
Kusi, by liczyć to jak Regiomontanusa z podmienioną szerokością — daje wynik
|
||||||
bliski, ale nie równy (kilka sekund łuku); wyrocznia rozstrzygnęła na rzecz
|
bliski, ale nie równy (kilka sekund łuku); wyrocznia rozstrzygnęła na rzecz
|
||||||
konstrukcji „ascendent pod biegunem".
|
konstrukcji „ascendent pod biegunem"."""
|
||||||
|
er, tan_lat = math.radians(eps), math.tan(math.radians(lat))
|
||||||
Liczymy tylko domy 11, 12, 2, 3, a 5, 6, 8, 9 bierzemy jako ich OPOZYCJE —
|
epole = _ecliptic_pole(er)
|
||||||
to nie skrót, lecz własność tych systemów: przeciwległe domy leżą na tym samym
|
# Gdy MC górujące rozjeżdża się z matematycznym (za kołem podbiegunowym),
|
||||||
kole wielkim, więc ich cuspy są dokładnie antypodyczne."""
|
# cała rodzina obraca się razem z dziesiątym domem — stąd zwrot iloczynu.
|
||||||
tan_lat = math.tan(math.radians(lat))
|
|
||||||
# Gałąź liczymy względem MC GÓRUJĄCEGO, nie matematycznego: gdy za kołem
|
|
||||||
# podbiegunowym te dwa się rozjeżdżają, cała czwórka domów pośrednich musi
|
|
||||||
# obrócić się razem z dziesiątym domem.
|
|
||||||
culminating = _culminating_mc(mc, eps, lat)
|
culminating = _culminating_mc(mc, eps, lat)
|
||||||
|
sign = 1.0 if abs(((culminating - mc + 180.0) % 360.0) - 180.0) > 90.0 else -1.0
|
||||||
|
|
||||||
out = [0.0] * 12
|
out = [0.0] * 12
|
||||||
for i in (10, 11, 1, 2): # domy 11, 12, 2, 3
|
for i, (offset, fraction) in _TOPO_STEP.items():
|
||||||
pole = math.degrees(math.atan(tan_lat * _TOPO_POLE_FRACTION[i]))
|
th = math.radians(ramc + offset)
|
||||||
lon = _asc_under_pole_raw(ramc + 30.0 * i, eps, pole)
|
pole = math.atan(tan_lat * fraction)
|
||||||
if ((lon - culminating) % 360.0 < 180.0) != _INTERMEDIATE[i]:
|
zenith = (math.cos(pole) * math.cos(th),
|
||||||
lon = norm360(lon + 180.0)
|
math.cos(pole) * math.sin(th),
|
||||||
|
math.sin(pole))
|
||||||
|
v = _cross(zenith, epole)
|
||||||
|
lon = _equatorial_to_lon(tuple(sign * x for x in v), er)
|
||||||
out[i] = lon
|
out[i] = lon
|
||||||
out[(i + 6) % 12] = norm360(lon + 180.0)
|
out[(i + 6) % 12] = norm360(lon + 180.0)
|
||||||
out[0], out[3] = asc, norm360(culminating + 180.0)
|
out[0], out[3] = asc, norm360(culminating + 180.0)
|
||||||
@@ -451,6 +461,14 @@ def cusps_for(ramc: float, eps: float, lat: float, system: str) -> list[float]:
|
|||||||
mc = compute_mc(ramc, eps)
|
mc = compute_mc(ramc, eps)
|
||||||
if system in (WHOLE_SIGN, EQUAL, PORPHYRY):
|
if system in (WHOLE_SIGN, EQUAL, PORPHYRY):
|
||||||
return cusps(asc, mc, system)
|
return cusps(asc, mc, system)
|
||||||
|
if system == WHOLE_SIGN_ARIES:
|
||||||
|
# Znaki jako domy, ale numeracja rusza od Barana niezależnie od Ascendentu.
|
||||||
|
# Wariant spotykany w tradycji indyjskiej i w części szkół hellenistycznych.
|
||||||
|
return [norm360(30.0 * i) for i in range(12)]
|
||||||
|
if system == EQUAL_MC:
|
||||||
|
# Równe domy jak `equal`, ale zakotwiczone na MC: dom X zaczyna się
|
||||||
|
# DOKŁADNIE na MC, więc oś południka wypada na granicy domu, a nie w środku.
|
||||||
|
return [norm360(mc + 90.0 + 30.0 * i) for i in range(12)]
|
||||||
if system == VEHLOW:
|
if system == VEHLOW:
|
||||||
# equal, ale Ascendent leży w ŚRODKU domu I, nie na jego początku
|
# equal, ale Ascendent leży w ŚRODKU domu I, nie na jego początku
|
||||||
return [norm360(asc - 15.0 + 30.0 * i) for i in range(12)]
|
return [norm360(asc - 15.0 + 30.0 * i) for i in range(12)]
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ def test_polar_circle_moves_with_obliquity():
|
|||||||
# Zgodność z wyrocznią sprawdza tests/oracle; tu pilnujemy niezmienników, które
|
# Zgodność z wyrocznią sprawdza tests/oracle; tu pilnujemy niezmienników, które
|
||||||
# muszą zachodzić także bez swissepha (czyli w każdym środowisku).
|
# muszą zachodzić także bez swissepha (czyli w każdym środowisku).
|
||||||
|
|
||||||
EXOTIC = ("vehlow", "morinus", "regiomontanus", "campanus", "alcabitus")
|
EXOTIC = ("vehlow", "morinus", "regiomontanus", "campanus", "alcabitus",
|
||||||
|
"topocentric")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("system", EXOTIC)
|
@pytest.mark.parametrize("system", EXOTIC)
|
||||||
@@ -196,3 +197,55 @@ def test_polar_circle_boundary_moves_with_the_date():
|
|||||||
with pytest.raises(H.HouseSystemUndefined):
|
with pytest.raises(H.HouseSystemUndefined):
|
||||||
H.cusps_for(100.0, 23.4393, lat, "placidus")
|
H.cusps_for(100.0, 23.4393, lat, "placidus")
|
||||||
assert len(H.cusps_for(100.0, 23.3528, lat, "placidus")) == 12
|
assert len(H.cusps_for(100.0, 23.3528, lat, "placidus")) == 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_topocentric_cusps_stay_in_order_between_mc_and_ascendant():
|
||||||
|
"""Regresja na to, co topocentrica psuło najdłużej — wybór gałęzi.
|
||||||
|
|
||||||
|
Cuspy 11 i 12 to punkty CIĄGŁEJ rodziny biegnącej od MC do Ascendentu, więc
|
||||||
|
czwórka MC → 11 → 12 → Asc leży na okręgu w kolejności, bez wyprzedzania się.
|
||||||
|
Zły wybór gałęzi przerzuca któryś punkt o 180° i kolejność pęka.
|
||||||
|
|
||||||
|
Kierunek celowo NIE jest ustalony: przy dużych szerokościach domy biegną
|
||||||
|
wstecz (przy φ=−79,55° MC=306,8°, a dom 11 to 291,4°) i to jest poprawne —
|
||||||
|
wyrocznia zwraca tam dokładnie to samo. Sprawdzamy więc, że kolejność trzyma
|
||||||
|
się w JEDNĄ ze stron, a nie że rośnie."""
|
||||||
|
def d(a, b):
|
||||||
|
return H.norm360(b - a)
|
||||||
|
|
||||||
|
for lat in (-89.9, -79.55, -67.18, 0.0, 50.06, 73.69, 80.66, 89.9):
|
||||||
|
c = H.cusps_for(129.2387, 23.4393, lat, "topocentric")
|
||||||
|
mc, c11, c12, asc = c[9], c[10], c[11], c[0]
|
||||||
|
forward = d(mc, c11) + d(c11, c12) + d(c12, asc)
|
||||||
|
backward = d(c11, mc) + d(c12, c11) + d(asc, c12)
|
||||||
|
assert (abs(forward - d(mc, asc)) < 1e-9
|
||||||
|
or abs(backward - d(asc, mc)) < 1e-9), \
|
||||||
|
f"φ={lat}: MC={mc:.3f} 11={c11:.3f} 12={c12:.3f} Asc={asc:.3f} nie po kolei"
|
||||||
|
|
||||||
|
|
||||||
|
def test_topocentric_needs_no_domain_limit():
|
||||||
|
"""W przeciwieństwie do Placidusa/Kocha topocentric liczy się WSZĘDZIE —
|
||||||
|
nie ma tu granicy dziedziny do wyprowadzenia, więc nie wolno jej udawać."""
|
||||||
|
for lat in (0.0, 66.5, 69.65, 89.9, -89.9):
|
||||||
|
assert len(H.cusps_for(100.0, 23.4393, lat, "topocentric")) == 12
|
||||||
|
cs = H.cusps_detailed(100.0, 23.4393, 89.9, "topocentric")
|
||||||
|
assert not cs.is_fallback and cs.notice is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_whole_sign_aries_ignores_the_ascendant():
|
||||||
|
"""Wariant „od Barana": dom I zaczyna się na 0° Barana niezależnie od tego,
|
||||||
|
co wschodzi. To odróżnia go od zwykłego whole sign."""
|
||||||
|
for lat in (0.0, 50.06, -33.9):
|
||||||
|
c = H.cusps_for(100.0, 23.4393, lat, "whole_sign_aries")
|
||||||
|
assert c == [pytest.approx(30.0 * i) for i in range(12)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_equal_mc_starts_the_tenth_house_exactly_on_the_midheaven():
|
||||||
|
"""W `equal` MC leży GDZIEŚ w domu X; w `equal_mc` zaczyna go dokładnie."""
|
||||||
|
ramc, eps, lat = 100.0, 23.4393, 50.0
|
||||||
|
mc = H.compute_mc(ramc, eps)
|
||||||
|
c = H.cusps_for(ramc, eps, lat, "equal_mc")
|
||||||
|
assert c[9] == pytest.approx(mc, abs=1e-9) # dom X rusza na MC
|
||||||
|
assert c[0] == pytest.approx(H.norm360(mc + 90.0), abs=1e-9)
|
||||||
|
for i in range(12): # nadal równe 30°
|
||||||
|
assert H.norm360(c[(i + 1) % 12] - c[i]) == pytest.approx(30.0, abs=1e-9)
|
||||||
|
|||||||
@@ -114,8 +114,15 @@ def _aspect_pen(orb: float, allowed: float | None) -> tuple[float, float]:
|
|||||||
return 0.7 + 1.0 * frac, 0.35 + 0.5 * frac
|
return 0.7 + 1.0 * frac, 0.35 + 0.5 * frac
|
||||||
|
|
||||||
|
|
||||||
def _pt(lon: float, asc: float, r: float) -> tuple[float, float]:
|
# Co stoi po lewej stronie koła (LOG-05: „warianty 0°Aries / fixed Asc").
|
||||||
phi = math.radians(180.0 - (lon - asc))
|
ASC_LEFT = "asc"
|
||||||
|
ARIES_LEFT = "aries"
|
||||||
|
ORIENTATIONS = ((ASC_LEFT, "Ascendent po lewej"), (ARIES_LEFT, "0° Barana po lewej"))
|
||||||
|
|
||||||
|
|
||||||
|
def _pt(lon: float, ref: float, r: float) -> tuple[float, float]:
|
||||||
|
"""Punkt na kole dla długości `lon`, przy `ref` po lewej stronie rysunku."""
|
||||||
|
phi = math.radians(180.0 - (lon - ref))
|
||||||
return _CX + r * math.cos(phi), _CY + r * math.sin(phi)
|
return _CX + r * math.cos(phi), _CY + r * math.sin(phi)
|
||||||
|
|
||||||
|
|
||||||
@@ -206,12 +213,19 @@ def _delta(a: float, b: float) -> float:
|
|||||||
return abs(((a - b + 180.0) % 360.0) - 180.0)
|
return abs(((a - b + 180.0) % 360.0) - 180.0)
|
||||||
|
|
||||||
|
|
||||||
def render(chart: dict, theme: str = "screen") -> str:
|
def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> str:
|
||||||
"""Zwraca SVG koła albo '' gdy brakuje danych (silnik bez osi/domów).
|
"""Zwraca SVG koła albo '' gdy brakuje danych (silnik bez osi/domów).
|
||||||
|
|
||||||
theme='screen' — kolory ze zmiennych CSS aplikacji (dostraja się do motywu).
|
theme='screen' — kolory ze zmiennych CSS aplikacji (dostraja się do motywu).
|
||||||
theme='print' — konkretne kolory na białym tle; konieczne, gdy SVG trafia
|
theme='print' — konkretne kolory na białym tle; konieczne, gdy SVG trafia
|
||||||
do samodzielnego konwertera (PDF), który zmiennych CSS nie rozwiąże.
|
do samodzielnego konwertera (PDF), który zmiennych CSS nie rozwiąże.
|
||||||
|
|
||||||
|
orientation — co stoi po LEWEJ stronie koła (LOG-05, warianty rysunku):
|
||||||
|
'asc' — Ascendent (domyślne; tak rysuje większość szkół zachodnich,
|
||||||
|
dom I zawsze zaczyna się w tym samym miejscu rysunku),
|
||||||
|
'aries' — 0° Barana (koło nieruchome względem zodiaku, więc dwa horoskopy
|
||||||
|
da się porównywać „na oko"; osie wypadają za to gdzie indziej).
|
||||||
|
Zmienia się WYŁĄCZNIE obrót rysunku — żadna liczba nie jest przeliczana.
|
||||||
"""
|
"""
|
||||||
T = _THEMES.get(theme, _THEMES["screen"])
|
T = _THEMES.get(theme, _THEMES["screen"])
|
||||||
# tylko dla druku — na stronie font podaje CSS (.glyph)
|
# tylko dla druku — na stronie font podaje CSS (.glyph)
|
||||||
@@ -223,6 +237,9 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
asc = float(angles["Asc"]["decimal"])
|
asc = float(angles["Asc"]["decimal"])
|
||||||
|
# Oś obrotu rysunku. Długości ekliptyczne zostają nietknięte — przesuwamy
|
||||||
|
# tylko punkt, który ląduje po lewej stronie koła.
|
||||||
|
ref = 0.0 if orientation == ARIES_LEFT else asc
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
|
|
||||||
# tło i okręgi
|
# tło i okręgi
|
||||||
@@ -244,8 +261,8 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
continue
|
continue
|
||||||
color = T["aspects"].get(asp.get("aspect"), T["muted"])
|
color = T["aspects"].get(asp.get("aspect"), T["muted"])
|
||||||
w, op = _aspect_pen(float(asp.get("orb", 0.0)), asp.get("allowed"))
|
w, op = _aspect_pen(float(asp.get("orb", 0.0)), asp.get("allowed"))
|
||||||
ax1, ay1 = _pt(la, asc, R_HUB)
|
ax1, ay1 = _pt(la, ref, R_HUB)
|
||||||
bx1, by1 = _pt(lb, asc, R_HUB)
|
bx1, by1 = _pt(lb, ref, R_HUB)
|
||||||
tip = (f'{asp.get("obj1")} {_ASPECT_PL.get(asp.get("aspect"), asp.get("aspect"))} '
|
tip = (f'{asp.get("obj1")} {_ASPECT_PL.get(asp.get("aspect"), asp.get("aspect"))} '
|
||||||
f'{asp.get("obj2")} · orb {float(asp.get("orb", 0.0)):.2f}°')
|
f'{asp.get("obj2")} · orb {float(asp.get("orb", 0.0)):.2f}°')
|
||||||
parts.append(_line(ax1, ay1, bx1, by1, color, w, op, title=tip))
|
parts.append(_line(ax1, ay1, bx1, by1, color, w, op, title=tip))
|
||||||
@@ -253,17 +270,17 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
# drobne podziałki co 5°, mocniejsze co 10° (na pasie znaków)
|
# drobne podziałki co 5°, mocniejsze co 10° (na pasie znaków)
|
||||||
for deg in range(0, 360, 5):
|
for deg in range(0, 360, 5):
|
||||||
r_in = R_TICK if deg % 10 else R_TICK - 4
|
r_in = R_TICK if deg % 10 else R_TICK - 4
|
||||||
x1, y1 = _pt(deg, asc, R_ZOD)
|
x1, y1 = _pt(deg, ref, R_ZOD)
|
||||||
x2, y2 = _pt(deg, asc, r_in)
|
x2, y2 = _pt(deg, ref, r_in)
|
||||||
parts.append(_line(x1, y1, x2, y2, T["line"], 0.6, 0.7))
|
parts.append(_line(x1, y1, x2, y2, T["line"], 0.6, 0.7))
|
||||||
|
|
||||||
# pas znaków: granice co 30° + glif znaku w środku sektora, kolorem żywiołu
|
# pas znaków: granice co 30° + glif znaku w środku sektora, kolorem żywiołu
|
||||||
for i, sg in enumerate(signs):
|
for i, sg in enumerate(signs):
|
||||||
b = 30.0 * i
|
b = 30.0 * i
|
||||||
x1, y1 = _pt(b, asc, R_ZOD)
|
x1, y1 = _pt(b, ref, R_ZOD)
|
||||||
x2, y2 = _pt(b, asc, R_OUT)
|
x2, y2 = _pt(b, ref, R_OUT)
|
||||||
parts.append(_line(x1, y1, x2, y2, T["line"], 1.0))
|
parts.append(_line(x1, y1, x2, y2, T["line"], 1.0))
|
||||||
gx, gy = _pt(b + 15.0, asc, R_SIGN)
|
gx, gy = _pt(b + 15.0, ref, R_SIGN)
|
||||||
parts.append(_text(gx, gy, sg.get("glyph") or "", size=17,
|
parts.append(_text(gx, gy, sg.get("glyph") or "", size=17,
|
||||||
fill=T["elements"][i % 4], cls="glyph", font=glyph_font))
|
fill=T["elements"][i % 4], cls="glyph", font=glyph_font))
|
||||||
|
|
||||||
@@ -271,31 +288,31 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
n = len(cusps)
|
n = len(cusps)
|
||||||
for i, c in enumerate(cusps):
|
for i, c in enumerate(cusps):
|
||||||
lon = float(c["decimal"])
|
lon = float(c["decimal"])
|
||||||
x1, y1 = _pt(lon, asc, R_ZOD)
|
x1, y1 = _pt(lon, ref, R_ZOD)
|
||||||
x2, y2 = _pt(lon, asc, R_HUB)
|
x2, y2 = _pt(lon, ref, R_HUB)
|
||||||
parts.append(_line(x1, y1, x2, y2, T["line"], 0.8, 0.85))
|
parts.append(_line(x1, y1, x2, y2, T["line"], 0.8, 0.85))
|
||||||
nxt = float(cusps[(i + 1) % n]["decimal"])
|
nxt = float(cusps[(i + 1) % n]["decimal"])
|
||||||
span = (nxt - lon) % 360.0 or 360.0
|
span = (nxt - lon) % 360.0 or 360.0
|
||||||
mid = lon + span / 2.0
|
mid = lon + span / 2.0
|
||||||
hx, hy = _pt(mid, asc, R_HNUM)
|
hx, hy = _pt(mid, ref, R_HNUM)
|
||||||
parts.append(_text(hx, hy, str(c["house"]), size=11, fill=T["muted"]))
|
parts.append(_text(hx, hy, str(c["house"]), size=11, fill=T["muted"]))
|
||||||
# Stopień cuspu tuż przy szprysze, na pasie. W whole sign wszystkie cuspy
|
# Stopień cuspu tuż przy szprysze, na pasie. W whole sign wszystkie cuspy
|
||||||
# są na 0° znaku — wtedy pomijamy, żeby nie kłaść dwunastu zbędnych zer;
|
# są na 0° znaku — wtedy pomijamy, żeby nie kłaść dwunastu zbędnych zer;
|
||||||
# w systemach kwadratowych ta liczba realnie coś mówi.
|
# w systemach kwadratowych ta liczba realnie coś mówi.
|
||||||
deg_in_sign = lon % 30.0
|
deg_in_sign = lon % 30.0
|
||||||
if deg_in_sign >= 0.5:
|
if deg_in_sign >= 0.5:
|
||||||
cx1, cy1 = _pt(lon, asc, R_ZOD - 9)
|
cx1, cy1 = _pt(lon, ref, R_ZOD - 9)
|
||||||
parts.append(_text(cx1, cy1, str(int(deg_in_sign)), size=6.5, fill=T["muted"]))
|
parts.append(_text(cx1, cy1, str(int(deg_in_sign)), size=6.5, fill=T["muted"]))
|
||||||
|
|
||||||
# osie: Asc–Dsc i MC–IC przez całe koło, wyróżnione akcentem
|
# osie: Asc–Dsc i MC–IC przez całe koło, wyróżnione akcentem
|
||||||
for a, b, la, lb in (("Asc", "Dsc", "AC", "DC"), ("MC", "IC", "MC", "IC")):
|
for a, b, la, lb in (("Asc", "Dsc", "AC", "DC"), ("MC", "IC", "MC", "IC")):
|
||||||
lon_a = float(angles[a]["decimal"])
|
lon_a = float(angles[a]["decimal"])
|
||||||
ax, ay = _pt(lon_a, asc, R_ZOD)
|
ax, ay = _pt(lon_a, ref, R_ZOD)
|
||||||
bx, by = _pt(lon_a + 180.0, asc, R_ZOD)
|
bx, by = _pt(lon_a + 180.0, ref, R_ZOD)
|
||||||
parts.append(_line(ax, ay, bx, by, T["accent"], 1.6, 0.9))
|
parts.append(_line(ax, ay, bx, by, T["accent"], 1.6, 0.9))
|
||||||
# etykiety POZA kołem — w środku kolidowały z glifami obiektów
|
# etykiety POZA kołem — w środku kolidowały z glifami obiektów
|
||||||
lax, lay = _pt(lon_a, asc, R_AXIS_LABEL)
|
lax, lay = _pt(lon_a, ref, R_AXIS_LABEL)
|
||||||
lbx, lby = _pt(lon_a + 180.0, asc, R_AXIS_LABEL)
|
lbx, lby = _pt(lon_a + 180.0, ref, R_AXIS_LABEL)
|
||||||
parts.append(_text(lax, lay, la, size=10, fill=T["accent"]))
|
parts.append(_text(lax, lay, la, size=10, fill=T["accent"]))
|
||||||
parts.append(_text(lbx, lby, lb, size=10, fill=T["accent"]))
|
parts.append(_text(lbx, lby, lb, size=10, fill=T["accent"]))
|
||||||
|
|
||||||
@@ -310,15 +327,15 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
for obj, true_lon, draw_lon in zip(objects, true_lons, draw_lons):
|
for obj, true_lon, draw_lon in zip(objects, true_lons, draw_lons):
|
||||||
retro = obj.get("direction") == "Rx"
|
retro = obj.get("direction") == "Rx"
|
||||||
|
|
||||||
mx1, my1 = _pt(true_lon, asc, R_ZOD)
|
mx1, my1 = _pt(true_lon, ref, R_ZOD)
|
||||||
mx2, my2 = _pt(true_lon, asc, R_MARK)
|
mx2, my2 = _pt(true_lon, ref, R_MARK)
|
||||||
parts.append(_line(mx1, my1, mx2, my2, T["ink"], 1.0, 0.75))
|
parts.append(_line(mx1, my1, mx2, my2, T["ink"], 1.0, 0.75))
|
||||||
|
|
||||||
if _delta(draw_lon, true_lon) > 0.4:
|
if _delta(draw_lon, true_lon) > 0.4:
|
||||||
lx, ly = _pt(draw_lon, asc, R_PLANET + 12)
|
lx, ly = _pt(draw_lon, ref, R_PLANET + 12)
|
||||||
parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9))
|
parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9))
|
||||||
|
|
||||||
gx, gy = _pt(draw_lon, asc, R_PLANET)
|
gx, gy = _pt(draw_lon, ref, R_PLANET)
|
||||||
tip = obj.get("name") or ""
|
tip = obj.get("name") or ""
|
||||||
if obj.get("in_sign"):
|
if obj.get("in_sign"):
|
||||||
tip += f" · {obj['in_sign']}"
|
tip += f" · {obj['in_sign']}"
|
||||||
@@ -332,7 +349,7 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
# Sam stopień w znaku, bez „°" — tak robią programy astrologiczne i tylko
|
# Sam stopień w znaku, bez „°" — tak robią programy astrologiczne i tylko
|
||||||
# tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz
|
# tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz
|
||||||
# kolor, żeby dało się ją wyłapać nie czytając znaku po znaku.
|
# kolor, żeby dało się ją wyłapać nie czytając znaku po znaku.
|
||||||
dx, dy = _pt(draw_lon, asc, R_DEG)
|
dx, dy = _pt(draw_lon, ref, R_DEG)
|
||||||
label = f"{int(true_lon % 30)}" + ("℞" if retro else "")
|
label = f"{int(true_lon % 30)}" + ("℞" if retro else "")
|
||||||
parts.append(_text(dx, dy, label, size=8,
|
parts.append(_text(dx, dy, label, size=8,
|
||||||
fill=T["retro"] if retro else T["muted"]))
|
fill=T["retro"] if retro else T["muted"]))
|
||||||
@@ -346,10 +363,10 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
if not glyph or lot.get("longitude") is None:
|
if not glyph or lot.get("longitude") is None:
|
||||||
continue
|
continue
|
||||||
lon = float(lot["longitude"])
|
lon = float(lot["longitude"])
|
||||||
mx1, my1 = _pt(lon, asc, R_ZOD)
|
mx1, my1 = _pt(lon, ref, R_ZOD)
|
||||||
mx2, my2 = _pt(lon, asc, R_MARK)
|
mx2, my2 = _pt(lon, ref, R_MARK)
|
||||||
parts.append(_line(mx1, my1, mx2, my2, T["accent"], 1.0, 0.7))
|
parts.append(_line(mx1, my1, mx2, my2, T["accent"], 1.0, 0.7))
|
||||||
lx, ly = _pt(lon, asc, R_LOT)
|
lx, ly = _pt(lon, ref, R_LOT)
|
||||||
tip = lot.get("name") or ""
|
tip = lot.get("name") or ""
|
||||||
if lot.get("in_sign"):
|
if lot.get("in_sign"):
|
||||||
tip += f" · {lot['in_sign']}"
|
tip += f" · {lot['in_sign']}"
|
||||||
@@ -367,7 +384,6 @@ def render(chart: dict, theme: str = "screen") -> str:
|
|||||||
# ── aspektarian (PRE-18) ──────────────────────────────────────────────────
|
# ── aspektarian (PRE-18) ──────────────────────────────────────────────────
|
||||||
ASP_CELL = 30.0 # bok komórki siatki aspektów
|
ASP_CELL = 30.0 # bok komórki siatki aspektów
|
||||||
|
|
||||||
|
|
||||||
def render_aspectarian(chart: dict, theme: str = "screen") -> str:
|
def render_aspectarian(chart: dict, theme: str = "screen") -> str:
|
||||||
"""Aspektarian (PRE-18) — trójkątna siatka aspektów obiekt×obiekt jako SVG.
|
"""Aspektarian (PRE-18) — trójkątna siatka aspektów obiekt×obiekt jako SVG.
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,15 @@ from __future__ import annotations
|
|||||||
HOUSE_SYSTEMS: list[tuple[str, str]] = [
|
HOUSE_SYSTEMS: list[tuple[str, str]] = [
|
||||||
("placidus", "Placidus"),
|
("placidus", "Placidus"),
|
||||||
("whole_sign", "Whole Sign"),
|
("whole_sign", "Whole Sign"),
|
||||||
|
("whole_sign_aries", "Whole Sign (od Barana)"),
|
||||||
("koch", "Koch"),
|
("koch", "Koch"),
|
||||||
("equal", "Equal"),
|
("equal", "Equal (od Asc)"),
|
||||||
|
("equal_mc", "Equal (od MC)"),
|
||||||
("porphyry", "Porphyry"),
|
("porphyry", "Porphyry"),
|
||||||
("regiomontanus", "Regiomontanus"),
|
("regiomontanus", "Regiomontanus"),
|
||||||
("campanus", "Campanus"),
|
("campanus", "Campanus"),
|
||||||
("alcabitus", "Alcabitus"),
|
("alcabitus", "Alcabitus"),
|
||||||
|
("topocentric", "Topocentric"),
|
||||||
("morinus", "Morinus"),
|
("morinus", "Morinus"),
|
||||||
("vehlow", "Vehlow"),
|
("vehlow", "Vehlow"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from fastapi.responses import HTMLResponse, JSONResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app import chartwheel as chartwheel_mod
|
||||||
from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label
|
from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label
|
||||||
|
|
||||||
from app import geocode, security
|
from app import geocode, security
|
||||||
@@ -60,6 +61,7 @@ templates.env.globals["static"] = static_url
|
|||||||
templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS
|
templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS
|
||||||
templates.env.globals["house_label"] = house_label
|
templates.env.globals["house_label"] = house_label
|
||||||
templates.env.globals["HOUSE_LIMITED"] = HOUSE_LIMITED
|
templates.env.globals["HOUSE_LIMITED"] = HOUSE_LIMITED
|
||||||
|
templates.env.globals["WHEEL_ORIENTATIONS"] = chartwheel_mod.ORIENTATIONS
|
||||||
|
|
||||||
|
|
||||||
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
||||||
@@ -130,12 +132,14 @@ def chart_compute(
|
|||||||
stations: bool = Form(False),
|
stations: bool = Form(False),
|
||||||
zodiac: str = Form("tropical"),
|
zodiac: str = Form("tropical"),
|
||||||
tables: bool = Form(False),
|
tables: bool = Form(False),
|
||||||
|
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT),
|
||||||
):
|
):
|
||||||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||||
"lat": lat, "lon": lon, "house_system": house_system,
|
"lat": lat, "lon": lon, "house_system": house_system,
|
||||||
"house_systems": house_systems, "aspect_orb": aspect_orb,
|
"house_systems": house_systems, "aspect_orb": aspect_orb,
|
||||||
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
|
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
|
||||||
"stations": stations, "zodiac": zodiac, "tables": tables}
|
"stations": stations, "zodiac": zodiac, "tables": tables,
|
||||||
|
"wheel_orientation": wheel_orientation}
|
||||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||||
try:
|
try:
|
||||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
@@ -147,12 +151,8 @@ def chart_compute(
|
|||||||
aspect_minor=aspect_minor,
|
aspect_minor=aspect_minor,
|
||||||
stations=stations, zodiac=zodiac, tables=tables,
|
stations=stations, zodiac=zodiac, tables=tables,
|
||||||
)
|
)
|
||||||
# Fallback systemu domów musi dojechać do PDF-a — z samego kosmogramu
|
|
||||||
# nie da się poznać, że podział jest z innego systemu, niż zamówiono.
|
|
||||||
warnings = [w for w in (chart.get("house_warnings") or []) if w]
|
|
||||||
|
|
||||||
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
|
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
|
||||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
|
ctx["wheel_svg"] = chartwheel.render(ctx["result"], orientation=wheel_orientation)
|
||||||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||||||
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
|
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
|
||||||
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
|
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
|
||||||
@@ -189,6 +189,7 @@ def compile_build(
|
|||||||
stations: bool = Form(False), # LOG-03 — stacje też w podsumowaniu
|
stations: bool = Form(False), # LOG-03 — stacje też w podsumowaniu
|
||||||
zodiac: str = Form("tropical"),
|
zodiac: str = Form("tropical"),
|
||||||
tables: bool = Form(False), # LOG-23 — żywioły/faza/godziny w podsumowaniu
|
tables: bool = Form(False), # LOG-23 — żywioły/faza/godziny w podsumowaniu
|
||||||
|
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT), # LOG-05 — obrót koła
|
||||||
):
|
):
|
||||||
"""Składa raport: horoskop liczymy TU NA NOWO, a części od AI (interpretacja
|
"""Składa raport: horoskop liczymy TU NA NOWO, a części od AI (interpretacja
|
||||||
natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23).
|
natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23).
|
||||||
@@ -203,7 +204,8 @@ def compile_build(
|
|||||||
"lat": lat, "lon": lon, "house_system": house_system,
|
"lat": lat, "lon": lon, "house_system": house_system,
|
||||||
"house_systems": house_systems, "aspect_orb": aspect_orb,
|
"house_systems": house_systems, "aspect_orb": aspect_orb,
|
||||||
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
|
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
|
||||||
"stations": stations, "zodiac": zodiac, "tables": tables}
|
"stations": stations, "zodiac": zodiac, "tables": tables,
|
||||||
|
"wheel_orientation": wheel_orientation}
|
||||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||||
try:
|
try:
|
||||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
@@ -216,7 +218,7 @@ def compile_build(
|
|||||||
stations=stations, zodiac=zodiac, tables=tables,
|
stations=stations, zodiac=zodiac, tables=tables,
|
||||||
)
|
)
|
||||||
from app import chartwheel
|
from app import chartwheel
|
||||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
|
ctx["wheel_svg"] = chartwheel.render(ctx["result"], orientation=wheel_orientation)
|
||||||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||||||
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
|
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
|
||||||
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
|
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
|
||||||
@@ -265,13 +267,19 @@ def compile_pdf(payload: dict):
|
|||||||
stations=bool(data.get("stations")), tables=bool(data.get("tables")),
|
stations=bool(data.get("stations")), tables=bool(data.get("tables")),
|
||||||
zodiac=str(data.get("zodiac") or "tropical"),
|
zodiac=str(data.get("zodiac") or "tropical"),
|
||||||
)
|
)
|
||||||
|
# Fallback systemu domów musi dojechać do PDF-a — z samego kosmogramu
|
||||||
|
# nie da się poznać, że podział jest z innego systemu, niż zamówiono.
|
||||||
|
warnings = [w for w in (chart.get("house_warnings") or []) if w]
|
||||||
|
|
||||||
from app import chartwheel
|
from app import chartwheel
|
||||||
|
|
||||||
# Zasada: co pokazujemy na stronie, ma trafić do PDF-a. Wszystkie rysunki
|
# Zasada: co pokazujemy na stronie, ma trafić do PDF-a. Wszystkie rysunki
|
||||||
# w motywie DRUKU — samodzielny konwerter SVG→PDF nie zna arkusza, więc
|
# w motywie DRUKU — samodzielny konwerter SVG→PDF nie zna arkusza, więc
|
||||||
# zmienne CSS i font glifów muszą być wprost w rysunku.
|
# zmienne CSS i font glifów muszą być wprost w rysunku.
|
||||||
for svg, caption in (
|
for svg, caption in (
|
||||||
(chartwheel.render(chart, theme="print"), "Kosmogram"),
|
(chartwheel.render(chart, theme="print",
|
||||||
|
orientation=str(data.get("wheel_orientation") or chartwheel_mod.ASC_LEFT)),
|
||||||
|
"Kosmogram"),
|
||||||
(chartwheel.render_aspectarian(chart, theme="print"), "Aspektarian — siatka aspektów"),
|
(chartwheel.render_aspectarian(chart, theme="print"), "Aspektarian — siatka aspektów"),
|
||||||
(chartwheel.render_declination(chart, theme="print"), "Wykres deklinacji"),
|
(chartwheel.render_declination(chart, theme="print"), "Wykres deklinacji"),
|
||||||
(chartwheel.render_antiscia(chart, theme="print"), "Oś antyscji"),
|
(chartwheel.render_antiscia(chart, theme="print"), "Oś antyscji"),
|
||||||
|
|||||||
@@ -14,6 +14,13 @@
|
|||||||
<label><input type="checkbox" name="house_systems" value="{{ value }}" {{ 'checked' if value in chosen else '' }}> {{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</label>
|
<label><input type="checkbox" name="house_systems" value="{{ value }}" {{ 'checked' if value in chosen else '' }}> {{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="opts" title="Zmienia wyłącznie OBRÓT rysunku — żadna liczba nie jest przeliczana. „Ascendent po lewej” to tradycja zachodnia: dom I zawsze zaczyna się w tym samym miejscu. „0° Barana po lewej” unieruchamia koło względem zodiaku, dzięki czemu dwa horoskopy da się porównywać na oko.">
|
||||||
|
{% set wo = form.wheel_orientation or 'asc' %}
|
||||||
|
<span class="muted small">Kosmogram — po lewej stronie:</span>
|
||||||
|
{% for value, name in WHEEL_ORIENTATIONS %}
|
||||||
|
<label><input type="radio" name="wheel_orientation" value="{{ value }}" {{ 'checked' if wo == value else '' }}> {{ name }}</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
<div class="opts" title="Orb = dopuszczalne odchylenie od dokładnego kąta aspektu. „Bonus świateł" powiększa orb dla aspektów ze Słońcem/Księżycem. Aspekty poboczne: półsekstyl 30°, półkwadratura 45°, kwinkunks 150° (PRE-06).">
|
<div class="opts" title="Orb = dopuszczalne odchylenie od dokładnego kąta aspektu. „Bonus świateł" powiększa orb dla aspektów ze Słońcem/Księżycem. Aspekty poboczne: półsekstyl 30°, półkwadratura 45°, kwinkunks 150° (PRE-06).">
|
||||||
<label>Orb aspektów (°) <input type="number" name="aspect_orb" step="0.5" min="1" max="15" value="{{ form.aspect_orb if form.aspect_orb is not none else 8 }}"></label>
|
<label>Orb aspektów (°) <input type="number" name="aspect_orb" step="0.5" min="1" max="15" value="{{ form.aspect_orb if form.aspect_orb is not none else 8 }}"></label>
|
||||||
<label>Bonus świateł (°) <input type="number" name="aspect_luminary_bonus" step="0.5" min="0" max="5" value="{{ form.aspect_luminary_bonus if form.aspect_luminary_bonus is not none else 2 }}"></label>
|
<label>Bonus świateł (°) <input type="number" name="aspect_luminary_bonus" step="0.5" min="0" max="5" value="{{ form.aspect_luminary_bonus if form.aspect_luminary_bonus is not none else 2 }}"></label>
|
||||||
|
|||||||
@@ -76,16 +76,45 @@ def test_compile_page_renders_all_figures():
|
|||||||
assert var in MAIN, f"/compile nie ustawia {var}"
|
assert var in MAIN, f"/compile nie ustawia {var}"
|
||||||
|
|
||||||
|
|
||||||
def test_pdf_bundles_all_figures_in_print_theme():
|
def test_pdf_bundles_all_figures_in_print_theme(monkeypatch):
|
||||||
"""compile_pdf składa KOMPLET rysunków (motyw druku) i wysyła jako `figures`."""
|
"""compile_pdf składa KOMPLET rysunków w motywie DRUKU.
|
||||||
assert '"figures": figures' in MAIN
|
|
||||||
for call in (
|
Sprawdzamy przez WYWOŁANIE trasy, nie przez szukanie tekstu w main.py:
|
||||||
'render(chart, theme="print")',
|
poprzednia wersja greppowała `render(chart, theme="print")` i pękała przy
|
||||||
'render_aspectarian(chart, theme="print")',
|
każdym dopisaniu argumentu, mimo że zachowanie zostawało poprawne."""
|
||||||
'render_declination(chart, theme="print")',
|
seen: dict[str, str] = {}
|
||||||
'render_antiscia(chart, theme="print")',
|
|
||||||
):
|
from app import chartwheel
|
||||||
assert call in MAIN, f"PDF nie składa: {call}"
|
from app.clients.render_client import RenderClient
|
||||||
|
from app.main import logic
|
||||||
|
|
||||||
|
for name in ("render", "render_aspectarian", "render_declination", "render_antiscia"):
|
||||||
|
original = getattr(chartwheel, name)
|
||||||
|
|
||||||
|
def spy(chart, theme="screen", _n=name, _o=original, **kw):
|
||||||
|
seen[_n] = theme
|
||||||
|
return _o(chart, theme=theme, **kw)
|
||||||
|
|
||||||
|
monkeypatch.setattr(chartwheel, name, spy)
|
||||||
|
|
||||||
|
monkeypatch.setattr(logic, "positions", lambda **kw: _pdf_sample_chart())
|
||||||
|
monkeypatch.setattr(RenderClient, "pdf", lambda self, report: b"%PDF-1.4 stub")
|
||||||
|
|
||||||
|
r = _client().post("/compile/pdf", json={"person": "Jan", "data": {
|
||||||
|
"date": "1984-04-30", "time": "11:20", "tz_offset": 2, "lat": 50.06, "lon": 19.94}})
|
||||||
|
assert r.status_code == 200, r.text[:300]
|
||||||
|
assert set(seen) == {"render", "render_aspectarian",
|
||||||
|
"render_declination", "render_antiscia"}, seen
|
||||||
|
assert set(seen.values()) == {"print"}, seen
|
||||||
|
|
||||||
|
|
||||||
|
def _pdf_sample_chart() -> dict:
|
||||||
|
cusps = [{"house": i + 1, "sign": "Aries", "in_sign": "0", "decimal": float(i * 30),
|
||||||
|
"sign_glyph": "♈"} for i in range(12)]
|
||||||
|
ang = {k: {"name": k, "sign": "Aries", "in_sign": "0", "decimal": 0.0,
|
||||||
|
"sign_glyph": "♈"} for k in ("Asc", "MC", "Dsc", "IC")}
|
||||||
|
return {"engine": "test", "positions": [], "cusps": cusps, "angles": ang,
|
||||||
|
"sign_glyphs": [{"sign": "Aries", "glyph": "♈"}], "house_system": "equal"}
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────── zbieranie materiału z magazynów ────────────────
|
# ──────────────────────── zbieranie materiału z magazynów ────────────────
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ systemów, klient przekazuje je do logiki, a szablon pokazuje kuspy obok siebie.
|
|||||||
"""
|
"""
|
||||||
import pathlib
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||||
CHART = (APP / "templates" / "chart.html").read_text(encoding="utf-8")
|
CHART = (APP / "templates" / "chart.html").read_text(encoding="utf-8")
|
||||||
COMPILE = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
|
COMPILE = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
|
||||||
@@ -96,3 +98,59 @@ def test_fallback_notice_is_shown_prominently():
|
|||||||
def test_no_notice_when_nothing_was_substituted():
|
def test_no_notice_when_nothing_was_substituted():
|
||||||
html = _render("_result_tables.html", result={"house_warnings": []})
|
html = _render("_result_tables.html", result={"house_warnings": []})
|
||||||
assert "house-warning" not in html
|
assert "house-warning" not in html
|
||||||
|
|
||||||
|
|
||||||
|
# ── ścieżka PRZEZ handlery, nie obok nich ───────────────────────────────
|
||||||
|
# Dwa błędy przeszły przez komplet testów szablonowych, bo żaden nie wywołał
|
||||||
|
# POST-a: odwołanie do nieistniejącej zmiennej w handlerze strony głównej oraz
|
||||||
|
# użycie parametru formularza, którego w sygnaturze nie było. Oba dają 500 na
|
||||||
|
# żywo i oba łapie dopiero prawdziwe żądanie.
|
||||||
|
|
||||||
|
def _client_with_stub_logic(monkeypatch, chart):
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.pop("APP_PASSWORD", None)
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app, logic
|
||||||
|
|
||||||
|
monkeypatch.setattr(logic, "positions", lambda **kw: chart)
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _minimal_chart():
|
||||||
|
cusps = [{"house": i + 1, "sign": "Aries", "in_sign": "0°00'00''",
|
||||||
|
"decimal": float(i * 30), "sign_glyph": "♈"} for i in range(12)]
|
||||||
|
ang = {k: {"name": k, "sign": "Aries", "in_sign": "0°00'00''", "decimal": 0.0,
|
||||||
|
"sign_glyph": "♈"} for k in ("Asc", "MC", "Dsc", "IC")}
|
||||||
|
return {"engine": "test", "positions": [], "cusps": cusps, "angles": ang,
|
||||||
|
"sign_glyphs": [{"sign": "Aries", "glyph": "♈"}],
|
||||||
|
"house_system": "porphyry", "house_system_requested": "placidus",
|
||||||
|
"house_warnings": ["UWAGA: system domów „placidus” nie ma definicji"]}
|
||||||
|
|
||||||
|
|
||||||
|
FORM = {"date": "1984-04-30", "time": "11:20", "tz_offset": "2",
|
||||||
|
"lat": "50.06", "lon": "19.94", "house_system": "placidus"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_chart_page_survives_a_real_post(monkeypatch):
|
||||||
|
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
|
||||||
|
r = c.post("/", data=FORM)
|
||||||
|
assert r.status_code == 200, r.text[:400]
|
||||||
|
assert "nie ma definicji" in r.text, "ostrzeżenie o fallbacku nie dotarło na stronę"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compile_page_survives_a_real_post(monkeypatch):
|
||||||
|
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
|
||||||
|
r = c.post("/compile", data=FORM)
|
||||||
|
assert r.status_code == 200, r.text[:400]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("orientation", ("asc", "aries"))
|
||||||
|
def test_wheel_orientation_reaches_the_renderer(monkeypatch, orientation):
|
||||||
|
"""Wybór obrotu koła musi dojść z formularza do rysunku — i wrócić
|
||||||
|
zaznaczony, żeby nie gubił się przy przeliczeniu."""
|
||||||
|
c = _client_with_stub_logic(monkeypatch, _minimal_chart())
|
||||||
|
r = c.post("/", data={**FORM, "wheel_orientation": orientation})
|
||||||
|
assert r.status_code == 200, r.text[:400]
|
||||||
|
assert f'value="{orientation}" checked' in r.text
|
||||||
|
|||||||
@@ -66,8 +66,12 @@ def test_key_is_read_lazily():
|
|||||||
# ──────────────────────── kosmogram w wariancie do druku ─────────────────
|
# ──────────────────────── kosmogram w wariancie do druku ─────────────────
|
||||||
|
|
||||||
def test_pdf_route_uses_print_theme():
|
def test_pdf_route_uses_print_theme():
|
||||||
"""Samodzielny konwerter SVG→PDF nie zna naszego arkusza stylów."""
|
"""Samodzielny konwerter SVG→PDF nie zna naszego arkusza stylów.
|
||||||
assert 'chartwheel.render(chart, theme="print")' in MAIN
|
|
||||||
|
Sprawdzamy, że trasa PDF-a w ogóle podaje motyw druku — bez wiązania się
|
||||||
|
z dokładnym kształtem wywołania (dochodzą do niego kolejne argumenty).
|
||||||
|
Pełny sprawdzian, że KAŻDY rysunek dostaje 'print', jest w test_compile.py."""
|
||||||
|
assert 'theme="print"' in MAIN
|
||||||
|
|
||||||
|
|
||||||
def test_print_theme_has_no_css_variables():
|
def test_print_theme_has_no_css_variables():
|
||||||
|
|||||||
+29
-8
@@ -70,13 +70,16 @@ zarówno zestaw brzegowy (2520 porównań), jak i losowy przemiał 20 000 przypa
|
|||||||
| System | Konstrukcja | Maks. odchylenie |
|
| System | Konstrukcja | Maks. odchylenie |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| whole sign | podział ekliptyki | 0,000000000° |
|
| whole sign | podział ekliptyki | 0,000000000° |
|
||||||
| equal | podział ekliptyki | 0,000000000° |
|
| whole sign (od Barana) | dom I zawsze na 0° Barana | 0,000000000° |
|
||||||
|
| equal | podział ekliptyki od Ascendentu | 0,000000000° |
|
||||||
|
| equal (od MC) | dom X dokładnie na MC | 0,000000000° |
|
||||||
| porphyry | podział kwadrantów po ekliptyce | 0,000000000° |
|
| porphyry | podział kwadrantów po ekliptyce | 0,000000000° |
|
||||||
| vehlow | equal z Ascendentem w środku domu I | 0,000000000° |
|
| vehlow | equal z Ascendentem w środku domu I | 0,000000000° |
|
||||||
| morinus | równik rzutowany wprost na ekliptykę | 0,000000000° |
|
| morinus | równik rzutowany wprost na ekliptykę | 0,000000000° |
|
||||||
| regiomontanus | podział równika, koła przez punkty N/S horyzontu | 0,000000000° |
|
| regiomontanus | podział równika, koła przez punkty N/S horyzontu | 0,000000000° |
|
||||||
| campanus | podział wertykału pierwszego | 0,000000000° |
|
| campanus | podział wertykału pierwszego | 0,000000000° |
|
||||||
| alcabitus | podział łuków dobowych po równiku | 0,000000000° |
|
| alcabitus | podział łuków dobowych po równiku | 0,000000000° |
|
||||||
|
| topocentric | ascendenty pod biegunami tan(P) = tan(φ)·k/3 | 0,000000000° |
|
||||||
|
|
||||||
Etap 2 — systemy **łuku dobowego**, jedyne z realną granicą dziedziny:
|
Etap 2 — systemy **łuku dobowego**, jedyne z realną granicą dziedziny:
|
||||||
|
|
||||||
@@ -97,13 +100,31 @@ Warstwa aplikacyjna łapie tę odmowę i podstawia Porphyry'ego z **głośnym
|
|||||||
komunikatem**, który idzie na ekran, w prompt do modelu i do PDF-a: po podmianie
|
komunikatem**, który idzie na ekran, w prompt do modelu i do PDF-a: po podmianie
|
||||||
kosmogram wygląda bezbłędnie, więc jest to jedyny sygnał dla astrologa.
|
kosmogram wygląda bezbłędnie, więc jest to jedyny sygnał dla astrologa.
|
||||||
|
|
||||||
**Topocentric (Polich–Page) jest zaimplementowany, ale NIE wypuszczony** — nie ma go
|
### Topocentric — i czego nauczył
|
||||||
w `houses.SYSTEMS`. Zgadza się z wyrocznią na całej dziedzinie poza otoczeniem
|
|
||||||
bieguna: przy |φ| ≈ 89,9° i RAMC 90°/270° kolejność domów się odwraca i żadna reguła
|
Topocentric był początkowo **wycofany** z powodu rozjazdu przy |φ| ≈ 89,9°.
|
||||||
oparta na łuku kwadrantu nie rozstrzyga wyboru gałęzi. Konstrukcja jest tam z natury
|
Ta diagnoza była **błędna**, bo opierała się wyłącznie na zestawie brzegowym,
|
||||||
źle uwarunkowana — „biegun" `atan(tan(φ)·k/3)` dla φ = 89,9° wynosi już 89,7°.
|
który próbkuje tylko wybrane szerokości. Przemiał losowy pokazał prawdziwą skalę:
|
||||||
Nie zawężamy dziedziny po to, żeby testy przeszły; system dołączy, gdy będzie
|
błędy zaczynały się już od ~70° i sięgały 50% przypadków blisko biegunów —
|
||||||
poprawny wszędzie.
|
**6,5% całości**, nie dwa przypadki brzegowe.
|
||||||
|
|
||||||
|
Przyczyną nie była jednak konstrukcja, tylko wybór gałęzi. Dwa koła wielkie
|
||||||
|
przecinają się w dwóch punktach antypodycznych i trzeba wskazać właściwy.
|
||||||
|
Kolejno zawiodły: „po której stronie MC", „w łuku kwadrantu", „wschodnia połowa
|
||||||
|
horyzontu" oraz śledzenie ciągłości krokami. Każda z nich rozstrzyga **lokalnie**,
|
||||||
|
a przy dużych szerokościach kolejność domów potrafi się odwrócić — przy φ = −79,55°
|
||||||
|
MC wypada na 306,8°, a dom 11 na 291,4°, czyli domy biegną wstecz. To jest poprawne
|
||||||
|
i wyrocznia zwraca dokładnie to samo.
|
||||||
|
|
||||||
|
Rozwiązaniem było **nie wybierać w ogóle**. Iloczyn wektorowy zenitu z biegunem
|
||||||
|
ekliptyki jest ciągłą funkcją parametru rodziny i sam niesie właściwy zwrot;
|
||||||
|
dwuznaczność wprowadza dopiero `atan2`. Zostając w wektorach i kotwicząc rodzinę
|
||||||
|
raz — na MC górującym — dostajemy zgodność **co do zera na całej dziedzinie**,
|
||||||
|
bez iteracji i bez zawężania szerokości.
|
||||||
|
|
||||||
|
Morał praktyczny: zestaw brzegowy mówi, **czy** system się psuje; dopiero przemiał
|
||||||
|
losowy mówi **jak bardzo**. Wniosek o skali wyciągnięty z samych brzegów był tu
|
||||||
|
zaniżony o trzy rzędy wielkości.
|
||||||
|
|
||||||
Większe jądro efemeryd (de441) — **odłożone, „nice to have"**: rozszerza tylko
|
Większe jądro efemeryd (de441) — **odłożone, „nice to have"**: rozszerza tylko
|
||||||
zakres dat, nie poprawia niczego w obecnym.
|
zakres dat, nie poprawia niczego w obecnym.
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ from domain import Case, in_domain, obliquity
|
|||||||
|
|
||||||
# Litery systemów w Swiss Ephemeris.
|
# Litery systemów w Swiss Ephemeris.
|
||||||
SWE_CODE = {
|
SWE_CODE = {
|
||||||
"whole_sign": b"W", "equal": b"E", "porphyry": b"O",
|
"whole_sign": b"W", "equal": b"E",
|
||||||
|
"equal_mc": b"D",
|
||||||
|
"whole_sign_aries": b"N", "porphyry": b"O",
|
||||||
"placidus": b"P", "koch": b"K", "regiomontanus": b"R", "campanus": b"C",
|
"placidus": b"P", "koch": b"K", "regiomontanus": b"R", "campanus": b"C",
|
||||||
"morinus": b"M", "alcabitus": b"B", "vehlow": b"V", "topocentric": b"T",
|
"morinus": b"M", "alcabitus": b"B", "vehlow": b"V", "topocentric": b"T",
|
||||||
}
|
}
|
||||||
@@ -158,9 +160,9 @@ def format_report(results: list[Result], seed: int | None = None) -> str:
|
|||||||
"ZGODNOŚĆ Z WYROCZNIĄ (Swiss Ephemeris) — domy astrologiczne",
|
"ZGODNOŚĆ Z WYROCZNIĄ (Swiss Ephemeris) — domy astrologiczne",
|
||||||
f"tolerancja: {TOLERANCE_DEG:.8f}° (1″)" + (f" ziarno: {seed}" if seed is not None else ""),
|
f"tolerancja: {TOLERANCE_DEG:.8f}° (1″)" + (f" ziarno: {seed}" if seed is not None else ""),
|
||||||
"=" * 78,
|
"=" * 78,
|
||||||
f"{'SYSTEM':<14}{'SPRAWDZONYCH':>13}{'>TOL':>7}{'POZA DZIEDZ.':>14}{'MAX ODCH.':>14} WYNIK"]
|
f"{'SYSTEM':<18}{'SPRAWDZONYCH':>13}{'>TOL':>7}{'POZA DZIEDZ.':>14}{'MAX ODCH.':>14} WYNIK"]
|
||||||
for r in results:
|
for r in results:
|
||||||
lines.append(f"{r.system:<14}{r.checked:>13}{r.over_tolerance:>7}"
|
lines.append(f"{r.system:<18}{r.checked:>13}{r.over_tolerance:>7}"
|
||||||
f"{r.skipped_out_of_domain:>14}{r.max_dev:>14.9f} "
|
f"{r.skipped_out_of_domain:>14}{r.max_dev:>14.9f} "
|
||||||
f"{'OK' if r.passed else 'BŁĄD'}")
|
f"{'OK' if r.passed else 'BŁĄD'}")
|
||||||
for r in results:
|
for r in results:
|
||||||
|
|||||||
+2
-2
@@ -27,8 +27,8 @@ from harness import ( # noqa: E402
|
|||||||
|
|
||||||
# Systemy do sprawdzenia. Rośnie wraz z implementacją kolejnych (Etap 1 i 2) —
|
# Systemy do sprawdzenia. Rośnie wraz z implementacją kolejnych (Etap 1 i 2) —
|
||||||
# dopisanie nazwy tutaj wystarcza, żeby weszła do każdego builda.
|
# dopisanie nazwy tutaj wystarcza, żeby weszła do każdego builda.
|
||||||
SYSTEMS = ["whole_sign", "equal", "porphyry", "vehlow", "morinus",
|
SYSTEMS = ["whole_sign", "whole_sign_aries", "equal", "equal_mc", "porphyry", "vehlow", "morinus",
|
||||||
"regiomontanus", "campanus", "alcabitus", "placidus", "koch"]
|
"regiomontanus", "campanus", "alcabitus", "topocentric", "placidus", "koch"]
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
|
|||||||
Reference in New Issue
Block a user