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 %}
+
@@ -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 }})
-
Dom
{% for hs in result.house_systems %}
{{ HSN.get(hs.system, hs.system) }}
{% endfor %}
+
Dom
{% for hs in result.house_systems %}
{{ house_label(hs.system) }}
{% endfor %}
{% for i in range(12) %}
{{ i + 1 }}
@@ -219,7 +226,7 @@
{% if result.cusps %}
- Cusps domów ({{ result.house_system }})
+ Cusps domów ({{ house_label(result.house_system) }})