Compare commits

..

4 Commits

Author SHA1 Message Date
gitea 4d1daab35a 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>
2026-08-05 22:38:43 +02:00
gitea aec3f84331 feat(domy): topocentric zgodny co do zera — wybór gałęzi liczony, nie zgadywany
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m29s
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 9s
build-render / build (push) Successful in 5m34s
build / build (push) Successful in 39s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m46s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m31s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m27s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 19s
Testy / Kontrola składni wszystkich warstw (push) Successful in 11s
Topocentric był wycofany z powodu rozjazdu przy |φ| ≈ 89,9°. Ta diagnoza była
BŁĘDNA: opierała się wyłącznie na zestawie brzegowym, który próbkuje tylko wybrane
szerokości. Przemiał losowy pokazał prawdziwą skalę — błędy od ~70° w górę,
do 50% przypadków blisko biegunów, 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. Kolejno zawiodły reguły „po której
stronie MC", „w łuku kwadrantu", „wschodnia połowa horyzontu" i śledzenie ciągłości
krokami (to ostatnie maskuje własną patologię — po korekcie do bliższej gałęzi
zmierzony skok ZAWSZE wychodzi ≤ 90°, więc detektor nigdy się nie zapala).

Wszystkie te reguły rozstrzygają 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°. To jest poprawne — wyrocznia zwraca to samo.

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 w wektorach, znak ustalamy raz — kotwicząc
rodzinę na MC górującym.

Wynik: 0,000000000° na 240 000 porównań, cała dziedzina, bez iteracji i bez
zawężania szerokości. Żadna granica dziedziny nie jest tu potrzebna, więc żadnej
nie udajemy — test pilnuje, że topocentric liczy się wszędzie i nie fallbackuje.

Morał do frameworka: 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. Odnotowane w tests/oracle/README.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:24:40 +02:00
gitea 7266f671a4 feat(domy): Placidus i Koch + jawny fallback poza kołem podbiegunowym
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m30s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 17s
Testy / Kontrola składni wszystkich warstw (push) Successful in 8s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m28s
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 8s
Etap 2: dwa systemy łuku dobowego — jedyne, które mają miejsca, gdzie po prostu
NIE ISTNIEJĄ. Koch zgadza się z wyrocznią co do zera; Placidus do 1,7e-6°, przy
czym to próg zbieżności WYROCZNI, nie nasz: nasze cuspy spełniają definicję
Placidusa z dokładnością 1e-12° (osobny test, działa bez swissepha).

