diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx index e8260d6..17f2259 100644 Binary files a/docs/astrololo_wymagania.xlsx and b/docs/astrololo_wymagania.xlsx differ diff --git a/services/logic/app/engine/houses.py b/services/logic/app/engine/houses.py index 5701f8e..52e728d 100644 --- a/services/logic/app/engine/houses.py +++ b/services/logic/app/engine/houses.py @@ -14,6 +14,8 @@ from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401 WHOLE_SIGN = "whole_sign" 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" # Systemy o ZAMKNIĘTYM wzorze (bez iteracji). Placidus i Koch wymagają rozwiązania # iteracyjnego i dochodzą osobno. @@ -28,8 +30,9 @@ KOCH = "koch" # Systemy WYPUSZCZONE — każdy zweryfikowany wobec Swiss Ephemeris # (tests/oracle). Placidus i Koch jako jedyne mają granicę dziedziny: # powyżej koła podbiegunowego nie istnieją i podlegają jawnemu fallbackowi. -SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY, VEHLOW, MORINUS, REGIOMONTANUS, - CAMPANUS, ALCABITUS, TOPOCENTRIC, PLACIDUS, KOCH) +SYSTEMS = (WHOLE_SIGN, WHOLE_SIGN_ARIES, EQUAL, EQUAL_MC, PORPHYRY, VEHLOW, + MORINUS, REGIOMONTANUS, CAMPANUS, ALCABITUS, TOPOCENTRIC, + PLACIDUS, KOCH) def mean_obliquity(tt_jd: float) -> float: @@ -458,6 +461,14 @@ def cusps_for(ramc: float, eps: float, lat: float, system: str) -> list[float]: mc = compute_mc(ramc, eps) if system in (WHOLE_SIGN, EQUAL, PORPHYRY): 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: # 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)] diff --git a/services/logic/tests/test_houses.py b/services/logic/tests/test_houses.py index 598ba79..cdfbd55 100644 --- a/services/logic/tests/test_houses.py +++ b/services/logic/tests/test_houses.py @@ -230,3 +230,22 @@ def test_topocentric_needs_no_domain_limit(): 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) diff --git a/services/presentation/app/chartwheel.py b/services/presentation/app/chartwheel.py index 6581267..5bd9cbd 100644 --- a/services/presentation/app/chartwheel.py +++ b/services/presentation/app/chartwheel.py @@ -114,8 +114,15 @@ def _aspect_pen(orb: float, allowed: float | None) -> tuple[float, float]: return 0.7 + 1.0 * frac, 0.35 + 0.5 * frac -def _pt(lon: float, asc: float, r: float) -> tuple[float, float]: - phi = math.radians(180.0 - (lon - asc)) +# Co stoi po lewej stronie koła (LOG-05: „warianty 0°Aries / fixed Asc"). +ASC_LEFT = "asc" +ARIES_LEFT = "aries" +ORIENTATIONS = ((ASC_LEFT, "Ascendent po lewej"), (ARIES_LEFT, "0° Barana po lewej")) + + +def _pt(lon: float, ref: float, r: float) -> tuple[float, float]: + """Punkt na kole dla długości `lon`, przy `ref` po lewej stronie rysunku.""" + phi = math.radians(180.0 - (lon - ref)) return _CX + r * math.cos(phi), _CY + r * math.sin(phi) @@ -206,12 +213,19 @@ def _delta(a: float, b: float) -> float: return abs(((a - b + 180.0) % 360.0) - 180.0) -def render(chart: dict, theme: str = "screen") -> str: +def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> str: """Zwraca SVG koła albo '' gdy brakuje danych (silnik bez osi/domów). theme='screen' — kolory ze zmiennych CSS aplikacji (dostraja się do motywu). theme='print' — konkretne kolory na białym tle; konieczne, gdy SVG trafia do samodzielnego konwertera (PDF), który zmiennych CSS nie rozwiąże. + + orientation — co stoi po LEWEJ stronie koła (LOG-05, warianty rysunku): + 'asc' — Ascendent (domyślne; tak rysuje większość szkół zachodnich, + dom I zawsze zaczyna się w tym samym miejscu rysunku), + 'aries' — 0° Barana (koło nieruchome względem zodiaku, więc dwa horoskopy + da się porównywać „na oko"; osie wypadają za to gdzie indziej). + Zmienia się WYŁĄCZNIE obrót rysunku — żadna liczba nie jest przeliczana. """ T = _THEMES.get(theme, _THEMES["screen"]) # tylko dla druku — na stronie font podaje CSS (.glyph) @@ -223,6 +237,9 @@ def render(chart: dict, theme: str = "screen") -> str: return "" asc = float(angles["Asc"]["decimal"]) + # Oś obrotu rysunku. Długości ekliptyczne zostają nietknięte — przesuwamy + # tylko punkt, który ląduje po lewej stronie koła. + ref = 0.0 if orientation == ARIES_LEFT else asc parts: list[str] = [] # tło i okręgi @@ -244,8 +261,8 @@ def render(chart: dict, theme: str = "screen") -> str: continue color = T["aspects"].get(asp.get("aspect"), T["muted"]) w, op = _aspect_pen(float(asp.get("orb", 0.0)), asp.get("allowed")) - ax1, ay1 = _pt(la, asc, R_HUB) - bx1, by1 = _pt(lb, asc, R_HUB) + ax1, ay1 = _pt(la, ref, R_HUB) + bx1, by1 = _pt(lb, ref, R_HUB) tip = (f'{asp.get("obj1")} {_ASPECT_PL.get(asp.get("aspect"), asp.get("aspect"))} ' f'{asp.get("obj2")} · orb {float(asp.get("orb", 0.0)):.2f}°') parts.append(_line(ax1, ay1, bx1, by1, color, w, op, title=tip)) @@ -253,17 +270,17 @@ def render(chart: dict, theme: str = "screen") -> str: # drobne podziałki co 5°, mocniejsze co 10° (na pasie znaków) for deg in range(0, 360, 5): r_in = R_TICK if deg % 10 else R_TICK - 4 - x1, y1 = _pt(deg, asc, R_ZOD) - x2, y2 = _pt(deg, asc, r_in) + x1, y1 = _pt(deg, ref, R_ZOD) + x2, y2 = _pt(deg, ref, r_in) parts.append(_line(x1, y1, x2, y2, T["line"], 0.6, 0.7)) # pas znaków: granice co 30° + glif znaku w środku sektora, kolorem żywiołu for i, sg in enumerate(signs): b = 30.0 * i - x1, y1 = _pt(b, asc, R_ZOD) - x2, y2 = _pt(b, asc, R_OUT) + x1, y1 = _pt(b, ref, R_ZOD) + x2, y2 = _pt(b, ref, R_OUT) parts.append(_line(x1, y1, x2, y2, T["line"], 1.0)) - gx, gy = _pt(b + 15.0, asc, R_SIGN) + gx, gy = _pt(b + 15.0, ref, R_SIGN) parts.append(_text(gx, gy, sg.get("glyph") or "", size=17, fill=T["elements"][i % 4], cls="glyph", font=glyph_font)) @@ -271,31 +288,31 @@ def render(chart: dict, theme: str = "screen") -> str: n = len(cusps) for i, c in enumerate(cusps): lon = float(c["decimal"]) - x1, y1 = _pt(lon, asc, R_ZOD) - x2, y2 = _pt(lon, asc, R_HUB) + x1, y1 = _pt(lon, ref, R_ZOD) + x2, y2 = _pt(lon, ref, R_HUB) parts.append(_line(x1, y1, x2, y2, T["line"], 0.8, 0.85)) nxt = float(cusps[(i + 1) % n]["decimal"]) span = (nxt - lon) % 360.0 or 360.0 mid = lon + span / 2.0 - hx, hy = _pt(mid, asc, R_HNUM) + hx, hy = _pt(mid, ref, R_HNUM) parts.append(_text(hx, hy, str(c["house"]), size=11, fill=T["muted"])) # Stopień cuspu tuż przy szprysze, na pasie. W whole sign wszystkie cuspy # są na 0° znaku — wtedy pomijamy, żeby nie kłaść dwunastu zbędnych zer; # w systemach kwadratowych ta liczba realnie coś mówi. deg_in_sign = lon % 30.0 if deg_in_sign >= 0.5: - cx1, cy1 = _pt(lon, asc, R_ZOD - 9) + cx1, cy1 = _pt(lon, ref, R_ZOD - 9) parts.append(_text(cx1, cy1, str(int(deg_in_sign)), size=6.5, fill=T["muted"])) # osie: Asc–Dsc i MC–IC przez całe koło, wyróżnione akcentem for a, b, la, lb in (("Asc", "Dsc", "AC", "DC"), ("MC", "IC", "MC", "IC")): lon_a = float(angles[a]["decimal"]) - ax, ay = _pt(lon_a, asc, R_ZOD) - bx, by = _pt(lon_a + 180.0, asc, R_ZOD) + ax, ay = _pt(lon_a, ref, R_ZOD) + bx, by = _pt(lon_a + 180.0, ref, R_ZOD) parts.append(_line(ax, ay, bx, by, T["accent"], 1.6, 0.9)) # etykiety POZA kołem — w środku kolidowały z glifami obiektów - lax, lay = _pt(lon_a, asc, R_AXIS_LABEL) - lbx, lby = _pt(lon_a + 180.0, asc, R_AXIS_LABEL) + lax, lay = _pt(lon_a, ref, R_AXIS_LABEL) + lbx, lby = _pt(lon_a + 180.0, ref, R_AXIS_LABEL) parts.append(_text(lax, lay, la, size=10, fill=T["accent"])) parts.append(_text(lbx, lby, lb, size=10, fill=T["accent"])) @@ -310,15 +327,15 @@ def render(chart: dict, theme: str = "screen") -> str: for obj, true_lon, draw_lon in zip(objects, true_lons, draw_lons): retro = obj.get("direction") == "Rx" - mx1, my1 = _pt(true_lon, asc, R_ZOD) - mx2, my2 = _pt(true_lon, asc, R_MARK) + mx1, my1 = _pt(true_lon, ref, R_ZOD) + mx2, my2 = _pt(true_lon, ref, R_MARK) parts.append(_line(mx1, my1, mx2, my2, T["ink"], 1.0, 0.75)) if _delta(draw_lon, true_lon) > 0.4: - lx, ly = _pt(draw_lon, asc, R_PLANET + 12) + lx, ly = _pt(draw_lon, ref, R_PLANET + 12) parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9)) - gx, gy = _pt(draw_lon, asc, R_PLANET) + gx, gy = _pt(draw_lon, ref, R_PLANET) tip = obj.get("name") or "" if obj.get("in_sign"): tip += f" · {obj['in_sign']}" @@ -332,7 +349,7 @@ def render(chart: dict, theme: str = "screen") -> str: # Sam stopień w znaku, bez „°" — tak robią programy astrologiczne i tylko # tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz # kolor, żeby dało się ją wyłapać nie czytając znaku po znaku. - dx, dy = _pt(draw_lon, asc, R_DEG) + dx, dy = _pt(draw_lon, ref, R_DEG) label = f"{int(true_lon % 30)}" + ("℞" if retro else "") parts.append(_text(dx, dy, label, size=8, fill=T["retro"] if retro else T["muted"])) @@ -346,10 +363,10 @@ def render(chart: dict, theme: str = "screen") -> str: if not glyph or lot.get("longitude") is None: continue lon = float(lot["longitude"]) - mx1, my1 = _pt(lon, asc, R_ZOD) - mx2, my2 = _pt(lon, asc, R_MARK) + mx1, my1 = _pt(lon, ref, R_ZOD) + mx2, my2 = _pt(lon, ref, R_MARK) parts.append(_line(mx1, my1, mx2, my2, T["accent"], 1.0, 0.7)) - lx, ly = _pt(lon, asc, R_LOT) + lx, ly = _pt(lon, ref, R_LOT) tip = lot.get("name") or "" if lot.get("in_sign"): tip += f" · {lot['in_sign']}" @@ -367,7 +384,6 @@ def render(chart: dict, theme: str = "screen") -> str: # ── aspektarian (PRE-18) ────────────────────────────────────────────────── ASP_CELL = 30.0 # bok komórki siatki aspektów - def render_aspectarian(chart: dict, theme: str = "screen") -> str: """Aspektarian (PRE-18) — trójkątna siatka aspektów obiekt×obiekt jako SVG. diff --git a/services/presentation/app/house_systems.py b/services/presentation/app/house_systems.py index 0683a21..3475948 100644 --- a/services/presentation/app/house_systems.py +++ b/services/presentation/app/house_systems.py @@ -15,8 +15,10 @@ from __future__ import annotations HOUSE_SYSTEMS: list[tuple[str, str]] = [ ("placidus", "Placidus"), ("whole_sign", "Whole Sign"), + ("whole_sign_aries", "Whole Sign (od Barana)"), ("koch", "Koch"), - ("equal", "Equal"), + ("equal", "Equal (od Asc)"), + ("equal_mc", "Equal (od MC)"), ("porphyry", "Porphyry"), ("regiomontanus", "Regiomontanus"), ("campanus", "Campanus"), diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index 5021ec1..95a8280 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -22,6 +22,7 @@ from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from app import chartwheel as chartwheel_mod from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label from app import geocode, security @@ -60,6 +61,7 @@ templates.env.globals["static"] = static_url templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS templates.env.globals["house_label"] = house_label templates.env.globals["HOUSE_LIMITED"] = HOUSE_LIMITED +templates.env.globals["WHEEL_ORIENTATIONS"] = chartwheel_mod.ORIENTATIONS def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]: @@ -130,12 +132,14 @@ def chart_compute( stations: bool = Form(False), zodiac: str = Form("tropical"), tables: bool = Form(False), + wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT), ): form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon, "house_system": house_system, "house_systems": house_systems, "aspect_orb": aspect_orb, "aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor, - "stations": stations, "zodiac": zodiac, "tables": tables} + "stations": stations, "zodiac": zodiac, "tables": tables, + "wheel_orientation": wheel_orientation} ctx: dict = {"form": form, "result": None, "error": None, "moment": None} try: iso_utc, label = _build_utc(date, time, tz_offset) @@ -147,12 +151,8 @@ def chart_compute( aspect_minor=aspect_minor, stations=stations, zodiac=zodiac, tables=tables, ) - # Fallback systemu domów musi dojechać do PDF-a — z samego kosmogramu - # nie da się poznać, że podział jest z innego systemu, niż zamówiono. - warnings = [w for w in (chart.get("house_warnings") or []) if w] - from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera - ctx["wheel_svg"] = chartwheel.render(ctx["result"]) + ctx["wheel_svg"] = chartwheel.render(ctx["result"], orientation=wheel_orientation) ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18 ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6) ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6) @@ -189,6 +189,7 @@ def compile_build( stations: bool = Form(False), # LOG-03 — stacje też w podsumowaniu zodiac: str = Form("tropical"), tables: bool = Form(False), # LOG-23 — żywioły/faza/godziny w podsumowaniu + wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT), # LOG-05 — obrót koła ): """Składa raport: horoskop liczymy TU NA NOWO, a części od AI (interpretacja natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23). @@ -203,7 +204,8 @@ def compile_build( "lat": lat, "lon": lon, "house_system": house_system, "house_systems": house_systems, "aspect_orb": aspect_orb, "aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor, - "stations": stations, "zodiac": zodiac, "tables": tables} + "stations": stations, "zodiac": zodiac, "tables": tables, + "wheel_orientation": wheel_orientation} ctx: dict = {"form": form, "result": None, "error": None, "moment": None} try: iso_utc, label = _build_utc(date, time, tz_offset) @@ -216,7 +218,7 @@ def compile_build( stations=stations, zodiac=zodiac, tables=tables, ) from app import chartwheel - ctx["wheel_svg"] = chartwheel.render(ctx["result"]) + ctx["wheel_svg"] = chartwheel.render(ctx["result"], orientation=wheel_orientation) ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18 ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6) ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6) @@ -265,13 +267,19 @@ def compile_pdf(payload: dict): stations=bool(data.get("stations")), tables=bool(data.get("tables")), zodiac=str(data.get("zodiac") or "tropical"), ) + # Fallback systemu domów musi dojechać do PDF-a — z samego kosmogramu + # nie da się poznać, że podział jest z innego systemu, niż zamówiono. + warnings = [w for w in (chart.get("house_warnings") or []) if w] + from app import chartwheel # Zasada: co pokazujemy na stronie, ma trafić do PDF-a. Wszystkie rysunki # w motywie DRUKU — samodzielny konwerter SVG→PDF nie zna arkusza, więc # zmienne CSS i font glifów muszą być wprost w rysunku. for svg, caption in ( - (chartwheel.render(chart, theme="print"), "Kosmogram"), + (chartwheel.render(chart, theme="print", + orientation=str(data.get("wheel_orientation") or chartwheel_mod.ASC_LEFT)), + "Kosmogram"), (chartwheel.render_aspectarian(chart, theme="print"), "Aspektarian — siatka aspektów"), (chartwheel.render_declination(chart, theme="print"), "Wykres deklinacji"), (chartwheel.render_antiscia(chart, theme="print"), "Oś antyscji"), diff --git a/services/presentation/app/templates/_form_options.html b/services/presentation/app/templates/_form_options.html index 9ade456..c49d8ce 100644 --- a/services/presentation/app/templates/_form_options.html +++ b/services/presentation/app/templates/_form_options.html @@ -14,6 +14,13 @@ {% endfor %} +