feat(domy): warianty od Barana i od MC + obrót kosmogramu — LOG-05 zamknięte
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m34s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 18s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 10s
build / build (push) Successful in 26s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m37s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m27s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 17s
Testy / Kontrola składni wszystkich warstw (push) Successful in 10s

Ostatnie trzy pozycje z treści LOG-05, których wcześniej nie było:

- whole_sign_aries — znaki jako domy, ale dom I ZAWSZE na 0° Barana, niezależnie
  od Ascendentu (tradycja indyjska i część szkół hellenistycznych),
- equal_mc — równe domy zakotwiczone na MC: dom X zaczyna się dokładnie na
  południku, a nie ma go gdzieś w środku,
- obrót kosmogramu: Ascendent albo 0° Barana po lewej stronie koła. Zmienia
  WYŁĄCZNIE rysunek, żadna liczba nie jest przeliczana. Wariant „od Barana"
  unieruchamia koło względem zodiaku, więc dwa horoskopy da się porównać na oko.

Oba nowe systemy zgodne z wyrocznią co do zera od pierwszego uruchomienia.
Razem 13 systemów: 2 833 424 porównania w przemiale, zero przekroczeń.

PRZY OKAZJI — DWA BŁĘDY, KTÓRE SAM WPROWADZIŁEM I KTÓRYCH TESTY NIE WIDZIAŁY:
- linia zbierająca ostrzeżenia o fallbacku trafiła do handlera strony głównej
  zamiast do compile_pdf: odwoływała się do nieistniejącej zmiennej, czyli 500
  na stronie głównej, a do PDF-a ostrzeżenia nie docierały wcale,
- compile_build używał parametru formularza, którego nie miał w sygnaturze.

Oba przeszły przez komplet zielonych testów, bo żaden nie wywoływał POST-a —
testy prezentacji sprawdzały teksty w szablonach i w main.py. Doszły więc testy
uderzające w prawdziwe trasy (POST / i POST /compile ze stubowaną logiką), które
łapią tę klasę błędu.

Z tego samego powodu przepisane dwa testy PDF-a: greppowały z main.py dokładny
kształt wywołania render(chart, theme="print") i pękały przy dopisaniu argumentu,
mimo poprawnego zachowania. Teraz wołają trasę i sprawdzają, że KAŻDY z czterech
rysunków dostaje motyw druku.