Placidus jako jedyny nie ma wzoru zamkniętego — cusp jest zdefiniowany warunkiem
na samego siebie („punkt, który przebył 1/3 swojego półłuku"), więc iterujemy po
punkcie stałym do 1e-11°. Brak zbieżności traktujemy jako wyjście poza dziedzinę,
nie jako wynik.

Koch okazał się natomiast ZAMKNIĘTY: jego kryterium to czas od wschodu stopnia
stojącego na MC, a półłuk tego stopnia znamy wprost z jego deklinacji. Definicja
za Astrodienst (astro.com/astrowiki/en/Koch_House_System) — nie zgadywana.

Powyżej koła podbiegunowego oba odmawiają liczenia i wyrocznia odmawia dokładnie
tych samych przypadków (5245/20 000 losowych, zero rozjazdów dziedziny). Odmowa
jest wyjątkiem, nie liczbą: cicha podmiana systemu jest niewykrywalna z wykresu.

Ustępstwo wobec rzeczywistości siedzi osobno, w cusps_detailed(): podstawia
Porphyry'ego i ZAWSZE zostawia ślad. Ten ślad idzie wszystkimi trzema wyjściami —
na ekran (ramka, nie „muted"), w prompt do modelu (inaczej napisze „Twój Placidus"
o Porphyrym) i do PDF-a, w ramce PRZED rysunkami. Astrolog z Tromsø dostaje
wynik i wie, że go dostał inaczej.

Przy okazji: nazwy systemów były zaszyte w czterech szablonach naraz. Przy trzech
systemach uchodziło to na sucho, przy dziesięciu nie — jest katalog w jednym
miejscu, a testy szablonów RENDERUJĄ je zamiast szukać tekstu w źródle, więc
łapią też literówki w Jinja.

Większe jądro efemeryd (de441): świadomie zdegradowane do „nice to have" —
rozszerza wyłącznie zakres dat, nie poprawia niczego w obecnym. Odnotowane
w domain.py przy JD_MIN/JD_MAX, żeby nie wróciło po cichu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:04:33 +02:00
gitea 548d9301f3 fix(domy): przepnij build_chart na cusps_for — inaczej nowe systemy dają 500
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m30s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 20s
Testy / Kontrola składni wszystkich warstw (push) Successful in 9s
Rozszerzenie houses.SYSTEMS do ośmiu pozycji odblokowało w chart.py filtr
`house_system in H.SYSTEMS`, ale liczenie zostało na H.cusps(), które zna
wyłącznie trzy systemy dzielące ekliptykę i dla pozostałych rzuca ValueError.
Wybranie campanusa przechodziło więc walidację i dopiero potem wywalało 500.

Test parametryzowany po H.SYSTEMS zamyka tę klasę błędu na przyszłość: każdy
system ogłoszony na liście musi przejść przez build_chart, więc dopisanie
nazwy bez przepięcia liczenia od razu zapali się na czerwono.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:53:54 +02:00
23 changed files with 792 additions and 122 deletions
Binary file not shown.
+18 -5
View File
@@ -97,7 +97,11 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
asc = H.compute_asc(ramc, eps, moment.lat) asc = H.compute_asc(ramc, eps, moment.lat)
mc = H.compute_mc(ramc, eps) mc = H.compute_mc(ramc, eps)
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
cusp_list = H.cusps(asc, mc, system) # tropikalne — geometria domów jest niezmiennicza # cusps_detailed, nie cusps_for: Placidus i Koch nie istnieją powyżej koła
# podbiegunowego, a astrolog z Tromsø ma dostać wynik ZE ŚLADEM, czym go
# policzyliśmy. Ten ślad musi dojść aż do raportu i PDF-a.
primary = H.cusps_detailed(ramc, eps, moment.lat, system)
cusp_list = primary.cusps # tropikalne — geometria domów jest niezmiennicza
def _cusps_out(cl: list[float]) -> list[dict]: def _cusps_out(cl: list[float]) -> list[dict]:
"""Cuspy → wiersze pod UI/kosmogram: znak, stopień w znaku, długość, glif.""" """Cuspy → wiersze pod UI/kosmogram: znak, stopień w znaku, długość, glif."""
@@ -109,7 +113,11 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
for i, c in enumerate(cl) for i, c in enumerate(cl)
] ]
result["house_system"] = system result["house_system"] = primary.system # FAKTYCZNIE użyty
result["house_system_requested"] = primary.requested
# Lista ostrzeżeń dla całego horoskopu — prezentacja, raport i PDF czytają
# jedno miejsce, więc żaden z nich nie może „zapomnieć" o fallbacku.
result["house_warnings"] = [primary.notice] if primary.notice else []
result["angles"] = { result["angles"] = {
"Asc": _fmt("Asc", asc, off), "Asc": _fmt("Asc", asc, off),
"MC": _fmt("MC", mc, off), "MC": _fmt("MC", mc, off),
@@ -132,10 +140,15 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
if len(ordered) > 1: if len(ordered) > 1:
result["house_systems"] = [] result["house_systems"] = []
for s in ordered: for s in ordered:
cl = cusp_list if s == system else H.cusps(asc, mc, s) cs = primary if s == system else H.cusps_detailed(ramc, eps, moment.lat, s)
result["house_systems"].append({"system": s, "cusps": _cusps_out(cl)}) block = {"system": s, "used_system": cs.system, "cusps": _cusps_out(cs.cusps)}
if cs.notice:
block["notice"] = cs.notice
if cs.notice not in result["house_warnings"]:
result["house_warnings"].append(cs.notice)
result["house_systems"].append(block)
for pdict, obj in zip(result["positions"], positions): for pdict, obj in zip(result["positions"], positions):
pdict.setdefault("houses", {})[s] = H.assign_house(obj.longitude, cl) pdict.setdefault("houses", {})[s] = H.assign_house(obj.longitude, cs.cusps)
# Lots (LOG-08) — wymagają Asc i sekty (dzień/noc) # Lots (LOG-08) — wymagają Asc i sekty (dzień/noc)
from app.engine.firdaria import is_day_birth from app.engine.firdaria import is_day_birth
+211 -36
View File
@@ -8,11 +8,14 @@ silnik dostarcza tylko RAMC i ε.
from __future__ import annotations from __future__ import annotations
import math import math
from dataclasses import dataclass
from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401 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.
@@ -22,14 +25,14 @@ REGIOMONTANUS = "regiomontanus"
CAMPANUS = "campanus" CAMPANUS = "campanus"
ALCABITUS = "alcabitus" ALCABITUS = "alcabitus"
TOPOCENTRIC = "topocentric" TOPOCENTRIC = "topocentric"
# Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris co do zera PLACIDUS = "placidus"
# (tests/oracle). TOPOCENTRIC celowo POZA listą: implementacja jest zgodna KOCH = "koch"
# z wyrocznią wszędzie poza otoczeniem bieguna (|φ| ≈ 89,9° przy RAMC 90°/270°), # Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris
# gdzie kolejność domów się odwraca i konstrukcja traci sens — jego „biegun" # (tests/oracle). Placidus i Koch jako jedyne mają granicę dziedziny:
# atan(tan(89,9°)/3) to już 89,7°. Nie zawężamy dziedziny wyłącznie po to, by # powyżej koła podbiegunowego nie istnieją i podlegają jawnemu fallbackowi.
# testy przeszły; system dołączy, gdy będzie poprawny na całej dziedzinie. SYSTEMS = (WHOLE_SIGN, WHOLE_SIGN_ARIES, EQUAL, EQUAL_MC, PORPHYRY, VEHLOW,
SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY, VEHLOW, MORINUS, MORINUS, REGIOMONTANUS, CAMPANUS, ALCABITUS, TOPOCENTRIC,
REGIOMONTANUS, CAMPANUS, ALCABITUS) PLACIDUS, KOCH)
def mean_obliquity(tt_jd: float) -> float: def mean_obliquity(tt_jd: float) -> float:
@@ -286,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 (PolichPage). # Ułamek szerokości geograficznej użyty jako „biegun" koła domu (PolichPage).
# 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, # PolichPage: 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 611% 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]:
"""PolichPage: 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)
@@ -331,6 +344,111 @@ def _cusps_topocentric(ramc: float, eps: float, lat: float,
return out return out
# ── systemy łuku dobowego (Placidus, Koch) ───────────────────────────────
# Różnią się od wszystkich poprzednich tym, że NIE MAJĄ wzoru zamkniętego: cusp
# jest zdefiniowany warunkiem na samego siebie („punkt, który przebył 1/3 swojego
# półłuku"), więc trzeba go znaleźć iteracyjnie. Mają też jako jedyne REALNĄ
# granicę dziedziny — powyżej koła podbiegunowego stopnie ekliptyki bywają
# okołobiegunowe, nie wschodzą ani nie zachodzą, i półłuk po prostu nie istnieje.
class HouseSystemUndefined(ValueError):
"""System domów nie ma definicji dla podanych parametrów (nie: błąd liczenia).
Podnoszone zamiast zwrócenia liczby, bo cicha podmiana systemu jest gorsza
niż błąd: wykres wygląda poprawnie, a planety siedzą w innych domach, niż
astrolog zamawiał. Warstwa aplikacyjna łapie to w cusps_detailed() i robi
JAWNY fallback."""
_ITER_MAX = 100
_ITER_TOL_DEG = 1e-11
def _declination_of_ecliptic_lon(lon_deg: float, eps_rad: float) -> float:
"""Deklinacja punktu LEŻĄCEGO NA EKLIPTYCE o danej długości."""
return math.degrees(math.asin(math.sin(eps_rad) * math.sin(math.radians(lon_deg))))
def _ascensional_difference(dec_deg: float, lat_deg: float) -> float:
"""Różnica wschodnia: o ile półłuk dobowy odbiega od 90°.
sin(AD) = tan(φ)·tan(δ). Gdy |tan(φ)·tan(δ)| ≥ 1, punkt jest okołobiegunowy
(nigdy nie wschodzi albo nigdy nie zachodzi) i półłuk nie istnieje."""
v = math.tan(math.radians(lat_deg)) * math.tan(math.radians(dec_deg))
if abs(v) >= 1.0:
raise HouseSystemUndefined(
f"punkt okołobiegunowy (tan φ·tan δ = {v:.6f}): półłuk dobowy nie istnieje")
return math.degrees(math.asin(v))
# Dom → (ułamek półłuku, czy łuk NOCNY). Domy 11 i 12 dzielą łuk dzienny licząc
# od MC; domy 2 i 3 — łuk nocny, licząc WSTECZ od IC.
_PLACIDUS_STEP = {10: (1 / 3, False), 11: (2 / 3, False),
1: (2 / 3, True), 2: (1 / 3, True)}
def _placidus_cusp(ramc: float, eps: float, lat: float,
fraction: float, nocturnal: bool) -> float:
"""Punkt ekliptyki, który przebył `fraction` swojego półłuku.
Warunek jest uwikłany: półłuk zależy od deklinacji, deklinacja od długości,
a długość od położenia — więc iterujemy po punkcie stałym. Zbieżność jest
szybka z dala od koła podbiegunowego i psuje się przy nim, dlatego brak
zbieżności traktujemy jako wyjście poza dziedzinę, a nie jako wynik."""
eps_rad = math.radians(eps)
# Start od podziału równomiernego — to Porphyry na równiku, czyli dokładnie
# ten przypadek, w którym Placidus się do niego sprowadza.
ra = ramc + 180.0 - 90.0 * fraction if nocturnal else ramc + 90.0 * fraction
for _ in range(_ITER_MAX):
dec = _declination_of_ecliptic_lon(_ra_to_ecliptic_lon(ra, eps_rad), eps_rad)
ad = _ascensional_difference(dec, lat)
nxt = (ramc + 180.0 - fraction * (90.0 - ad) if nocturnal
else ramc + fraction * (90.0 + ad))
if abs(nxt - ra) < _ITER_TOL_DEG:
return _ra_to_ecliptic_lon(nxt, eps_rad)
ra = nxt
raise HouseSystemUndefined(
f"brak zbieżności po {_ITER_MAX} krokach (φ={lat:.4f}, RAMC={ramc:.4f})")
def _cusps_placidus(ramc: float, eps: float, lat: float,
asc: float, mc: float) -> list[float]:
"""Półłuki dobowe i nocne dzielone na trzy — każdy punkt swoim własnym łukiem."""
if abs(lat) >= polar_circle(eps):
raise HouseSystemUndefined(
f"φ={lat:.4f}° poza kołem podbiegunowym ({polar_circle(eps):.4f}° dla ε={eps:.4f}°)")
inter = {i: _placidus_cusp(ramc, eps, lat, f, noct)
for i, (f, noct) in _PLACIDUS_STEP.items()}
inter.update({(i + 6) % 12: norm360(v + 180.0) for i, v in list(inter.items())})
return _with_exact_angles(inter, asc, mc)
# Koch dzieli CZAS, nie łuk na niebie. Kryterium: ile czasu minęło od wschodu
# tego stopnia zodiaku, który stoi na MC. Ten odcinek (półłuk dobowy stopnia MC)
# dzielimy na trzy i dla punktów podziału liczymy ZWYKŁY Ascendent — stąd nazwa
# „system miejsca urodzenia". Zgodne z definicją Astrodienst (astro.com/astrowiki).
#
# W przeciwieństwie do Placidusa NIE wymaga iteracji: półłuk zależy od deklinacji
# stopnia MC, którą znamy wprost. Granicę dziedziny dzieli natomiast z Placidusem —
# gdy stopień MC jest okołobiegunowy, „moment jego wschodu" nie istnieje.
_KOCH_OFFSET = {10: -2 / 3, 11: -1 / 3, 1: 1 / 3, 2: 2 / 3}
def _cusps_koch(ramc: float, eps: float, lat: float,
asc: float, mc: float) -> list[float]:
"""Ascendenty dla chwil trójdzielących drogę stopnia MC od wschodu do górowania."""
if abs(lat) >= polar_circle(eps):
raise HouseSystemUndefined(
f"φ={lat:.4f}° poza kołem podbiegunowym ({polar_circle(eps):.4f}° dla ε={eps:.4f}°)")
dec_mc = _declination_of_ecliptic_lon(mc, math.radians(eps))
half_arc = 90.0 + _ascensional_difference(dec_mc, lat)
inter = {i: compute_asc(ramc + f * half_arc, eps, lat)
for i, f in _KOCH_OFFSET.items()}
inter.update({(i + 6) % 12: norm360(v + 180.0) for i, v in list(inter.items())})
return _with_exact_angles(inter, asc, mc)
def cusps_for(ramc: float, eps: float, lat: float, system: str) -> list[float]: def cusps_for(ramc: float, eps: float, lat: float, system: str) -> list[float]:
"""Kanoniczne wejście: (RAMC, ε, φ) → 12 cusps. """Kanoniczne wejście: (RAMC, ε, φ) → 12 cusps.
@@ -343,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)]
@@ -356,9 +482,58 @@ def cusps_for(ramc: float, eps: float, lat: float, system: str) -> list[float]:
return _cusps_alcabitus(ramc, eps, asc) return _cusps_alcabitus(ramc, eps, asc)
if system == TOPOCENTRIC: if system == TOPOCENTRIC:
return _cusps_topocentric(ramc, eps, lat, asc, mc) return _cusps_topocentric(ramc, eps, lat, asc, mc)
if system == PLACIDUS:
return _cusps_placidus(ramc, eps, lat, asc, mc)
if system == KOCH:
return _cusps_koch(ramc, eps, lat, asc, mc)
raise ValueError(f"nieznany system domów: {system}") raise ValueError(f"nieznany system domów: {system}")
# ── jawny fallback poza dziedziną ────────────────────────────────────────
# Placidus i Koch jako jedyne mają miejsca, w których po prostu NIE ISTNIEJĄ.
# Astrolog z Tromsø ma dostać wynik, ale musi wiedzieć, że dostał inny system —
# cicha podmiana jest gorsza niż brak wyniku, bo jest nie do wykrycia z wykresu.
FALLBACK_SYSTEM = PORPHYRY
@dataclass(frozen=True)
class CuspSet:
"""12 cuspów + uczciwa informacja, czym naprawdę zostały policzone."""
cusps: list[float]
system: str # system FAKTYCZNIE użyty
requested: str # o który poproszono
reason: str | None = None # dlaczego nie dało się użyć żądanego
@property
def is_fallback(self) -> bool:
return self.system != self.requested
@property
def notice(self) -> str | None:
"""Komunikat dla człowieka. Ma trafić na ekran, do raportu i do PDF-a."""
if not self.is_fallback:
return None
return (f"UWAGA: system domów \u201e{self.requested}\u201d nie ma definicji "
f"dla tego miejsca i czasu \u2014 {self.reason}. Domy policzono "
f"systemem \u201e{self.system}\u201d. To NIE jest ten sam podzia\u0142: "
f"pozycje planet s\u0105 poprawne, ale przypisanie ich do dom\u00f3w "
f"pochodzi z innego systemu.")
def cusps_detailed(ramc: float, eps: float, lat: float, system: str) -> CuspSet:
"""Jak cusps_for, ale zamiast wyjątku poza dziedziną robi JAWNY fallback.
cusps_for zostaje funkcją czystą i nieustępliwą (to ją porównuje wyrocznia);
ustępstwo wobec rzeczywistości jest tutaj — i zawsze zostawia ślad."""
try:
return CuspSet(cusps_for(ramc, eps, lat, system), system, system)
except HouseSystemUndefined as e:
return CuspSet(cusps_for(ramc, eps, lat, FALLBACK_SYSTEM),
FALLBACK_SYSTEM, system, str(e))
def assign_house(lon: float, cusp_list: list[float]) -> int: def assign_house(lon: float, cusp_list: list[float]) -> int:
"""Numer domu (1..12), w którym leży dana długość ekliptyczna.""" """Numer domu (1..12), w którym leży dana długość ekliptyczna."""
lon = norm360(lon) lon = norm360(lon)
+5
View File
@@ -200,6 +200,11 @@ def _chart_section(chart: dict, moment_label: str | None) -> str:
if meta: if meta:
lines.append(" · ".join(meta)) lines.append(" · ".join(meta))
# Model interpretuje domy, więc musi wiedzieć, że podział pochodzi z innego
# systemu, niż zamówiono — inaczej napisze „Twój Placidus" o Porphyrym.
for w in chart.get("house_warnings") or []:
lines.append(f"\n{w}")
if chart.get("positions"): if chart.get("positions"):
lines.append("\n## Pozycje") lines.append("\n## Pozycje")
for p in chart["positions"]: for p in chart["positions"]:
+29 -3
View File
@@ -2,6 +2,9 @@
Waliduje względem astro.com dla horoskopu referencyjnego. Waliduje względem astro.com dla horoskopu referencyjnego.
""" """
import pytest
from app.engine import houses as H
from app.engine.chart import build_chart from app.engine.chart import build_chart
@@ -61,8 +64,31 @@ def test_primary_always_first_even_if_not_listed(own_engine, reference_moment):
def test_unknown_extra_system_is_ignored(own_engine, reference_moment): def test_unknown_extra_system_is_ignored(own_engine, reference_moment):
"""Nieobsługiwany system (np. placidus — dojdzie przez swisseph osobno) jest """Nieznana nazwa systemu jest po prostu pomijana, nie wywala horoskopu.
po prostu pomijany, nie wywala horoskopu."""
(Placidus był tu kiedyś przykładem systemu nieobsługiwanego — od Etapu 2
jest liczony, więc przykładem musi być nazwa, której naprawdę nie znamy.)"""
chart = build_chart(own_engine, reference_moment, "whole_sign", chart = build_chart(own_engine, reference_moment, "whole_sign",
house_systems=["placidus", "equal"]) house_systems=["nie-ma-takiego-systemu", "equal"])
assert [h["system"] for h in chart["house_systems"]] == ["whole_sign", "equal"] assert [h["system"] for h in chart["house_systems"]] == ["whole_sign", "equal"]
@pytest.mark.parametrize("system", H.SYSTEMS)
def test_every_advertised_system_builds_a_chart(own_engine, reference_moment, system):
"""Każdy system z houses.SYSTEMS musi przejść przez build_chart. Pilnuje,
żeby rozszerzenie SYSTEMS bez przepięcia chart.py nie dało 500 przy wyborze
systemu, który jest na liście, ale nie w użytej funkcji."""
chart = build_chart(own_engine, reference_moment, system)
assert chart["house_system"] == system
assert len(chart["cusps"]) == 12
assert all(1 <= p["house"] <= 12 for p in chart["positions"])
def test_multiple_systems_side_by_side_cover_all(own_engine, reference_moment):
chart = build_chart(own_engine, reference_moment, H.WHOLE_SIGN,
house_systems=list(H.SYSTEMS))
blocks = chart["house_systems"]
assert blocks[0]["system"] == H.WHOLE_SIGN # prymarny zawsze pierwszy
assert {b["system"] for b in blocks} == set(H.SYSTEMS)
for p in chart["positions"]:
assert set(p["houses"]) == set(H.SYSTEMS)
+124 -1
View File
@@ -1,4 +1,6 @@
"""Testy osi i domów — czysta matematyka (LOG-05, bez efemeryd).""" """Testy osi i domów — czysta matematyka (LOG-05, bez efemeryd)."""
import math
import pytest import pytest
from app.engine import houses as H from app.engine import houses as H
@@ -81,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)
@@ -126,3 +129,123 @@ def test_morinus_ignores_latitude():
def test_unknown_system_is_rejected(): def test_unknown_system_is_rejected():
with pytest.raises(ValueError): with pytest.raises(ValueError):
H.cusps_for(100.0, 23.4393, 50.0, "nie-ma-takiego") H.cusps_for(100.0, 23.4393, 50.0, "nie-ma-takiego")
# ── systemy łuku dobowego: Placidus i Koch (Etap 2) ──────────────────────
DIURNAL = ("placidus", "koch")
TROMSO_LAT = 69.65 # za kołem podbiegunowym — tam te systemy nie istnieją
KRAKOW_LAT = 50.06
@pytest.mark.parametrize("system", DIURNAL)
def test_diurnal_systems_refuse_beyond_the_polar_circle(system):
"""Odmowa, nie liczba. Cicha podmiana systemu jest niewykrywalna z wykresu."""
with pytest.raises(H.HouseSystemUndefined):
H.cusps_for(100.0, 23.4393, TROMSO_LAT, system)
@pytest.mark.parametrize("system", DIURNAL)
def test_fallback_says_what_it_did(system):
cs = H.cusps_detailed(100.0, 23.4393, TROMSO_LAT, system)
assert cs.is_fallback and cs.system == H.FALLBACK_SYSTEM and cs.requested == system
assert len(cs.cusps) == 12
assert cs.notice and system in cs.notice and H.FALLBACK_SYSTEM in cs.notice
@pytest.mark.parametrize("system", DIURNAL)
def test_no_fallback_and_no_notice_where_the_system_exists(system):
cs = H.cusps_detailed(100.0, 23.4393, KRAKOW_LAT, system)
assert not cs.is_fallback and cs.notice is None
assert cs.cusps == H.cusps_for(100.0, 23.4393, KRAKOW_LAT, system)
def test_placidus_cusps_satisfy_their_own_definition():
"""Sprawdzian NIEZALEŻNY od wyroczni: czy znaleziony punkt naprawdę przebył
zadany ułamek swojego półłuku. Wyrocznia mówi „zgadza się z swissephem",
to mówi „zgadza się z definicją Placidusa" — i działa bez swissepha."""
ramc, eps, lat = 212.9986, 23.4393, 66.3144 # tuż pod kołem, najtrudniej
eps_rad = math.radians(eps)
c = H.cusps_for(ramc, eps, lat, "placidus")
for house, (frac, nocturnal) in ((11, (1 / 3, False)), (12, (2 / 3, False)),
(2, (2 / 3, True)), (3, (1 / 3, True))):
lon = c[house - 1]
dec = H._declination_of_ecliptic_lon(lon, eps_rad)
ad = H._ascensional_difference(dec, lat)
ra = math.degrees(math.atan2(math.sin(math.radians(lon)) * math.cos(eps_rad),
math.cos(math.radians(lon))))
want = (ramc + 180.0 - frac * (90.0 - ad) if nocturnal
else ramc + frac * (90.0 + ad))
assert abs((ra - want + 180.0) % 360.0 - 180.0) < 1e-9, f"dom {house}"
def test_koch_reduces_to_equal_spacing_on_the_equator():
"""Na równiku półłuk każdego stopnia to dokładnie 90°, więc trójdzielenie
czasu daje Ascendenty co 30° czasu gwiazdowego — dobry test zdrowia wzoru."""
ramc, eps = 100.0, 23.4393
c = H.cusps_for(ramc, eps, 0.0, "koch")
for house, offset in ((11, -60.0), (12, -30.0), (2, 30.0), (3, 60.0)):
assert c[house - 1] == pytest.approx(
H.compute_asc(ramc + offset, eps, 0.0), abs=1e-9), f"dom {house}"
def test_polar_circle_boundary_moves_with_the_date():
"""Granica dziedziny NIE jest stałą 66,56° — zależy od ε, czyli od daty."""
lat = 66.60
assert lat > H.polar_circle(23.4393) # dziś: poza dziedziną
assert lat < H.polar_circle(23.3528) # rok 2270: już w dziedzinie
with pytest.raises(H.HouseSystemUndefined):
H.cusps_for(100.0, 23.4393, lat, "placidus")
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)
+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.
@@ -0,0 +1,39 @@
"""Katalog systemów domów dla warstwy prezentacji — JEDNO źródło nazw.
Nazwy były zaszyte w czterech szablonach naraz (selektor na „Horoskop", selektor
na „Skompiluj", checkboxy porównania, nagłówki tabeli). Przy trzech systemach
uchodziło to na sucho; przy dziesięciu rozjazd jest kwestią czasu — wystarczy
dopisać system w jednym miejscu i zapomnieć o trzech pozostałych.
Kolejność jest celowa: najpierw dwa najpowszechniejsze (Placidus i Whole Sign),
potem reszta. `LIMITED` oznacza systemy, które powyżej koła podbiegunowego nie
istnieją i podlegają jawnemu fallbackowi — UI ma o tym uprzedzać ZANIM ktoś
policzy horoskop dla Tromsø, a nie dopiero w ostrzeżeniu przy wyniku.
"""
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 (od Asc)"),
("equal_mc", "Equal (od MC)"),
("porphyry", "Porphyry"),
("regiomontanus", "Regiomontanus"),
("campanus", "Campanus"),
("alcabitus", "Alcabitus"),
("topocentric", "Topocentric"),
("morinus", "Morinus"),
("vehlow", "Vehlow"),
]
# Systemy oparte na łuku dobowym — jedyne z realną granicą dziedziny.
LIMITED = frozenset({"placidus", "koch"})
HOUSE_SYSTEM_NAMES: dict[str, str] = dict(HOUSE_SYSTEMS)
def label(system: str) -> str:
"""Nazwa do pokazania; nieznany identyfikator zwracamy bez zmian, nie gubimy go."""
return HOUSE_SYSTEM_NAMES.get(system, system)
+25 -5
View File
@@ -22,6 +22,9 @@ 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 import geocode, security from app import geocode, security
from app.clients.logic_client import LogicClient from app.clients.logic_client import LogicClient
from app.config import DEFAULT_LOCATION_LABEL, default_form from app.config import DEFAULT_LOCATION_LABEL, default_form
@@ -54,6 +57,11 @@ def static_url(name: str) -> str:
templates.env.globals["static"] = static_url templates.env.globals["static"] = static_url
# Katalog systemów domów — jedno źródło nazw dla wszystkich szablonów.
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]: def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
@@ -124,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)
@@ -142,7 +152,7 @@ def chart_compute(
stations=stations, zodiac=zodiac, tables=tables, stations=stations, zodiac=zodiac, tables=tables,
) )
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)
@@ -179,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).
@@ -193,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)
@@ -206,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)
@@ -242,6 +254,7 @@ def compile_pdf(payload: dict):
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422) return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
figures: list[dict] = [] figures: list[dict] = []
warnings: list[str] = []
try: try:
chart = logic.positions( chart = logic.positions(
when_utc_iso=iso_utc, when_utc_iso=iso_utc,
@@ -254,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"),
@@ -275,6 +294,7 @@ def compile_pdf(payload: dict):
"person": payload.get("person") or "", "person": payload.get("person") or "",
"data": {**data, "moment_utc": label}, "data": {**data, "moment_utc": label},
"figures": figures, "figures": figures,
"warnings": warnings,
"natal": payload.get("natal") or {}, "natal": payload.get("natal") or {},
"predictions": payload.get("predictions") or [], "predictions": payload.get("predictions") or [],
} }
@@ -93,6 +93,18 @@ button:hover { filter: brightness(1.1); }
.opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; } .opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; }
.opts input[type=number] { width: 5rem; } .opts input[type=number] { width: 5rem; }
.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; } .meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
/* Fallback systemu domów. Celowo NIE jest to dyskretny „muted" — po fallbacku
kosmogram wygląda bezbłędnie, więc ten akapit jest jedynym sygnałem, że
podział domów pochodzi z innego systemu, niż zamówiono. */
.house-warning {
margin: 1rem 0; padding: .75rem 1rem; font-size: .92rem; line-height: 1.45;
border: 1px solid var(--warn-border, #b8860b);
border-left-width: 4px;
border-radius: 4px;
background: var(--warn-bg, rgba(184, 134, 11, .10));
color: var(--warn-fg, inherit);
}
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; } .error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
.empty { color: var(--muted); } .empty { color: var(--muted); }
table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; } table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; }
@@ -10,9 +10,16 @@
<div class="opts" title="Domy dzielą to samo niebo inaczej. Zaznacz kilka, by porównać kuspy obok siebie (PRE-05). Kosmogram rysuje system wybrany wyżej."> <div class="opts" title="Domy dzielą to samo niebo inaczej. Zaznacz kilka, by porównać kuspy obok siebie (PRE-05). Kosmogram rysuje system wybrany wyżej.">
{% set chosen = form.house_systems or [] %} {% set chosen = form.house_systems or [] %}
<span class="muted small">Porównaj systemy domów:</span> <span class="muted small">Porównaj systemy domów:</span>
<label><input type="checkbox" name="house_systems" value="whole_sign" {{ 'checked' if 'whole_sign' in chosen else '' }}> Whole Sign</label> {% for value, name in HOUSE_SYSTEMS %}
<label><input type="checkbox" name="house_systems" value="equal" {{ 'checked' if 'equal' in chosen else '' }}> Equal</label> <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="porphyry" {{ 'checked' if 'porphyry' in chosen else '' }}> Porphyry</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>
<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>
@@ -6,6 +6,14 @@
deklinacja/antyscja) renderuje rodzic; tu są same tabele. Każda sekcja pokazuje deklinacja/antyscja) renderuje rodzic; tu są same tabele. Każda sekcja pokazuje
się tylko, gdy jej dane są w wyniku (opcja policzona → sekcja jest). #} się tylko, gdy jej dane są w wyniku (opcja policzona → sekcja jest). #}
{# Fallback systemu domów: system nie istniał dla tego miejsca i policzyliśmy
inny. Musi być WIDOCZNE, bo z samego wykresu tego nie da się poznać. #}
{% if result.house_warnings %}
{% for w in result.house_warnings %}
<p class="house-warning">{{ w }}</p>
{% endfor %}
{% endif %}
{% if result.angles %} {% if result.angles %}
<table class="angles"> <table class="angles">
<thead><tr><th></th><th>Znak</th><th>W znaku</th></tr></thead> <thead><tr><th></th><th>Znak</th><th>W znaku</th></tr></thead>
@@ -20,10 +28,9 @@
{# Porównanie systemów domów (PRE-05) — te same osie, inny podział na domy #} {# Porównanie systemów domów (PRE-05) — te same osie, inny podział na domy #}
{% if result.house_systems and result.house_systems | length > 1 %} {% if result.house_systems and result.house_systems | length > 1 %}
{% set HSN = {'whole_sign': 'Whole Sign', 'equal': 'Equal', 'porphyry': 'Porphyry'} %}
<div class="meta" title="Osie (Asc/MC) są wspólne — różni się podział na domy. Kosmogram rysuje pierwszy z listy.">Porównanie systemów domów ({{ result.house_systems | length }})</div> <div class="meta" title="Osie (Asc/MC) są wspólne — różni się podział na domy. Kosmogram rysuje pierwszy z listy.">Porównanie systemów domów ({{ result.house_systems | length }})</div>
<table class="angles"> <table class="angles">
<thead><tr><th>Dom</th>{% for hs in result.house_systems %}<th>{{ HSN.get(hs.system, hs.system) }}</th>{% endfor %}</tr></thead> <thead><tr><th>Dom</th>{% for hs in result.house_systems %}<th>{{ house_label(hs.system) }}</th>{% endfor %}</tr></thead>
<tbody> <tbody>
{% for i in range(12) %} {% for i in range(12) %}
<tr><td>{{ i + 1 }}</td> <tr><td>{{ i + 1 }}</td>
@@ -219,7 +226,7 @@
{% if result.cusps %} {% if result.cusps %}
<details class="loc"> <details class="loc">
<summary>Cusps domów ({{ result.house_system }})</summary> <summary>Cusps domów ({{ house_label(result.house_system) }})</summary>
<table> <table>
<thead><tr><th>Dom</th><th>Znak</th><th>Cusp</th></tr></thead> <thead><tr><th>Dom</th><th>Znak</th><th>Cusp</th></tr></thead>
<tbody> <tbody>
@@ -34,10 +34,11 @@
<label>System domów <label>System domów
<select name="house_system"> <select name="house_system">
{% set hs = form.house_system or 'whole_sign' %} {% set hs = form.house_system or 'whole_sign' %}
<option value="whole_sign" {{ 'selected' if hs == 'whole_sign' else '' }}>Whole Sign</option> {% for value, name in HOUSE_SYSTEMS %}
<option value="equal" {{ 'selected' if hs == 'equal' else '' }}>Equal</option> <option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option> {% endfor %}
</select> </select>
<span class="muted small" title="Placidus i Koch dzielą łuk dobowy, który powyżej koła podbiegunowego (ok. 66,5°) nie istnieje — dla takich miejsc policzymy Porphyry i wyraźnie o tym napiszemy.">* nie działa za kołem podbiegunowym</span>
</label> </label>
<label>Zodiak <label>Zodiak
<select name="zodiac"> <select name="zodiac">
@@ -65,10 +66,14 @@
{% endif %} {% endif %}
{% if result %} {% if result %}
{# Fallback systemu domów — z kosmogramu nie da się go poznać, więc mówimy wprost #}
{% for w in result.house_warnings or [] %}
<p class="house-warning">{{ w }}</p>
{% endfor %}
<div class="meta"> <div class="meta">
Silnik: <strong>{{ result.engine }}</strong> · Silnik: <strong>{{ result.engine }}</strong> ·
obiektów: {{ result.positions | length }} obiektów: {{ result.positions | length }}
{% if result.house_system %}· domy: {{ result.house_system }}{% endif %} {% if result.house_system %}· domy: {{ house_label(result.house_system) }}{% endif %}
{% if result.zodiac %}· zodiak: {{ result.zodiac }}{% if result.ayanamsha is defined %} (ayanamsa {{ '%.4f'|format(result.ayanamsha) }}°){% endif %}{% endif %} {% if result.zodiac %}· zodiak: {{ result.zodiac }}{% if result.ayanamsha is defined %} (ayanamsa {{ '%.4f'|format(result.ayanamsha) }}°){% endif %}{% endif %}
{% if moment %}· moment: {{ moment }}{% endif %} {% if moment %}· moment: {{ moment }}{% endif %}
</div> </div>
@@ -39,10 +39,11 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
<label>System domów <label>System domów
<select name="house_system"> <select name="house_system">
{% set hs = form.house_system or 'whole_sign' %} {% set hs = form.house_system or 'whole_sign' %}
<option value="whole_sign" {{ 'selected' if hs == 'whole_sign' else '' }}>Whole Sign</option> {% for value, name in HOUSE_SYSTEMS %}
<option value="equal" {{ 'selected' if hs == 'equal' else '' }}>Equal</option> <option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option> {% endfor %}
</select> </select>
<span class="muted small" title="Placidus i Koch dzielą łuk dobowy, który powyżej koła podbiegunowego (ok. 66,5°) nie istnieje — dla takich miejsc policzymy Porphyry i wyraźnie o tym napiszemy.">* nie działa za kołem podbiegunowym</span>
</label> </label>
<label>Zodiak <label>Zodiak
<select name="zodiac"> <select name="zodiac">
+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,12 +5,16 @@ 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")
OPTS = (APP / "templates" / "_form_options.html").read_text(encoding="utf-8") # wspólne opcje OPTS = (APP / "templates" / "_form_options.html").read_text(encoding="utf-8") # wspólne opcje
TABLES = (APP / "templates" / "_result_tables.html").read_text(encoding="utf-8") # wspólne tabele TABLES = (APP / "templates" / "_result_tables.html").read_text(encoding="utf-8") # wspólne tabele
MAIN = (APP / "main.py").read_text(encoding="utf-8") MAIN = (APP / "main.py").read_text(encoding="utf-8")
from app.house_systems import HOUSE_SYSTEMS, LIMITED, label as house_label # noqa: E402
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8") CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
@@ -20,10 +24,37 @@ def test_both_forms_use_shared_option_partial():
assert '{% include "_form_options.html" %}' in COMPILE assert '{% include "_form_options.html" %}' in COMPILE
def test_form_lets_you_pick_extra_systems(): def _render(name: str, **ctx) -> str:
"""Checkboxy dla trzech systemów — wielokrotny wybór (name powtórzony).""" """Renderuje szablon z tymi samymi globalsami, co produkcja.
for val in ("whole_sign", "equal", "porphyry"):
assert f'name="house_systems" value="{val}"' in OPTS, f"brak checkboxa {val}" Szablony budują listę systemów PĘTLĄ po katalogu, więc sprawdzanie ich
surowego tekstu nic już nie mówi — trzeba je wykonać. Przy okazji test
łapie literówki w Jinja, których grep po źródle nigdy nie wyłapał."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader(str(APP / "templates")), autoescape=True)
env.globals.update(HOUSE_SYSTEMS=HOUSE_SYSTEMS, HOUSE_LIMITED=LIMITED,
house_label=house_label, static=lambda p: p)
return env.get_template(name).render(**ctx)
def test_form_offers_every_system_the_engine_advertises():
"""Każdy system z katalogu ma checkbox. Katalog jest jednym źródłem nazw,
więc dopisanie systemu bez odświeżenia UI zapali się tutaj."""
html = _render("_form_options.html", form={})
for value, name in HOUSE_SYSTEMS:
assert f'name="house_systems" value="{value}"' in html, f"brak checkboxa {value}"
assert name in html, f"brak nazwy {name}"
def test_form_marks_systems_with_a_domain_limit():
"""Placidus i Koch nie istnieją za kołem podbiegunowym — UI ma uprzedzać
ZANIM ktoś policzy horoskop dla Tromsø, nie dopiero w ostrzeżeniu wyniku."""
html = _render("_form_options.html", form={})
assert LIMITED, "katalog nie oznacza żadnego systemu jako ograniczonego"
for value, name in HOUSE_SYSTEMS:
starred = f"{name} *" in html
assert starred == (value in LIMITED), f"{value}: gwiazdka={starred}"
def test_handler_accepts_and_passes_house_systems(): def test_handler_accepts_and_passes_house_systems():
@@ -49,4 +80,77 @@ def test_comparison_table_shows_cusp_per_system():
def test_checkbox_state_survives_submit(): def test_checkbox_state_survives_submit():
"""Zaznaczone systemy zostają zaznaczone po przeliczeniu.""" """Zaznaczone systemy zostają zaznaczone po przeliczeniu."""
assert "in chosen" in OPTS and "form.house_systems" in OPTS html = _render("_form_options.html", form={"house_systems": ["koch", "campanus"]})
for value, _ in HOUSE_SYSTEMS:
marker = f'value="{value}" checked'
assert (marker in html) == (value in ("koch", "campanus")), value
def test_fallback_notice_is_shown_prominently():
"""Po fallbacku kosmogram wygląda bezbłędnie — ostrzeżenie jest jedynym
sygnałem, że podział domów jest z innego systemu."""
html = _render("_result_tables.html",
result={"house_warnings": ["UWAGA: system domów nie ma definicji"]})
assert "house-warning" in html
assert "UWAGA: system domów nie ma definicji" in html
def test_no_notice_when_nothing_was_substituted():
html = _render("_result_tables.html", result={"house_warnings": []})
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():
+16
View File
@@ -210,6 +210,22 @@ def build(report: dict, wheel_pdf: str | None = None,
parts.append(r"\end{tabular}") parts.append(r"\end{tabular}")
parts.append(r"\vspace{1em}") parts.append(r"\vspace{1em}")
# ── 2a. ostrzeżenia o systemie domów ────────────────────────────────
# Muszą stać PRZED rysunkami: kosmogram narysowany po fallbacku wygląda
# bezbłędnie, więc jedyną informacją, że podział domów jest z innego systemu,
# jest ten akapit. W ramce, żeby nie dało się go wziąć za podpis.
warnings = [w for w in (report.get("warnings") or []) if str(w).strip()]
if warnings:
parts.append(r"\begin{center}")
parts.append(r"\fbox{\begin{minipage}{0.92\textwidth}\small")
for i, w in enumerate(warnings):
if i:
parts.append(r"\par\medskip")
parts.append(esc(str(w)))
parts.append(r"\end{minipage}}")
parts.append(r"\end{center}")
parts.append(r"\vspace{1em}")
# ── 3. rysunki kosmogramu ─────────────────────────────────────────── # ── 3. rysunki kosmogramu ───────────────────────────────────────────
# Jedna reguła na wszystkie: keepaspectratio z limitem SZEROKOŚCI i WYSOKOŚCI. # Jedna reguła na wszystkie: keepaspectratio z limitem SZEROKOŚCI i WYSOKOŚCI.
# Koło i aspektarian są kwadratowe (ogranicza je wysokość), deklinacja i # Koło i aspektarian są kwadratowe (ogranicza je wysokość), deklinacja i
+21
View File
@@ -307,3 +307,24 @@ def test_full_ai_markdown_leaves_no_visible_markers():
"###Podsekcja bez spacji\n\n- punkt **ważny**\n- i ***kluczowy***\n\n" "###Podsekcja bez spacji\n\n- punkt **ważny**\n- i ***kluczowy***\n\n"
"#### Zamknięty ####") "#### Zamknięty ####")
assert _no_visible_markers(md(src)) assert _no_visible_markers(md(src))
def test_house_fallback_warning_lands_in_the_pdf():
"""Zasada projektu: co pokazujemy na stronie, trafia do raportu i PDF-a.
Po fallbacku systemu domów kosmogram wygląda bezbłędnie, więc ten akapit
jest JEDYNĄ informacją, że podział domów pochodzi z innego systemu, niż
zamówiono. Musi też stać PRZED rysunkami, nie po nich."""
tex = build({"person": "Jan Kowalski",
"data": {"date": "1984-04-30", "house_system": "porphyry"},
"warnings": ["UWAGA: system domów „placidus” nie ma definicji"]},
figures=[{"pdf": "wheel.pdf", "caption": "Kosmogram"}])
assert "fbox" in tex
assert "nie ma definicji" in tex
assert tex.index("nie ma definicji") < tex.index("wheel.pdf"), \
"ostrzeżenie musi stać przed rysunkami"
def test_no_warning_box_when_nothing_was_substituted():
tex = build({"person": "Jan Kowalski", "data": {}, "warnings": []})
assert "fbox" not in tex
+50 -10
View File
@@ -70,24 +70,64 @@ 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° |
**Topocentric (PolichPage) jest zaimplementowany, ale NIE wypuszczony** — nie ma go Etap 2 — systemy **łuku dobowego**, jedyne z realną granicą dziedziny:
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
oparta na łuku kwadrantu nie rozstrzyga wyboru gałęzi. Konstrukcja jest tam z natury
źle uwarunkowana — „biegun" `atan(tan(φ)·k/3)` dla φ = 89,9° wynosi już 89,7°.
Nie zawężamy dziedziny po to, żeby testy przeszły; system dołączy, gdy będzie
poprawny wszędzie.
Placidus i Koch (iteracyjne, z realną granicą dziedziny na kole podbiegunowym) — | System | Konstrukcja | Maks. odchylenie |
Etap 2. |---|---|---|
| placidus | trójdzielenie półłuków, iteracyjnie | 0,000001686° |
| koch | trójdzielenie CZASU od wschodu stopnia MC | 0,000000000° |
Placidus jest jedynym systemem bez wzoru zamkniętego: cusp jest zdefiniowany
warunkiem na samego siebie, więc szukamy go iteracyjnie do 1e-11°. Pozostałe
1,7e-6° to **próg zbieżności wyroczni**, nie nasz błąd — nasz cusp spełnia
definicję Placidusa z dokładnością 1e-12° (test `test_placidus_cusps_satisfy_
their_own_definition` sprawdza to bez swissepha).
Powyżej koła podbiegunowego oba **odmawiają liczenia** — i wyrocznia odmawia
dokładnie tych samych przypadków (5245 na 20 000 losowych, zero rozjazdów).
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
kosmogram wygląda bezbłędnie, więc jest to jedyny sygnał dla astrologa.
### Topocentric — i czego nauczył
Topocentric był początkowo **wycofany** z powodu rozjazdu przy |φ| ≈ 89,9°.
Ta diagnoza była **błędna**, bo opierała się wyłącznie na zestawie brzegowym,
który próbkuje tylko wybrane szerokości. Przemiał losowy pokazał prawdziwą skalę:
błędy zaczynały się już od ~70° i sięgały 50% przypadków blisko biegunów —
**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
zakres dat, nie poprawia niczego w obecnym.
## Licencja ## Licencja
+5
View File
@@ -17,6 +17,11 @@ import random
from dataclasses import dataclass from dataclasses import dataclass
# Zakres dat programu jako dni juliańskie (rok astronomiczny: 370 p.n.e. = -369). # Zakres dat programu jako dni juliańskie (rok astronomiczny: 370 p.n.e. = -369).
# Wyznacza go jądro efemeryd de421, którego używa silnik. Większe jądro (de441,
# ~13 000 lat) jest DECYZJĄ ODŁOŻONĄ — świadomie „nice to have", nie „must":
# rozszerza wyłącznie zakres dat, a nie poprawność czegokolwiek w obecnym
# zakresie. Gdy kiedyś dojdzie, tu jest jedno miejsce do zmiany — i wtedy
# wartości brzegowe (ε, koło podbiegunowe) trzeba przeliczyć dla nowych krańców.
JD_MIN = 1_566_839.5 # -369-01-01 JD_MIN = 1_566_839.5 # -369-01-01
JD_MAX = 2_695_000.5 # ~2270-12-31 JD_MAX = 2_695_000.5 # ~2270-12-31
JD_J2000 = 2_451_545.0 # punkt odniesienia — środek dziedziny JD_J2000 = 2_451_545.0 # punkt odniesienia — środek dziedziny
+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:
+2 -2
View File
@@ -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"] "regiomontanus", "campanus", "alcabitus", "topocentric", "placidus", "koch"]
def main() -> int: def main() -> int: