feat: konfigurowalne aspekty i orby (PRE-06)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m47s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m27s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 12s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 9s
build / build (push) Successful in 26s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m30s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 12s
Testy / Kontrola składni wszystkich warstw (push) Successful in 8s

Dotąd orb (8°) i aspekty były zaszyte w silniku. Astrolodzy pracują na różnych
orbach i lubią dokładać aspekty poboczne — teraz da się to ustawić w UI.

Silnik (aspects.py): `find_aspects` przyjmuje orb, luminary_bonus i `minor`.
Aspekty poboczne = tylko trzy (półsekstyl 30° / półkwadratura 45° / kwinkunks
150°) — bo mają już glify i barwy w prezentacji i w engine/glyphs.py, więc
dokładają się bez ruszania czegokolwiek poza silnikiem. Bazy zwykle ich nie
opisują (brak tokenu), więc trafiają na kosmogram i do tabeli, ale NIE tworzą
faset — most sygnifikatorów po cichu je pomija (skip przy braku tokenu).

Plumbing przez wszystkie warstwy: request logiki, build_chart, klient prezentacji,
oba handlery (/, /compile) ORAZ PDF (/compile/pdf). Ustawienia wędrują między
zakładkami (formsync) i lecą do PDF-a (compile.js) — żeby podsumowanie liczyło
aspekty tym samym orbem co horoskop (ta sama zasada co fix podsumowania #46).
UI: orb, bonus dla świateł, checkbox „aspekty poboczne" na obu formularzach.

Weryfikacja na żywym /chart/positions: domyślnie 24 aspekty (główne); minor → 52
(dochodzą quincunx/semisextile/semisquare); orb 3 → 13, orb 12 → 32. Testy:
logika +3 (minor/orb/bonus), prezentacja +6 (obecność pól, przekazanie przez
warstwy, sync, PDF). Logika 273, prezentacja 220.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #49.
This commit is contained in:
2026-08-03 13:16:32 +02:00
parent a0d1135db1
commit b36b3bee19
11 changed files with 146 additions and 12 deletions
+17 -4
View File
@@ -15,6 +15,16 @@ MAJOR = {
"trine": 120.0,
"opposition": 180.0,
}
# Aspekty POBOCZNE (opcjonalne, PRE-06). Tylko te trzy — bo mają już glify i barwy
# w warstwie prezentacji (chartwheel) oraz w engine/glyphs.py, więc dokładają się
# bez ruszania czegokolwiek poza silnikiem. Bazy interpretacyjne zwykle ich nie
# opisują (brak tokenu w DB_TOKEN), więc trafiają na kosmogram i do tabeli, ale
# NIE generują faset sygnifikatorów — most po cichu je pomija (significators.py).
MINOR = {
"semisextile": 30.0,
"semisquare": 45.0,
"quincunx": 150.0,
}
DB_TOKEN = {
"conjunction": "[conj", "sextile": "[sex", "square": "[sq",
"trine": "[tri", "opposition": "[opp",
@@ -62,15 +72,18 @@ def _is_applying(la: float, lb: float, sa: float, sb: float, angle: float, dt: f
def find_aspects(
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS,
minor: bool = False,
) -> list[dict]:
"""positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę).
Zwraca listę aspektów głównych; gdy znane są prędkości, każdy aspekt ma
applying (bool) i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny).
Zwraca listę aspektów; gdy znane są prędkości, każdy aspekt ma applying (bool)
i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny). Orb i bonus dla świateł są
KONFIGUROWALNE (PRE-06); `minor=True` dokłada aspekty poboczne (30/45/150°).
Pary z RIGID_PAIRS (np. NN/SN) są pomijane — ich kąt jest definicyjny.
"""
checks = {**MAJOR, **MINOR} if minor else MAJOR
out: list[dict] = []
n = len(positions)
for i in range(n):
@@ -83,7 +96,7 @@ def find_aspects(
continue
sep = separation(float(la), float(lb))
allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0)
for asp, angle in MAJOR.items():
for asp, angle in checks.items():
dev = abs(sep - angle)
if dev <= allowed:
applying = _is_applying(
+6 -2
View File
@@ -36,7 +36,9 @@ def _shift_pos(pdict: dict, off: float) -> None:
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
lots_method: str = "degree", zodiac: str = Z.TROPICAL,
house_systems: list[str] | None = None) -> dict:
house_systems: list[str] | None = None,
aspect_orb: float = 8.0, aspect_luminary_bonus: float = 2.0,
aspect_minor: bool = False) -> dict:
from app.engine import glyphs as GL
from app.engine.aspects import find_aspects
from app.engine.houses import mean_obliquity
@@ -50,7 +52,9 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
positions = engine.positions(moment)
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
# aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
result["aspects"] = find_aspects( # aspekty (LOG-06), konfigurowalne (PRE-06)
result["positions"], orb=aspect_orb,
luminary_bonus=aspect_luminary_bonus, minor=aspect_minor)
# Aspekty pozazodiakalne (LOG-07): deklinacja i antyscja liczone na
# współrzędnych TROPIKALNYCH of-date — deklinacja jest fizyczna (równikowa),
+7 -1
View File
@@ -45,6 +45,9 @@ class PositionsRequest(BaseModel):
objects: list[str] | None = None
house_system: str = "whole_sign" # whole_sign | equal | porphyry (PRYMARNY — pod kosmogram)
house_systems: list[str] | None = None # PRE-05: dodatkowe systemy do porównania obok
aspect_orb: float = 8.0 # PRE-06: orb aspektów (konfigurowalny)
aspect_luminary_bonus: float = 2.0 # PRE-06: dodatkowy orb dla Słońca/Księżyca
aspect_minor: bool = False # PRE-06: dołóż aspekty poboczne (30/45/150°)
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
tables: bool = False # tabele dodatkowe (LOG-23; szuka wschodu/zachodu)
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
@@ -69,7 +72,10 @@ def chart_positions(req: PositionsRequest) -> dict:
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
try:
chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac,
house_systems=req.house_systems)
house_systems=req.house_systems,
aspect_orb=req.aspect_orb,
aspect_luminary_bonus=req.aspect_luminary_bonus,
aspect_minor=req.aspect_minor)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
if req.stations:
+25
View File
@@ -2,6 +2,31 @@
from app.engine.aspects import RIGID_PAIRS, find_aspects, separation
# ── konfigurowalne aspekty i orby (PRE-06) ──
def test_minor_aspects_only_with_flag():
"""Domyślnie tylko główne; `minor=True` dokłada poboczne (30/45/150°)."""
pos = [{"name": "A", "decimal": 10.0}, {"name": "B", "decimal": 40.0}] # 30° = półsekstyl
assert find_aspects(pos) == [] # główne: brak
assert [a["aspect"] for a in find_aspects(pos, minor=True)] == ["semisextile"]
def test_orb_is_configurable():
"""Węższy orb odsiewa aspekt, który szerszy łapie."""
pos = [{"name": "A", "decimal": 10.0}, {"name": "B", "decimal": 78.0}] # 68° = sextile dev 8°
assert find_aspects(pos, orb=5.0) == []
assert [a["aspect"] for a in find_aspects(pos, orb=10.0)] == ["sextile"]
def test_luminary_bonus_widens_orb_for_lights():
"""Bonus dolicza orb tylko gdy w parze jest Słońce/Księżyc."""
lights = [{"name": "Sun", "decimal": 10.0}, {"name": "Mars", "decimal": 79.0}] # dev 9° od sextile
assert find_aspects(lights, orb=8.0, luminary_bonus=0.0) == [] # 9 > 8
assert [a["aspect"] for a in find_aspects(lights, orb=8.0, luminary_bonus=2.0)] == ["sextile"] # 9 < 10
plain = [{"name": "Venus", "decimal": 10.0}, {"name": "Mars", "decimal": 79.0}]
assert find_aspects(plain, orb=8.0, luminary_bonus=2.0) == [] # brak światła → bez bonusu
def test_separation_wraparound():
assert separation(10, 350) == 20
assert separation(0, 180) == 180
@@ -55,6 +55,9 @@ class LogicClient:
zodiac: str = "tropical",
tables: bool = False,
house_systems: list[str] | None = None,
aspect_orb: float = 8.0,
aspect_luminary_bonus: float = 2.0,
aspect_minor: bool = False,
) -> dict[str, Any]:
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
payload = {
@@ -64,6 +67,9 @@ class LogicClient:
"objects": objects,
"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,
+19 -4
View File
@@ -106,14 +106,18 @@ def chart_compute(
lon: float = Form(0.0),
house_system: str = Form("whole_sign"),
house_systems: list[str] = Form([]), # PRE-05: dodatkowe systemy do porównania
aspect_orb: float = Form(8.0), # PRE-06: konfigurowalny orb aspektów
aspect_luminary_bonus: float = Form(2.0),
aspect_minor: bool = Form(False),
stations: bool = Form(False),
zodiac: str = Form("tropical"),
tables: bool = Form(False),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system,
"house_systems": house_systems, "stations": stations,
"zodiac": zodiac, "tables": tables}
"house_systems": house_systems, "aspect_orb": aspect_orb,
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
"stations": stations, "zodiac": zodiac, "tables": tables}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
@@ -121,6 +125,8 @@ def chart_compute(
ctx["result"] = logic.positions(
when_utc_iso=iso_utc, 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,
)
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
@@ -155,6 +161,9 @@ def compile_build(
lon: float = Form(0.0),
house_system: str = Form("whole_sign"),
house_systems: list[str] = Form([]), # PRE-05 — porównanie domów w podsumowaniu
aspect_orb: float = Form(8.0), # PRE-06 — te same ustawienia aspektów
aspect_luminary_bonus: float = Form(2.0),
aspect_minor: bool = Form(False),
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
@@ -170,8 +179,9 @@ def compile_build(
"""
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system,
"house_systems": house_systems, "stations": stations,
"zodiac": zodiac, "tables": tables}
"house_systems": house_systems, "aspect_orb": aspect_orb,
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
"stations": stations, "zodiac": zodiac, "tables": tables}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
@@ -179,6 +189,8 @@ def compile_build(
ctx["result"] = logic.positions(
when_utc_iso=iso_utc, 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,
)
from app import chartwheel
@@ -224,6 +236,9 @@ def compile_pdf(payload: dict):
lat=float(data.get("lat") or 0.0), lon=float(data.get("lon") or 0.0),
house_system=str(data.get("house_system") or "whole_sign"),
house_systems=[s for s in (data.get("house_systems") or []) if s],
aspect_orb=float(data.get("aspect_orb") or 8.0),
aspect_luminary_bonus=float(data.get("aspect_luminary_bonus") or 2.0),
aspect_minor=bool(data.get("aspect_minor")),
stations=bool(data.get("stations")), tables=bool(data.get("tables")),
zodiac=str(data.get("zodiac") or "tropical"),
)
@@ -109,6 +109,8 @@
lat: v('lat'), lon: v('lon'),
house_system: v('house_system'), zodiac: v('zodiac'),
house_systems: multi('house_systems'),
aspect_orb: v('aspect_orb'), aspect_luminary_bonus: v('aspect_luminary_bonus'),
aspect_minor: checked('aspect_minor'),
stations: checked('stations'), tables: checked('tables'),
place: (document.querySelector('#geoSearch') || {}).value || ''
};
+4 -1
View File
@@ -36,7 +36,10 @@
{ key: 'place', sel: '#geoSearch' },
{ key: 'stations', sel: 'input[name=stations]', type: 'check' },
{ key: 'tables', sel: 'input[name=tables]', type: 'check' },
{ key: 'house_systems', name: 'house_systems', type: 'multi' }
{ key: 'house_systems', name: 'house_systems', type: 'multi' },
{ key: 'aspect_orb', sel: 'input[name=aspect_orb]' },
{ key: 'aspect_luminary_bonus', sel: 'input[name=aspect_luminary_bonus]' },
{ key: 'aspect_minor', sel: 'input[name=aspect_minor]', type: 'check' }
];
function elementsOf(f) {
@@ -64,6 +64,11 @@
<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="porphyry" {{ 'checked' if 'porphyry' in chosen else '' }}> Porphyry</label>
</div>
<div class="opts" title="Orb = dopuszczalne odchylenie od dokładnego kąta aspektu. „Bonus świateł" powiększa orb dla aspektów ze Słońcem/Księżycem. Aspekty poboczne: półsekstyl 30°, półkwadratura 45°, kwinkunks 150° (PRE-06).">
<label>Orb aspektów (°) <input type="number" name="aspect_orb" step="0.5" min="1" max="15" value="{{ form.aspect_orb if form.aspect_orb is not none else 8 }}"></label>
<label>Bonus świateł (°) <input type="number" name="aspect_luminary_bonus" step="0.5" min="0" max="5" value="{{ form.aspect_luminary_bonus if form.aspect_luminary_bonus is not none else 2 }}"></label>
<label><input type="checkbox" name="aspect_minor" value="true" {{ 'checked' if form.aspect_minor else '' }}> aspekty poboczne (30/45/150°)</label>
</div>
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
<div class="actions">
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
@@ -70,6 +70,11 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
<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="porphyry" {{ 'checked' if 'porphyry' in chosen else '' }}> Porphyry</label>
</div>
<div class="opts" title="Te same ustawienia aspektów co przy horoskopie (PRE-06). Aspekty poboczne: półsekstyl 30°, półkwadratura 45°, kwinkunks 150°.">
<label>Orb aspektów (°) <input type="number" name="aspect_orb" step="0.5" min="1" max="15" value="{{ form.aspect_orb if form.aspect_orb is not none else 8 }}"></label>
<label>Bonus świateł (°) <input type="number" name="aspect_luminary_bonus" step="0.5" min="0" max="5" value="{{ form.aspect_luminary_bonus if form.aspect_luminary_bonus is not none else 2 }}"></label>
<label><input type="checkbox" name="aspect_minor" value="true" {{ 'checked' if form.aspect_minor else '' }}> aspekty poboczne (30/45/150°)</label>
</div>
<div class="actions">
<button type="submit">Złóż podsumowanie</button>
<button type="button" id="pdfBtn" class="ghost"
@@ -0,0 +1,50 @@
"""Konfigurowalne aspekty i orby w UI (PRE-06).
Ustawienia muszą: (1) być na obu formularzach (Horoskop i Skompiluj), (2) wędrować
między zakładkami (formsync), (3) trafiać do logiki na ekran i do PDF-a inaczej
podsumowanie liczyłoby aspekty innym orbem niż horoskop (ta sama klasa błędu co
regresja podsumowania).
"""
import pathlib
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
CHART = (APP / "templates" / "chart.html").read_text(encoding="utf-8")
COMPILE = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
MAIN = (APP / "main.py").read_text(encoding="utf-8")
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
SYNC = (APP / "static" / "formsync.js").read_text(encoding="utf-8")
COMPILE_JS = (APP / "static" / "compile.js").read_text(encoding="utf-8")
FIELDS = ("aspect_orb", "aspect_luminary_bonus", "aspect_minor")
def test_both_forms_have_aspect_settings():
for f in FIELDS:
assert f'name="{f}"' in CHART, f"Horoskop: brak {f}"
assert f'name="{f}"' in COMPILE, f"Skompiluj: brak {f}"
def test_handlers_accept_and_pass_aspect_settings():
# oba wywołania logic.positions (chart + compile) dostają orb
assert MAIN.count("aspect_orb=aspect_orb") == 2
assert "aspect_minor: bool = Form(False)" in MAIN
def test_client_forwards_aspect_settings():
for f in FIELDS:
assert f'"{f}": {f}' in CLIENT, f"klient nie przekazuje {f}"
def test_settings_are_synced_between_tabs():
for f in FIELDS:
assert f"'{f}'" in SYNC, f"formsync nie synchronizuje {f}"
def test_pdf_payload_includes_aspect_settings():
assert "aspect_orb: v('aspect_orb')" in COMPILE_JS
assert "aspect_minor: checked('aspect_minor')" in COMPILE_JS
def test_pdf_handler_reads_aspect_settings():
assert 'aspect_orb=float(data.get("aspect_orb")' in MAIN
assert 'aspect_minor=bool(data.get("aspect_minor"))' in MAIN