Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7910e54c9 | |||
| 4eacf30883 |
@@ -1,33 +0,0 @@
|
|||||||
# Kontekst builda dla obrazów budowanych z KORZENIA repo (dziś: pomocniczy obraz
|
|
||||||
# wyroczni w CI — patrz .gitea/workflows/tests.yml). Obrazy usług mają własne
|
|
||||||
# konteksty (services/<usługa>), więc ten plik ich nie dotyczy.
|
|
||||||
#
|
|
||||||
# UWAGA: docker NIE czyta .gitignore. Bez tego pliku do demona poleciałby m.in.
|
|
||||||
# lokalny wirtualenv (~344 MB) i jądra efemeryd — a runner miał już incydent
|
|
||||||
# „no space left on device".
|
|
||||||
|
|
||||||
# środowiska lokalne
|
|
||||||
.env/
|
|
||||||
.venv/
|
|
||||||
venv/
|
|
||||||
|
|
||||||
# cache Pythona i narzędzi
|
|
||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
*.pyo
|
|
||||||
.pytest_cache/
|
|
||||||
.ruff_cache/
|
|
||||||
.mypy_cache/
|
|
||||||
|
|
||||||
# dane generowane/pobierane, odtwarzalne
|
|
||||||
**/.ephemeris/
|
|
||||||
**/.cache/
|
|
||||||
|
|
||||||
# historia i metadane repo
|
|
||||||
.git/
|
|
||||||
.gitea/
|
|
||||||
.claude/
|
|
||||||
|
|
||||||
# rzeczy nieużywane w obrazach
|
|
||||||
docs/
|
|
||||||
*.md
|
|
||||||
@@ -141,34 +141,6 @@ jobs:
|
|||||||
print("SMOKE OK")
|
print("SMOKE OK")
|
||||||
PY
|
PY
|
||||||
|
|
||||||
# Zgodność naszych domów z wyrocznią (Swiss Ephemeris). Odpalamy WEWNĄTRZ
|
|
||||||
# obrazu silnika B — tylko tam jest pyswisseph — montując naszą warstwę
|
|
||||||
# logiczną i framework. Bez klastra i bez HTTP: to czysta funkcja.
|
|
||||||
# BLOKUJE build: błędny system domów jest CICHY (wykres wygląda dobrze,
|
|
||||||
# planety siedzą w złych domach), więc lepiej zatrzymać go przed wypuszczeniem
|
|
||||||
# niż wykryć po fakcie.
|
|
||||||
- name: Domy — zgodność z wyrocznią (brzegi + wnętrze)
|
|
||||||
run: |
|
|
||||||
# Pliki WBUDOWUJEMY w obraz, a nie montujemy przez -v. Powód ten sam,
|
|
||||||
# dla którego wyżej nie startujemy kontenera w tle: job Gitea Actions sam
|
|
||||||
# działa w kontenerze, więc `-v $PWD/...` docker rozwiązuje na HOŚCIE,
|
|
||||||
# gdzie tej ścieżki nie ma. Docker nie zgłasza wtedy błędu — po cichu
|
|
||||||
# tworzy PUSTY katalog, przez co skrypt „znika". Kontekst builda jest
|
|
||||||
# strumieniowany do demona, więc działa niezależnie od tego, gdzie on stoi.
|
|
||||||
docker build -t astrololo/oracle:ci -f - . <<'DOCKERFILE'
|
|
||||||
FROM astrololo/engine-swisseph:ci
|
|
||||||
COPY services/logic /logic
|
|
||||||
COPY tests/oracle /oracle
|
|
||||||
ENV LOGIC_PATH=/logic
|
|
||||||
DOCKERFILE
|
|
||||||
docker run --rm astrololo/oracle:ci python /oracle/run.py --mode build
|
|
||||||
|
|
||||||
# Obraz pomocniczy nie jest już potrzebny — a runner miał już incydent
|
|
||||||
# „no space left on device", więc sprzątamy po sobie od razu.
|
|
||||||
- name: Usuń obraz pomocniczy wyroczni
|
|
||||||
if: always()
|
|
||||||
run: docker rmi -f astrololo/oracle:ci 2>/dev/null || true
|
|
||||||
|
|
||||||
# Sprzątanie po POPRZEDNICH przebiegach starej wersji workflow, która
|
# Sprzątanie po POPRZEDNICH przebiegach starej wersji workflow, która
|
||||||
# zostawiała kontener „swe" na runnerze i blokowała nazwę. Nowa wersja
|
# zostawiała kontener „swe" na runnerze i blokowała nazwę. Nowa wersja
|
||||||
# kontenera w tle nie tworzy, więc to tylko jednorazowe uprzątnięcie.
|
# kontenera w tle nie tworzy, więc to tylko jednorazowe uprzątnięcie.
|
||||||
|
|||||||
Binary file not shown.
@@ -1,171 +0,0 @@
|
|||||||
# LOG-33 — sekrety w spoczynku i procedura rotacji
|
|
||||||
|
|
||||||
Sekrety (`APP_PASSWORD`/`APP_USERS`, `INTERNAL_TOKEN`, klucze łącz AES, klucze API
|
|
||||||
do dostawców LLM) trafiają do obiektów Secret w Kubernetesie, gdzie domyślnie są
|
|
||||||
**tylko zakodowane base64** — jawne dla każdego, kto przeczyta magazyn stanu k3s
|
|
||||||
albo ma prawo odczytu sekretów w namespace.
|
|
||||||
|
|
||||||
Ten dokument opisuje: **macierz rotacji** (co restartować przy zmianie czego),
|
|
||||||
**procedury rotacji per sekret** i **kroki hartowania**, które wymagają dostępu do
|
|
||||||
węzła.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Macierz zależności — kto używa którego sekretu
|
|
||||||
|
|
||||||
Wyliczona z żywych deploymentów, nie z założeń:
|
|
||||||
|
|
||||||
| Sekret / klucz | Usługi, które go czytają | Restart obejmuje |
|
|
||||||
|---|---|---|
|
|
||||||
| `astrololo-auth` / **`INTERNAL_TOKEN`** | data, logic, presentation, **render** | **wszystkie cztery, równocześnie** |
|
|
||||||
| `astrololo-auth` / `APP_PASSWORD`, `APP_USERS` | presentation | tylko presentation |
|
|
||||||
| `astrololo-link` / `LINK_KEY_LOGIC_DATA` | logic, data | **para**: logic + data |
|
|
||||||
| `astrololo-link` / `LINK_KEY_PRESENTATION_LOGIC` | presentation, logic | **para**: presentation + logic |
|
|
||||||
| `astrololo-link` / `LINK_KEY_PRESENTATION_RENDER` | presentation, render | **para**: presentation + render |
|
|
||||||
| `astrololo-llm` / `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` | logic | tylko logic |
|
|
||||||
|
|
||||||
> **UWAGA — częsty błąd.** Wcześniejsza wersja tego wymagania mówiła o „wszystkich
|
|
||||||
> **trzech** usługach" przy `INTERNAL_TOKEN`. To już nieprawda: `render` (PRE-24)
|
|
||||||
> również go używa. Restart trzech zostawi render ze starym tokenem i **usługa po
|
|
||||||
> cichu przestanie się dogadywać** — dokładnie ta awaria, przed którą wymaganie
|
|
||||||
> ostrzega.
|
|
||||||
|
|
||||||
Klucze łącz są **parami** — rotacja jednego wymaga restartu tylko dwóch usług, nie
|
|
||||||
całej czwórki. W trakcie wymiany para chwilowo się nie dogaduje (klucze muszą być
|
|
||||||
zgodne po obu stronach łącza), dlatego restart obu naraz.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Procedury rotacji
|
|
||||||
|
|
||||||
### A. Klucze LLM (najbezpieczniejsze do przećwiczenia)
|
|
||||||
|
|
||||||
Dotykają wyłącznie logiki, a awaria jest widoczna od razu i nieszkodliwa
|
|
||||||
(niedostępna chmura, model lokalny działa dalej). **Zacznij ćwiczenie od tego.**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
read -rs -p "Nowy ANTHROPIC_API_KEY: " NEW; echo
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl -n astrololo create secret generic astrololo-llm \
|
|
||||||
--from-literal=OPENAI_API_KEY="$(kubectl -n astrololo get secret astrololo-llm -o jsonpath='{.data.OPENAI_API_KEY}' | base64 -d)" \
|
|
||||||
--from-literal=ANTHROPIC_API_KEY="$NEW" \
|
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
unset NEW && kubectl -n astrololo rollout restart deploy/logic && kubectl -n astrololo rollout status deploy/logic
|
|
||||||
```
|
|
||||||
|
|
||||||
### B. Klucz łącza (para usług)
|
|
||||||
|
|
||||||
Przykład dla `LINK_KEY_PRESENTATION_RENDER`. Pozostałe klucze zachowujemy bez zmian,
|
|
||||||
odczytując je z istniejącego sekretu — inaczej skasowalibyśmy pozostałe łącza.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl -n astrololo create secret generic astrololo-link \
|
|
||||||
--from-literal=LINK_KEY_PRESENTATION_LOGIC="$(kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data.LINK_KEY_PRESENTATION_LOGIC}' | base64 -d)" \
|
|
||||||
--from-literal=LINK_KEY_LOGIC_DATA="$(kubectl -n astrololo get secret astrololo-link -o jsonpath='{.data.LINK_KEY_LOGIC_DATA}' | base64 -d)" \
|
|
||||||
--from-literal=LINK_KEY_PRESENTATION_RENDER="$(openssl rand -hex 32)" \
|
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl -n astrololo rollout restart deploy/presentation deploy/render
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl -n astrololo rollout status deploy/presentation && kubectl -n astrololo rollout status deploy/render
|
|
||||||
```
|
|
||||||
|
|
||||||
### C. `INTERNAL_TOKEN` (wszystkie cztery naraz)
|
|
||||||
|
|
||||||
Najbardziej wrażliwa rotacja: w trakcie usługi z różnymi tokenami **odrzucają się
|
|
||||||
nawzajem**, więc restart musi objąć całą czwórkę.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl -n astrololo create secret generic astrololo-auth \
|
|
||||||
--from-literal=APP_PASSWORD="$(kubectl -n astrololo get secret astrololo-auth -o jsonpath='{.data.APP_PASSWORD}' | base64 -d)" \
|
|
||||||
--from-literal=INTERNAL_TOKEN="$(openssl rand -hex 32)" \
|
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl -n astrololo rollout restart deploy/data deploy/logic deploy/presentation deploy/render
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl -n astrololo rollout status deploy/data && kubectl -n astrololo rollout status deploy/logic && kubectl -n astrololo rollout status deploy/presentation && kubectl -n astrololo rollout status deploy/render
|
|
||||||
```
|
|
||||||
|
|
||||||
> Jeśli używasz kont imiennych (PRE-17), zamiast `APP_PASSWORD` zachowaj `APP_USERS`
|
|
||||||
> — patrz [`konta-i-audyt.md`](konta-i-audyt.md).
|
|
||||||
|
|
||||||
### Weryfikacja po KAŻDEJ rotacji
|
|
||||||
|
|
||||||
Sam `Running` nie wystarczy — pody wstaną nawet, gdy warstwy się nie dogadują.
|
|
||||||
Trzeba sprawdzić **realny przelot przez wszystkie łącza**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
kubectl -n astrololo get pods
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s -o /dev/null -w "aplikacja: %{http_code}\n" -u "<login>:<hasło>" https://astrololo.czernobog.pl/
|
|
||||||
```
|
|
||||||
|
|
||||||
Policz horoskop w przeglądarce (dotyka presentation→logic→data) i wygeneruj PDF
|
|
||||||
(dotyka presentation→render). Dopiero to potwierdza, że wszystkie cztery klucze
|
|
||||||
i token są spójne.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Hartowanie — kroki wymagające dostępu do węzła
|
|
||||||
|
|
||||||
### 1. Szyfrowanie sekretów w spoczynku (najważniejsze)
|
|
||||||
|
|
||||||
k3s z jednym serwerem trzyma stan w **SQLite**, nie w etcd, więc „szyfrowanie etcd"
|
|
||||||
sprowadza się do wbudowanej funkcji k3s. Kroki są w sekcji „Co musisz zrobić sam"
|
|
||||||
poniżej. Efekt: kopia pliku stanu albo snapshot VM przestaje być wyciekiem haseł
|
|
||||||
i kluczy API.
|
|
||||||
|
|
||||||
**Granica:** klucz szyfrujący leży na tym samym serwerze. Chroni przed kradzieżą
|
|
||||||
pliku/snapshotu — nie przed kimś, kto ma roota na węźle.
|
|
||||||
|
|
||||||
### 2. Ograniczenie tokenów kont serwisowych ✅
|
|
||||||
|
|
||||||
Zrobione: `automountServiceAccountToken: false` we wszystkich czterech usługach
|
|
||||||
(deploy #13). Żadna nie rozmawia z API Kubernetesa — sekrety wstrzykuje kubelet,
|
|
||||||
nie pod — więc token był zbędny, a stanowił gotowy punkt wyjścia do klastra.
|
|
||||||
|
|
||||||
### 3. RBAC
|
|
||||||
|
|
||||||
Stan sprawdzony: **zero RoleBindings** w `astrololo`, `cluster-admin` tylko dla
|
|
||||||
`system:masters` i dwóch kont Helma w `kube-system`. Nie ma rozdanych nadmiarowych
|
|
||||||
uprawnień do cofania.
|
|
||||||
|
|
||||||
Realna ekspozycja to **kubeconfig admina**. Sensowny krok: osobny, ograniczony
|
|
||||||
kubeconfig do codziennej pracy, a admin tylko wtedy, gdy naprawdę potrzebny.
|
|
||||||
|
|
||||||
### 4. Sealed Secrets / SOPS — świadomie ODŁOŻONE
|
|
||||||
|
|
||||||
Dziś sekrety tworzone są ręcznie i **nie ma ich w repo GitOps** — czyli zasada
|
|
||||||
„nie wpisywać sekretów do repozytorium" **jest już spełniona**. Kosztem jest
|
|
||||||
odtwarzalność: po utracie klastra nikt nie wie, co tam było.
|
|
||||||
|
|
||||||
Sealed Secrets pozwoliłoby trzymać je w gicie w postaci zaszyfrowanej, ale to
|
|
||||||
zmiana filozofii i **nowy pojedynczy punkt awarii**: utrata klucza kontrolera =
|
|
||||||
utrata wszystkich sekretów. Rekomendacja: dopiero po punkcie 1, i tylko jeśli
|
|
||||||
zależy Ci na odtwarzalności klastra z gita.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Czego NIE robimy
|
|
||||||
|
|
||||||
- **Nie wpisujemy sekretów do logów.** Dziennik audytowy (PRE-17) niesie wyłącznie
|
|
||||||
metadane i liczby.
|
|
||||||
- **Nie wpisujemy sekretów do repo GitOps.** Manifesty odwołują się do sekretów
|
|
||||||
przez `secretKeyRef` i celowo nie zawierają wartości.
|
|
||||||
- **Nie zostawiamy haseł w historii powłoki** — stąd `read -rs` i odczyt istniejących
|
|
||||||
wartości przez `kubectl … | base64 -d` zamiast wpisywania ich ponownie.
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
"""Przegląd baz na udziale i ich globalne włączanie/wyłączanie (DAN-15 / PRE-09).
|
|
||||||
|
|
||||||
Wymaganie przedefiniowane pod model serwerowy: pliki baz leżą na stałym NFS, więc
|
|
||||||
nie wybiera się folderu — potrzeba za to WIDZIEĆ, jakie bazy są dostępne (nazwa +
|
|
||||||
metaopis) i móc globalnie zdecydować, które biorą udział w interpretacji.
|
|
||||||
|
|
||||||
Stan przełączników trzymamy DEKLARATYWNIE w zmiennej `DISABLED_BASES`, a nie w
|
|
||||||
pliku, bo warstwa danych nie ma gdzie trwale zapisywać: udział z bazami jest
|
|
||||||
montowany read-only, a katalog cache to `emptyDir` (ginie przy restarcie poda).
|
|
||||||
Zapis do pliku po cichu wracałby więc do stanu sprzed restartu — a ciche
|
|
||||||
przywrócenie wyłączonej bazy jest gorsze niż konieczność edycji konfiguracji.
|
|
||||||
|
|
||||||
Dopasowanie jest tolerancyjne: wpis pasuje po nazwie pliku ALBO po ścieżce
|
|
||||||
względnej — żeby dało się wyłączyć zarówno „stara_baza.xlsx", jak i
|
|
||||||
„archiwum/stara_baza.xlsx".
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
def disabled_entries() -> list[str]:
|
|
||||||
return [e.strip() for e in os.getenv("DISABLED_BASES", "").split(",") if e.strip()]
|
|
||||||
|
|
||||||
|
|
||||||
def _relative(path: Path, root: Path) -> str:
|
|
||||||
try:
|
|
||||||
return str(path.relative_to(root))
|
|
||||||
except ValueError:
|
|
||||||
return path.name
|
|
||||||
|
|
||||||
|
|
||||||
def is_enabled(path: str | Path, root: str | Path, entries: list[str] | None = None) -> bool:
|
|
||||||
"""Czy baza bierze udział w wyszukiwaniu interpretacji."""
|
|
||||||
entries = disabled_entries() if entries is None else entries
|
|
||||||
if not entries:
|
|
||||||
return True
|
|
||||||
p = Path(path)
|
|
||||||
rel = _relative(p, Path(root))
|
|
||||||
return not any(e == p.name or e == rel for e in entries)
|
|
||||||
|
|
||||||
|
|
||||||
def list_bases(root: str | Path, paths: list[str]) -> list[dict]:
|
|
||||||
"""Bazy dostępne na udziale + metaopis. Celowo TANI opis (dane z systemu
|
|
||||||
plików): przy setkach plików liczenie rekordów oznaczałoby wczytanie każdego."""
|
|
||||||
root = Path(root)
|
|
||||||
entries = disabled_entries()
|
|
||||||
out: list[dict] = []
|
|
||||||
for raw in paths:
|
|
||||||
p = Path(raw)
|
|
||||||
try:
|
|
||||||
st = p.stat()
|
|
||||||
size_mb, modified = round(st.st_size / (1024 * 1024), 2), st.st_mtime
|
|
||||||
except OSError:
|
|
||||||
size_mb, modified = None, None
|
|
||||||
out.append({
|
|
||||||
"name": p.name,
|
|
||||||
"path": _relative(p, root),
|
|
||||||
"size_mb": size_mb,
|
|
||||||
"modified": (datetime.fromtimestamp(modified, tz=timezone.utc).strftime("%Y-%m-%d")
|
|
||||||
if modified else None),
|
|
||||||
"enabled": is_enabled(p, root, entries),
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
@@ -44,16 +44,6 @@ def search(query: SearchQuery) -> SearchResult:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@app.get("/bases")
|
|
||||||
def bases() -> dict:
|
|
||||||
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09).
|
|
||||||
|
|
||||||
Same METADANE — nazwy plików, rozmiar, data. Żadnej treści baz, więc podgląd
|
|
||||||
listy nie jest kolejną drogą do ich wyniesienia."""
|
|
||||||
items = provider.list_bases()
|
|
||||||
return {"bases": items, "enabled": sum(1 for b in items if b["enabled"]), "total": len(items)}
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", response_model=HealthInfo)
|
@app.get("/health", response_model=HealthInfo)
|
||||||
def health() -> HealthInfo:
|
def health() -> HealthInfo:
|
||||||
return provider.health()
|
return provider.health()
|
||||||
|
|||||||
@@ -25,10 +25,3 @@ class DataProvider(ABC):
|
|||||||
def warmup(self) -> None:
|
def warmup(self) -> None:
|
||||||
"""Opcjonalne wstępne zbudowanie cache/indeksu przy starcie."""
|
"""Opcjonalne wstępne zbudowanie cache/indeksu przy starcie."""
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def list_bases(self) -> list[dict]:
|
|
||||||
"""Bazy widoczne dla dostawcy + metaopis i stan włączenia (DAN-15/PRE-09).
|
|
||||||
|
|
||||||
Opcjonalne: dostawca SQL nie operuje na plikach, więc domyślnie pusto —
|
|
||||||
UI pokaże wtedy, że nie ma czego przełączać, zamiast się wywrócić."""
|
|
||||||
return []
|
|
||||||
|
|||||||
@@ -91,31 +91,10 @@ class ExcelDataProvider(DataProvider):
|
|||||||
base = Path(self.s.excel_dir)
|
base = Path(self.s.excel_dir)
|
||||||
return [str(p) for p in sorted(base.glob("**/*.xlsx")) if not p.name.startswith("~$")]
|
return [str(p) for p in sorted(base.glob("**/*.xlsx")) if not p.name.startswith("~$")]
|
||||||
|
|
||||||
def _enabled_files(self, paths: list[str]) -> list[str]:
|
|
||||||
"""Odsiewa bazy WYŁĄCZONE globalnie (DAN-15) — nie biorą udziału
|
|
||||||
w interpretacji, choć fizycznie leżą na udziale."""
|
|
||||||
from app import bases
|
|
||||||
|
|
||||||
entries = bases.disabled_entries()
|
|
||||||
if not entries:
|
|
||||||
return paths
|
|
||||||
return [p for p in paths if bases.is_enabled(p, self.s.excel_dir, entries)]
|
|
||||||
|
|
||||||
def list_bases(self) -> list[dict]:
|
|
||||||
"""Bazy dostępne na udziale + metaopis + stan włączenia (DAN-15/PRE-09)."""
|
|
||||||
from app import bases
|
|
||||||
|
|
||||||
return bases.list_bases(self.s.excel_dir, self._excel_files())
|
|
||||||
|
|
||||||
# ---- publiczne API ----
|
# ---- publiczne API ----
|
||||||
def search(self, query: SearchQuery) -> SearchResult:
|
def search(self, query: SearchQuery) -> SearchResult:
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
# Lista wyłączonych baz wchodzi do klucza cache: bez tego zmiana ustawień
|
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}"
|
||||||
# oddawałaby wynik sprzed zmiany, czyli treść bazy uznanej za wyłączoną.
|
|
||||||
from app import bases
|
|
||||||
|
|
||||||
disabled = ",".join(bases.disabled_entries())
|
|
||||||
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}|{disabled}"
|
|
||||||
|
|
||||||
hit = self.queries.get(cache_key) # poziom 3: wynik zapytania
|
hit = self.queries.get(cache_key) # poziom 3: wynik zapytania
|
||||||
if hit is not None:
|
if hit is not None:
|
||||||
@@ -126,9 +105,6 @@ class ExcelDataProvider(DataProvider):
|
|||||||
if not candidates:
|
if not candidates:
|
||||||
# brak w indeksie (np. klucz nieindeksowany) -> przeszukaj wszystkie pliki
|
# brak w indeksie (np. klucz nieindeksowany) -> przeszukaj wszystkie pliki
|
||||||
candidates = [(p, "0") for p in self._excel_files()]
|
candidates = [(p, "0") for p in self._excel_files()]
|
||||||
# bazy wyłączone globalnie (DAN-15) pomijamy niezależnie od źródła kandydatów
|
|
||||||
allowed = set(self._enabled_files([p for p, _ in candidates]))
|
|
||||||
candidates = [(p, s) for p, s in candidates if p in allowed]
|
|
||||||
|
|
||||||
rows: list[dict] = []
|
rows: list[dict] = []
|
||||||
for path, _sheet in candidates:
|
for path, _sheet in candidates:
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
"""Przegląd baz na udziale i ich globalne wyłączanie (DAN-15 / PRE-09)."""
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app import bases
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _clean_env(monkeypatch):
|
|
||||||
monkeypatch.delenv("DISABLED_BASES", raising=False)
|
|
||||||
|
|
||||||
|
|
||||||
ROOT = "/dane"
|
|
||||||
PATHS = [f"{ROOT}/main_base.xlsx", f"{ROOT}/archiwum/stara.xlsx"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_everything_enabled_by_default():
|
|
||||||
for b in bases.list_bases(ROOT, PATHS):
|
|
||||||
assert b["enabled"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_disable_by_file_name(monkeypatch):
|
|
||||||
monkeypatch.setenv("DISABLED_BASES", "stara.xlsx")
|
|
||||||
state = {b["name"]: b["enabled"] for b in bases.list_bases(ROOT, PATHS)}
|
|
||||||
assert state == {"main_base.xlsx": True, "stara.xlsx": False}
|
|
||||||
|
|
||||||
|
|
||||||
def test_disable_by_relative_path(monkeypatch):
|
|
||||||
"""Wpis może wskazywać ścieżkę względną, nie tylko samą nazwę."""
|
|
||||||
monkeypatch.setenv("DISABLED_BASES", "archiwum/stara.xlsx")
|
|
||||||
assert bases.is_enabled(f"{ROOT}/archiwum/stara.xlsx", ROOT) is False
|
|
||||||
assert bases.is_enabled(f"{ROOT}/main_base.xlsx", ROOT) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_entries_are_trimmed_and_multiple(monkeypatch):
|
|
||||||
monkeypatch.setenv("DISABLED_BASES", " stara.xlsx , main_base.xlsx ")
|
|
||||||
assert [b["enabled"] for b in bases.list_bases(ROOT, PATHS)] == [False, False]
|
|
||||||
|
|
||||||
|
|
||||||
def test_listing_carries_metadata_not_content(tmp_path):
|
|
||||||
"""Metaopis: nazwa, ścieżka, rozmiar, data — ŻADNEJ treści bazy."""
|
|
||||||
f = tmp_path / "baza.xlsx"
|
|
||||||
f.write_bytes(b"x" * 2048)
|
|
||||||
item = bases.list_bases(tmp_path, [str(f)])[0]
|
|
||||||
assert set(item) == {"name", "path", "size_mb", "modified", "enabled"}
|
|
||||||
assert item["name"] == "baza.xlsx" and item["size_mb"] is not None and item["modified"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_file_does_not_crash_the_listing():
|
|
||||||
item = bases.list_bases(ROOT, [f"{ROOT}/nie-ma.xlsx"])[0]
|
|
||||||
assert item["size_mb"] is None and item["modified"] is None
|
|
||||||
@@ -45,12 +45,6 @@ class DataClient:
|
|||||||
payload=payload, headers=_auth_headers(),
|
payload=payload, headers=_auth_headers(),
|
||||||
link=_link())
|
link=_link())
|
||||||
|
|
||||||
def bases(self) -> dict[str, Any]:
|
|
||||||
"""Lista baz na udziale + metaopis (DAN-15) — same metadane, bez treści."""
|
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
|
||||||
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
|
|
||||||
headers=_auth_headers(), link=_link())
|
|
||||||
|
|
||||||
def health(self) -> dict[str, Any]:
|
def health(self) -> dict[str, Any]:
|
||||||
# /health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
|
# /health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
|
||||||
# nie mają, a nie przechodzi tędy nic z baz.
|
# nie mają, a nie przechodzi tędy nic z baz.
|
||||||
|
|||||||
@@ -97,11 +97,7 @@ 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
|
||||||
# cusps_detailed, nie cusps_for: Placidus i Koch nie istnieją powyżej koła
|
cusp_list = H.cusps(asc, mc, system) # tropikalne — geometria domów jest niezmiennicza
|
||||||
# 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."""
|
||||||
@@ -113,11 +109,7 @@ 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"] = primary.system # FAKTYCZNIE użyty
|
result["house_system"] = system
|
||||||
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),
|
||||||
@@ -140,15 +132,10 @@ 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:
|
||||||
cs = primary if s == system else H.cusps_detailed(ramc, eps, moment.lat, s)
|
cl = cusp_list if s == system else H.cusps(asc, mc, s)
|
||||||
block = {"system": s, "used_system": cs.system, "cusps": _cusps_out(cs.cusps)}
|
result["house_systems"].append({"system": s, "cusps": _cusps_out(cl)})
|
||||||
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, cs.cusps)
|
pdict.setdefault("houses", {})[s] = H.assign_house(obj.longitude, cl)
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
@@ -8,31 +8,13 @@ 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"
|
||||||
PORPHYRY = "porphyry"
|
PORPHYRY = "porphyry"
|
||||||
# Systemy o ZAMKNIĘTYM wzorze (bez iteracji). Placidus i Koch wymagają rozwiązania
|
SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY)
|
||||||
# iteracyjnego i dochodzą osobno.
|
|
||||||
VEHLOW = "vehlow"
|
|
||||||
MORINUS = "morinus"
|
|
||||||
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°),
|
|
||||||
# gdzie kolejność domów się odwraca i konstrukcja traci sens — jego „biegun"
|
|
||||||
# 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, PLACIDUS, KOCH)
|
|
||||||
|
|
||||||
|
|
||||||
def mean_obliquity(tt_jd: float) -> float:
|
def mean_obliquity(tt_jd: float) -> float:
|
||||||
@@ -49,27 +31,12 @@ def compute_mc(ramc_deg: float, eps_deg: float) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def compute_asc(ramc_deg: float, eps_deg: float, lat_deg: float) -> float:
|
def compute_asc(ramc_deg: float, eps_deg: float, lat_deg: float) -> float:
|
||||||
"""Ascendent — punkt ekliptyki wschodzący na horyzoncie.
|
|
||||||
|
|
||||||
KOREKTA GAŁĘZI (błąd wykryty przez porównanie z wyrocznią, tests/oracle):
|
|
||||||
ekliptyka przecina horyzont w DWÓCH punktach — wschodzącym (Asc) i zachodzącym
|
|
||||||
(Dsc). `atan2` wybiera jeden z nich, ale powyżej koła podbiegunowego potrafi
|
|
||||||
wskazać ten NIEWŁAŚCIWY: dla szerokości 67°+ i szerokiego zakresu RAMC
|
|
||||||
zwracaliśmy Descendent, czyli Ascendent przesunięty o 180°. Skutek nie był
|
|
||||||
subtelny — planety lądowały w PRZECIWNYCH domach dla całej Skandynawii
|
|
||||||
północnej (Tromsø, Rovaniemi, Murmańsk).
|
|
||||||
|
|
||||||
Rozstrzyga położenie względem MC: punkt wschodzący leży zawsze w półkolu
|
|
||||||
(0°, 180°) na wschód od MC. Reguła zweryfikowana na 46 800 przypadkach wobec
|
|
||||||
Swiss Ephemeris — zero rozbieżności.
|
|
||||||
"""
|
|
||||||
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(lat_deg)
|
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(lat_deg)
|
||||||
asc = norm360(math.degrees(math.atan2(
|
asc = math.atan2(
|
||||||
math.cos(r),
|
math.cos(r),
|
||||||
-(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)),
|
-(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)),
|
||||||
)))
|
)
|
||||||
mc = compute_mc(ramc_deg, eps_deg)
|
return norm360(math.degrees(asc))
|
||||||
return norm360(asc + 180.0) if (asc - mc) % 360.0 > 180.0 else asc
|
|
||||||
|
|
||||||
|
|
||||||
def _trisect(a: float, b: float) -> tuple[float, float]:
|
def _trisect(a: float, b: float) -> tuple[float, float]:
|
||||||
@@ -78,29 +45,10 @@ def _trisect(a: float, b: float) -> tuple[float, float]:
|
|||||||
return norm360(a + span / 3.0), norm360(a + 2.0 * span / 3.0)
|
return norm360(a + span / 3.0), norm360(a + 2.0 * span / 3.0)
|
||||||
|
|
||||||
|
|
||||||
# Poniżej tej odległości od granicy znaku traktujemy Ascendent jak leżący DOKŁADNIE
|
|
||||||
# na niej. 1e-9° to 3,6 mikrosekundy łuku — o rzędy wielkości poniżej jakiejkolwiek
|
|
||||||
# realnej dokładności danych urodzeniowych, więc nie zmienia to żadnego horoskopu.
|
|
||||||
_SIGN_SNAP_DEG = 1e-9
|
|
||||||
|
|
||||||
|
|
||||||
def _snap_to_sign_boundary(lon: float) -> float:
|
|
||||||
"""Przyciąga długość do granicy znaku, gdy jest od niej o włos.
|
|
||||||
|
|
||||||
Whole sign jest NIECIĄGŁY na granicach znaków: różnica 10⁻¹¹° w Ascendencie
|
|
||||||
przerzuca cały dom I o 30°. Bez tego przyciągania ten sam horoskop policzony
|
|
||||||
na innej maszynie mógłby dać inny wynik (wykryte przez porównanie z wyrocznią:
|
|
||||||
nasz Asc = 359,999999999976, swissepha = 1e-10 — ta sama wartość po dwóch
|
|
||||||
stronach granicy Ryby/Baran). Determinizm jest tu ważniejszy niż dosłowność
|
|
||||||
zmiennoprzecinkowa."""
|
|
||||||
nearest = round(lon / 30.0) * 30.0
|
|
||||||
return norm360(nearest) if abs(lon - nearest) < _SIGN_SNAP_DEG else lon
|
|
||||||
|
|
||||||
|
|
||||||
def cusps(asc: float, mc: float, system: str) -> list[float]:
|
def cusps(asc: float, mc: float, system: str) -> list[float]:
|
||||||
"""Zwraca 12 cusps (długości) domów 1..12."""
|
"""Zwraca 12 cusps (długości) domów 1..12."""
|
||||||
if system == WHOLE_SIGN:
|
if system == WHOLE_SIGN:
|
||||||
start = sign_index(_snap_to_sign_boundary(asc)) * 30.0
|
start = sign_index(asc) * 30.0
|
||||||
return [norm360(start + 30.0 * i) for i in range(12)]
|
return [norm360(start + 30.0 * i) for i in range(12)]
|
||||||
if system == EQUAL:
|
if system == EQUAL:
|
||||||
return [norm360(asc + 30.0 * i) for i in range(12)]
|
return [norm360(asc + 30.0 * i) for i in range(12)]
|
||||||
@@ -116,406 +64,6 @@ def cusps(asc: float, mc: float, system: str) -> list[float]:
|
|||||||
raise ValueError(f"nieznany system domów: {system}")
|
raise ValueError(f"nieznany system domów: {system}")
|
||||||
|
|
||||||
|
|
||||||
def polar_circle(eps_deg: float) -> float:
|
|
||||||
"""Szerokość koła podbiegunowego [°] dla danego nachylenia ekliptyki.
|
|
||||||
|
|
||||||
NIE jest to stała 66,56°: ε zmienia się z datą (ok. 23,71° w 370 p.n.e.,
|
|
||||||
23,44° dziś), więc granica przesuwa się o ~0,3° w zakresie dat programu.
|
|
||||||
Powyżej niej stopnie ekliptyki bywają okołobiegunowe — nie wschodzą ani nie
|
|
||||||
zachodzą — przez co systemy oparte na łuku dobowym (Placidus, Koch) tracą
|
|
||||||
definicję."""
|
|
||||||
return 90.0 - abs(eps_deg)
|
|
||||||
|
|
||||||
|
|
||||||
# ── geometria wektorowa dla systemów dzielących koła wielkie ─────────────
|
|
||||||
# Wzory na te systemy krążą w literaturze w kilku wariantach i łatwo o pomyłkę
|
|
||||||
# w gałęzi albo znaku. Liczymy więc WPROST z geometrii: budujemy wektory kierunkowe
|
|
||||||
# w układzie równikowym, przecinamy płaszczyzny i dopiero wynik zamieniamy na
|
|
||||||
# długość ekliptyczną. Jest to dłuższe, ale jednoznaczne i sprawdzalne.
|
|
||||||
|
|
||||||
def _cross(a, b):
|
|
||||||
return (a[1] * b[2] - a[2] * b[1],
|
|
||||||
a[2] * b[0] - a[0] * b[2],
|
|
||||||
a[0] * b[1] - a[1] * b[0])
|
|
||||||
|
|
||||||
|
|
||||||
def _dot(a, b):
|
|
||||||
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
||||||
|
|
||||||
|
|
||||||
def _equatorial_to_lon(v, eps_rad: float) -> float:
|
|
||||||
"""Wektor w układzie RÓWNIKOWYM → długość ekliptyczna [°]."""
|
|
||||||
x, y, z = v
|
|
||||||
y_ecl = y * math.cos(eps_rad) + z * math.sin(eps_rad)
|
|
||||||
return norm360(math.degrees(math.atan2(y_ecl, x)))
|
|
||||||
|
|
||||||
|
|
||||||
def _ecliptic_pole(eps_rad: float):
|
|
||||||
"""Biegun ekliptyki (normalna płaszczyzny ekliptyki) w układzie równikowym."""
|
|
||||||
return (0.0, -math.sin(eps_rad), math.cos(eps_rad))
|
|
||||||
|
|
||||||
|
|
||||||
def _horizon_north(ramc_rad: float, lat_rad: float):
|
|
||||||
"""Punkt północny horyzontu: RA = RAMC+180°, deklinacja = 90°−φ."""
|
|
||||||
return (-math.sin(lat_rad) * math.cos(ramc_rad),
|
|
||||||
-math.sin(lat_rad) * math.sin(ramc_rad),
|
|
||||||
math.cos(lat_rad))
|
|
||||||
|
|
||||||
|
|
||||||
# Domy POŚREDNIE (bez osi) i to, po której stronie MC leżą. Domy 11, 12, 2, 3
|
|
||||||
# są na wschód od MC (przesunięcie 0–180°), domy 5, 6, 8, 9 — na zachód.
|
|
||||||
_INTERMEDIATE = {1: True, 2: True, 4: False, 5: False,
|
|
||||||
7: False, 8: False, 10: True, 11: True}
|
|
||||||
|
|
||||||
|
|
||||||
def _house_circle_cusp(north, q, eps_rad: float, mc: float, east_of_mc: bool) -> float:
|
|
||||||
"""Cusp = przecięcie ekliptyki z kołem domu.
|
|
||||||
|
|
||||||
Koło domu przechodzi przez punkty N/S horyzontu oraz przez punkt podziału `q`
|
|
||||||
(na równiku dla Regiomontanusa, na pierwszym wertykale dla Campanusa).
|
|
||||||
Przecięcie dwóch płaszczyzn daje PROSTĄ, czyli DWA antypodyczne kierunki;
|
|
||||||
wybieramy ten po właściwej stronie południka.
|
|
||||||
|
|
||||||
Używane WYŁĄCZNIE dla domów pośrednich. Osie (1, 4, 7, 10) znamy dokładnie
|
|
||||||
z Asc i MC — liczenie ich tą drogą było błędem, bo leżą dokładnie na granicy
|
|
||||||
„wschód/zachód" (przesunięcie 0° i 180°), gdzie porównanie zmiennoprzecinkowe
|
|
||||||
się chwieje i potrafi wybrać przeciwny punkt nieba."""
|
|
||||||
normal = _cross(north, q) # normalna płaszczyzny koła domu
|
|
||||||
line = _cross(normal, _ecliptic_pole(eps_rad))
|
|
||||||
lon = _equatorial_to_lon(line, eps_rad)
|
|
||||||
return lon if ((lon - mc) % 360.0 < 180.0) == east_of_mc else norm360(lon + 180.0)
|
|
||||||
|
|
||||||
|
|
||||||
def _culminating_mc(mc: float, eps: float, lat: float) -> float:
|
|
||||||
"""Punkt południka, który dla tej szerokości leży NAD horyzontem.
|
|
||||||
|
|
||||||
Systemy oparte na horyzoncie (Regiomontanus, Campanus, Topocentric) biorą jako
|
|
||||||
dziesiąty dom punkt GÓRUJĄCY, a nie matematyczne MC — a za kołem podbiegunowym
|
|
||||||
to nie zawsze to samo. Punkt południka o deklinacji δ ma wysokość 90−|φ−δ|,
|
|
||||||
więc jest nad horyzontem dokładnie wtedy, gdy |φ−δ| < 90.
|
|
||||||
|
|
||||||
Systemy dzielące samą ekliptykę (porphyry, equal, alcabitus, whole sign) tego
|
|
||||||
nie robią — i tak samo zachowuje się wyrocznia."""
|
|
||||||
dec = math.degrees(math.asin(math.sin(math.radians(mc)) * math.sin(math.radians(eps))))
|
|
||||||
return norm360(mc + 180.0) if abs(lat - dec) > 90.0 else mc
|
|
||||||
|
|
||||||
|
|
||||||
def _with_exact_angles(intermediate, asc: float, mc: float) -> list[float]:
|
|
||||||
"""Składa 12 cuspów: osie wstawione dokładnie, reszta z geometrii."""
|
|
||||||
out = [0.0] * 12
|
|
||||||
out[0], out[3] = asc, norm360(mc + 180.0) # Asc, IC
|
|
||||||
out[6], out[9] = norm360(asc + 180.0), mc # Dsc, MC
|
|
||||||
for i, value in intermediate.items():
|
|
||||||
out[i] = value
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _ra_to_ecliptic_lon(ra_deg: float, eps_rad: float) -> float:
|
|
||||||
"""Punkt ekliptyki o zadanej rektascensji (koło godzinne → ekliptyka)."""
|
|
||||||
r = math.radians(ra_deg)
|
|
||||||
return norm360(math.degrees(math.atan2(math.sin(r), math.cos(r) * math.cos(eps_rad))))
|
|
||||||
|
|
||||||
|
|
||||||
def _equator_point(ra_deg: float):
|
|
||||||
"""Kierunek punktu na równiku niebieskim o danej rektascensji."""
|
|
||||||
r = math.radians(ra_deg)
|
|
||||||
return (math.cos(r), math.sin(r), 0.0)
|
|
||||||
|
|
||||||
|
|
||||||
def _prime_vertical_point(ramc_rad: float, lat_rad: float, angle_deg: float):
|
|
||||||
"""Punkt pierwszego wertykału, `angle_deg` od punktu wschodu w stronę nadiru.
|
|
||||||
|
|
||||||
Pierwszy wertykał to koło przez wschód, zenit, zachód i nadir — Campanus dzieli
|
|
||||||
właśnie je."""
|
|
||||||
east = (-math.sin(ramc_rad), math.cos(ramc_rad), 0.0)
|
|
||||||
zenith = (math.cos(lat_rad) * math.cos(ramc_rad),
|
|
||||||
math.cos(lat_rad) * math.sin(ramc_rad),
|
|
||||||
math.sin(lat_rad))
|
|
||||||
a = math.radians(angle_deg)
|
|
||||||
return tuple(east[i] * math.cos(a) - zenith[i] * math.sin(a) for i in range(3))
|
|
||||||
|
|
||||||
|
|
||||||
def _cusps_regiomontanus(ramc: float, eps: float, lat: float,
|
|
||||||
asc: float, mc: float) -> list[float]:
|
|
||||||
"""Równik niebieski dzielony na 12 równych łuków, rzut kołami przez N/S horyzontu."""
|
|
||||||
er, rr, lr = math.radians(eps), math.radians(ramc), math.radians(lat)
|
|
||||||
north = _horizon_north(rr, lr)
|
|
||||||
mid = {i: _house_circle_cusp(north, _equator_point(ramc + 90.0 + 30.0 * i), er, mc, e)
|
|
||||||
for i, e in _INTERMEDIATE.items()}
|
|
||||||
return _with_exact_angles(mid, asc, _culminating_mc(mc, eps, lat))
|
|
||||||
|
|
||||||
|
|
||||||
def _cusps_campanus(ramc: float, eps: float, lat: float,
|
|
||||||
asc: float, mc: float) -> list[float]:
|
|
||||||
"""Pierwszy wertykał dzielony na 12 równych łuków, rzut tak samo jak wyżej."""
|
|
||||||
er, rr, lr = math.radians(eps), math.radians(ramc), math.radians(lat)
|
|
||||||
north = _horizon_north(rr, lr)
|
|
||||||
mid = {i: _house_circle_cusp(north, _prime_vertical_point(rr, lr, 30.0 * i), er, mc, e)
|
|
||||||
for i, e in _INTERMEDIATE.items()}
|
|
||||||
return _with_exact_angles(mid, asc, _culminating_mc(mc, eps, lat))
|
|
||||||
|
|
||||||
|
|
||||||
def _cusps_morinus(ramc: float, eps: float) -> list[float]:
|
|
||||||
"""Równik dzielony od RAMC i rzutowany WPROST na ekliptykę — bez horyzontu.
|
|
||||||
|
|
||||||
Dlatego Morinus jako jedyny nie zależy od szerokości geograficznej, a jego
|
|
||||||
dom I nie pokrywa się z Ascendentem. Uwaga: to ZAMIANA WSPÓŁRZĘDNYCH punktu
|
|
||||||
równika (RA, dec=0) na ekliptyczne, a nie rzut kołem godzinnym — te dwie
|
|
||||||
operacje dają różne wyniki i pomylenie ich kosztowało tu do 5°."""
|
|
||||||
er = math.radians(eps)
|
|
||||||
return [_equatorial_to_lon(_equator_point(ramc + 90.0 + 30.0 * i), er)
|
|
||||||
for i in range(12)]
|
|
||||||
|
|
||||||
|
|
||||||
def _cusps_alcabitus(ramc: float, eps: float, asc: float) -> list[float]:
|
|
||||||
"""Łuki równika MC→Asc i Asc→IC dzielone na trzy; rzut kołami godzinnymi."""
|
|
||||||
er = math.radians(eps)
|
|
||||||
a = math.radians(asc)
|
|
||||||
ra_asc = norm360(math.degrees(math.atan2(math.sin(a) * math.cos(er), math.cos(a))))
|
|
||||||
day = (ra_asc - ramc) % 360.0 # łuk MC → Asc (domy 11, 12)
|
|
||||||
night = (ramc + 180.0 - ra_asc) % 360.0 # łuk Asc → IC (domy 2, 3)
|
|
||||||
|
|
||||||
ra = [0.0] * 12
|
|
||||||
ra[9] = ramc # dom 10 = MC
|
|
||||||
ra[10] = ramc + day / 3.0 # dom 11
|
|
||||||
ra[11] = ramc + 2.0 * day / 3.0 # dom 12
|
|
||||||
ra[0] = ra_asc # dom 1 = Asc
|
|
||||||
ra[1] = ra_asc + night / 3.0 # dom 2
|
|
||||||
ra[2] = ra_asc + 2.0 * night / 3.0 # dom 3
|
|
||||||
for i in range(6): # domy 4–9 naprzeciw 10–3
|
|
||||||
ra[i + 3] = ra[(i + 9) % 12] + 180.0
|
|
||||||
return [_ra_to_ecliptic_lon(x, er) for x in ra]
|
|
||||||
|
|
||||||
|
|
||||||
# Ułamek szerokości geograficznej użyty jako „biegun" koła domu (Polich–Page).
|
|
||||||
# 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,
|
|
||||||
1.0, 2 / 3, 1 / 3, 0.0, 1 / 3, 2 / 3)
|
|
||||||
|
|
||||||
|
|
||||||
def _asc_under_pole_raw(ramc_deg: float, eps_deg: float, pole_deg: float) -> float:
|
|
||||||
"""Surowy wzór na ascendent pod zadanym „biegunem", BEZ korekty gałęzi.
|
|
||||||
|
|
||||||
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,
|
|
||||||
asc: float, mc: float) -> list[float]:
|
|
||||||
"""Polich–Page: dom pośredni to ASCENDENT policzony pod własnym „biegunem"
|
|
||||||
tan(P) = tan(φ)·k/3, dla RAMC przesuniętego o pozycję domu.
|
|
||||||
|
|
||||||
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
|
|
||||||
konstrukcji „ascendent pod biegunem".
|
|
||||||
|
|
||||||
Liczymy tylko domy 11, 12, 2, 3, a 5, 6, 8, 9 bierzemy jako ich OPOZYCJE —
|
|
||||||
to nie skrót, lecz własność tych systemów: przeciwległe domy leżą na tym samym
|
|
||||||
kole wielkim, więc ich cuspy są dokładnie antypodyczne."""
|
|
||||||
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)
|
|
||||||
out = [0.0] * 12
|
|
||||||
for i in (10, 11, 1, 2): # domy 11, 12, 2, 3
|
|
||||||
pole = math.degrees(math.atan(tan_lat * _TOPO_POLE_FRACTION[i]))
|
|
||||||
lon = _asc_under_pole_raw(ramc + 30.0 * i, eps, pole)
|
|
||||||
if ((lon - culminating) % 360.0 < 180.0) != _INTERMEDIATE[i]:
|
|
||||||
lon = norm360(lon + 180.0)
|
|
||||||
out[i] = lon
|
|
||||||
out[(i + 6) % 12] = norm360(lon + 180.0)
|
|
||||||
out[0], out[3] = asc, norm360(culminating + 180.0)
|
|
||||||
out[6], out[9] = norm360(asc + 180.0), culminating
|
|
||||||
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.
|
|
||||||
|
|
||||||
Systemy proste (whole sign / equal / porphyry) potrzebują tylko Asc i MC,
|
|
||||||
ale systemy egzotyczne dzielą inne koła wielkie i wymagają pełnego zestawu
|
|
||||||
(RAMC, ε, φ). Ta funkcja jest wspólnym punktem wejścia dla obu rodzajów —
|
|
||||||
i to ją porównuje z wyrocznią framework testowy (tests/oracle).
|
|
||||||
"""
|
|
||||||
asc = compute_asc(ramc, eps, lat)
|
|
||||||
mc = compute_mc(ramc, eps)
|
|
||||||
if system in (WHOLE_SIGN, EQUAL, PORPHYRY):
|
|
||||||
return cusps(asc, mc, system)
|
|
||||||
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)]
|
|
||||||
if system == MORINUS:
|
|
||||||
return _cusps_morinus(ramc, eps)
|
|
||||||
if system == REGIOMONTANUS:
|
|
||||||
return _cusps_regiomontanus(ramc, eps, lat, asc, mc)
|
|
||||||
if system == CAMPANUS:
|
|
||||||
return _cusps_campanus(ramc, eps, lat, asc, mc)
|
|
||||||
if system == ALCABITUS:
|
|
||||||
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:
|
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)
|
||||||
|
|||||||
@@ -108,19 +108,6 @@ class SynastryRequest(BaseModel):
|
|||||||
aspect_minor: bool = False
|
aspect_minor: bool = False
|
||||||
|
|
||||||
|
|
||||||
@app.get("/bases")
|
|
||||||
def bases() -> dict:
|
|
||||||
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09).
|
|
||||||
|
|
||||||
Przelot do warstwy danych — same METADANE, żadnej treści baz."""
|
|
||||||
from app.clients.data_client import DataClient
|
|
||||||
|
|
||||||
try:
|
|
||||||
return DataClient().bases()
|
|
||||||
except httpx.HTTPError as e:
|
|
||||||
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/chart/synastry")
|
@app.post("/chart/synastry")
|
||||||
def chart_synastry(req: SynastryRequest) -> dict:
|
def chart_synastry(req: SynastryRequest) -> dict:
|
||||||
"""Synastria (PRE-04): dwa horoskopy natalne + aspekty MIĘDZY nimi (planeta
|
"""Synastria (PRE-04): dwa horoskopy natalne + aspekty MIĘDZY nimi (planeta
|
||||||
|
|||||||
@@ -200,11 +200,6 @@ 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"]:
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -64,31 +61,8 @@ 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):
|
||||||
"""Nieznana nazwa systemu jest po prostu pomijana, nie wywala horoskopu.
|
"""Nieobsługiwany system (np. placidus — dojdzie przez swisseph osobno) jest
|
||||||
|
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=["nie-ma-takiego-systemu", "equal"])
|
house_systems=["placidus", "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)
|
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
"""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
|
|
||||||
|
|
||||||
from app.engine import houses as H
|
from app.engine import houses as H
|
||||||
|
|
||||||
# RAMC i ε policzone Skyfieldem dla horoskopu referencyjnego (30.04.1984, Warszawa)
|
# RAMC i ε policzone Skyfieldem dla horoskopu referencyjnego (30.04.1984, Warszawa)
|
||||||
@@ -43,156 +39,3 @@ def test_assign_house_whole_sign():
|
|||||||
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN) # dom 1 = Rak (90–120°)
|
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN) # dom 1 = Rak (90–120°)
|
||||||
assert H.assign_house(100.0, cusps) == 1 # w Raku
|
assert H.assign_house(100.0, cusps) == 1 # w Raku
|
||||||
assert H.assign_house(40.0, cusps) == 11 # Byk -> 11. dom
|
assert H.assign_house(40.0, cusps) == 11 # Byk -> 11. dom
|
||||||
|
|
||||||
|
|
||||||
# ── regresje wykryte przez porównanie z wyrocznią (tests/oracle) ──────────
|
|
||||||
|
|
||||||
def test_ascendant_branch_beyond_polar_circle():
|
|
||||||
"""REGRESJA: powyżej koła podbiegunowego `atan2` wybierał NIEWŁAŚCIWY punkt
|
|
||||||
przecięcia ekliptyki z horyzontem — zwracaliśmy Descendent zamiast Ascendenta,
|
|
||||||
czyli błąd o 180°. Planety lądowały w PRZECIWNYCH domach dla całej północnej
|
|
||||||
Skandynawii. Rozstrzyga położenie względem MC: punkt wschodzący leży zawsze
|
|
||||||
w półkolu (0°,180°) na wschód od MC."""
|
|
||||||
eps = 23.4393
|
|
||||||
for lat in (67.0, 70.0, 85.0, -70.0):
|
|
||||||
for ramc in (60.0, 100.0, 200.0, 270.0, 300.0):
|
|
||||||
asc = H.compute_asc(ramc, eps, lat)
|
|
||||||
mc = H.compute_mc(ramc, eps)
|
|
||||||
offset = (asc - mc) % 360.0
|
|
||||||
assert 0.0 <= offset <= 180.0, (
|
|
||||||
f"Asc poza półkolem wschodzącym: lat={lat} ramc={ramc} offset={offset:.4f}")
|
|
||||||
|
|
||||||
|
|
||||||
def test_whole_sign_is_deterministic_at_sign_boundary():
|
|
||||||
"""REGRESJA: Ascendent o włos od granicy znaku (359,999999999976 vs 1e-10 —
|
|
||||||
ta sama wartość, różne strony) przerzucał cały dom I o 30°. Ten sam horoskop
|
|
||||||
na innej maszynie dawał inny wynik."""
|
|
||||||
below = H.cusps(359.999999999976, 270.0, H.WHOLE_SIGN)
|
|
||||||
above = H.cusps(1e-10, 270.0, H.WHOLE_SIGN)
|
|
||||||
assert below[0] == above[0] == 0.0, (below[0], above[0])
|
|
||||||
|
|
||||||
|
|
||||||
def test_polar_circle_moves_with_obliquity():
|
|
||||||
"""Granica dziedziny Placidusa/Kocha NIE jest stałą 66,56° — zależy od ε,
|
|
||||||
a ε zmienia się z datą. Testy brzegowe muszą ją liczyć per data."""
|
|
||||||
assert H.polar_circle(23.4393) == pytest.approx(66.5607, abs=1e-4) # dziś
|
|
||||||
assert H.polar_circle(23.747) == pytest.approx(66.253, abs=1e-4) # 370 p.n.e.
|
|
||||||
|
|
||||||
|
|
||||||
# ── systemy egzotyczne o zamkniętym wzorze (Etap 1) ──────────────────────
|
|
||||||
# Zgodność z wyrocznią sprawdza tests/oracle; tu pilnujemy niezmienników, które
|
|
||||||
# muszą zachodzić także bez swissepha (czyli w każdym środowisku).
|
|
||||||
|
|
||||||
EXOTIC = ("vehlow", "morinus", "regiomontanus", "campanus", "alcabitus")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("system", EXOTIC)
|
|
||||||
def test_exotic_returns_twelve_cusps_in_range(system):
|
|
||||||
out = H.cusps_for(100.0, 23.4393, 50.0, system)
|
|
||||||
assert len(out) == 12
|
|
||||||
assert all(0.0 <= c < 360.0 for c in out)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("system", EXOTIC)
|
|
||||||
def test_opposite_houses_are_antipodal(system):
|
|
||||||
"""Domy przeciwległe leżą na tym samym kole wielkim, więc ich cuspy są
|
|
||||||
dokładnie antypodyczne. Naruszenie tego oznacza błąd w wyborze gałęzi."""
|
|
||||||
out = H.cusps_for(137.0, 23.4393, 42.0, system)
|
|
||||||
for i in range(6):
|
|
||||||
assert abs(((out[i + 6] - out[i]) % 360.0) - 180.0) < 1e-9, f"domy {i+1}/{i+7}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("system", ("regiomontanus", "campanus", "alcabitus"))
|
|
||||||
def test_quadrant_systems_anchor_on_ascendant(system):
|
|
||||||
"""Systemy kwadrantowe zaczynają dom I na Ascendencie."""
|
|
||||||
ramc, eps, lat = 100.0, 23.4393, 50.0
|
|
||||||
assert H.cusps_for(ramc, eps, lat, system)[0] == pytest.approx(
|
|
||||||
H.compute_asc(ramc, eps, lat), abs=1e-9)
|
|
||||||
|
|
||||||
|
|
||||||
def test_vehlow_puts_ascendant_in_the_middle_of_house_one():
|
|
||||||
ramc, eps, lat = 100.0, 23.4393, 50.0
|
|
||||||
asc = H.compute_asc(ramc, eps, lat)
|
|
||||||
assert H.cusps_for(ramc, eps, lat, "vehlow")[0] == pytest.approx(
|
|
||||||
H.norm360(asc - 15.0), abs=1e-9)
|
|
||||||
|
|
||||||
|
|
||||||
def test_morinus_ignores_latitude():
|
|
||||||
"""Morinus rzutuje równik wprost na ekliptykę, bez horyzontu — jako jedyny
|
|
||||||
nie zależy od szerokości geograficznej."""
|
|
||||||
a = H.cusps_for(100.0, 23.4393, 20.0, "morinus")
|
|
||||||
b = H.cusps_for(100.0, 23.4393, 65.0, "morinus")
|
|
||||||
assert a == pytest.approx(b, abs=1e-12)
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
@@ -152,12 +152,6 @@ class LogicClient:
|
|||||||
client, f"{self.base_url}/chart/horoscope/stream",
|
client, f"{self.base_url}/chart/horoscope/stream",
|
||||||
payload=payload, headers=_auth_headers(), link=_link())
|
payload=payload, headers=_auth_headers(), link=_link())
|
||||||
|
|
||||||
def bases(self) -> dict[str, Any]:
|
|
||||||
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09)."""
|
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
|
||||||
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
|
|
||||||
headers=_auth_headers(), link=_link())
|
|
||||||
|
|
||||||
def llm_models(self) -> dict[str, Any]:
|
def llm_models(self) -> dict[str, Any]:
|
||||||
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
|
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -22,8 +22,6 @@ 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.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
|
||||||
@@ -56,10 +54,6 @@ 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
|
|
||||||
|
|
||||||
|
|
||||||
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]:
|
||||||
@@ -147,10 +141,6 @@ def chart_compute(
|
|||||||
aspect_minor=aspect_minor,
|
aspect_minor=aspect_minor,
|
||||||
stations=stations, zodiac=zodiac, tables=tables,
|
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
|
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"])
|
||||||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||||||
@@ -252,7 +242,6 @@ 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,
|
||||||
@@ -286,7 +275,6 @@ 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 [],
|
||||||
}
|
}
|
||||||
@@ -301,23 +289,6 @@ def compile_pdf(payload: dict):
|
|||||||
headers={"Content-Disposition": 'attachment; filename="raport.pdf"'})
|
headers={"Content-Disposition": 'attachment; filename="raport.pdf"'})
|
||||||
|
|
||||||
|
|
||||||
# ---------------- Ustawienia: bazy na udziale (DAN-15/PRE-09) ----------------
|
|
||||||
@app.get("/settings", response_class=HTMLResponse)
|
|
||||||
def settings_view(request: Request):
|
|
||||||
"""Podgląd baz dostępnych na udziale + które biorą udział w interpretacji.
|
|
||||||
|
|
||||||
Same metadane (nazwa, rozmiar, data) — bez treści baz. Przełączniki są
|
|
||||||
DEKLARATYWNE (`DISABLED_BASES`), bo warstwa danych nie ma trwałego zapisu:
|
|
||||||
udział jest read-only, a cache to emptyDir. Zapis do pliku ginąłby po
|
|
||||||
restarcie, po cichu włączając z powrotem wyłączoną bazę."""
|
|
||||||
ctx: dict = {"result": None, "error": None}
|
|
||||||
try:
|
|
||||||
ctx["result"] = logic.bases()
|
|
||||||
except httpx.HTTPError as e:
|
|
||||||
ctx["error"] = _logic_error(e)
|
|
||||||
return templates.TemplateResponse(request, "settings.html", ctx)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------- Synastria (technika relacyjna) ----------------
|
# ---------------- Synastria (technika relacyjna) ----------------
|
||||||
@app.get("/synastry", response_class=HTMLResponse)
|
@app.get("/synastry", response_class=HTMLResponse)
|
||||||
def synastry_form(request: Request):
|
def synastry_form(request: Request):
|
||||||
|
|||||||
@@ -93,18 +93,6 @@ 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,9 @@
|
|||||||
<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>
|
||||||
{% for value, name in HOUSE_SYSTEMS %}
|
<label><input type="checkbox" name="house_systems" value="whole_sign" {{ 'checked' if 'whole_sign' in chosen else '' }}> Whole Sign</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="equal" {{ 'checked' if 'equal' in chosen else '' }}> Equal</label>
|
||||||
{% endfor %}
|
<label><input type="checkbox" name="house_systems" value="porphyry" {{ 'checked' if 'porphyry' in chosen else '' }}> Porphyry</label>
|
||||||
</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,14 +6,6 @@
|
|||||||
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>Oś</th><th>Znak</th><th>W znaku</th></tr></thead>
|
<thead><tr><th>Oś</th><th>Znak</th><th>W znaku</th></tr></thead>
|
||||||
@@ -28,9 +20,10 @@
|
|||||||
|
|
||||||
{# 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>{{ house_label(hs.system) }}</th>{% endfor %}</tr></thead>
|
<thead><tr><th>Dom</th>{% for hs in result.house_systems %}<th>{{ HSN.get(hs.system, 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>
|
||||||
@@ -226,7 +219,7 @@
|
|||||||
|
|
||||||
{% if result.cusps %}
|
{% if result.cusps %}
|
||||||
<details class="loc">
|
<details class="loc">
|
||||||
<summary>Cusps domów ({{ house_label(result.house_system) }})</summary>
|
<summary>Cusps domów ({{ 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>
|
||||||
|
|||||||
@@ -25,7 +25,6 @@
|
|||||||
<a href="/synastry" class="{% block nav_synastry %}{% endblock %}">Synastria</a>
|
<a href="/synastry" class="{% block nav_synastry %}{% endblock %}">Synastria</a>
|
||||||
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
|
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
|
||||||
<a href="/compile" class="{% block nav_compile %}{% endblock %}">Skompiluj</a>
|
<a href="/compile" class="{% block nav_compile %}{% endblock %}">Skompiluj</a>
|
||||||
<a href="/settings" class="{% block nav_settings %}{% endblock %}">Ustawienia</a>
|
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
|
|||||||
@@ -34,11 +34,10 @@
|
|||||||
<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' %}
|
||||||
{% for value, name in HOUSE_SYSTEMS %}
|
<option value="whole_sign" {{ 'selected' if hs == 'whole_sign' else '' }}>Whole Sign</option>
|
||||||
<option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
|
<option value="equal" {{ 'selected' if hs == 'equal' else '' }}>Equal</option>
|
||||||
{% endfor %}
|
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option>
|
||||||
</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">
|
||||||
@@ -66,14 +65,10 @@
|
|||||||
{% 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: {{ house_label(result.house_system) }}{% endif %}
|
{% if result.house_system %}· domy: {{ 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,11 +39,10 @@ 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' %}
|
||||||
{% for value, name in HOUSE_SYSTEMS %}
|
<option value="whole_sign" {{ 'selected' if hs == 'whole_sign' else '' }}>Whole Sign</option>
|
||||||
<option value="{{ value }}" {{ 'selected' if hs == value else '' }}>{{ name }}{{ ' *' if value in HOUSE_LIMITED else '' }}</option>
|
<option value="equal" {{ 'selected' if hs == 'equal' else '' }}>Equal</option>
|
||||||
{% endfor %}
|
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option>
|
||||||
</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">
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Ustawienia{% endblock %}
|
|
||||||
{% block nav_settings %}active{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<p class="sub">Bazy interpretacyjne dostępne na udziale sieciowym — i to, które biorą udział w interpretacji.</p>
|
|
||||||
|
|
||||||
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
|
||||||
|
|
||||||
{% if result %}
|
|
||||||
<div class="meta">Bazy na udziale: <strong>{{ result.total }}</strong> ·
|
|
||||||
biorą udział w interpretacji: <strong>{{ result.enabled }}</strong>
|
|
||||||
{% if result.total and result.enabled < result.total %}
|
|
||||||
· wyłączonych: <strong>{{ result.total - result.enabled }}</strong>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if result.bases %}
|
|
||||||
<table>
|
|
||||||
<thead><tr><th>Baza</th><th>Ścieżka</th><th>Rozmiar</th><th>Zmieniono</th><th>W interpretacji</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for b in result.bases %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ b.name }}</td>
|
|
||||||
<td class="muted small">{{ b.path }}</td>
|
|
||||||
<td class="mono">{% if b.size_mb is not none %}{{ b.size_mb }} MB{% else %}—{% endif %}</td>
|
|
||||||
<td class="mono">{{ b.modified or '—' }}</td>
|
|
||||||
<td>{% if b.enabled %}tak{% else %}<span class="badge">wyłączona</span>{% endif %}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% else %}
|
|
||||||
<p class="muted">Na udziale nie widać żadnych baz (albo aktywny dostawca nie operuje na plikach).</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="meta">Jak wyłączyć bazę</div>
|
|
||||||
<p class="muted small">
|
|
||||||
Lista wyłączonych jest <strong>deklaratywna</strong> — trzyma ją zmienna
|
|
||||||
<span class="mono">DISABLED_BASES</span> usługi danych (nazwy plików po przecinku,
|
|
||||||
np. <span class="mono">stara_baza.xlsx,archiwum/testowa.xlsx</span>). Po zmianie
|
|
||||||
zrestartuj warstwę danych.
|
|
||||||
</p>
|
|
||||||
<p class="muted small">
|
|
||||||
Dlaczego nie klikaniem: udział z bazami jest montowany tylko do odczytu, a katalog
|
|
||||||
cache to <span class="mono">emptyDir</span> — zapisany przełącznik ginąłby przy
|
|
||||||
restarcie poda i po cichu włączał z powrotem wyłączoną bazę. Przełączanie z tego
|
|
||||||
ekranu wymagałoby dołożenia trwałego wolumenu.
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -11,8 +11,6 @@ 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")
|
||||||
|
|
||||||
|
|
||||||
@@ -22,37 +20,10 @@ def test_both_forms_use_shared_option_partial():
|
|||||||
assert '{% include "_form_options.html" %}' in COMPILE
|
assert '{% include "_form_options.html" %}' in COMPILE
|
||||||
|
|
||||||
|
|
||||||
def _render(name: str, **ctx) -> str:
|
def test_form_lets_you_pick_extra_systems():
|
||||||
"""Renderuje szablon z tymi samymi globalsami, co produkcja.
|
"""Checkboxy dla trzech systemów — wielokrotny wybór (name powtórzony)."""
|
||||||
|
for val in ("whole_sign", "equal", "porphyry"):
|
||||||
Szablony budują listę systemów PĘTLĄ po katalogu, więc sprawdzanie ich
|
assert f'name="house_systems" value="{val}"' in OPTS, f"brak checkboxa {val}"
|
||||||
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():
|
||||||
@@ -78,21 +49,4 @@ 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."""
|
||||||
html = _render("_form_options.html", form={"house_systems": ["koch", "campanus"]})
|
assert "in chosen" in OPTS and "form.house_systems" in OPTS
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
"""Ekran ustawień: bazy na udziale (PRE-09)."""
|
|
||||||
import pathlib
|
|
||||||
|
|
||||||
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
|
||||||
TPL = (APP / "templates" / "settings.html").read_text(encoding="utf-8")
|
|
||||||
BASE = (APP / "templates" / "base.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")
|
|
||||||
|
|
||||||
|
|
||||||
def test_tab_is_in_the_menu():
|
|
||||||
assert 'href="/settings"' in BASE and "Ustawienia" in BASE
|
|
||||||
|
|
||||||
|
|
||||||
def test_route_and_client_exist():
|
|
||||||
assert '@app.get("/settings"' in MAIN and "logic.bases()" in MAIN
|
|
||||||
assert 'f"{self.base_url}/bases"' in CLIENT
|
|
||||||
|
|
||||||
|
|
||||||
def test_listing_shows_name_and_metadata():
|
|
||||||
for col in ("b.name", "b.path", "b.size_mb", "b.modified"):
|
|
||||||
assert col in TPL, f"brak kolumny {col}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_listing_shows_whether_base_counts_in_interpretation():
|
|
||||||
assert "b.enabled" in TPL and "wyłączona" in TPL
|
|
||||||
|
|
||||||
|
|
||||||
def test_page_explains_how_to_disable_and_why_not_clickable():
|
|
||||||
"""Ekran ma mówić, JAK wyłączyć bazę — inaczej podgląd bez sprawczości myli."""
|
|
||||||
assert "DISABLED_BASES" in TPL
|
|
||||||
assert "emptyDir" in TPL # uzasadnienie: brak trwałego zapisu
|
|
||||||
|
|
||||||
|
|
||||||
def test_error_from_logic_does_not_break_the_page():
|
|
||||||
assert "_logic_error(e)" in MAIN[MAIN.index("def settings_view"):MAIN.index("def settings_view") + 900]
|
|
||||||
@@ -210,22 +210,6 @@ 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
|
||||||
|
|||||||
@@ -307,24 +307,3 @@ 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
|
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
# Porównanie domów z wyrocznią (Swiss Ephemeris)
|
|
||||||
|
|
||||||
Framework testowy do systemów domów. Liczenie cuspów traktujemy jako **funkcję**
|
|
||||||
parametrów `(RAMC, ε, φ)`, a **Swiss Ephemeris jako wyrocznię** — czyli niezależną
|
|
||||||
implementację, wobec której sprawdzamy naszą.
|
|
||||||
|
|
||||||
Powstał pod implementację systemów egzotycznych (Placidus, Koch, Regiomontanus,
|
|
||||||
Campanus…), gdzie błąd jest **cichy**: wykres wygląda poprawnie, tylko planety
|
|
||||||
siedzą w złych domach. Pojedyncze cuspy przepisane z internetu tego nie wyłapią —
|
|
||||||
tysiące porównań na całej dziedzinie tak.
|
|
||||||
|
|
||||||
## Dlaczego akurat tak
|
|
||||||
|
|
||||||
**Izolacja jednostki.** Obu implementacjom podajemy TE SAME wejścia przez
|
|
||||||
`swe_houses_armc`. Gdybyśmy porównywali „nasz horoskop" z „horoskopem swissepha",
|
|
||||||
różnica mogłaby wynikać z innego czasu gwiazdowego albo innego ε — czyli z czegoś,
|
|
||||||
co nie jest testowaną funkcją, i utonęlibyśmy w fałszywych alarmach. Czas gwiazdowy
|
|
||||||
i ε mają własny, osobny test (`asc/mc`).
|
|
||||||
|
|
||||||
**Kryterium, nie procent.** Raportujemy liczbę przypadków **powyżej tolerancji
|
|
||||||
(1″)**, **maksymalne odchylenie** i **miejsce** jego wystąpienia oraz histogram.
|
|
||||||
Sam procent zgodności ukrywa kształt błędu: „97%" nie odróżnia szumu
|
|
||||||
zmiennoprzecinkowego od jednego rogu dziedziny, w którym mylimy się o 30°.
|
|
||||||
|
|
||||||
**Granice liczone per data.** Koło podbiegunowe — granica dziedziny Placidusa
|
|
||||||
i Kocha — **nie jest stałą 66,56°**. Zależy od ε, które zmienia się z datą (23,75°
|
|
||||||
w 370 p.n.e., 23,44° dziś), więc w zakresie programu przesuwa się o ~0,3°.
|
|
||||||
|
|
||||||
**Zgodność co do dziedziny.** Gdy wyrocznia odmawia liczenia (poza dziedziną),
|
|
||||||
a my uważamy, że liczymy — to też jest błąd, zgłaszany osobno. Wyrocznia mówi nam
|
|
||||||
zatem nie tylko *jakie są wartości*, ale i *gdzie kończy się dziedzina*.
|
|
||||||
|
|
||||||
## Uruchomienie
|
|
||||||
|
|
||||||
```bash
|
|
||||||
LOGIC_PATH=../../services/logic python run.py --mode build
|
|
||||||
```
|
|
||||||
|
|
||||||
| Tryb | Co robi | Gdzie działa |
|
|
||||||
|---|---|---|
|
|
||||||
| `--mode build` | zestaw **brzegowy** + tyle samo przypadków z wnętrza | CI, **blokuje** build |
|
|
||||||
| `--mode sweep --count N --seed S` | wielki **losowy** przemiał | Job w klastrze, nie blokuje |
|
|
||||||
|
|
||||||
Kod wyjścia: `0` = zgodność w granicach tolerancji, `1` = przekroczenia albo
|
|
||||||
niezgodność dziedziny. Ziarno jest zawsze raportowane — każdy błąd da się
|
|
||||||
**odtworzyć** komendą wypisaną w raporcie.
|
|
||||||
|
|
||||||
## Co ten framework już znalazł
|
|
||||||
|
|
||||||
Uruchomiony na kodzie, który uchodził za poprawny (whole sign / equal / porphyry),
|
|
||||||
wykrył **dwa realne błędy** w pierwszym przebiegu:
|
|
||||||
|
|
||||||
1. **Ascendent o 180° za kołem podbiegunowym.** `atan2` wybierał niewłaściwy punkt
|
|
||||||
przecięcia ekliptyki z horyzontem — zwracaliśmy Descendent. Planety lądowały
|
|
||||||
w przeciwnych domach dla całej północnej Skandynawii (Tromsø, Rovaniemi,
|
|
||||||
Murmańsk). Dotyczyło ~11% przypadków na tych szerokościach.
|
|
||||||
2. **Niedeterminizm whole sign na granicy znaku.** Ascendent o włos od granicy
|
|
||||||
(359,999999999976 vs 1e-10 — ta sama wartość, różne strony) przerzucał dom I
|
|
||||||
o 30°. Ten sam horoskop na innej maszynie mógł dać inny wynik.
|
|
||||||
|
|
||||||
Oba mają teraz testy regresji w `services/logic/tests/test_houses.py`, więc są
|
|
||||||
łapane także bez swissepha.
|
|
||||||
|
|
||||||
## Stan systemów domów
|
|
||||||
|
|
||||||
Etap 1 — systemy o **zamkniętym wzorze** (bez iteracji). Każdy poniższy przeszedł
|
|
||||||
zarówno zestaw brzegowy (2520 porównań), jak i losowy przemiał 20 000 przypadków
|
|
||||||
(240 000 porównań na system):
|
|
||||||
|
|
||||||
| System | Konstrukcja | Maks. odchylenie |
|
|
||||||
|---|---|---|
|
|
||||||
| whole sign | podział ekliptyki | 0,000000000° |
|
|
||||||
| equal | podział ekliptyki | 0,000000000° |
|
|
||||||
| porphyry | podział kwadrantów po ekliptyce | 0,000000000° |
|
|
||||||
| vehlow | equal z Ascendentem w środku domu I | 0,000000000° |
|
|
||||||
| morinus | równik rzutowany wprost na ekliptykę | 0,000000000° |
|
|
||||||
| regiomontanus | podział równika, koła przez punkty N/S horyzontu | 0,000000000° |
|
|
||||||
| campanus | podział wertykału pierwszego | 0,000000000° |
|
|
||||||
| alcabitus | podział łuków dobowych po równiku | 0,000000000° |
|
|
||||||
|
|
||||||
Etap 2 — systemy **łuku dobowego**, jedyne z realną granicą dziedziny:
|
|
||||||
|
|
||||||
| System | Konstrukcja | Maks. odchylenie |
|
|
||||||
|---|---|---|
|
|
||||||
| 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 (Polich–Page) jest zaimplementowany, ale NIE wypuszczony** — nie ma go
|
|
||||||
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.
|
|
||||||
|
|
||||||
Większe jądro efemeryd (de441) — **odłożone, „nice to have"**: rozszerza tylko
|
|
||||||
zakres dat, nie poprawia niczego w obecnym.
|
|
||||||
|
|
||||||
## Licencja
|
|
||||||
|
|
||||||
Swiss Ephemeris jest na AGPL i jest tu **wyłącznie wyrocznią testową** — nie wchodzi
|
|
||||||
do ścieżki produkcyjnej ani do zależności produktu. Izolacja z LOG-27 zostaje
|
|
||||||
nienaruszona.
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
"""Dziedzina wejścia funkcji liczącej domy — klasy równoważności i wartości brzegowe.
|
|
||||||
|
|
||||||
Liczenie cuspów traktujemy jako FUNKCJĘ trzech parametrów: (RAMC, ε, φ). ε nie jest
|
|
||||||
niezależne — wynika z daty, więc realnymi parametrami są (data, φ) plus RAMC, który
|
|
||||||
zależy od czasu gwiazdowego i długości geograficznej.
|
|
||||||
|
|
||||||
Zakres dat programu: 370 p.n.e. – 2270 n.e. (rok astronomiczny -369 … 2270).
|
|
||||||
|
|
||||||
PUŁAPKA, o którą łatwo się potknąć: granica dziedziny dla Placidusa i Kocha —
|
|
||||||
koło podbiegunowe — NIE jest stałą 66,56°. Zależy od ε, a ε zmienia się z datą.
|
|
||||||
W zakresie programu przesuwa się o ~0,3°. Wartości brzegowe MUSZĄ więc być liczone
|
|
||||||
per data, inaczej testy brzegowe celują obok granicy.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import random
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
# 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_MAX = 2_695_000.5 # ~2270-12-31
|
|
||||||
JD_J2000 = 2_451_545.0 # punkt odniesienia — środek dziedziny
|
|
||||||
|
|
||||||
# Systemy oparte na łuku dobowym: tracą definicję za kołem podbiegunowym.
|
|
||||||
DIURNAL_ARC_SYSTEMS = frozenset({"placidus", "koch"})
|
|
||||||
|
|
||||||
# Jak blisko granicy stawiamy wartości brzegowe (po obu stronach).
|
|
||||||
EPSILON_NUDGE = 0.01
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Case:
|
|
||||||
"""Pojedynczy zestaw danych wejściowych + skąd się wziął (do raportu)."""
|
|
||||||
ramc: float
|
|
||||||
lat: float
|
|
||||||
jd: float
|
|
||||||
label: str # "brzeg: równik" / "wnętrze" / "losowy" — grupowanie w raporcie
|
|
||||||
|
|
||||||
def key(self) -> str:
|
|
||||||
return f"ramc={self.ramc:.4f} lat={self.lat:.4f} jd={self.jd:.1f}"
|
|
||||||
|
|
||||||
|
|
||||||
def obliquity(jd: float) -> float:
|
|
||||||
"""ε dla daty — z NASZEJ implementacji, żeby granice liczyć tak samo jak silnik."""
|
|
||||||
from app.engine.houses import mean_obliquity
|
|
||||||
|
|
||||||
return mean_obliquity(jd)
|
|
||||||
|
|
||||||
|
|
||||||
def polar_circle(jd: float) -> float:
|
|
||||||
from app.engine.houses import polar_circle as pc
|
|
||||||
|
|
||||||
return pc(obliquity(jd))
|
|
||||||
|
|
||||||
|
|
||||||
def in_domain(system: str, lat: float, jd: float) -> bool:
|
|
||||||
"""Czy zestaw należy do dziedziny danego systemu.
|
|
||||||
|
|
||||||
Poza dziedziną są wyłącznie systemy łuku dobowego powyżej koła podbiegunowego
|
|
||||||
(i same bieguny, gdzie degeneruje się cała geometria)."""
|
|
||||||
if abs(lat) >= 90.0:
|
|
||||||
return False
|
|
||||||
if system in DIURNAL_ARC_SYSTEMS:
|
|
||||||
return abs(lat) < polar_circle(jd)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# ── wartości brzegowe ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _boundary_lats(jd: float) -> list[tuple[float, str]]:
|
|
||||||
"""Szerokości brzegowe DLA DANEJ DATY (koło podbiegunowe zależy od ε)."""
|
|
||||||
pc = polar_circle(jd)
|
|
||||||
out: list[tuple[float, str]] = [
|
|
||||||
(0.0, "brzeg: równik"),
|
|
||||||
(pc - EPSILON_NUDGE, "brzeg: tuż POD kołem podbieg."),
|
|
||||||
(pc + EPSILON_NUDGE, "brzeg: tuż ZA kołem podbieg."),
|
|
||||||
(-(pc - EPSILON_NUDGE), "brzeg: tuż pod kołem (płd.)"),
|
|
||||||
(-(pc + EPSILON_NUDGE), "brzeg: tuż za kołem (płd.)"),
|
|
||||||
(89.9, "brzeg: prawie biegun N"),
|
|
||||||
(-89.9, "brzeg: prawie biegun S"),
|
|
||||||
]
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _boundary_ramcs() -> list[tuple[float, str]]:
|
|
||||||
"""RAMC na krańcach i w punktach kardynalnych — tam wzory mają osobliwości."""
|
|
||||||
return [
|
|
||||||
(0.0, "brzeg: RAMC 0°"),
|
|
||||||
(90.0, "brzeg: RAMC 90°"),
|
|
||||||
(180.0, "brzeg: RAMC 180°"),
|
|
||||||
(270.0, "brzeg: RAMC 270°"),
|
|
||||||
(359.99, "brzeg: RAMC ~360°"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _boundary_jds() -> list[tuple[float, str]]:
|
|
||||||
return [
|
|
||||||
(JD_MIN, "brzeg: początek zakresu dat"),
|
|
||||||
(JD_J2000, "środek zakresu (J2000)"),
|
|
||||||
(JD_MAX, "brzeg: koniec zakresu dat"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def boundary_cases() -> list[Case]:
|
|
||||||
"""Iloczyn wartości brzegowych — pełne pokrycie kombinacji granicznych."""
|
|
||||||
out: list[Case] = []
|
|
||||||
for jd, jl in _boundary_jds():
|
|
||||||
for lat, ll in _boundary_lats(jd):
|
|
||||||
for ramc, rl in _boundary_ramcs():
|
|
||||||
out.append(Case(ramc, lat, jd, f"{ll} | {rl} | {jl}"))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def interior_cases(count: int, seed: int = 0) -> list[Case]:
|
|
||||||
"""Tyle samo przypadków z WNĘTRZA klas równoważności, ile brzegowych.
|
|
||||||
|
|
||||||
Deterministyczne przy danym ziarnie — ten sam build daje ten sam zestaw.
|
|
||||||
"""
|
|
||||||
rnd = random.Random(seed)
|
|
||||||
out: list[Case] = []
|
|
||||||
for _ in range(count):
|
|
||||||
jd = rnd.uniform(JD_MIN, JD_MAX)
|
|
||||||
pc = polar_circle(jd)
|
|
||||||
# wnętrze = z dala od granic, żeby nie dublować przypadków brzegowych
|
|
||||||
lat = rnd.uniform(-pc + 1.0, pc - 1.0)
|
|
||||||
out.append(Case(rnd.uniform(0.0, 360.0), lat, jd, "wnętrze"))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def random_cases(count: int, seed: int) -> list[Case]:
|
|
||||||
"""Wielki losowy przemiał — CAŁA dziedzina, także za kołem podbiegunowym.
|
|
||||||
|
|
||||||
Ziarno jest wymagane i raportowane: losowy test, którego nie da się odtworzyć,
|
|
||||||
jest bezużyteczny, bo czerwień raz na tydzień nie daje się zdiagnozować.
|
|
||||||
"""
|
|
||||||
rnd = random.Random(seed)
|
|
||||||
return [
|
|
||||||
Case(rnd.uniform(0.0, 360.0), rnd.uniform(-89.9, 89.9),
|
|
||||||
rnd.uniform(JD_MIN, JD_MAX), "losowy")
|
|
||||||
for _ in range(count)
|
|
||||||
]
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
"""Porównanie naszych domów z wyrocznią (Swiss Ephemeris) i raport zgodności.
|
|
||||||
|
|
||||||
ZASADA IZOLACJI: obu implementacjom podajemy TE SAME wejścia (RAMC, ε, φ) przez
|
|
||||||
`swe.houses_armc`. Gdybyśmy porównywali „nasz horoskop" z „horoskopem swissepha",
|
|
||||||
różnica w cuspie mogłaby wynikać z innego czasu gwiazdowego albo innego ε — czyli
|
|
||||||
z czegoś, co nie jest testowaną funkcją. Wtedy przy tysiącach przypadków utonęlibyśmy
|
|
||||||
w fałszywych alarmach. Czas gwiazdowy i ε testujemy OSOBNO (`compare_asc_mc`).
|
|
||||||
|
|
||||||
KRYTERIUM: nie procent zgodności, tylko liczba przypadków POWYŻEJ tolerancji plus
|
|
||||||
maksymalne odchylenie i MIEJSCE jego wystąpienia. Procent ukrywa kształt błędu:
|
|
||||||
„97%" nie odróżnia szumu zmiennoprzecinkowego od jednego rogu dziedziny, w którym
|
|
||||||
mylimy się o 30°.
|
|
||||||
|
|
||||||
LICENCJA: Swiss Ephemeris (AGPL) jest tu WYŁĄCZNIE wyrocznią testową. Nie wchodzi
|
|
||||||
do ścieżki produkcyjnej ani do zależności produktu — izolacja z LOG-27 zostaje.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
import swisseph as swe
|
|
||||||
|
|
||||||
from domain import Case, in_domain, obliquity
|
|
||||||
|
|
||||||
# Litery systemów w Swiss Ephemeris.
|
|
||||||
SWE_CODE = {
|
|
||||||
"whole_sign": b"W", "equal": b"E", "porphyry": b"O",
|
|
||||||
"placidus": b"P", "koch": b"K", "regiomontanus": b"R", "campanus": b"C",
|
|
||||||
"morinus": b"M", "alcabitus": b"B", "vehlow": b"V", "topocentric": b"T",
|
|
||||||
}
|
|
||||||
|
|
||||||
TOLERANCE_DEG = 1.0 / 3600.0 # 1″ — uzgodniony próg
|
|
||||||
|
|
||||||
|
|
||||||
def angular_diff(a: float, b: float) -> float:
|
|
||||||
"""Najmniejsza różnica kątowa [0,180] — 359,9° i 0,1° dzieli 0,2°, nie 359,8°."""
|
|
||||||
d = abs(a - b) % 360.0
|
|
||||||
return min(d, 360.0 - d)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Result:
|
|
||||||
system: str
|
|
||||||
checked: int = 0
|
|
||||||
over_tolerance: int = 0
|
|
||||||
skipped_out_of_domain: int = 0
|
|
||||||
max_dev: float = 0.0
|
|
||||||
max_where: str = ""
|
|
||||||
domain_mismatch: list[str] = field(default_factory=list)
|
|
||||||
deviations: list[float] = field(default_factory=list)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def passed(self) -> bool:
|
|
||||||
return self.over_tolerance == 0 and not self.domain_mismatch
|
|
||||||
|
|
||||||
|
|
||||||
def oracle_cusps(case: Case, eps: float, system: str) -> list[float] | None:
|
|
||||||
"""Cuspy z wyroczni albo None, gdy wyrocznia odmawia (poza dziedziną).
|
|
||||||
|
|
||||||
pyswisseph zgłasza wyjątek dla Placidusa/Kocha powyżej koła podbiegunowego —
|
|
||||||
to sygnał, że zestaw jest poza dziedziną, i sam w sobie jest użyteczny: pozwala
|
|
||||||
sprawdzić, czy NASZA detekcja granicy pokrywa się z wyrocznią."""
|
|
||||||
try:
|
|
||||||
cusps, _ = swe.houses_armc(case.ramc % 360.0, case.lat, eps, SWE_CODE[system])
|
|
||||||
return list(cusps)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def compare_asc_mc(cases: list[Case]) -> Result:
|
|
||||||
"""Test funkcji WEJŚCIOWEJ: nasze Asc/MC vs wyrocznia, przy tym samym (RAMC, ε, φ).
|
|
||||||
|
|
||||||
Osobno, bo błąd tutaj kaskadowałby na wszystkie systemy i wyglądał jak błąd domów.
|
|
||||||
"""
|
|
||||||
from app.engine.houses import compute_asc, compute_mc
|
|
||||||
|
|
||||||
r = Result(system="asc/mc")
|
|
||||||
for c in cases:
|
|
||||||
eps = obliquity(c.jd)
|
|
||||||
try:
|
|
||||||
_, ascmc = swe.houses_armc(c.ramc % 360.0, c.lat, eps, b"O")
|
|
||||||
except Exception:
|
|
||||||
r.skipped_out_of_domain += 1
|
|
||||||
continue
|
|
||||||
for ours, theirs, what in (
|
|
||||||
(compute_asc(c.ramc, eps, c.lat), ascmc[0], "Asc"),
|
|
||||||
(compute_mc(c.ramc, eps), ascmc[1], "MC"),
|
|
||||||
):
|
|
||||||
d = angular_diff(ours, theirs)
|
|
||||||
r.checked += 1
|
|
||||||
r.deviations.append(d)
|
|
||||||
if d > r.max_dev:
|
|
||||||
r.max_dev, r.max_where = d, f"{what} @ {c.key()} [{c.label}]"
|
|
||||||
if d > TOLERANCE_DEG:
|
|
||||||
r.over_tolerance += 1
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
def compare_system(system: str, cases: list[Case]) -> Result:
|
|
||||||
"""Porównanie jednego systemu domów z wyrocznią na zadanym zestawie przypadków."""
|
|
||||||
from app.engine.houses import cusps_for
|
|
||||||
|
|
||||||
r = Result(system=system)
|
|
||||||
for c in cases:
|
|
||||||
eps = obliquity(c.jd)
|
|
||||||
ours_in_domain = in_domain(system, c.lat, c.jd)
|
|
||||||
theirs = oracle_cusps(c, eps, system)
|
|
||||||
|
|
||||||
# Zgodność co do DZIEDZINY: jeśli wyrocznia odmawia, a my uważamy, że
|
|
||||||
# liczymy — nasza detekcja granicy jest zepsuta (albo odwrotnie).
|
|
||||||
if (theirs is None) != (not ours_in_domain):
|
|
||||||
r.domain_mismatch.append(
|
|
||||||
f"{c.key()} [{c.label}] — my:{'w dziedzinie' if ours_in_domain else 'poza'}, "
|
|
||||||
f"wyrocznia:{'odmawia' if theirs is None else 'liczy'}")
|
|
||||||
continue
|
|
||||||
if theirs is None:
|
|
||||||
r.skipped_out_of_domain += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
ours = cusps_for(c.ramc, eps, c.lat, system)
|
|
||||||
except Exception as e: # nasz kod NIE MOŻE się wywalić
|
|
||||||
r.domain_mismatch.append(f"{c.key()} [{c.label}] — nasz kod rzucił: {e}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
for i in range(12):
|
|
||||||
d = angular_diff(ours[i], theirs[i])
|
|
||||||
r.checked += 1
|
|
||||||
r.deviations.append(d)
|
|
||||||
if d > r.max_dev:
|
|
||||||
r.max_dev, r.max_where = d, f"dom {i + 1} @ {c.key()} [{c.label}]"
|
|
||||||
if d > TOLERANCE_DEG:
|
|
||||||
r.over_tolerance += 1
|
|
||||||
return r
|
|
||||||
|
|
||||||
|
|
||||||
def histogram(devs: list[float]) -> str:
|
|
||||||
"""Rozkład odchyleń — pokazuje KSZTAŁT zgodności, nie tylko sam maksimum."""
|
|
||||||
if not devs:
|
|
||||||
return " (brak danych)"
|
|
||||||
buckets = [
|
|
||||||
("= 0", lambda d: d == 0.0),
|
|
||||||
("< 1e-9°", lambda d: 0 < d < 1e-9),
|
|
||||||
("< 1e-6°", lambda d: 1e-9 <= d < 1e-6),
|
|
||||||
("< 1″", lambda d: 1e-6 <= d < TOLERANCE_DEG),
|
|
||||||
("≥ 1″ (BŁĄD)", lambda d: d >= TOLERANCE_DEG),
|
|
||||||
]
|
|
||||||
out = []
|
|
||||||
for name, pred in buckets:
|
|
||||||
n = sum(1 for d in devs if pred(d))
|
|
||||||
if n:
|
|
||||||
out.append(f" {name:14} {n:7} {'█' * min(40, 1 + n * 40 // len(devs))}")
|
|
||||||
return "\n".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
def format_report(results: list[Result], seed: int | None = None) -> str:
|
|
||||||
lines = ["", "=" * 78,
|
|
||||||
"ZGODNOŚĆ Z WYROCZNIĄ (Swiss Ephemeris) — domy astrologiczne",
|
|
||||||
f"tolerancja: {TOLERANCE_DEG:.8f}° (1″)" + (f" ziarno: {seed}" if seed is not None else ""),
|
|
||||||
"=" * 78,
|
|
||||||
f"{'SYSTEM':<14}{'SPRAWDZONYCH':>13}{'>TOL':>7}{'POZA DZIEDZ.':>14}{'MAX ODCH.':>14} WYNIK"]
|
|
||||||
for r in results:
|
|
||||||
lines.append(f"{r.system:<14}{r.checked:>13}{r.over_tolerance:>7}"
|
|
||||||
f"{r.skipped_out_of_domain:>14}{r.max_dev:>14.9f} "
|
|
||||||
f"{'OK' if r.passed else 'BŁĄD'}")
|
|
||||||
for r in results:
|
|
||||||
if r.max_dev > 0 or not r.passed:
|
|
||||||
lines += ["", f" [{r.system}] największe odchylenie: {r.max_dev:.9f}° — {r.max_where}"]
|
|
||||||
if r.domain_mismatch:
|
|
||||||
lines.append(f" [{r.system}] NIEZGODNOŚĆ DZIEDZINY ({len(r.domain_mismatch)}):")
|
|
||||||
lines += [f" {m}" for m in r.domain_mismatch[:5]]
|
|
||||||
if len(r.domain_mismatch) > 5:
|
|
||||||
lines.append(f" … i {len(r.domain_mismatch) - 5} więcej")
|
|
||||||
lines += ["", " Rozkład odchyleń (wszystkie systemy):", histogram(
|
|
||||||
[d for r in results for d in r.deviations]), "=" * 78]
|
|
||||||
return "\n".join(lines)
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
"""Uruchomienie porównania z wyrocznią. Punkt wejścia dla CI i dla Joba w klastrze.
|
|
||||||
|
|
||||||
Dwa tryby:
|
|
||||||
--mode build (domyślny) zestaw BRZEGOWY + tyle samo przypadków z wnętrza.
|
|
||||||
Szybki, deterministyczny, BLOKUJE build przy błędzie.
|
|
||||||
--mode sweep wielki losowy przemiał, o rząd wielkości większy.
|
|
||||||
Do Joba w klastrze; ziarno raportowane, więc każdy
|
|
||||||
błąd da się odtworzyć komendą z raportu.
|
|
||||||
|
|
||||||
Kod wyjścia: 0 = zgodność w granicach tolerancji, 1 = przekroczenia albo
|
|
||||||
niezgodność dziedziny. Dzięki temu CI może na tym polegać bez parsowania tekstu.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Ścieżka do warstwy logicznej: w CI montujemy ją do kontenera swissepha.
|
|
||||||
sys.path.insert(0, os.environ.get("LOGIC_PATH", "/logic"))
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
|
|
||||||
from domain import boundary_cases, interior_cases, random_cases # noqa: E402
|
|
||||||
from harness import ( # noqa: E402
|
|
||||||
SWE_CODE, compare_asc_mc, compare_system, format_report,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Systemy do sprawdzenia. Rośnie wraz z implementacją kolejnych (Etap 1 i 2) —
|
|
||||||
# dopisanie nazwy tutaj wystarcza, żeby weszła do każdego builda.
|
|
||||||
SYSTEMS = ["whole_sign", "equal", "porphyry", "vehlow", "morinus",
|
|
||||||
"regiomontanus", "campanus", "alcabitus", "placidus", "koch"]
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
p = argparse.ArgumentParser(description="Zgodność domów z wyrocznią (Swiss Ephemeris)")
|
|
||||||
p.add_argument("--mode", choices=("build", "sweep"), default="build")
|
|
||||||
p.add_argument("--seed", type=int, default=0, help="ziarno losowania (do odtworzenia błędu)")
|
|
||||||
p.add_argument("--count", type=int, default=20000, help="liczba przypadków w trybie sweep")
|
|
||||||
p.add_argument("--systems", default=",".join(SYSTEMS))
|
|
||||||
args = p.parse_args()
|
|
||||||
|
|
||||||
systems = [s.strip() for s in args.systems.split(",") if s.strip()]
|
|
||||||
unknown = [s for s in systems if s not in SWE_CODE]
|
|
||||||
if unknown:
|
|
||||||
print(f"Nieznane systemy: {unknown}", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
if args.mode == "build":
|
|
||||||
boundary = boundary_cases()
|
|
||||||
# Tyle samo z wnętrza, ile brzegowych — brzegi łapią osobliwości,
|
|
||||||
# wnętrze pilnuje, że nie zepsuliśmy zwykłych przypadków.
|
|
||||||
cases = boundary + interior_cases(len(boundary), seed=args.seed)
|
|
||||||
seed_note = args.seed
|
|
||||||
else:
|
|
||||||
cases = random_cases(args.count, seed=args.seed)
|
|
||||||
seed_note = args.seed
|
|
||||||
|
|
||||||
results = [compare_asc_mc(cases)]
|
|
||||||
results += [compare_system(s, cases) for s in systems]
|
|
||||||
|
|
||||||
print(f"\nprzypadków wejściowych: {len(cases)} (tryb: {args.mode})")
|
|
||||||
print(format_report(results, seed=seed_note))
|
|
||||||
|
|
||||||
failed = [r for r in results if not r.passed]
|
|
||||||
if failed:
|
|
||||||
print("\nBŁĄD: " + ", ".join(r.system for r in failed))
|
|
||||||
print(f"Odtworzenie: python run.py --mode {args.mode} --seed {args.seed}"
|
|
||||||
+ (f" --count {args.count}" if args.mode == "sweep" else ""))
|
|
||||||
return 1
|
|
||||||
print("\nWszystkie systemy zgodne z wyrocznią w granicach tolerancji.")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
Reference in New Issue
Block a user