Domy: warianty od Barana i od MC + obrót kosmogramu — LOG-05 zamknięte #64

Merged
gitea merged 1 commits from feat/log05-warianty into master 2026-08-06 10:10:12 +00:00
13 changed files with 214 additions and 56 deletions
Binary file not shown.
+13 -2
View File
@@ -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.
@@ -28,8 +30,9 @@ KOCH = "koch"
# Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris # Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris
# (tests/oracle). Placidus i Koch jako jedyne mają granicę dziedziny: # (tests/oracle). Placidus i Koch jako jedyne mają granicę dziedziny:
# powyżej koła podbiegunowego nie istnieją i podlegają jawnemu fallbackowi. # powyżej koła podbiegunowego nie istnieją i podlegają jawnemu fallbackowi.
SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY, VEHLOW, MORINUS, REGIOMONTANUS, SYSTEMS = (WHOLE_SIGN, WHOLE_SIGN_ARIES, EQUAL, EQUAL_MC, PORPHYRY, VEHLOW,
CAMPANUS, ALCABITUS, TOPOCENTRIC, PLACIDUS, KOCH) MORINUS, REGIOMONTANUS, CAMPANUS, ALCABITUS, TOPOCENTRIC,
PLACIDUS, KOCH)
def mean_obliquity(tt_jd: float) -> float: def mean_obliquity(tt_jd: float) -> float:
@@ -458,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)]
+19
View File
@@ -230,3 +230,22 @@ def test_topocentric_needs_no_domain_limit():
assert len(H.cusps_for(100.0, 23.4393, lat, "topocentric")) == 12 assert len(H.cusps_for(100.0, 23.4393, lat, "topocentric")) == 12
cs = H.cusps_detailed(100.0, 23.4393, 89.9, "topocentric") cs = H.cusps_detailed(100.0, 23.4393, 89.9, "topocentric")
assert not cs.is_fallback and cs.notice is None 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)
+43 -27
View File
@@ -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: AscDsc i MCIC przez całe koło, wyróżnione akcentem # osie: AscDsc i MCIC 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.
+3 -1
View File
@@ -15,8 +15,10 @@ 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"),
+17 -9
View File
@@ -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>
+39 -10
View File
@@ -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():
+3 -1
View File
@@ -70,7 +70,9 @@ 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° |
+5 -3
View File
@@ -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:
+1 -1
View File
@@ -27,7 +27,7 @@ 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", "topocentric", "placidus", "koch"] "regiomontanus", "campanus", "alcabitus", "topocentric", "placidus", "koch"]