LOG-05 i PRE-05 → Zrobione w docs/astrololo_wymagania.xlsx.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit was merged in pull request #64.
This commit is contained in:
2026-08-05 22:38:43 +02:00
parent aec3f84331
commit 4d1daab35a
13 changed files with 214 additions and 56 deletions
+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
def _pt(lon: float, asc: float, r: float) -> tuple[float, float]:
phi = math.radians(180.0 - (lon - asc))
# Co stoi po lewej stronie koła (LOG-05: „warianty 0°Aries / fixed 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)
@@ -206,12 +213,19 @@ def _delta(a: float, b: float) -> float:
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).
theme='screen' — kolory ze zmiennych CSS aplikacji (dostraja się do motywu).
theme='print' — konkretne kolory na białym tle; konieczne, gdy SVG trafia
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"])
# tylko dla druku — na stronie font podaje CSS (.glyph)
@@ -223,6 +237,9 @@ def render(chart: dict, theme: str = "screen") -> str:
return ""
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] = []
# tło i okręgi
@@ -244,8 +261,8 @@ def render(chart: dict, theme: str = "screen") -> str:
continue
color = T["aspects"].get(asp.get("aspect"), T["muted"])
w, op = _aspect_pen(float(asp.get("orb", 0.0)), asp.get("allowed"))
ax1, ay1 = _pt(la, asc, R_HUB)
bx1, by1 = _pt(lb, asc, R_HUB)
ax1, ay1 = _pt(la, ref, R_HUB)
bx1, by1 = _pt(lb, ref, R_HUB)
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}°')
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)
for deg in range(0, 360, 5):
r_in = R_TICK if deg % 10 else R_TICK - 4
x1, y1 = _pt(deg, asc, R_ZOD)
x2, y2 = _pt(deg, asc, r_in)
x1, y1 = _pt(deg, ref, R_ZOD)
x2, y2 = _pt(deg, ref, r_in)
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
for i, sg in enumerate(signs):
b = 30.0 * i
x1, y1 = _pt(b, asc, R_ZOD)
x2, y2 = _pt(b, asc, R_OUT)
x1, y1 = _pt(b, ref, R_ZOD)
x2, y2 = _pt(b, ref, R_OUT)
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,
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)
for i, c in enumerate(cusps):
lon = float(c["decimal"])
x1, y1 = _pt(lon, asc, R_ZOD)
x2, y2 = _pt(lon, asc, R_HUB)
x1, y1 = _pt(lon, ref, R_ZOD)
x2, y2 = _pt(lon, ref, R_HUB)
parts.append(_line(x1, y1, x2, y2, T["line"], 0.8, 0.85))
nxt = float(cusps[(i + 1) % n]["decimal"])
span = (nxt - lon) % 360.0 or 360.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"]))
# 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;
# w systemach kwadratowych ta liczba realnie coś mówi.
deg_in_sign = lon % 30.0
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"]))
# 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")):
lon_a = float(angles[a]["decimal"])
ax, ay = _pt(lon_a, asc, R_ZOD)
bx, by = _pt(lon_a + 180.0, asc, R_ZOD)
ax, ay = _pt(lon_a, ref, 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))
# etykiety POZA kołem — w środku kolidowały z glifami obiektów
lax, lay = _pt(lon_a, asc, R_AXIS_LABEL)
lbx, lby = _pt(lon_a + 180.0, asc, R_AXIS_LABEL)
lax, lay = _pt(lon_a, ref, 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(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):
retro = obj.get("direction") == "Rx"
mx1, my1 = _pt(true_lon, asc, R_ZOD)
mx2, my2 = _pt(true_lon, asc, R_MARK)
mx1, my1 = _pt(true_lon, ref, R_ZOD)
mx2, my2 = _pt(true_lon, ref, R_MARK)
parts.append(_line(mx1, my1, mx2, my2, T["ink"], 1.0, 0.75))
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))
gx, gy = _pt(draw_lon, asc, R_PLANET)
gx, gy = _pt(draw_lon, ref, R_PLANET)
tip = obj.get("name") or ""
if obj.get("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
# tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz
# 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 "")
parts.append(_text(dx, dy, label, size=8,
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:
continue
lon = float(lot["longitude"])
mx1, my1 = _pt(lon, asc, R_ZOD)
mx2, my2 = _pt(lon, asc, R_MARK)
mx1, my1 = _pt(lon, ref, R_ZOD)
mx2, my2 = _pt(lon, ref, R_MARK)
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 ""
if lot.get("in_sign"):
tip += f" · {lot['in_sign']}"
@@ -367,7 +384,6 @@ def render(chart: dict, theme: str = "screen") -> str:
# ── aspektarian (PRE-18) ──────────────────────────────────────────────────
ASP_CELL = 30.0 # bok komórki siatki aspektów
def render_aspectarian(chart: dict, theme: str = "screen") -> str:
"""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]] = [
("placidus", "Placidus"),
("whole_sign", "Whole Sign"),
("whole_sign_aries", "Whole Sign (od Barana)"),
("koch", "Koch"),
("equal", "Equal"),
("equal", "Equal (od Asc)"),
("equal_mc", "Equal (od MC)"),
("porphyry", "Porphyry"),
("regiomontanus", "Regiomontanus"),
("campanus", "Campanus"),
+17 -9
View File
@@ -22,6 +22,7 @@ from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
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 import geocode, security
@@ -60,6 +61,7 @@ templates.env.globals["static"] = static_url
templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS
templates.env.globals["house_label"] = house_label
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]:
@@ -130,12 +132,14 @@ def chart_compute(
stations: bool = Form(False),
zodiac: str = Form("tropical"),
tables: bool = Form(False),
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system,
"house_systems": house_systems, "aspect_orb": aspect_orb,
"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}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
@@ -147,12 +151,8 @@ def chart_compute(
aspect_minor=aspect_minor,
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
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["declination_svg"] = chartwheel.render_declination(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
zodiac: str = Form("tropical"),
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
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,
"house_systems": house_systems, "aspect_orb": aspect_orb,
"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}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
@@ -216,7 +218,7 @@ def compile_build(
stations=stations, zodiac=zodiac, tables=tables,
)
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["declination_svg"] = chartwheel.render_declination(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")),
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
# Zasada: co pokazujemy na stronie, ma trafić do PDF-a. Wszystkie rysunki
# w motywie DRUKU — samodzielny konwerter SVG→PDF nie zna arkusza, więc
# zmienne CSS i font glifów muszą być wprost w rysunku.
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_declination(chart, theme="print"), "Wykres deklinacji"),
(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>
{% endfor %}
</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).">
<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>