From 7266f671a4fb1d5303e6b4124c68bae056527994 Mon Sep 17 00:00:00 2001 From: migatu Date: Wed, 5 Aug 2026 22:04:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(domy):=20Placidus=20i=20Koch=20+=20jawny?= =?UTF-8?q?=20fallback=20poza=20ko=C5=82em=20podbiegunowym?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- services/logic/app/engine/chart.py | 25 ++- services/logic/app/engine/houses.py | 159 +++++++++++++++++- services/logic/app/prompt.py | 5 + services/logic/tests/test_chart.py | 8 +- services/logic/tests/test_houses.py | 70 ++++++++ services/presentation/app/house_systems.py | 36 ++++ services/presentation/app/main.py | 12 ++ services/presentation/app/static/styles.css | 12 ++ .../app/templates/_form_options.html | 6 +- .../app/templates/_result_tables.html | 13 +- .../presentation/app/templates/chart.html | 13 +- .../presentation/app/templates/compile.html | 7 +- .../presentation/tests/test_house_compare.py | 56 +++++- services/render/app/latex.py | 16 ++ services/render/tests/test_latex.py | 21 +++ tests/oracle/README.md | 23 ++- tests/oracle/domain.py | 5 + tests/oracle/run.py | 2 +- 18 files changed, 457 insertions(+), 32 deletions(-) create mode 100644 services/presentation/app/house_systems.py diff --git a/services/logic/app/engine/chart.py b/services/logic/app/engine/chart.py index c5c4b39..c3b9fa2 100644 --- a/services/logic/app/engine/chart.py +++ b/services/logic/app/engine/chart.py @@ -97,9 +97,11 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str asc = H.compute_asc(ramc, eps, moment.lat) mc = H.compute_mc(ramc, eps) system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN - # cusps_for, nie cusps: to drugie zna tylko trzy systemy dzielące ekliptykę. - # Systemy egzotyczne dzielą inne koła wielkie i potrzebują pełnego (RAMC, ε, φ). - cusp_list = H.cusps_for(ramc, eps, moment.lat, 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]: """Cuspy → wiersze pod UI/kosmogram: znak, stopień w znaku, długość, glif.""" @@ -111,7 +113,11 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str 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"] = { "Asc": _fmt("Asc", asc, off), "MC": _fmt("MC", mc, off), @@ -134,10 +140,15 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str if len(ordered) > 1: result["house_systems"] = [] for s in ordered: - cl = cusp_list if s == system else H.cusps_for(ramc, eps, moment.lat, s) - result["house_systems"].append({"system": s, "cusps": _cusps_out(cl)}) + cs = primary if s == system else H.cusps_detailed(ramc, eps, moment.lat, s) + 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): - 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) from app.engine.firdaria import is_day_birth diff --git a/services/logic/app/engine/houses.py b/services/logic/app/engine/houses.py index 7a96280..653b6fa 100644 --- a/services/logic/app/engine/houses.py +++ b/services/logic/app/engine/houses.py @@ -8,6 +8,7 @@ silnik dostarcza tylko RAMC i ε. from __future__ import annotations import math +from dataclasses import dataclass from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401 @@ -22,6 +23,8 @@ REGIOMONTANUS = "regiomontanus" CAMPANUS = "campanus" ALCABITUS = "alcabitus" TOPOCENTRIC = "topocentric" +PLACIDUS = "placidus" +KOCH = "koch" # Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris co do zera # (tests/oracle). TOPOCENTRIC celowo POZA listą: implementacja jest zgodna # z wyrocznią wszędzie poza otoczeniem bieguna (|φ| ≈ 89,9° przy RAMC 90°/270°), @@ -29,7 +32,7 @@ TOPOCENTRIC = "topocentric" # atan(tan(89,9°)/3) to już 89,7°. Nie zawężamy dziedziny wyłącznie po to, by # testy przeszły; system dołączy, gdy będzie poprawny na całej dziedzinie. SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY, VEHLOW, MORINUS, - REGIOMONTANUS, CAMPANUS, ALCABITUS) + REGIOMONTANUS, CAMPANUS, ALCABITUS, PLACIDUS, KOCH) def mean_obliquity(tt_jd: float) -> float: @@ -331,6 +334,111 @@ def _cusps_topocentric(ramc: float, eps: float, lat: float, 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]: """Kanoniczne wejście: (RAMC, ε, φ) → 12 cusps. @@ -356,9 +464,58 @@ def cusps_for(ramc: float, eps: float, lat: float, system: str) -> list[float]: return _cusps_alcabitus(ramc, eps, asc) if system == TOPOCENTRIC: 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}") +# ── 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: """Numer domu (1..12), w którym leży dana długość ekliptyczna.""" lon = norm360(lon) diff --git a/services/logic/app/prompt.py b/services/logic/app/prompt.py index c77fd58..266b67d 100644 --- a/services/logic/app/prompt.py +++ b/services/logic/app/prompt.py @@ -200,6 +200,11 @@ def _chart_section(chart: dict, moment_label: str | None) -> str: if 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"): lines.append("\n## Pozycje") for p in chart["positions"]: diff --git a/services/logic/tests/test_chart.py b/services/logic/tests/test_chart.py index 9d30755..aa420ea 100644 --- a/services/logic/tests/test_chart.py +++ b/services/logic/tests/test_chart.py @@ -64,10 +64,12 @@ def test_primary_always_first_even_if_not_listed(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 - po prostu pomijany, nie wywala horoskopu.""" + """Nieznana nazwa systemu jest po prostu pomijana, 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", - house_systems=["placidus", "equal"]) + house_systems=["nie-ma-takiego-systemu", "equal"]) assert [h["system"] for h in chart["house_systems"]] == ["whole_sign", "equal"] diff --git a/services/logic/tests/test_houses.py b/services/logic/tests/test_houses.py index 34a28b0..e7b8347 100644 --- a/services/logic/tests/test_houses.py +++ b/services/logic/tests/test_houses.py @@ -1,4 +1,6 @@ """Testy osi i domów — czysta matematyka (LOG-05, bez efemeryd).""" +import math + import pytest from app.engine import houses as H @@ -126,3 +128,71 @@ def test_morinus_ignores_latitude(): def test_unknown_system_is_rejected(): with pytest.raises(ValueError): 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 diff --git a/services/presentation/app/house_systems.py b/services/presentation/app/house_systems.py new file mode 100644 index 0000000..8deafd1 --- /dev/null +++ b/services/presentation/app/house_systems.py @@ -0,0 +1,36 @@ +"""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"), + ("koch", "Koch"), + ("equal", "Equal"), + ("porphyry", "Porphyry"), + ("regiomontanus", "Regiomontanus"), + ("campanus", "Campanus"), + ("alcabitus", "Alcabitus"), + ("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) diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index c82cb8e..5021ec1 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -22,6 +22,8 @@ from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label + from app import geocode, security from app.clients.logic_client import LogicClient from app.config import DEFAULT_LOCATION_LABEL, default_form @@ -54,6 +56,10 @@ def static_url(name: str) -> str: 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 def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]: @@ -141,6 +147,10 @@ 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["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18 @@ -242,6 +252,7 @@ def compile_pdf(payload: dict): return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422) figures: list[dict] = [] + warnings: list[str] = [] try: chart = logic.positions( when_utc_iso=iso_utc, @@ -275,6 +286,7 @@ def compile_pdf(payload: dict): "person": payload.get("person") or "", "data": {**data, "moment_utc": label}, "figures": figures, + "warnings": warnings, "natal": payload.get("natal") or {}, "predictions": payload.get("predictions") or [], } diff --git a/services/presentation/app/static/styles.css b/services/presentation/app/static/styles.css index 795d017..e2ff58d 100644 --- a/services/presentation/app/static/styles.css +++ b/services/presentation/app/static/styles.css @@ -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 input[type=number] { width: 5rem; } .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; } .empty { color: var(--muted); } table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; } diff --git a/services/presentation/app/templates/_form_options.html b/services/presentation/app/templates/_form_options.html index 5efd8d8..9ade456 100644 --- a/services/presentation/app/templates/_form_options.html +++ b/services/presentation/app/templates/_form_options.html @@ -10,9 +10,9 @@
{% set chosen = form.house_systems or [] %} Porównaj systemy domów: - - - + {% for value, name in HOUSE_SYSTEMS %} + + {% endfor %}
diff --git a/services/presentation/app/templates/_result_tables.html b/services/presentation/app/templates/_result_tables.html index 738c73e..3b6143a 100644 --- a/services/presentation/app/templates/_result_tables.html +++ b/services/presentation/app/templates/_result_tables.html @@ -6,6 +6,14 @@ deklinacja/antyscja) renderuje rodzic; tu są same tabele. Każda sekcja pokazuje 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 %} +

{{ w }}

+{% endfor %} +{% endif %} + {% if result.angles %} @@ -20,10 +28,9 @@ {# 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 %} -{% set HSN = {'whole_sign': 'Whole Sign', 'equal': 'Equal', 'porphyry': 'Porphyry'} %}
Porównanie systemów domów ({{ result.house_systems | length }})
ZnakW znaku
- {% for hs in result.house_systems %}{% endfor %} + {% for hs in result.house_systems %}{% endfor %} {% for i in range(12) %} @@ -219,7 +226,7 @@ {% if result.cusps %}
- Cusps domów ({{ result.house_system }}) + Cusps domów ({{ house_label(result.house_system) }})
Dom{{ HSN.get(hs.system, hs.system) }}
Dom{{ house_label(hs.system) }}
{{ i + 1 }}
diff --git a/services/presentation/app/templates/chart.html b/services/presentation/app/templates/chart.html index 563604c..620bfbb 100644 --- a/services/presentation/app/templates/chart.html +++ b/services/presentation/app/templates/chart.html @@ -34,10 +34,11 @@
DomZnakCusp