Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2aabd37a5 | |||
| de5d58958c | |||
| ab6fc0722c | |||
| 6ced2ddd22 | |||
| 79bce8ae90 | |||
| 85e9182f12 | |||
| c75f8377bf | |||
| 5234e86c95 | |||
| 0ac8df250c | |||
| 9323803cb4 | |||
| 678c3bad76 | |||
| 88a1ecd3c4 | |||
| b28c612bf7 | |||
| 2bda4e311d | |||
| 5d0d86cb90 | |||
| 9d5b65ddbd | |||
| cff0ac194c | |||
| 73f39e7df8 | |||
| 96d983b26a | |||
| c4a181b810 | |||
| 97ad21d2e4 | |||
| 2dbd3410e9 | |||
| ca458fd741 | |||
| b013831492 | |||
| d14a77360a | |||
| a8c3072e62 | |||
| 93932246f3 | |||
| 8d579de34a | |||
| db3d1e5117 | |||
| 4a86d34f5a | |||
| fb317172ff | |||
| f6323cac10 | |||
| 243d02d55b | |||
| 166b438f83 | |||
| 41d7ca491d | |||
| 6acd3546fb | |||
| 82809665ff | |||
| 0e74566a78 | |||
| cea6907f84 | |||
| dabbaef9dd | |||
| a81ae698a3 | |||
| eef67d37b5 | |||
| 94c3023d3a | |||
| e4f6a16c9c | |||
| 413c46b5dd | |||
| 340b3058e4 | |||
| 376a4bfded | |||
| e69c0714b9 |
@@ -0,0 +1,32 @@
|
|||||||
|
name: build-swisseph
|
||||||
|
|
||||||
|
# Osobny pipeline dla silnika B (Swiss Ephemeris, AGPL) — celowo ODDZIELONY od
|
||||||
|
# głównego build.yaml (data/logic/presentation). Buduje się tylko, gdy zmienia się
|
||||||
|
# sam silnik, i nie miesza obrazu AGPL do pipeline'u permisywnego produktu.
|
||||||
|
#
|
||||||
|
# Obraz konsumuje profil deployu `astrololo-swisseph` w repo `deploy`.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- 'services/engine-swisseph/**'
|
||||||
|
- '.gitea/workflows/build-swisseph.yaml'
|
||||||
|
workflow_dispatch: {} # ręczne odpalenie (bootstrap pierwszego obrazu)
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Login
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
|
||||||
|
- name: Build & push engine-swisseph (AGPL, izolowany)
|
||||||
|
run: |
|
||||||
|
TAG=${GITHUB_SHA::8}
|
||||||
|
IMG=gitea.czernobog.pl/gitea/astrololo-engine-swisseph
|
||||||
|
# Obraz kompiluje pyswisseph ze źródeł (brak wheeli dla cp312) — build jest
|
||||||
|
# zarazem realnym testem Dockerfile'a.
|
||||||
|
docker build -t $IMG:$TAG -t $IMG:latest ./services/engine-swisseph
|
||||||
|
docker push $IMG:$TAG
|
||||||
|
docker push $IMG:latest
|
||||||
|
echo "Zbudowano i wypchnięto: $IMG:$TAG (+ latest)"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
name: build
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Login
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
|
||||||
|
- name: Build & push (data, logic, presentation)
|
||||||
|
run: |
|
||||||
|
TAG=${GITHUB_SHA::8}
|
||||||
|
for SVC in data logic presentation; do
|
||||||
|
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
|
||||||
|
docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG
|
||||||
|
done
|
||||||
|
echo "Tag: $TAG"
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
name: Testy
|
||||||
|
|
||||||
|
# Odpala się przy każdym pushu (dowolna gałąź) oraz dla pull requestów do master.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
logic-tests:
|
||||||
|
name: Testy warstwy logicznej (silnik)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Python 3.12 (jak w obrazach Dockera)
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: services/logic/requirements-dev.txt
|
||||||
|
|
||||||
|
# Jądro efemeryd JPL (de421.bsp, ~17 MB). Cache'ujemy je między runami.
|
||||||
|
- name: Cache jądra efemeryd
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: services/logic/.ephemeris
|
||||||
|
key: ephemeris-de421
|
||||||
|
|
||||||
|
# Pobieramy jawnie (a nie licząc na auto-pobranie przez Skyfield), żeby
|
||||||
|
# brak jądra był twardym błędem, a nie cichym pomijaniem testów.
|
||||||
|
- name: Pobierz jądro efemeryd (gdy brak w cache)
|
||||||
|
run: |
|
||||||
|
mkdir -p services/logic/.ephemeris
|
||||||
|
if [ ! -s services/logic/.ephemeris/de421.bsp ]; then
|
||||||
|
curl -fSL --retry 3 --max-time 300 \
|
||||||
|
-o services/logic/.ephemeris/de421.bsp \
|
||||||
|
https://ssd.jpl.nasa.gov/ftp/eph/planets/bsp/de421.bsp
|
||||||
|
fi
|
||||||
|
ls -lh services/logic/.ephemeris/de421.bsp
|
||||||
|
|
||||||
|
- name: Instalacja zależności
|
||||||
|
run: pip install -r services/logic/requirements-dev.txt
|
||||||
|
|
||||||
|
# CI=true (ustawiane przez GitHub) sprawia, że brak silnika = błąd,
|
||||||
|
# a nie pominięcie — patrz tests/conftest.py.
|
||||||
|
- name: Testy (pytest)
|
||||||
|
working-directory: services/logic
|
||||||
|
env:
|
||||||
|
PYTHONPATH: .
|
||||||
|
EPHEMERIS_DIR: ${{ github.workspace }}/services/logic/.ephemeris
|
||||||
|
run: pytest tests -q -rs
|
||||||
|
|
||||||
|
swisseph-image:
|
||||||
|
name: Build obrazu silnika B (swisseph)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Obraz kompiluje pyswisseph ze źródeł (brak wheeli dla cp312), więc ten
|
||||||
|
# build jest realnym testem Dockerfile'a — nie tylko pobraniem paczek.
|
||||||
|
- name: docker build
|
||||||
|
run: docker build -t astrololo/engine-swisseph:ci services/engine-swisseph
|
||||||
|
|
||||||
|
- name: Smoke test (health + pozycje)
|
||||||
|
run: |
|
||||||
|
docker run -d --name swe -p 8003:8003 astrololo/engine-swisseph:ci
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
curl -fsS http://localhost:8003/health >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
curl -fsS http://localhost:8003/health
|
||||||
|
echo
|
||||||
|
# Horoskop referencyjny (30.04.1984) — ten sam, na którym opieramy testy
|
||||||
|
# silnika własnego; sprawdzamy, że silnik B faktycznie liczy.
|
||||||
|
curl -fsS -X POST http://localhost:8003/positions \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"when_utc":"1984-04-30T09:20:00Z","lat":50.0647,"lon":19.9450}'
|
||||||
|
echo
|
||||||
|
|
||||||
|
- name: Logi kontenera (gdy coś padło)
|
||||||
|
if: failure()
|
||||||
|
run: docker logs swe || true
|
||||||
|
|
||||||
|
compile-all:
|
||||||
|
name: Kontrola składni wszystkich warstw
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
# Sam kompilator — bez instalowania zależności warstw (w tym AGPL-owego
|
||||||
|
# silnika swisseph, który nie wchodzi do produktu).
|
||||||
|
- name: py_compile
|
||||||
|
run: python -m compileall -q services
|
||||||
@@ -10,6 +10,10 @@ services/data/.cache/
|
|||||||
*.parquet
|
*.parquet
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
|
# Cache efemeryd silnika własnego (pobierane jądra JPL, regenerowalne)
|
||||||
|
services/logic/.ephemeris/
|
||||||
|
*.bsp
|
||||||
|
|
||||||
# Dane wejściowe (duże pliki Excela trzymane poza repo)
|
# Dane wejściowe (duże pliki Excela trzymane poza repo)
|
||||||
services/data/data_files/*.xlsx
|
services/data/data_files/*.xlsx
|
||||||
!services/data/data_files/.gitkeep
|
!services/data/data_files/.gitkeep
|
||||||
|
|||||||
@@ -1,31 +1,56 @@
|
|||||||
.PHONY: help up down sample reindex migrate sql dev-data dev-logic dev-presentation
|
.PHONY: help install up down sample reindex migrate sql test clean-cache \
|
||||||
|
dev-data dev-logic dev-presentation
|
||||||
|
|
||||||
|
# Autodetekcja narzędzia compose: nowe "docker compose" albo stare "docker-compose".
|
||||||
|
COMPOSE := $(shell if docker compose version >/dev/null 2>&1; then echo "docker compose"; \
|
||||||
|
elif command -v docker-compose >/dev/null 2>&1; then echo "docker-compose"; fi)
|
||||||
|
|
||||||
|
# Python aktywnego środowiska (venv). Nadpisz: make install PY=python3.12
|
||||||
|
PY ?= python
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "up - uruchom wszystkie 3 warstwy (docker compose)"
|
@echo "install - zainstaluj zależności WSZYSTKICH warstw do aktywnego venv"
|
||||||
@echo "down - zatrzymaj"
|
@echo "sample - wygeneruj przykładowe pliki .xlsx"
|
||||||
@echo "sample - wygeneruj przykładowe pliki .xlsx"
|
@echo "dev-data - uruchom warstwę danych lokalnie (:8002)"
|
||||||
@echo "reindex - zbuduj cache + indeks warstwy bazodanowej"
|
@echo "dev-logic - uruchom warstwę logiczną lokalnie (:8001)"
|
||||||
@echo "migrate - ETL: Excel -> SQL"
|
@echo "dev-presentation - uruchom warstwę prezentacji lokalnie (:8000)"
|
||||||
@echo "sql - uruchom z warstwą SQL (DATA_PROVIDER=sql)"
|
@echo "test - testy warstwy logicznej (silnik efemeryd)"
|
||||||
@echo "dev-* - uruchom pojedynczą warstwę lokalnie (bez dockera)"
|
@echo "clean-cache - wyczyść regenerowalny cache warstwy danych"
|
||||||
|
@echo "reindex - zbuduj cache + indeks warstwy bazodanowej"
|
||||||
|
@echo "migrate - ETL: Excel -> SQL"
|
||||||
|
@echo "up / down - uruchom / zatrzymaj cały stos (Docker; wykryto: $(COMPOSE))"
|
||||||
|
@echo "sql - Docker z warstwą SQL (DATA_PROVIDER=sql)"
|
||||||
|
@echo ""
|
||||||
|
@echo "Szybki start lokalny (bez Dockera), w 3 terminalach:"
|
||||||
|
@echo " make install"
|
||||||
|
@echo " make sample # opcjonalnie: dane przykładowe"
|
||||||
|
@echo " make dev-data # terminal 1"
|
||||||
|
@echo " make dev-logic # terminal 2"
|
||||||
|
@echo " make dev-presentation # terminal 3 -> http://localhost:8000"
|
||||||
|
|
||||||
up:
|
# --- instalacja zależności do aktywnego środowiska ---
|
||||||
docker compose up --build
|
install:
|
||||||
|
$(PY) -m pip install \
|
||||||
down:
|
-r services/data/requirements.txt \
|
||||||
docker compose down
|
-r services/logic/requirements.txt \
|
||||||
|
-r services/presentation/requirements.txt
|
||||||
|
@echo "OK. Zależności warstw zainstalowane. (Silnik AGPL 'engine-swisseph' instaluje się osobno.)"
|
||||||
|
|
||||||
sample:
|
sample:
|
||||||
cd services/data && python scripts/make_sample_data.py
|
cd services/data && $(PY) scripts/make_sample_data.py
|
||||||
|
|
||||||
reindex:
|
reindex:
|
||||||
cd services/data && python -m app.ingest.build_index
|
cd services/data && $(PY) -m app.ingest.build_index
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
cd services/data && python -m app.ingest.to_sql
|
cd services/data && $(PY) -m app.ingest.to_sql
|
||||||
|
|
||||||
sql:
|
test:
|
||||||
DATA_PROVIDER=sql docker compose up --build
|
cd services/logic && PYTHONPATH=. $(PY) -m pytest tests -q
|
||||||
|
|
||||||
|
clean-cache:
|
||||||
|
rm -rf services/data/.cache/* services/logic/.ephemeris/* 2>/dev/null || true
|
||||||
|
@echo "Cache wyczyszczony."
|
||||||
|
|
||||||
# --- lokalny dev (3 osobne terminale) ---
|
# --- lokalny dev (3 osobne terminale) ---
|
||||||
dev-data:
|
dev-data:
|
||||||
@@ -36,3 +61,20 @@ dev-logic:
|
|||||||
|
|
||||||
dev-presentation:
|
dev-presentation:
|
||||||
cd services/presentation && LOGIC_URL=http://localhost:8001 uvicorn app.main:app --reload --port 8000
|
cd services/presentation && LOGIC_URL=http://localhost:8001 uvicorn app.main:app --reload --port 8000
|
||||||
|
|
||||||
|
# --- Docker (opcjonalnie) ---
|
||||||
|
up:
|
||||||
|
@if [ -z "$(COMPOSE)" ]; then \
|
||||||
|
echo "Nie znaleziono Docker Compose (ani 'docker compose', ani 'docker-compose')."; \
|
||||||
|
echo "Użyj trybu lokalnego: make install, potem dev-data / dev-logic / dev-presentation."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
$(COMPOSE) up --build
|
||||||
|
|
||||||
|
down:
|
||||||
|
@if [ -z "$(COMPOSE)" ]; then echo "Brak Docker Compose."; exit 1; fi
|
||||||
|
$(COMPOSE) down
|
||||||
|
|
||||||
|
sql:
|
||||||
|
@if [ -z "$(COMPOSE)" ]; then echo "Brak Docker Compose."; exit 1; fi
|
||||||
|
DATA_PROVIDER=sql $(COMPOSE) up --build
|
||||||
|
|||||||
@@ -28,13 +28,20 @@ make up # zbuduj i uruchom 3 warstwy
|
|||||||
# otwórz http://localhost:8000
|
# otwórz http://localhost:8000
|
||||||
```
|
```
|
||||||
|
|
||||||
## Szybki start (lokalnie, 3 terminale)
|
## Szybki start (lokalnie, bez Dockera — zalecane)
|
||||||
|
```bash
|
||||||
|
python -m venv .env && source .env/bin/activate # venv (jednorazowo)
|
||||||
|
make install # zależności WSZYSTKICH warstw
|
||||||
|
make sample # opcjonalnie: dane przykładowe
|
||||||
|
```
|
||||||
|
Potem w 3 osobnych terminalach (w każdym `source .env/bin/activate`):
|
||||||
```bash
|
```bash
|
||||||
cd services/data && pip install -r requirements.txt && python scripts/make_sample_data.py
|
|
||||||
make dev-data # terminal 1 -> :8002
|
make dev-data # terminal 1 -> :8002
|
||||||
make dev-logic # terminal 2 -> :8001
|
make dev-logic # terminal 2 -> :8001
|
||||||
make dev-presentation # terminal 3 -> :8000
|
make dev-presentation # terminal 3 -> :8000 -> http://localhost:8000
|
||||||
```
|
```
|
||||||
|
> `make install` instaluje zależności wszystkich trzech warstw do aktywnego venv.
|
||||||
|
> Testy silnika: `make test`. Wyczyszczenie cache: `make clean-cache`.
|
||||||
|
|
||||||
## Modułowość — dowód
|
## Modułowość — dowód
|
||||||
- Wymień prezentację (np. na SPA/React) → reszta bez zmian, kontrakt `/api/query` stały.
|
- Wymień prezentację (np. na SPA/React) → reszta bez zmian, kontrakt `/api/query` stały.
|
||||||
@@ -66,3 +73,4 @@ export DATA_PROVIDER=sql # przełącz całą warstwę
|
|||||||
`SqlDataProvider` realizuje ten sam kontrakt `/search`, więc **warstwa logiczna i
|
`SqlDataProvider` realizuje ten sam kontrakt `/search`, więc **warstwa logiczna i
|
||||||
prezentacji nie zmieniają ani jednej linii**. Odwrócony indeks z L4 (SQLite) jest
|
prezentacji nie zmieniają ani jednej linii**. Odwrócony indeks z L4 (SQLite) jest
|
||||||
już pomostem — rozbudowa o wszystkie kolumny = docelowa baza.
|
już pomostem — rozbudowa o wszystkie kolumny = docelowa baza.
|
||||||
|
# updater test Mon 20 Jul 2026 19:52:08 CEST
|
||||||
|
|||||||
@@ -16,11 +16,23 @@ services:
|
|||||||
build: ./services/logic
|
build: ./services/logic
|
||||||
environment:
|
environment:
|
||||||
DATA_URL: http://data:8002
|
DATA_URL: http://data:8002
|
||||||
|
EPHEMERIS_ENGINE: own # silnik własny (permisywny) — domyślny
|
||||||
|
# adres silnika B (AGPL) używany tylko w trybie porównawczym (profil comparison)
|
||||||
|
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
|
||||||
depends_on:
|
depends_on:
|
||||||
- data
|
- data
|
||||||
ports:
|
ports:
|
||||||
- "8001:8001"
|
- "8001:8001"
|
||||||
|
|
||||||
|
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
|
||||||
|
# docker compose --profile comparison up
|
||||||
|
# Nie wchodzi do domyślnego (zamkniętego) produktu — patrz services/engine-swisseph/LICENSE.
|
||||||
|
engine-swisseph:
|
||||||
|
build: ./services/engine-swisseph
|
||||||
|
profiles: ["comparison"]
|
||||||
|
ports:
|
||||||
|
- "8003:8003"
|
||||||
|
|
||||||
presentation:
|
presentation:
|
||||||
build: ./services/presentation
|
build: ./services/presentation
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
+7
-2
@@ -31,5 +31,10 @@ class FrameCache:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def put(self, fp: str, sheet: str, frame: pd.DataFrame) -> None:
|
def put(self, fp: str, sheet: str, frame: pd.DataFrame) -> None:
|
||||||
# astype(str) na kolumnach object zapewnia stabilny zapis Parquet
|
# Kolumny z heterogenicznych plików Excela bywają mieszane (int + str +
|
||||||
frame.to_parquet(self._path(fp, sheet), index=False)
|
# NaN w jednej kolumnie) — pyarrow tego nie zapisze. Warstwa danych i tak
|
||||||
|
# operuje na tekście (wyszukiwanie po .astype(str)), więc zapisujemy ramkę
|
||||||
|
# jako string (brak wartości -> pusty tekst). Gwarantuje to stabilny zapis
|
||||||
|
# Parquet niezależnie od zawartości pliku źródłowego.
|
||||||
|
safe = frame.astype("string").fillna("")
|
||||||
|
safe.to_parquet(self._path(fp, sheet), index=False)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class SearchQuery(BaseModel):
|
|||||||
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
|
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
|
||||||
value: str = Field(..., description="Szukana wartość.")
|
value: str = Field(..., description="Szukana wartość.")
|
||||||
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
|
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
|
||||||
limit: int = Field(50, ge=1, le=1000)
|
limit: int = Field(50, ge=1, le=50000)
|
||||||
fields: list[str] | None = Field(
|
fields: list[str] | None = Field(
|
||||||
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
|
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -82,7 +82,10 @@ class ExcelDataProvider(DataProvider):
|
|||||||
|
|
||||||
def warmup(self) -> None:
|
def warmup(self) -> None:
|
||||||
for path in self._excel_files():
|
for path in self._excel_files():
|
||||||
self._ensure_indexed(path)
|
try:
|
||||||
|
self._ensure_indexed(path)
|
||||||
|
except Exception as e: # jeden uszkodzony plik nie może zablokować startu
|
||||||
|
print(f"[data] pominięto plik przy indeksowaniu: {path} — {e}")
|
||||||
|
|
||||||
def _excel_files(self) -> list[str]:
|
def _excel_files(self) -> list[str]:
|
||||||
base = Path(self.s.excel_dir)
|
base = Path(self.s.excel_dir)
|
||||||
@@ -112,7 +115,9 @@ class ExcelDataProvider(DataProvider):
|
|||||||
if query.exact:
|
if query.exact:
|
||||||
mask = col.str.lower() == query.value.lower()
|
mask = col.str.lower() == query.value.lower()
|
||||||
else:
|
else:
|
||||||
mask = col.str.lower().str.contains(query.value.lower(), na=False)
|
# regex=False: wartości sygnifikatorów zawierają znaki [ + itd.,
|
||||||
|
# które są metaznakami regex — szukamy dosłownie.
|
||||||
|
mask = col.str.lower().str.contains(query.value.lower(), na=False, regex=False)
|
||||||
matched = frame[mask]
|
matched = frame[mask]
|
||||||
if query.fields:
|
if query.fields:
|
||||||
keep = [c for c in query.fields if c in matched.columns]
|
keep = [c for c in query.fields if c in matched.columns]
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Build wieloetapowy — bo `pyswisseph` to rozszerzenie C bez gotowych wheeli.
|
||||||
|
#
|
||||||
|
# Na PyPI (2.10.3.2) wheels kończą się na cp311 i obejmują wyłącznie i686/x86_64.
|
||||||
|
# Dla Pythona 3.12 oraz dla arm64 pip ZAWSZE kompiluje ze źródeł, a `-slim` nie ma
|
||||||
|
# kompilatora — dlatego jednoetapowy build tu padał. Kompilujemy w etapie builder,
|
||||||
|
# a do obrazu finalnego wchodzi już tylko gotowy wheel (bez toolchaina).
|
||||||
|
|
||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
|
||||||
|
|
||||||
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /wheels /wheels
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \
|
||||||
|
&& rm -rf /wheels
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Sanity check na etapie budowania: brak działającego swissepha ma wywalić build,
|
||||||
|
# a nie dopiero pierwszy request.
|
||||||
|
RUN python -c "import swisseph as swe; swe.set_ephe_path(None); print('swisseph OK', swe.version)"
|
||||||
|
|
||||||
|
EXPOSE 8003
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8003"]
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
Ten komponent (services/engine-swisseph) jest licencjonowany na warunkach
|
||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE wersja 3 (AGPL-3.0-or-later).
|
||||||
|
|
||||||
|
Powód: linkuje bibliotekę pyswisseph / Swiss Ephemeris (Astrodienst AG), która
|
||||||
|
jest udostępniana na zasadzie podwójnego licencjonowania: AGPL-3.0 ALBO płatna
|
||||||
|
licencja komercyjna. Wybierając wariant AGPL, ten komponent również jest AGPL.
|
||||||
|
|
||||||
|
WAŻNE — IZOLACJA: ten komponent jest celowo wydzielony jako osobny proces/usługa
|
||||||
|
i komunikuje się z resztą systemu wyłącznie przez HTTP. Pozostałe komponenty
|
||||||
|
projektu (warstwa prezentacji, warstwa logiczna z silnikiem własnym, warstwa
|
||||||
|
danych) NIE są dziełem pochodnym tego komponentu ani Swiss Ephemeris i pozostają
|
||||||
|
na licencji permisywnej. Ten komponent NIE wchodzi do dystrybucji zamkniętego
|
||||||
|
produktu — służy jako wyrocznia walidacyjna / tryb porównawczy (dev/CI).
|
||||||
|
|
||||||
|
Pełny tekst licencji AGPL-3.0: https://www.gnu.org/licenses/agpl-3.0.txt
|
||||||
|
|
||||||
|
Alternatywa: zamiast wariantu AGPL można nabyć komercyjną licencję Swiss
|
||||||
|
Ephemeris od Astrodienst AG — wówczas warunki tego komponentu należy dostosować
|
||||||
|
do tej licencji.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# engine-swisseph (silnik B — AGPL, izolowany)
|
||||||
|
|
||||||
|
Osobna, **opcjonalna** usługa będąca drugim silnikiem efemeryd (LOG‑27). Liczy
|
||||||
|
pozycje przez **pyswisseph / Swiss Ephemeris** i służy jako **wyrocznia
|
||||||
|
walidacyjna / tryb porównawczy** (LOG‑25/26) dla naszego silnika własnego.
|
||||||
|
|
||||||
|
## ⚠️ Licencja
|
||||||
|
Ten komponent jest **AGPL‑3.0** (bo linkuje Swiss Ephemeris) — patrz [LICENSE](LICENSE).
|
||||||
|
Jest **wydzielony jako osobny proces** i wołany przez HTTP, więc nie „zaraża"
|
||||||
|
permisywnej reszty systemu. **Nie wchodzi do dystrybucji zamkniętego produktu.**
|
||||||
|
|
||||||
|
## API
|
||||||
|
- `POST /positions` → `{when_utc, lat, lon, objects?}` → pozycje (ten sam kształt co silnik własny)
|
||||||
|
- `GET /health`
|
||||||
|
|
||||||
|
Tryb Moshiera (`FLG_MOSEPH`) — bez plików efemeryd, zero konfiguracji.
|
||||||
|
|
||||||
|
## Build obrazu
|
||||||
|
```bash
|
||||||
|
docker compose --profile comparison build engine-swisseph
|
||||||
|
```
|
||||||
|
Dockerfile jest **wieloetapowy** i to nie jest ozdobnik: `pyswisseph` to rozszerzenie
|
||||||
|
C, a na PyPI (2.10.3.2) gotowe wheels kończą się na **cp311** i obejmują wyłącznie
|
||||||
|
**i686/x86_64**. Dla Pythona 3.12 oraz dla arm64 pip musi kompilować ze źródeł, więc
|
||||||
|
sam `python:3.12-slim` (bez kompilatora) build wywracał. Kompilacja idzie w etapie
|
||||||
|
`builder` (`build-essential`), a do obrazu finalnego trafia już tylko gotowy wheel —
|
||||||
|
runtime zostaje czysty i mały. Pierwszy build trwa ~1–2 min, kolejne idą z cache warstw.
|
||||||
|
|
||||||
|
Build kończy się sanity-checkiem (`import swisseph`), żeby niedziałający silnik
|
||||||
|
wykrzaczył build, a nie dopiero pierwszy request.
|
||||||
|
|
||||||
|
## Uruchomienie (tylko profil porównawczy / dev / CI)
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt # wymaga kompilatora C (patrz wyżej)
|
||||||
|
uvicorn app.main:app --port 8003
|
||||||
|
```
|
||||||
|
Następnie w warstwie logicznej ustaw `ENGINE_SWISSEPH_URL=http://localhost:8003`,
|
||||||
|
aby włączyć silnik B (tryb dual-run i testy kontraktowe silnika B).
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""engine-swisseph — IZOLOWANA usługa silnika B (AGPL).
|
||||||
|
|
||||||
|
UWAGA LICENCYJNA: ta usługa linkuje pyswisseph / Swiss Ephemeris, więc jest
|
||||||
|
objęta **AGPL-3.0** i jest licencjonowana osobno (patrz ./LICENSE). Jest celowo
|
||||||
|
wydzielona jako osobny proces i wołana przez HTTP — dzięki temu permisywny
|
||||||
|
produkt (prezentacja + logika z silnikiem własnym + dane) NIE jest linkowany z
|
||||||
|
kodem AGPL i nie podlega jego obowiązkom (LOG-27).
|
||||||
|
|
||||||
|
Rola: wyrocznia walidacyjna / tryb porównawczy (LOG-25/26). Nie wchodzi do
|
||||||
|
dystrybucji zamkniętej — uruchamiana tylko w profilu porównawczym/dev/CI.
|
||||||
|
|
||||||
|
Udostępnia ten sam kontrakt co RemoteEngine po stronie warstwy logicznej.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
import swisseph as swe
|
||||||
|
|
||||||
|
app = FastAPI(title="astrololo · engine-swisseph (AGPL, izolowany)")
|
||||||
|
|
||||||
|
# Tryb Moshiera: bez plików efemeryd, w pełni samowystarczalny (~0,1\" dokładności).
|
||||||
|
_FLAGS = swe.FLG_MOSEPH | swe.FLG_SPEED
|
||||||
|
|
||||||
|
_PLANETS = {
|
||||||
|
"Sun": swe.SUN, "Moon": swe.MOON, "Mercury": swe.MERCURY, "Venus": swe.VENUS,
|
||||||
|
"Mars": swe.MARS, "Jupiter": swe.JUPITER, "Saturn": swe.SATURN,
|
||||||
|
"Uranus": swe.URANUS, "Neptune": swe.NEPTUNE, "Pluto": swe.PLUTO,
|
||||||
|
# punkty wirtualne — mean, jak w silniku własnym (parzystość LOG-28)
|
||||||
|
"North Node": swe.MEAN_NODE, "Lilith": swe.MEAN_APOG,
|
||||||
|
# "South Node" obsługiwany pochodnie w /positions: NN + 180°
|
||||||
|
}
|
||||||
|
DEFAULT_OBJECTS = [
|
||||||
|
"Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn",
|
||||||
|
"Uranus", "Neptune", "Pluto", "North Node", "South Node", "Lilith",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PositionsRequest(BaseModel):
|
||||||
|
when_utc: datetime
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
objects: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/positions")
|
||||||
|
def positions(req: PositionsRequest) -> dict:
|
||||||
|
d = req.when_utc
|
||||||
|
ut_hours = d.hour + d.minute / 60.0 + d.second / 3600.0
|
||||||
|
jd = swe.julday(d.year, d.month, d.day, ut_hours) # czas uniwersalny
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for name in (req.objects or DEFAULT_OBJECTS):
|
||||||
|
lookup = "North Node" if name == "South Node" else name
|
||||||
|
xx, _retflag = swe.calc_ut(jd, _PLANETS[lookup], _FLAGS)
|
||||||
|
lon, lat, _dist, lon_speed = xx[0], xx[1], xx[2], xx[3]
|
||||||
|
if name == "South Node":
|
||||||
|
lon, lat = lon + 180.0, -lat
|
||||||
|
rows.append({
|
||||||
|
"name": name,
|
||||||
|
"longitude": lon % 360.0,
|
||||||
|
"latitude": lat,
|
||||||
|
"speed": lon_speed,
|
||||||
|
"retrograde": lon_speed < 0,
|
||||||
|
})
|
||||||
|
return {"engine": "swisseph", "positions": rows}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict:
|
||||||
|
return {"engine": "swisseph", "status": "ok", "mode": "moshier", "license": "AGPL-3.0"}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# UWAGA: pyswisseph (Swiss Ephemeris) jest na licencji AGPL-3.0 — dlatego ta
|
||||||
|
# usługa jest wydzielona i licencjonowana osobno (patrz LICENSE). Nie instaluj
|
||||||
|
# tego w obrazie permisywnego produktu.
|
||||||
|
fastapi>=0.115
|
||||||
|
uvicorn[standard]>=0.34
|
||||||
|
pyswisseph>=2.10
|
||||||
@@ -6,8 +6,31 @@ i nie w bazie.
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
- `POST /api/query` → `QueryRequest` → `QueryResponse`
|
- `POST /api/query` → `QueryRequest` → `QueryResponse`
|
||||||
|
- `POST /chart/positions` → `{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty główne z applying/separating (LOG-06) + opcjonalnie stacje planet (`stations:true`, LOG-03). Zwraca też sektę i 7 Lots hermetycznych z domami (LOG-08). Obiekty: 10 planet + mean NN/SN/Lilith (LOG-02). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`.
|
||||||
|
- `POST /chart/report` → `{when_utc, lat, lon, limit?}` → wynik obliczeń wyszukany w bazie: fasety sygnifikatorów **w znaku / w domu / w aspekcie**, z rozwinięciem skrótów, odsiewaniem duplikatów (ten sam sygnifikator i opis), rankingiem siły (LOG-21) oraz opcją group (grupowanie identycznych opisów)
|
||||||
|
- `POST /chart/profections` → `{when_utc, lat, lon, start_age?, count?}` → profekcje roczne: wiek, profektowany Asc, Władca Roku (+MC/Su/Mo) (LOG-10)
|
||||||
|
- `POST /chart/return` → `{when_utc, lat, lon, kind, around?}` → Solar/Lunar Return: moment powrotu + pełny horoskop na ten moment (LOG-12)
|
||||||
|
- `POST /chart/firdaria` → `{when_utc, lat, lon}` → Firdaria: sekta (dzień/noc), okresy główne i podokresy time-lordów (LOG-11)
|
||||||
|
- `POST /chart/timeline` → `{when_utc, lat, lon, from_date, to_date, techniques?}` → zbiorcza oś czasu: profekcje + Solar Return + dyrekcje solar-arc + Firdaria, posortowane (technique | significator | start | exact | end); z interpret=true dopina interpretacje z bazy do dat (LOG-14, 1B->2B)
|
||||||
|
- `POST /chart/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B)
|
||||||
- `GET /health` (sprawdza też warstwę bazodanową)
|
- `GET /health` (sprawdza też warstwę bazodanową)
|
||||||
|
|
||||||
|
## Silnik efemeryd (LOG-24, „wymienny silnik liczący")
|
||||||
|
W `app/engine/` żyje pluggable silnik za interfejsem `EphemerisEngine`:
|
||||||
|
- **`SkyfieldEngine`** — własny, permisywny (Skyfield MIT + dane JPL public domain). Domyślny.
|
||||||
|
- **`RemoteEngine`** — klient OSOBNEJ, izolowanej usługi `engine-swisseph` (AGPL), używany tylko w trybie porównawczym.
|
||||||
|
|
||||||
|
Wybór: `EPHEMERIS_ENGINE=own|swisseph`. Silnik B włącza się przez `ENGINE_SWISSEPH_URL`.
|
||||||
|
|
||||||
|
Walidacja (LOG-25/28): `app/engine/compare.py` zestawia oba silniki z progiem tolerancji;
|
||||||
|
ten sam kontrakt parzystości obowiązuje każdy silnik. Nasz `SkyfieldEngine` zgadza się
|
||||||
|
ze Swiss Ephemeris **co do ~1″** na horoskopie referencyjnym (patrz `tests/`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
PYTHONPATH=. pytest tests -q # silnik B pomijany, jeśli ENGINE_SWISSEPH_URL nieustawiony
|
||||||
|
```
|
||||||
|
|
||||||
## Zależności w dół
|
## Zależności w dół
|
||||||
Zna wyłącznie `DATA_URL` (adres warstwy bazodanowej) i jej kontrakt `/search`.
|
Zna wyłącznie `DATA_URL` (adres warstwy bazodanowej) i jej kontrakt `/search`.
|
||||||
Nie wie, czy pod spodem jest Excel czy SQL.
|
Nie wie, czy pod spodem jest Excel czy SQL.
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Rozwijanie skrótów sygnifikatorów do postaci czytelnej (na bazie SIGNIFICATORS KEY).
|
||||||
|
|
||||||
|
W bazie zapis jest skrótowy z prefiksem `[` (np. `[Su in [Tau`, `[Sa [conj [Su in 6th H.`).
|
||||||
|
Ten moduł zamienia go na tekst czytelny: „Sun in Taurus", „Saturn conjunction Sun
|
||||||
|
in 6th house". Słownik pochodzi z pliku SIGNIFICATORS KEY (mały, stabilny — wpięty
|
||||||
|
jako built-in; można rozszerzać).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# skrót (bez nawiasu) -> pełna nazwa
|
||||||
|
ABBREVIATIONS: dict[str, str] = {
|
||||||
|
# znaki zodiaku
|
||||||
|
"Ari": "Aries", "Tau": "Taurus", "Gem": "Gemini", "Can": "Cancer",
|
||||||
|
"Leo": "Leo", "Vir": "Virgo", "Lib": "Libra", "Sco": "Scorpio",
|
||||||
|
"Sag": "Sagittarius", "Cap": "Capricorn", "Aqu": "Aquarius", "Pis": "Pisces",
|
||||||
|
# planety klasyczne + światła
|
||||||
|
"Su": "Sun", "Mo": "Moon", "Me": "Mercury", "Ve": "Venus",
|
||||||
|
"Ma": "Mars", "Ju": "Jupiter", "Sa": "Saturn",
|
||||||
|
# planety nowożytne
|
||||||
|
"Ur": "Uranus", "Ne": "Neptune", "Pl": "Pluto",
|
||||||
|
# węzły i punkty
|
||||||
|
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
|
||||||
|
# osie
|
||||||
|
"Asc": "Ascendant", "Dsc": "Descendant", "MC": "Midheaven", "IC": "Imum Coeli",
|
||||||
|
# Lots (punkty arabskie)
|
||||||
|
"PF": "Part of Fortune", "Fortune": "Part of Fortune", "Spirit": "Lot of Spirit",
|
||||||
|
# aspekty
|
||||||
|
"conj": "conjunction", "sex": "sextile", "sq": "square", "tri": "trine",
|
||||||
|
"opp": "opposition", "semisex": "semisextile", "semisq": "semisquare",
|
||||||
|
"sesquisq": "sesquisquare", "quincunx": "quincunx", "asp": "aspect",
|
||||||
|
# domy jako tokeny [h1..[h12
|
||||||
|
**{f"h{i}": f"{i}th house" for i in range(1, 13)},
|
||||||
|
# ruch
|
||||||
|
"Rx": "retrograde", "R": "retrograde",
|
||||||
|
}
|
||||||
|
# poprawki nieregularnych liczebników domów
|
||||||
|
ABBREVIATIONS.update({"h1": "1st house", "h2": "2nd house", "h3": "3rd house"})
|
||||||
|
|
||||||
|
# rozwinięcia słów spoza składni `[`
|
||||||
|
_WORDS = {
|
||||||
|
"affl.": "afflicted",
|
||||||
|
"P. Dec.": "parallel of declination",
|
||||||
|
"espec.": "especially",
|
||||||
|
}
|
||||||
|
|
||||||
|
_TOKEN = re.compile(r"\[([A-Za-z]+)")
|
||||||
|
_HOUSE = re.compile(r"(\d+(?:st|nd|rd|th))\s*H\.", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def expand(text: str) -> str:
|
||||||
|
"""Zamienia skróty na pełne nazwy; nieznane tokeny zostawia bez nawiasu."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
out = _TOKEN.sub(lambda m: ABBREVIATIONS.get(m.group(1), m.group(1)), text)
|
||||||
|
out = _HOUSE.sub(lambda m: f"{m.group(1)} house", out)
|
||||||
|
for k, v in _WORDS.items():
|
||||||
|
out = out.replace(k, v)
|
||||||
|
return out
|
||||||
@@ -16,9 +16,16 @@ class DataClient:
|
|||||||
def __init__(self, base_url: str | None = None) -> None:
|
def __init__(self, base_url: str | None = None) -> None:
|
||||||
self.base_url = (base_url or settings.data_url).rstrip("/")
|
self.base_url = (base_url or settings.data_url).rstrip("/")
|
||||||
|
|
||||||
def search(self, key: str, value: str, exact: bool, limit: int) -> dict[str, Any]:
|
def search(
|
||||||
payload = {"key": key, "value": value, "exact": exact, "limit": limit}
|
self,
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
key: str,
|
||||||
|
value: str,
|
||||||
|
exact: bool,
|
||||||
|
limit: int,
|
||||||
|
fields: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields}
|
||||||
|
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
|
||||||
r = client.post(f"{self.base_url}/search", json=payload)
|
r = client.post(f"{self.base_url}/search", json=payload)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Aspekty — kąty między obiektami (LOG-06, wersja: aspekty główne).
|
||||||
|
|
||||||
|
Czysta matematyka na policzonych długościach ekliptycznych. Dla każdej pary
|
||||||
|
obiektów sprawdzamy, czy ich separacja kątowa mieści się w orbie któregoś z
|
||||||
|
aspektów głównych. Applying/separating (aplikacja/separacja) — na później.
|
||||||
|
|
||||||
|
Tokeny bazy (z SIGNIFICATORS KEY): [conj, [sex, [sq, [tri, [opp.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
MAJOR = {
|
||||||
|
"conjunction": 0.0,
|
||||||
|
"sextile": 60.0,
|
||||||
|
"square": 90.0,
|
||||||
|
"trine": 120.0,
|
||||||
|
"opposition": 180.0,
|
||||||
|
}
|
||||||
|
DB_TOKEN = {
|
||||||
|
"conjunction": "[conj", "sextile": "[sex", "square": "[sq",
|
||||||
|
"trine": "[tri", "opposition": "[opp",
|
||||||
|
}
|
||||||
|
PL_NAME = {
|
||||||
|
"conjunction": "koniunkcja", "sextile": "sekstyl", "square": "kwadratura",
|
||||||
|
"trine": "trygon", "opposition": "opozycja",
|
||||||
|
}
|
||||||
|
LUMINARIES = {"Sun", "Moon"}
|
||||||
|
DEFAULT_ORB = 8.0
|
||||||
|
LUMINARY_BONUS = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def separation(a: float, b: float) -> float:
|
||||||
|
"""Najmniejsza separacja kątowa [0,180]."""
|
||||||
|
d = abs(a - b) % 360.0
|
||||||
|
return min(d, 360.0 - d)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_applying(la: float, lb: float, sa: float, sb: float, angle: float, dt: float = 0.01) -> bool | None:
|
||||||
|
"""Czy aspekt aplikuje (dokładność 0° dopiero nastąpi)?
|
||||||
|
|
||||||
|
Porównujemy odchyłkę od dokładnego kąta teraz i po małym kroku czasu
|
||||||
|
(pozycje przesunięte o prędkość·dt). Malejąca odchyłka = applying.
|
||||||
|
dt celowo małe (0,01 doby), by szybki Księżyc nie „przeskoczył" dokładności.
|
||||||
|
Zwraca None, gdy brak prędkości (nie da się rozstrzygnąć).
|
||||||
|
"""
|
||||||
|
if sa is None or sb is None:
|
||||||
|
return None
|
||||||
|
dev_now = abs(separation(la, lb) - angle)
|
||||||
|
dev_next = abs(separation(la + sa * dt, lb + sb * dt) - angle)
|
||||||
|
return dev_next < dev_now
|
||||||
|
|
||||||
|
|
||||||
|
def find_aspects(
|
||||||
|
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS
|
||||||
|
) -> list[dict]:
|
||||||
|
"""positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę).
|
||||||
|
|
||||||
|
Zwraca listę aspektów głównych; gdy znane są prędkości, każdy aspekt ma
|
||||||
|
applying (bool) i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny).
|
||||||
|
"""
|
||||||
|
out: list[dict] = []
|
||||||
|
n = len(positions)
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(i + 1, n):
|
||||||
|
a, b = positions[i], positions[j]
|
||||||
|
la, lb = a.get("decimal"), b.get("decimal")
|
||||||
|
if la is None or lb is None:
|
||||||
|
continue
|
||||||
|
sep = separation(float(la), float(lb))
|
||||||
|
allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0)
|
||||||
|
for asp, angle in MAJOR.items():
|
||||||
|
dev = abs(sep - angle)
|
||||||
|
if dev <= allowed:
|
||||||
|
applying = _is_applying(
|
||||||
|
float(la), float(lb), a.get("speed"), b.get("speed"), angle
|
||||||
|
)
|
||||||
|
row = {
|
||||||
|
"obj1": a["name"], "obj2": b["name"],
|
||||||
|
"aspect": asp, "orb": round(dev, 2), "allowed": round(allowed, 2),
|
||||||
|
}
|
||||||
|
if applying is not None:
|
||||||
|
row["applying"] = applying
|
||||||
|
row["as"] = "A" if applying else "S"
|
||||||
|
out.append(row)
|
||||||
|
break # jedna para = jeden aspekt
|
||||||
|
return out
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Interfejs silnika efemeryd (LOG-24).
|
||||||
|
|
||||||
|
To jest „wymienny silnik liczący". Dziś realizują go: SkyfieldEngine (własny,
|
||||||
|
permisywny, in-process) i RemoteEngine (klient izolowanej usługi swisseph, AGPL).
|
||||||
|
Warstwa logiczna woła tylko ten interfejs — nie wie, który silnik liczy.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from app.engine.models import ChartMoment, ObjectPosition
|
||||||
|
|
||||||
|
|
||||||
|
class EphemerisEngine(ABC):
|
||||||
|
name: str = "base"
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def positions(
|
||||||
|
self, moment: ChartMoment, objects: list[str] | None = None
|
||||||
|
) -> list[ObjectPosition]:
|
||||||
|
"""Pozycje obiektów dla danego momentu (LOG-01). None = zestaw domyślny."""
|
||||||
|
|
||||||
|
def health(self) -> dict:
|
||||||
|
return {"engine": self.name, "status": "ok"}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Złożenie pełnego horoskopu: pozycje + osie + domy (LOG-01 + LOG-05).
|
||||||
|
|
||||||
|
Silnik-agnostyczne: potrzebuje tylko `positions()` oraz (dla osi/domów)
|
||||||
|
`sidereal()`. Jeśli silnik nie umie policzyć czasu gwiazdowego, zwraca same
|
||||||
|
pozycje.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.engine import houses as H
|
||||||
|
from app.engine.base import EphemerisEngine
|
||||||
|
from app.engine.formats import SIGNS, in_sign, norm360, sign_index
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(name: str, lon: float) -> dict:
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"sign": SIGNS[sign_index(lon)],
|
||||||
|
"in_sign": in_sign(lon),
|
||||||
|
"decimal": round(norm360(lon), 6),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
|
||||||
|
lots_method: str = "degree") -> dict:
|
||||||
|
from app.engine.aspects import find_aspects
|
||||||
|
|
||||||
|
positions = engine.positions(moment)
|
||||||
|
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
|
||||||
|
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
|
||||||
|
|
||||||
|
if not hasattr(engine, "sidereal"):
|
||||||
|
return result
|
||||||
|
|
||||||
|
ramc, eps = engine.sidereal(moment)
|
||||||
|
asc = H.compute_asc(ramc, eps, moment.lat)
|
||||||
|
mc = H.compute_mc(ramc, eps)
|
||||||
|
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
|
||||||
|
cusp_list = H.cusps(asc, mc, system)
|
||||||
|
|
||||||
|
result["house_system"] = system
|
||||||
|
result["angles"] = {
|
||||||
|
"Asc": _fmt("Asc", asc),
|
||||||
|
"MC": _fmt("MC", mc),
|
||||||
|
"Dsc": _fmt("Dsc", norm360(asc + 180.0)),
|
||||||
|
"IC": _fmt("IC", norm360(mc + 180.0)),
|
||||||
|
}
|
||||||
|
result["cusps"] = [
|
||||||
|
{"house": i + 1, "sign": SIGNS[sign_index(c)], "in_sign": in_sign(c)}
|
||||||
|
for i, c in enumerate(cusp_list)
|
||||||
|
]
|
||||||
|
for pdict, obj in zip(result["positions"], positions):
|
||||||
|
pdict["house"] = H.assign_house(obj.longitude, cusp_list)
|
||||||
|
|
||||||
|
# Lots (LOG-08) — wymagają Asc i sekty (dzień/noc)
|
||||||
|
from app.engine.firdaria import is_day_birth
|
||||||
|
from app.engine.lots import compute_lots
|
||||||
|
|
||||||
|
pts = {p.name: p.longitude for p in positions}
|
||||||
|
pts["Asc"] = asc
|
||||||
|
day = is_day_birth(pts["Sun"], asc, mc) if "Sun" in pts else True
|
||||||
|
result["sect"] = "day" if day else "night"
|
||||||
|
result["lots"] = [
|
||||||
|
{**lot,
|
||||||
|
"sign": SIGNS[sign_index(lot["longitude"])],
|
||||||
|
"in_sign": in_sign(lot["longitude"]),
|
||||||
|
"house": H.assign_house(lot["longitude"], cusp_list)}
|
||||||
|
for lot in compute_lots(pts, day, lots_method)
|
||||||
|
]
|
||||||
|
return result
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Harness walidacyjno-porównawczy (LOG-25) i kontrakt parzystości (LOG-28).
|
||||||
|
|
||||||
|
`compare_positions` zestawia wyniki dwóch silników z progami tolerancji per
|
||||||
|
wielkość i flaguje rozbieżności — używane w testach regresyjnych (CI) i w trybie
|
||||||
|
dual-run na żądanie (LOG-26).
|
||||||
|
|
||||||
|
`check_engine_contract` to wspólny kontrakt, który MUSI spełnić każdy silnik —
|
||||||
|
ten sam test uruchamiamy dla EngineA i EngineB (LOG-28).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from app.engine.base import EphemerisEngine
|
||||||
|
from app.engine.models import DEFAULT_OBJECTS, ChartMoment, ObjectPosition
|
||||||
|
|
||||||
|
|
||||||
|
def angular_delta(a: float, b: float) -> float:
|
||||||
|
"""Najmniejsza różnica kątów w stopniach (z obsługą zawinięcia 0/360)."""
|
||||||
|
return ((a - b + 180.0) % 360.0) - 180.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ObjectDiff:
|
||||||
|
name: str
|
||||||
|
lon_a: float
|
||||||
|
lon_b: float
|
||||||
|
delta_arcsec: float # różnica długości w sekundach łuku
|
||||||
|
lat_delta: float
|
||||||
|
speed_sign_mismatch: bool
|
||||||
|
over_tolerance: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CompareReport:
|
||||||
|
lon_tol_arcsec: float
|
||||||
|
diffs: list[ObjectDiff] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return not any(d.over_tolerance or d.speed_sign_mismatch for d in self.diffs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_arcsec(self) -> float:
|
||||||
|
return max((abs(d.delta_arcsec) for d in self.diffs), default=0.0)
|
||||||
|
|
||||||
|
def summary(self) -> dict:
|
||||||
|
return {
|
||||||
|
"ok": self.ok,
|
||||||
|
"objects": len(self.diffs),
|
||||||
|
"max_arcsec": round(self.max_arcsec, 2),
|
||||||
|
"tolerance_arcsec": self.lon_tol_arcsec,
|
||||||
|
"flagged": [d.name for d in self.diffs if d.over_tolerance or d.speed_sign_mismatch],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compare_positions(
|
||||||
|
a: list[ObjectPosition],
|
||||||
|
b: list[ObjectPosition],
|
||||||
|
lon_tol_arcsec: float = 120.0,
|
||||||
|
) -> CompareReport:
|
||||||
|
by_b = {p.name: p for p in b}
|
||||||
|
report = CompareReport(lon_tol_arcsec=lon_tol_arcsec)
|
||||||
|
for pa in a:
|
||||||
|
pb = by_b.get(pa.name)
|
||||||
|
if pb is None:
|
||||||
|
continue
|
||||||
|
d_arcsec = angular_delta(pa.longitude, pb.longitude) * 3600.0
|
||||||
|
report.diffs.append(
|
||||||
|
ObjectDiff(
|
||||||
|
name=pa.name,
|
||||||
|
lon_a=pa.longitude,
|
||||||
|
lon_b=pb.longitude,
|
||||||
|
delta_arcsec=d_arcsec,
|
||||||
|
lat_delta=pa.latitude - pb.latitude,
|
||||||
|
speed_sign_mismatch=(pa.retrograde != pb.retrograde),
|
||||||
|
over_tolerance=abs(d_arcsec) > lon_tol_arcsec,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def compare_engines(
|
||||||
|
engine_a: EphemerisEngine,
|
||||||
|
engine_b: EphemerisEngine,
|
||||||
|
moment: ChartMoment,
|
||||||
|
lon_tol_arcsec: float = 120.0,
|
||||||
|
) -> CompareReport:
|
||||||
|
return compare_positions(
|
||||||
|
engine_a.positions(moment), engine_b.positions(moment), lon_tol_arcsec
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_engine_contract(engine: EphemerisEngine, moment: ChartMoment) -> None:
|
||||||
|
"""Kontrakt parzystości (LOG-28). Rzuca AssertionError przy naruszeniu."""
|
||||||
|
positions = engine.positions(moment)
|
||||||
|
names = {p.name for p in positions}
|
||||||
|
assert set(DEFAULT_OBJECTS) <= names, f"brakuje obiektów: {set(DEFAULT_OBJECTS) - names}"
|
||||||
|
for p in positions:
|
||||||
|
assert 0.0 <= p.longitude < 360.0, f"{p.name}: długość poza zakresem ({p.longitude})"
|
||||||
|
assert -90.0 <= p.latitude <= 90.0, f"{p.name}: szerokość poza zakresem ({p.latitude})"
|
||||||
|
assert p.retrograde in (True, False)
|
||||||
|
d = p.as_dict()
|
||||||
|
assert d["sign"] and d["in_sign"], f"{p.name}: brak formatów"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Fabryka silników (LOG-24) — jedyne miejsce znające konkretne implementacje.
|
||||||
|
|
||||||
|
EPHEMERIS_ENGINE = own (domyślnie, permisywny Skyfield) | swisseph (zdalny AGPL).
|
||||||
|
`available_engines()` zwraca to, co da się dziś uruchomić — używane przez
|
||||||
|
harness porównawczy i testy parzystości.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app.engine.base import EphemerisEngine
|
||||||
|
|
||||||
|
|
||||||
|
def build_engine(name: str | None = None) -> EphemerisEngine:
|
||||||
|
name = (name or os.getenv("EPHEMERIS_ENGINE", "own")).lower()
|
||||||
|
if name in ("swisseph", "remote", "b"):
|
||||||
|
from app.engine.remote_engine import RemoteEngine
|
||||||
|
|
||||||
|
return RemoteEngine()
|
||||||
|
from app.engine.skyfield_engine import SkyfieldEngine
|
||||||
|
|
||||||
|
return SkyfieldEngine()
|
||||||
|
|
||||||
|
|
||||||
|
def available_engines() -> dict[str, EphemerisEngine]:
|
||||||
|
"""Silniki gotowe do użycia teraz (own zawsze; swisseph jeśli skonfigurowany)."""
|
||||||
|
engines: dict[str, EphemerisEngine] = {"own": build_engine("own")}
|
||||||
|
if os.getenv("ENGINE_SWISSEPH_URL"):
|
||||||
|
engines["swisseph"] = build_engine("swisseph")
|
||||||
|
return engines
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Firdaria (LOG-11) — perska technika time-lord.
|
||||||
|
|
||||||
|
Sekwencja okresów głównych zależy od sekty (dzień/noc). Sekta: urodzenie dzienne,
|
||||||
|
gdy Słońce jest nad horyzontem, czyli po tej samej stronie osi Asc–Dsc co MC.
|
||||||
|
|
||||||
|
Klasyczne długości okresów (lata): Su 10, Ve 8, Me 13, Mo 9, Sa 11, Ju 12, Ma 7
|
||||||
|
(razem 70) + Węzeł Północny 3 + Węzeł Południowy 2 = 75 lat. Każdy okres główny
|
||||||
|
planety dzieli się na 7 podokresów (sub-lord w tej samej kolejności, cyklicznie).
|
||||||
|
Węzły — bez podokresów (najczęstsza konwencja).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
DAY_ORDER = ["Sun", "Venus", "Mercury", "Moon", "Saturn", "Jupiter", "Mars"]
|
||||||
|
NIGHT_ORDER = ["Moon", "Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury"]
|
||||||
|
YEARS = {"Sun": 10, "Venus": 8, "Mercury": 13, "Moon": 9,
|
||||||
|
"Saturn": 11, "Jupiter": 12, "Mars": 7}
|
||||||
|
NODES = [("North Node", 3), ("South Node", 2)]
|
||||||
|
DAYS_PER_YEAR = 365.2422
|
||||||
|
|
||||||
|
|
||||||
|
def is_day_birth(sun_lon: float, asc: float, mc: float) -> bool:
|
||||||
|
"""Słońce nad horyzontem = ta sama półkula osi Asc–Dsc co MC."""
|
||||||
|
return (((sun_lon - asc) % 360.0) < 180.0) == (((mc - asc) % 360.0) < 180.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _date(birth: datetime, years: float) -> str:
|
||||||
|
return (birth + timedelta(days=years * DAYS_PER_YEAR)).date().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def firdaria(birth: datetime, sun_lon: float, asc: float, mc: float) -> dict:
|
||||||
|
"""Pełny rozkład Firdarii: sekta, kolejność, okresy główne i podokresy."""
|
||||||
|
day = is_day_birth(sun_lon, asc, mc)
|
||||||
|
order = DAY_ORDER if day else NIGHT_ORDER
|
||||||
|
majors = [(lord, YEARS[lord]) for lord in order] + NODES
|
||||||
|
|
||||||
|
periods: list[dict] = []
|
||||||
|
age = 0.0
|
||||||
|
for lord, yrs in majors:
|
||||||
|
period = {"lord": lord, "years": yrs,
|
||||||
|
"start": _date(birth, age), "end": _date(birth, age + yrs)}
|
||||||
|
if lord in YEARS: # planeta -> 7 podokresów
|
||||||
|
sub_len = yrs / 7.0
|
||||||
|
i = order.index(lord)
|
||||||
|
sub_age = age
|
||||||
|
subs: list[dict] = []
|
||||||
|
for k in range(7):
|
||||||
|
sub_lord = order[(i + k) % 7]
|
||||||
|
subs.append({"lord": sub_lord,
|
||||||
|
"start": _date(birth, sub_age),
|
||||||
|
"end": _date(birth, sub_age + sub_len)})
|
||||||
|
sub_age += sub_len
|
||||||
|
period["sub"] = subs
|
||||||
|
periods.append(period)
|
||||||
|
age += yrs
|
||||||
|
return {"sect": "day" if day else "night", "order": order, "periods": periods}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Formatowanie długości ekliptycznej (LOG-01: kilka zapisów).
|
||||||
|
|
||||||
|
Astrolog myśli w stopniach/minutach/sekundach w znaku, Excel woli dziesiętne,
|
||||||
|
a część technik używa pozycji absolutnej 0–360°. Tu są czyste, bezstanowe
|
||||||
|
funkcje konwersji — bez zależności od jakiegokolwiek silnika.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
SIGNS = [
|
||||||
|
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
||||||
|
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
|
||||||
|
]
|
||||||
|
SIGN_ABBR = ["Ari", "Tau", "Gem", "Can", "Leo", "Vir",
|
||||||
|
"Lib", "Sco", "Sag", "Cap", "Aqu", "Pis"]
|
||||||
|
|
||||||
|
|
||||||
|
def norm360(lon: float) -> float:
|
||||||
|
return lon % 360.0
|
||||||
|
|
||||||
|
|
||||||
|
def sign_index(lon: float) -> int:
|
||||||
|
"""0 = Aries … 11 = Pisces."""
|
||||||
|
return int(norm360(lon) // 30)
|
||||||
|
|
||||||
|
|
||||||
|
def _dms(deg: float) -> tuple[int, int, int]:
|
||||||
|
"""Rozkład stopni (>=0) na (°, ', ") z poprawnym przeniesieniem zaokrąglenia."""
|
||||||
|
total = round(deg * 3600)
|
||||||
|
d, rem = divmod(total, 3600)
|
||||||
|
m, s = divmod(rem, 60)
|
||||||
|
return d, m, s
|
||||||
|
|
||||||
|
|
||||||
|
def in_sign(lon: float) -> str:
|
||||||
|
"""Np. 'Tau 28°12'57\"' — pozycja w znaku."""
|
||||||
|
lon = norm360(lon)
|
||||||
|
idx = sign_index(lon)
|
||||||
|
d, m, s = _dms(lon - idx * 30)
|
||||||
|
if d >= 30: # zaokrąglenie przekroczyło granicę znaku
|
||||||
|
idx = (idx + 1) % 12
|
||||||
|
d -= 30
|
||||||
|
return f"{SIGN_ABBR[idx]} {d}°{m:02d}'{s:02d}\""
|
||||||
|
|
||||||
|
|
||||||
|
def absolute(lon: float) -> str:
|
||||||
|
"""Np. '58°12'57\"' — pozycja absolutna 0–360°."""
|
||||||
|
d, m, s = _dms(norm360(lon))
|
||||||
|
return f"{d}°{m:02d}'{s:02d}\""
|
||||||
|
|
||||||
|
|
||||||
|
def decimal(lon: float, places: int = 6) -> float:
|
||||||
|
return round(norm360(lon), places)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Osie i domy — czysta matematyka sferyczna (LOG-05).
|
||||||
|
|
||||||
|
Bezstanowe funkcje: z lokalnego czasu gwiazdowego (RAMC), nachylenia ekliptyki
|
||||||
|
(ε) i szerokości geograficznej (φ) wyliczają Ascendent i MC, a stąd cusps domów
|
||||||
|
dla prostych systemów (Whole Sign, Equal, Porphyry). Niezależne od silnika —
|
||||||
|
silnik dostarcza tylko RAMC i ε.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from app.engine.formats import SIGN_ABBR, norm360, sign_index # noqa: F401
|
||||||
|
|
||||||
|
WHOLE_SIGN = "whole_sign"
|
||||||
|
EQUAL = "equal"
|
||||||
|
PORPHYRY = "porphyry"
|
||||||
|
SYSTEMS = (WHOLE_SIGN, EQUAL, PORPHYRY)
|
||||||
|
|
||||||
|
|
||||||
|
def mean_obliquity(tt_jd: float) -> float:
|
||||||
|
"""Średnie nachylenie ekliptyki [°] dla daty (Julian TT). Wystarcza do domów."""
|
||||||
|
t = (tt_jd - 2451545.0) / 36525.0
|
||||||
|
arcsec = 84381.448 - 46.8150 * t - 0.00059 * t * t + 0.001813 * t ** 3
|
||||||
|
return arcsec / 3600.0
|
||||||
|
|
||||||
|
|
||||||
|
def compute_mc(ramc_deg: float, eps_deg: float) -> float:
|
||||||
|
r, e = math.radians(ramc_deg), math.radians(eps_deg)
|
||||||
|
mc = math.atan2(math.sin(r), math.cos(r) * math.cos(e))
|
||||||
|
return norm360(math.degrees(mc))
|
||||||
|
|
||||||
|
|
||||||
|
def compute_asc(ramc_deg: float, eps_deg: float, lat_deg: float) -> float:
|
||||||
|
r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(lat_deg)
|
||||||
|
asc = math.atan2(
|
||||||
|
math.cos(r),
|
||||||
|
-(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)),
|
||||||
|
)
|
||||||
|
return norm360(math.degrees(asc))
|
||||||
|
|
||||||
|
|
||||||
|
def _trisect(a: float, b: float) -> tuple[float, float]:
|
||||||
|
"""Dwa punkty dzielące łuk a→b (w kierunku zodiaku) na trzy równe części."""
|
||||||
|
span = (b - a) % 360.0
|
||||||
|
return norm360(a + span / 3.0), norm360(a + 2.0 * span / 3.0)
|
||||||
|
|
||||||
|
|
||||||
|
def cusps(asc: float, mc: float, system: str) -> list[float]:
|
||||||
|
"""Zwraca 12 cusps (długości) domów 1..12."""
|
||||||
|
if system == WHOLE_SIGN:
|
||||||
|
start = sign_index(asc) * 30.0
|
||||||
|
return [norm360(start + 30.0 * i) for i in range(12)]
|
||||||
|
if system == EQUAL:
|
||||||
|
return [norm360(asc + 30.0 * i) for i in range(12)]
|
||||||
|
if system == PORPHYRY:
|
||||||
|
dsc, ic = norm360(asc + 180.0), norm360(mc + 180.0)
|
||||||
|
c = [0.0] * 12
|
||||||
|
c[0], c[3], c[6], c[9] = asc, ic, dsc, mc
|
||||||
|
c[1], c[2] = _trisect(asc, ic) # domy 2,3
|
||||||
|
c[4], c[5] = _trisect(ic, dsc) # domy 5,6
|
||||||
|
c[7], c[8] = _trisect(dsc, mc) # domy 8,9
|
||||||
|
c[10], c[11] = _trisect(mc, asc) # domy 11,12
|
||||||
|
return c
|
||||||
|
raise ValueError(f"nieznany system domów: {system}")
|
||||||
|
|
||||||
|
|
||||||
|
def assign_house(lon: float, cusp_list: list[float]) -> int:
|
||||||
|
"""Numer domu (1..12), w którym leży dana długość ekliptyczna."""
|
||||||
|
lon = norm360(lon)
|
||||||
|
for i in range(12):
|
||||||
|
start = cusp_list[i]
|
||||||
|
end = cusp_list[(i + 1) % 12]
|
||||||
|
span = (end - start) % 360.0
|
||||||
|
offset = (lon - start) % 360.0
|
||||||
|
if offset < span:
|
||||||
|
return i + 1
|
||||||
|
return 12
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Lots / punkty arabskie (LOG-08) — 7 Lots hermetycznych.
|
||||||
|
|
||||||
|
Formuła: Lot = C + A − B (od punktu C odmierzamy odległość między A i B).
|
||||||
|
Większość Lots **odwraca się w horoskopach nocnych** (zamiana A↔B) — np.
|
||||||
|
Fortuna: dzień Asc + Mo − Su, noc Asc + Su − Mo.
|
||||||
|
|
||||||
|
Dwa warianty liczenia (notes3):
|
||||||
|
- `degree` (domyślny) — dokładny stopień,
|
||||||
|
- `sign` — liczone całymi znakami (Lot wypada na 0° wyliczonego znaku).
|
||||||
|
|
||||||
|
Kolejność ma znaczenie: Fortuna i Duch liczone są pierwsze, bo pozostałe Lots
|
||||||
|
odwołują się do nich.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.engine.formats import norm360, sign_index
|
||||||
|
|
||||||
|
# (nazwa, C, A, B, odwracalny w nocy)
|
||||||
|
LOT_DEFS: list[tuple[str, str, str, str, bool]] = [
|
||||||
|
("Fortune", "Asc", "Moon", "Sun", True),
|
||||||
|
("Spirit", "Asc", "Sun", "Moon", True),
|
||||||
|
("Eros", "Asc", "Venus", "Spirit", True),
|
||||||
|
("Necessity", "Asc", "Fortune", "Mercury", True),
|
||||||
|
("Courage", "Asc", "Fortune", "Mars", True),
|
||||||
|
("Victory", "Asc", "Jupiter", "Spirit", True),
|
||||||
|
("Nemesis", "Asc", "Fortune", "Saturn", True),
|
||||||
|
]
|
||||||
|
|
||||||
|
METHODS = ("degree", "sign")
|
||||||
|
|
||||||
|
|
||||||
|
def compute_lots(
|
||||||
|
points: dict[str, float], is_day: bool, method: str = "degree"
|
||||||
|
) -> list[dict]:
|
||||||
|
"""points: nazwa → długość natalna (wymagane Asc + planety formuł).
|
||||||
|
|
||||||
|
Zwraca listę {name, longitude, formula} w kolejności definicji.
|
||||||
|
"""
|
||||||
|
if method not in METHODS:
|
||||||
|
raise ValueError(f"nieznana metoda liczenia Lots: {method}")
|
||||||
|
|
||||||
|
vals = dict(points)
|
||||||
|
out: list[dict] = []
|
||||||
|
for name, c, a, b, reversible in LOT_DEFS:
|
||||||
|
first, second = (a, b) if (is_day or not reversible) else (b, a)
|
||||||
|
if any(k not in vals for k in (c, first, second)):
|
||||||
|
continue # brak składnika — pomijamy
|
||||||
|
if method == "sign":
|
||||||
|
idx = (sign_index(vals[c]) + sign_index(vals[first])
|
||||||
|
- sign_index(vals[second])) % 12
|
||||||
|
lon = idx * 30.0
|
||||||
|
else:
|
||||||
|
lon = norm360(vals[c] + vals[first] - vals[second])
|
||||||
|
vals[name] = lon # dostępny dla kolejnych Lots
|
||||||
|
out.append({
|
||||||
|
"name": name,
|
||||||
|
"longitude": lon,
|
||||||
|
"formula": f"{c} + {first} − {second}",
|
||||||
|
})
|
||||||
|
return out
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Modele domenowe silnika efemeryd — wspólny kontrakt dla KAŻDEGO silnika.
|
||||||
|
|
||||||
|
To jest część LOG-28 (parzystość): każdy silnik (własny Skyfield czy zdalny
|
||||||
|
swisseph) przyjmuje `ChartMoment` i zwraca listę `ObjectPosition` w identycznym
|
||||||
|
kształcie i jednostkach. Dzięki temu wyniki są bezpośrednio porównywalne, a
|
||||||
|
prezentacja/dane nie wiedzą, który silnik liczył.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.engine import formats
|
||||||
|
|
||||||
|
# kanoniczny zestaw i kolejność obiektów (LOG-02: światła + 7 klasycznych +
|
||||||
|
# 3 nowożytne + punkty wirtualne: węzły mean i mean Lilith)
|
||||||
|
DEFAULT_OBJECTS = [
|
||||||
|
"Sun", "Moon", "Mercury", "Venus", "Mars",
|
||||||
|
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto",
|
||||||
|
"North Node", "South Node", "Lilith",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChartMoment:
|
||||||
|
"""Wejście silnika: moment w UTC + lokalizacja geograficzna.
|
||||||
|
|
||||||
|
Pozycje obiektów zależą tylko od czasu (geocentrycznie); szerokość/długość
|
||||||
|
geograficzna będą potrzebne dopiero przy osiach i domach (LOG-05).
|
||||||
|
"""
|
||||||
|
|
||||||
|
when_utc: datetime # musi być świadome strefy (UTC)
|
||||||
|
lat: float = 0.0 # szerokość geograficzna, + na północ
|
||||||
|
lon: float = 0.0 # długość geograficzna, + na wschód
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ObjectPosition:
|
||||||
|
"""Wynik dla jednego obiektu — surowe wartości w jednostkach SI astrologii."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
longitude: float # długość ekliptyczna 0–360° (tropikalna, of-date)
|
||||||
|
latitude: float # szerokość ekliptyczna (°)
|
||||||
|
speed: float # prędkość w długości (°/dobę)
|
||||||
|
retrograde: bool
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sign(self) -> str:
|
||||||
|
return formats.SIGNS[formats.sign_index(self.longitude)]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def direction(self) -> str:
|
||||||
|
return "Rx" if self.retrograde else "D"
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"sign": self.sign,
|
||||||
|
"in_sign": formats.in_sign(self.longitude),
|
||||||
|
"absolute": formats.absolute(self.longitude),
|
||||||
|
"decimal": formats.decimal(self.longitude),
|
||||||
|
"latitude": round(self.latitude, 6),
|
||||||
|
"speed": round(self.speed, 6),
|
||||||
|
"direction": self.direction,
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Punkty wirtualne liczone analitycznie (LOG-02): mean Node i mean Lilith.
|
||||||
|
|
||||||
|
Wzory Meeusa (Astronomical Algorithms) w stuleciach juliańskich od J2000 (TT):
|
||||||
|
- Ω — średni węzeł wstępujący orbity Księżyca (mean ascending node). Porusza się
|
||||||
|
zawsze wstecz (~−0,053°/dobę) — stąd węzły są wiecznie Rx.
|
||||||
|
- średnie perygeum orbity Księżyca; mean Lilith (Black Moon) = średnie APOGEUM
|
||||||
|
= perygeum + 180° (~+0,111°/dobę).
|
||||||
|
|
||||||
|
Wersje TRUE (oskulacyjne) — osobny, późniejszy krok (notatki: mean to
|
||||||
|
historyczny standard i domyślne ustawienie programów).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
|
||||||
|
_DAYS_PER_CENTURY = 36525.0
|
||||||
|
|
||||||
|
|
||||||
|
def _t(tt_jd: float) -> float:
|
||||||
|
return (tt_jd - 2451545.0) / _DAYS_PER_CENTURY
|
||||||
|
|
||||||
|
|
||||||
|
def mean_lunar_node(tt_jd: float) -> float:
|
||||||
|
"""Długość ekliptyczna średniego Węzła Północnego (Ω) [°]."""
|
||||||
|
t = _t(tt_jd)
|
||||||
|
omega = (125.0445479 - 1934.1362891 * t + 0.0020754 * t * t
|
||||||
|
+ t ** 3 / 467441.0 - t ** 4 / 60616000.0)
|
||||||
|
return norm360(omega)
|
||||||
|
|
||||||
|
|
||||||
|
def mean_lilith(tt_jd: float) -> float:
|
||||||
|
"""Długość ekliptyczna mean Lilith (średnie apogeum Księżyca) [°]."""
|
||||||
|
t = _t(tt_jd)
|
||||||
|
perigee = (83.3532465 + 4069.0137287 * t - 0.0103200 * t * t
|
||||||
|
- t ** 3 / 80053.0 + t ** 4 / 18999000.0)
|
||||||
|
return norm360(perigee + 180.0)
|
||||||
|
|
||||||
|
|
||||||
|
def point_speed(fn, tt_jd: float, dt_days: float = 0.1) -> float:
|
||||||
|
"""Prędkość [°/dobę] punktu analitycznego — różnica po małym kroku."""
|
||||||
|
a = fn(tt_jd)
|
||||||
|
b = fn(tt_jd + dt_days)
|
||||||
|
return (((b - a + 180.0) % 360.0) - 180.0) / dt_days
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Profekcje roczne (LOG-10) — hellenistyczna technika time-lord.
|
||||||
|
|
||||||
|
Zasada (Whole Sign): co każde urodziny profektowany Ascendent przeskakuje o jeden
|
||||||
|
znak do przodu (wiek mod 12). Władca Roku (Lord of Year) = władca domicylowy
|
||||||
|
znaku profektowanego Asc. Profektować można każdy punkt natalny (MC, Słońce…) —
|
||||||
|
wszystkie przeskakują o tyle samo znaków.
|
||||||
|
|
||||||
|
Referencja: tabela profekcji w notes3 (astro-seek) dla horoskopu 30.04.1984
|
||||||
|
(wiek 0: Can/Moon, 1: Leo/Sun, …, 42: Cap/Saturn).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from app.engine.formats import SIGNS, sign_index
|
||||||
|
|
||||||
|
# władcy domicylowi (tradycyjni) — zgodni z tabelą referencyjną notes3
|
||||||
|
DOMICILE_RULERS = {
|
||||||
|
"Aries": "Mars", "Taurus": "Venus", "Gemini": "Mercury", "Cancer": "Moon",
|
||||||
|
"Leo": "Sun", "Virgo": "Mercury", "Libra": "Venus", "Scorpio": "Mars",
|
||||||
|
"Sagittarius": "Jupiter", "Capricorn": "Saturn", "Aquarius": "Saturn",
|
||||||
|
"Pisces": "Jupiter",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def age_at(birth_utc: datetime, when_utc: datetime) -> int:
|
||||||
|
"""Pełne lata między urodzeniem a danym momentem (wiek profekcyjny)."""
|
||||||
|
age = when_utc.year - birth_utc.year
|
||||||
|
if (when_utc.month, when_utc.day) < (birth_utc.month, birth_utc.day):
|
||||||
|
age -= 1
|
||||||
|
return max(age, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def profected_sign(natal_lon: float, age: int) -> str:
|
||||||
|
"""Znak, do którego profektował punkt natalny po `age` latach."""
|
||||||
|
return SIGNS[(sign_index(natal_lon) + age) % 12]
|
||||||
|
|
||||||
|
|
||||||
|
def profection_rows(
|
||||||
|
natal_points: dict[str, float],
|
||||||
|
birth_utc: datetime,
|
||||||
|
start_age: int,
|
||||||
|
count: int,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Tabela profekcji dla zakresu lat życia.
|
||||||
|
|
||||||
|
natal_points: nazwa -> natalna długość ekliptyczna (musi zawierać 'Asc').
|
||||||
|
Każdy wiersz: wiek, data początku roku profekcyjnego (urodziny), znak
|
||||||
|
profektowanego Asc, Władca Roku oraz profekcje pozostałych punktów.
|
||||||
|
"""
|
||||||
|
def _birthday(year: int) -> datetime:
|
||||||
|
try:
|
||||||
|
return birth_utc.replace(year=year)
|
||||||
|
except ValueError: # 29 lutego w roku nieprzestępnym
|
||||||
|
return birth_utc.replace(year=year, day=28)
|
||||||
|
|
||||||
|
rows: list[dict] = []
|
||||||
|
for age in range(start_age, start_age + count):
|
||||||
|
asc_sign = profected_sign(natal_points["Asc"], age)
|
||||||
|
row = {
|
||||||
|
"age": age,
|
||||||
|
"from": _birthday(birth_utc.year + age).strftime("%Y-%m-%d"),
|
||||||
|
"profected_asc": asc_sign,
|
||||||
|
"lord_of_year": DOMICILE_RULERS[asc_sign],
|
||||||
|
}
|
||||||
|
for name, lon in natal_points.items():
|
||||||
|
if name != "Asc":
|
||||||
|
row[name] = profected_sign(lon, age)
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""RemoteEngine — klient izolowanej usługi silnika (LOG-24 backend nr 2, LOG-27).
|
||||||
|
|
||||||
|
Realizuje ten sam interfejs co SkyfieldEngine, ale liczenie deleguje przez HTTP
|
||||||
|
do OSOBNEJ usługi `engine-swisseph` (AGPL). Dzięki granicy sieciowej kod AGPL
|
||||||
|
nigdy nie jest linkowany do permisywnego produktu — patrz services/engine-swisseph.
|
||||||
|
|
||||||
|
Używany tylko, gdy skonfigurowano ENGINE_SWISSEPH_URL (tryb porównawczy/dev/CI).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.engine.base import EphemerisEngine
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
from app.engine.models import DEFAULT_OBJECTS, ChartMoment, ObjectPosition
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteEngine(EphemerisEngine):
|
||||||
|
name = "swisseph"
|
||||||
|
|
||||||
|
def __init__(self, base_url: str | None = None, timeout: float = 15.0) -> None:
|
||||||
|
self.base_url = (base_url or os.getenv("ENGINE_SWISSEPH_URL", "")).rstrip("/")
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def positions(
|
||||||
|
self, moment: ChartMoment, objects: list[str] | None = None
|
||||||
|
) -> list[ObjectPosition]:
|
||||||
|
if not self.base_url:
|
||||||
|
raise RuntimeError("ENGINE_SWISSEPH_URL nie ustawiony — silnik B niedostępny")
|
||||||
|
payload = {
|
||||||
|
"when_utc": moment.when_utc.isoformat(),
|
||||||
|
"lat": moment.lat,
|
||||||
|
"lon": moment.lon,
|
||||||
|
"objects": objects or DEFAULT_OBJECTS,
|
||||||
|
}
|
||||||
|
with httpx.Client(timeout=self.timeout) as client:
|
||||||
|
r = client.post(f"{self.base_url}/positions", json=payload)
|
||||||
|
r.raise_for_status()
|
||||||
|
rows = r.json()["positions"]
|
||||||
|
return [
|
||||||
|
ObjectPosition(
|
||||||
|
name=row["name"],
|
||||||
|
longitude=norm360(row["longitude"]),
|
||||||
|
latitude=row["latitude"],
|
||||||
|
speed=row["speed"],
|
||||||
|
retrograde=row["retrograde"],
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def health(self) -> dict:
|
||||||
|
if not self.base_url:
|
||||||
|
return {"engine": self.name, "status": "disabled"}
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=self.timeout) as client:
|
||||||
|
r = client.get(f"{self.base_url}/health")
|
||||||
|
r.raise_for_status()
|
||||||
|
return {"engine": self.name, "status": "ok", "remote": r.json()}
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return {"engine": self.name, "status": "down", "error": str(e)}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Solar / Lunar Return (LOG-12) — moment powrotu do pozycji natalnej.
|
||||||
|
|
||||||
|
Solar Return (solariusz): moment, w którym Słońce wraca dokładnie do natalnej
|
||||||
|
długości ekliptycznej (raz na rok, w okolicy urodzin). Lunar Return: to samo
|
||||||
|
dla Księżyca (raz na ~27,3 dnia). Dwa warianty użycia (osobny horoskop vs
|
||||||
|
tranzyt do natalu) obsługujemy zwracając pełny horoskop na znaleziony moment —
|
||||||
|
interpretacja pozostaje po stronie technik wyżej.
|
||||||
|
|
||||||
|
Metoda: podpisana różnica długości Δ = lon − natal (zawinięta do ±180°) rośnie
|
||||||
|
monotonicznie i przechodzi przez 0 dokładnie w momencie powrotu. Skan dobowy
|
||||||
|
wykrywa przejście −→+ (skok +180→−180 to artefakt zawinięcia — pomijany,
|
||||||
|
warunek d_hi − d_lo < 180), potem bisekcja do ~sekundy.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
# szerokość okna skanu wokół `around` [dni]: solar kotwiczymy przy urodzinach,
|
||||||
|
# lunar musi objąć cały okres syderyczny Księżyca (27,3 d)
|
||||||
|
SCAN_WINDOW = {"solar": 6.0, "lunar": 15.0}
|
||||||
|
|
||||||
|
|
||||||
|
def _lon_delta(engine, body: str, natal_lon: float, when: datetime) -> float:
|
||||||
|
m = ChartMoment(when_utc=when)
|
||||||
|
lon = engine.positions(m, [body])[0].longitude
|
||||||
|
return ((lon - natal_lon + 180.0) % 360.0) - 180.0
|
||||||
|
|
||||||
|
|
||||||
|
def find_return(
|
||||||
|
engine, kind: str, natal_moment: ChartMoment, around: datetime
|
||||||
|
) -> datetime | None:
|
||||||
|
"""Moment powrotu (kind: 'solar'/'lunar') najbliższy dacie `around`."""
|
||||||
|
body = "Sun" if kind == "solar" else "Moon"
|
||||||
|
natal_lon = engine.positions(natal_moment, [body])[0].longitude
|
||||||
|
if around.tzinfo is None:
|
||||||
|
around = around.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
window = SCAN_WINDOW[kind]
|
||||||
|
step = timedelta(days=1.0)
|
||||||
|
t = around - timedelta(days=window)
|
||||||
|
end = around + timedelta(days=window)
|
||||||
|
|
||||||
|
candidates: list[datetime] = []
|
||||||
|
d_prev = _lon_delta(engine, body, natal_lon, t)
|
||||||
|
while t < end:
|
||||||
|
t_next = t + step
|
||||||
|
d_next = _lon_delta(engine, body, natal_lon, t_next)
|
||||||
|
# prawdziwe przejście przez zero: − -> + bez skoku zawinięcia
|
||||||
|
if d_prev < 0 <= d_next and (d_next - d_prev) < 180.0:
|
||||||
|
lo, hi, d_lo = t, t_next, d_prev
|
||||||
|
for _ in range(40): # bisekcja do ułamka sekundy
|
||||||
|
mid = lo + (hi - lo) / 2
|
||||||
|
if (_lon_delta(engine, body, natal_lon, mid) < 0) == (d_lo < 0):
|
||||||
|
lo = mid
|
||||||
|
else:
|
||||||
|
hi = mid
|
||||||
|
candidates.append(lo + (hi - lo) / 2)
|
||||||
|
t, d_prev = t_next, d_next
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
return min(candidates, key=lambda c: abs(c - around))
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""SkyfieldEngine — własny, permisywny silnik (LOG-01).
|
||||||
|
|
||||||
|
Ścieżka A: Skyfield (MIT) + efemerydy JPL (public domain). Liczy geocentryczne
|
||||||
|
pozycje pozorne (apparent) i rzutuje je na ekliptykę daty → długość tropikalna,
|
||||||
|
szerokość, prędkość i kierunek. Brak zależności AGPL.
|
||||||
|
|
||||||
|
Prędkość liczymy numerycznie (różnica długości po małym kroku czasu) — wystarcza
|
||||||
|
do kierunku (D/Rx) i do wykrywania stacji w LOG-03.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from app.engine.base import EphemerisEngine
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
from app.engine.models import DEFAULT_OBJECTS, ChartMoment, ObjectPosition
|
||||||
|
|
||||||
|
# nazwa obiektu -> cel w jądrze efemeryd (de421 ma centra Merkurego/Wenus,
|
||||||
|
# dla pozostałych planet używamy barycentrów — różnica nieistotna astrologicznie)
|
||||||
|
_TARGETS = {
|
||||||
|
"Sun": "sun",
|
||||||
|
"Moon": "moon",
|
||||||
|
"Mercury": "mercury",
|
||||||
|
"Venus": "venus",
|
||||||
|
"Mars": "mars barycenter",
|
||||||
|
"Jupiter": "jupiter barycenter",
|
||||||
|
"Saturn": "saturn barycenter",
|
||||||
|
"Uranus": "uranus barycenter",
|
||||||
|
"Neptune": "neptune barycenter",
|
||||||
|
"Pluto": "pluto barycenter",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=4)
|
||||||
|
def _load(kernel: str, data_dir: str):
|
||||||
|
"""Wczytuje skalę czasu i jądro efemeryd raz (kosztowne) i cache'uje."""
|
||||||
|
from skyfield.api import Loader
|
||||||
|
|
||||||
|
load = Loader(data_dir)
|
||||||
|
ts = load.timescale()
|
||||||
|
eph = load(kernel)
|
||||||
|
return ts, eph, eph["earth"]
|
||||||
|
|
||||||
|
|
||||||
|
class SkyfieldEngine(EphemerisEngine):
|
||||||
|
name = "skyfield"
|
||||||
|
|
||||||
|
def __init__(self, kernel: str | None = None, data_dir: str | None = None) -> None:
|
||||||
|
self.kernel = kernel or os.getenv("EPHEMERIS_KERNEL", "de421.bsp")
|
||||||
|
self.data_dir = data_dir or os.getenv(
|
||||||
|
"EPHEMERIS_DIR", os.path.join(os.path.dirname(__file__), "..", "..", ".ephemeris")
|
||||||
|
)
|
||||||
|
os.makedirs(self.data_dir, exist_ok=True)
|
||||||
|
self.ts, self.eph, self.earth = _load(self.kernel, os.path.abspath(self.data_dir))
|
||||||
|
|
||||||
|
def _ecliptic_lon_lat(self, target, t):
|
||||||
|
astrometric = self.earth.at(t).observe(target).apparent()
|
||||||
|
lat, lon, _dist = astrometric.ecliptic_latlon(epoch="date")
|
||||||
|
return lon.degrees, lat.degrees
|
||||||
|
|
||||||
|
def _virtual_point(self, name: str, tt_jd: float) -> ObjectPosition:
|
||||||
|
"""Punkty analityczne (LOG-02): mean Node (NN/SN) i mean Lilith.
|
||||||
|
|
||||||
|
Liczone wzorami Meeusa, nie z jądra JPL. SN = NN + 180° (ta sama prędkość).
|
||||||
|
Punkty leżą na ekliptyce (latitude = 0).
|
||||||
|
"""
|
||||||
|
from app.engine.points import mean_lilith, mean_lunar_node, point_speed
|
||||||
|
|
||||||
|
if name in ("North Node", "South Node"):
|
||||||
|
lon = mean_lunar_node(tt_jd)
|
||||||
|
if name == "South Node":
|
||||||
|
lon = norm360(lon + 180.0)
|
||||||
|
speed = point_speed(mean_lunar_node, tt_jd)
|
||||||
|
else: # Lilith
|
||||||
|
lon = mean_lilith(tt_jd)
|
||||||
|
speed = point_speed(mean_lilith, tt_jd)
|
||||||
|
return ObjectPosition(
|
||||||
|
name=name, longitude=float(lon), latitude=0.0,
|
||||||
|
speed=float(speed), retrograde=bool(speed < 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
def positions(
|
||||||
|
self, moment: ChartMoment, objects: list[str] | None = None
|
||||||
|
) -> list[ObjectPosition]:
|
||||||
|
names = objects or DEFAULT_OBJECTS
|
||||||
|
t = self.ts.from_datetime(moment.when_utc)
|
||||||
|
dt = timedelta(hours=1)
|
||||||
|
t2 = self.ts.from_datetime(moment.when_utc + dt)
|
||||||
|
|
||||||
|
out: list[ObjectPosition] = []
|
||||||
|
for name in names:
|
||||||
|
if name not in _TARGETS: # punkt wirtualny (NN/SN/Lilith)
|
||||||
|
out.append(self._virtual_point(name, t.tt))
|
||||||
|
continue
|
||||||
|
target = self.eph[_TARGETS[name]]
|
||||||
|
lon, lat = self._ecliptic_lon_lat(target, t)
|
||||||
|
lon2, _ = self._ecliptic_lon_lat(target, t2)
|
||||||
|
# prędkość °/dobę z poprawką na przejście przez 0°/360°
|
||||||
|
step = ((lon2 - lon + 180.0) % 360.0) - 180.0
|
||||||
|
speed = step * 24.0
|
||||||
|
# rzutowanie na czysty float — Skyfield zwraca numpy.float64
|
||||||
|
out.append(
|
||||||
|
ObjectPosition(
|
||||||
|
name=name,
|
||||||
|
longitude=float(norm360(lon)),
|
||||||
|
latitude=float(lat),
|
||||||
|
speed=float(speed),
|
||||||
|
retrograde=bool(speed < 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def sidereal(self, moment: ChartMoment) -> tuple[float, float]:
|
||||||
|
"""(RAMC, ε) w stopniach — lokalny apparent sidereal time i nachylenie ekliptyki.
|
||||||
|
|
||||||
|
Materiał wejściowy do osi i domów (LOG-05). RAMC = GAST·15 + długość geo.
|
||||||
|
"""
|
||||||
|
from app.engine.houses import mean_obliquity
|
||||||
|
|
||||||
|
t = self.ts.from_datetime(moment.when_utc)
|
||||||
|
ramc = norm360(t.gast * 15.0 + moment.lon)
|
||||||
|
eps = mean_obliquity(t.tt)
|
||||||
|
return ramc, eps
|
||||||
|
|
||||||
|
def health(self) -> dict:
|
||||||
|
return {"engine": self.name, "status": "ok", "kernel": self.kernel}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Wykrywanie stacji planet (LOG-03): poprzednia/następna stacja, SD/SR, flaga <7 dni.
|
||||||
|
|
||||||
|
Stacja ścisła = moment, w którym prędkość zodiakalna przechodzi przez zero.
|
||||||
|
Metoda: próbki prędkości co 1 dzień w oknie ± SEARCH_DAYS → zmiana znaku →
|
||||||
|
bisekcja do dokładności ~1 minuty. Klasyfikacja: prędkość przed<0 i po>0 → SD
|
||||||
|
(stationary direct), odwrotnie → SR (stationary retrograde).
|
||||||
|
|
||||||
|
Pomijamy Słońce/Księżyc (nigdy Rx) i punkty mean (NN/SN/Lilith — ruch jednostajny).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from app.engine.formats import in_sign
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
# obiekty bez stacji
|
||||||
|
NO_STATIONS = {"Sun", "Moon", "North Node", "South Node", "Lilith"}
|
||||||
|
|
||||||
|
# Okno musi pokryć najdłuższą przerwę między stacjami (Mars/Wenus: ~700 dni),
|
||||||
|
# a krok skanu musi być krótszy niż najkrótsza retrogradacja (Merkury ~21 dni).
|
||||||
|
SEARCH_DAYS = 800 # okno poszukiwań w każdą stronę
|
||||||
|
SCAN_STEP_DAYS = 4.0 # krok zgrubnego skanu (potem bisekcja)
|
||||||
|
STATION_SOON_DAYS = 7.0 # próg flagi "stacja blisko" (konfigurowalny, notes2)
|
||||||
|
|
||||||
|
|
||||||
|
def _speed_fn(engine, name: str):
|
||||||
|
"""Zwraca funkcję: dni_od_momentu_bazowego -> prędkość [°/dobę]."""
|
||||||
|
def speed(base_moment: ChartMoment, offset_days: float) -> float:
|
||||||
|
m = ChartMoment(
|
||||||
|
when_utc=base_moment.when_utc + timedelta(days=offset_days),
|
||||||
|
lat=base_moment.lat, lon=base_moment.lon,
|
||||||
|
)
|
||||||
|
return engine.positions(m, [name])[0].speed
|
||||||
|
return speed
|
||||||
|
|
||||||
|
|
||||||
|
def _bisect_zero(speed, moment: ChartMoment, lo: float, hi: float, iters: int = 20) -> float:
|
||||||
|
"""Bisekcja miejsca zerowego prędkości między dniami lo i hi."""
|
||||||
|
s_lo = speed(moment, lo)
|
||||||
|
for _ in range(iters):
|
||||||
|
mid = (lo + hi) / 2.0
|
||||||
|
s_mid = speed(moment, mid)
|
||||||
|
if (s_lo < 0) == (s_mid < 0):
|
||||||
|
lo, s_lo = mid, s_mid
|
||||||
|
else:
|
||||||
|
hi = mid
|
||||||
|
return (lo + hi) / 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def _station_info(engine, moment: ChartMoment, name: str, day: float, speed) -> dict:
|
||||||
|
"""Opis stacji w danym dniu (offset od momentu bazowego)."""
|
||||||
|
before = speed(moment, day - 0.5)
|
||||||
|
kind = "SD" if before < 0 else "SR"
|
||||||
|
when = moment.when_utc + timedelta(days=day)
|
||||||
|
m = ChartMoment(when_utc=when, lat=moment.lat, lon=moment.lon)
|
||||||
|
lon = engine.positions(m, [name])[0].longitude
|
||||||
|
return {
|
||||||
|
"type": kind,
|
||||||
|
"date": when.strftime("%Y-%m-%d %H:%M"),
|
||||||
|
"days": round(day, 1), # ujemne = w przeszłości
|
||||||
|
"degree": in_sign(lon),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_stations(engine, moment: ChartMoment, name: str, step_days: float = SCAN_STEP_DAYS) -> dict | None:
|
||||||
|
"""Poprzednia i następna stacja obiektu względem momentu horoskopu."""
|
||||||
|
if name in NO_STATIONS:
|
||||||
|
return None
|
||||||
|
speed = _speed_fn(engine, name)
|
||||||
|
|
||||||
|
prev_day = next_day = None
|
||||||
|
# w przeszłość
|
||||||
|
s_right = speed(moment, 0.0)
|
||||||
|
d = 0.0
|
||||||
|
while d > -SEARCH_DAYS:
|
||||||
|
s_left = speed(moment, d - step_days)
|
||||||
|
if (s_left < 0) != (s_right < 0):
|
||||||
|
prev_day = _bisect_zero(speed, moment, d - step_days, d)
|
||||||
|
break
|
||||||
|
d, s_right = d - step_days, s_left
|
||||||
|
# w przyszłość
|
||||||
|
s_left = speed(moment, 0.0)
|
||||||
|
d = 0.0
|
||||||
|
while d < SEARCH_DAYS:
|
||||||
|
s_right = speed(moment, d + step_days)
|
||||||
|
if (s_left < 0) != (s_right < 0):
|
||||||
|
next_day = _bisect_zero(speed, moment, d, d + step_days)
|
||||||
|
break
|
||||||
|
d, s_left = d + step_days, s_right
|
||||||
|
|
||||||
|
result: dict = {}
|
||||||
|
if prev_day is not None:
|
||||||
|
result["prev"] = _station_info(engine, moment, name, prev_day, speed)
|
||||||
|
if next_day is not None:
|
||||||
|
result["next"] = _station_info(engine, moment, name, next_day, speed)
|
||||||
|
result["station_soon"] = any(
|
||||||
|
abs(x["days"]) < STATION_SOON_DAYS for x in result.values() if isinstance(x, dict)
|
||||||
|
)
|
||||||
|
return result or None
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""Zbiorcza tabela dat z technik (LOG-14).
|
||||||
|
|
||||||
|
Spina w jedną, posortowaną oś czasu daty z kilku technik:
|
||||||
|
- profekcje roczne (LOG-10) — rok życia,
|
||||||
|
- Solar Return (LOG-12) — moment powrotu Słońca,
|
||||||
|
- dyrekcje solar-arc — daty dokładnych aspektów kierowanych planet do punktów
|
||||||
|
natalnych (wzorzec z notes3: „Profection planet | Aspect | Birth planet |
|
||||||
|
Exact Date"). Klucz łuku konfigurowalny; domyślnie Naiboda (0°59'08"/rok).
|
||||||
|
|
||||||
|
Każdy wiersz ma kształt z notes2: technique | significator | start | exact | end.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from app.engine.aspects import MAJOR, PL_NAME
|
||||||
|
from app.engine.profections import DOMICILE_RULERS, profected_sign
|
||||||
|
from app.engine.returns import find_return
|
||||||
|
|
||||||
|
NAIBOD_KEY = 0.9856472 # °/rok (0°59'08") — domyślny klucz solar-arc
|
||||||
|
DAYS_PER_YEAR = 365.2422
|
||||||
|
DIRECTED = ["Sun", "Moon", "Mercury", "Venus", "Mars",
|
||||||
|
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto"]
|
||||||
|
|
||||||
|
|
||||||
|
def _add_years(birth: datetime, years: float) -> datetime:
|
||||||
|
return birth + timedelta(days=years * DAYS_PER_YEAR)
|
||||||
|
|
||||||
|
|
||||||
|
def _row(technique, significator, start, exact, end) -> dict:
|
||||||
|
def iso(x):
|
||||||
|
return x.date().isoformat() if isinstance(x, datetime) else x
|
||||||
|
return {"technique": technique, "significator": significator,
|
||||||
|
"start": iso(start), "exact": iso(exact), "end": iso(end)}
|
||||||
|
|
||||||
|
|
||||||
|
def solar_arc_directions(
|
||||||
|
natal: dict[str, float], birth: datetime, lo: datetime, hi: datetime,
|
||||||
|
key: float = NAIBOD_KEY, orb_years: float = 1.0,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Daty dyrekcji solar-arc w oknie [lo, hi].
|
||||||
|
|
||||||
|
natal: nazwa punktu -> długość natalna (planety + Asc/MC). Kierowane są planety
|
||||||
|
(DIRECTED), celem każdy punkt natalny. Aspekt dokładny gdy łuk = odległość
|
||||||
|
kątowa (mod 360). Wiek = łuk/klucz; data = urodziny + wiek.
|
||||||
|
"""
|
||||||
|
out: list[dict] = []
|
||||||
|
lo_age = (lo - birth).days / DAYS_PER_YEAR - orb_years
|
||||||
|
hi_age = (hi - birth).days / DAYS_PER_YEAR + orb_years
|
||||||
|
for p in DIRECTED:
|
||||||
|
if p not in natal:
|
||||||
|
continue
|
||||||
|
for q, q_lon in natal.items():
|
||||||
|
for asp, angle in MAJOR.items():
|
||||||
|
for target in ({angle, (360.0 - angle) % 360.0}):
|
||||||
|
arc = (q_lon + target - natal[p]) % 360.0
|
||||||
|
age = arc / key
|
||||||
|
if not (lo_age <= age <= hi_age) or (p == q and arc < 1e-6):
|
||||||
|
continue
|
||||||
|
exact = _add_years(birth, age)
|
||||||
|
row = _row(
|
||||||
|
"solar_arc",
|
||||||
|
f"dyr. {p} {PL_NAME[asp]} {q}",
|
||||||
|
_add_years(birth, age - orb_years),
|
||||||
|
exact,
|
||||||
|
_add_years(birth, age + orb_years),
|
||||||
|
)
|
||||||
|
row.update(directed=p, aspect=asp, target=q) # do budowy tokenów (1B->2B)
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def profection_events(natal_asc: float, birth: datetime, lo: datetime, hi: datetime) -> list[dict]:
|
||||||
|
"""Lata profekcyjne (LOG-10) nachodzące na okno."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for age in range((lo.year - birth.year) - 1, (hi.year - birth.year) + 1):
|
||||||
|
if age < 0:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
start = birth.replace(year=birth.year + age)
|
||||||
|
end = birth.replace(year=birth.year + age + 1)
|
||||||
|
except ValueError: # 29 lutego
|
||||||
|
start = birth.replace(year=birth.year + age, day=28)
|
||||||
|
end = birth.replace(year=birth.year + age + 1, day=28)
|
||||||
|
if end < lo or start > hi:
|
||||||
|
continue
|
||||||
|
sign = profected_sign(natal_asc, age)
|
||||||
|
lord = DOMICILE_RULERS[sign]
|
||||||
|
row = _row(
|
||||||
|
"profection", f"Władca Roku: {lord} (Asc {sign}, wiek {age})",
|
||||||
|
start, start, end,
|
||||||
|
)
|
||||||
|
row.update(lord=lord, sign=sign) # do budowy tokenów (1B->2B)
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def solar_return_events(engine, natal_moment, birth: datetime, lo: datetime, hi: datetime) -> list[dict]:
|
||||||
|
"""Solariusze w oknie (LOG-12) — jeden na rok."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for year in range(lo.year, hi.year + 1):
|
||||||
|
try:
|
||||||
|
around = birth.replace(year=year)
|
||||||
|
except ValueError:
|
||||||
|
around = birth.replace(year=year, day=28)
|
||||||
|
hit = find_return(engine, "solar", natal_moment, around)
|
||||||
|
if hit and lo <= hit <= hi:
|
||||||
|
out.append(_row("solar_return", "Solar Return", hit, hit, _add_years(hit, 1)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def firdaria_events(natal_points: dict[str, float], birth: datetime, lo: datetime, hi: datetime) -> list[dict]:
|
||||||
|
"""Starty okresów/podokresów Firdarii (LOG-11) nachodzące na okno."""
|
||||||
|
from app.engine.firdaria import firdaria
|
||||||
|
|
||||||
|
fd = firdaria(birth, natal_points["Sun"], natal_points["Asc"], natal_points["MC"])
|
||||||
|
out: list[dict] = []
|
||||||
|
for period in fd["periods"]:
|
||||||
|
if "sub" in period:
|
||||||
|
for s in period["sub"]:
|
||||||
|
if lo <= _as_dt(s["start"]) <= hi:
|
||||||
|
row = _row("firdaria", f"Firdaria: {period['lord']} / {s['lord']}",
|
||||||
|
s["start"], s["start"], s["end"])
|
||||||
|
row.update(fd_major=period["lord"], fd_sub=s["lord"])
|
||||||
|
out.append(row)
|
||||||
|
elif lo <= _as_dt(period["start"]) <= hi: # węzeł — bez podokresów
|
||||||
|
row = _row("firdaria", f"Firdaria: {period['lord']}",
|
||||||
|
period["start"], period["start"], period["end"])
|
||||||
|
row.update(fd_major=period["lord"])
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _as_dt(d) -> datetime:
|
||||||
|
if isinstance(d, datetime):
|
||||||
|
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
|
||||||
|
if isinstance(d, date):
|
||||||
|
return datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
|
||||||
|
return datetime.fromisoformat(str(d)).replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def build_timeline(
|
||||||
|
engine, natal_moment, natal_points: dict[str, float],
|
||||||
|
from_d, to_d, techniques: list[str] | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Scala wybrane techniki w jedną oś czasu, posortowaną po dacie dokładnej."""
|
||||||
|
lo, hi = _as_dt(from_d), _as_dt(to_d)
|
||||||
|
birth = natal_moment.when_utc
|
||||||
|
want = set(techniques or ["profection", "solar_return", "solar_arc", "firdaria"])
|
||||||
|
events: list[dict] = []
|
||||||
|
if "profection" in want:
|
||||||
|
events += profection_events(natal_points["Asc"], birth, lo, hi)
|
||||||
|
if "solar_return" in want:
|
||||||
|
events += solar_return_events(engine, natal_moment, birth, lo, hi)
|
||||||
|
if "solar_arc" in want:
|
||||||
|
events += solar_arc_directions(natal_points, birth, lo, hi)
|
||||||
|
if "firdaria" in want:
|
||||||
|
events += firdaria_events(natal_points, birth, lo, hi)
|
||||||
|
events.sort(key=lambda e: e["exact"])
|
||||||
|
return events
|
||||||
@@ -6,8 +6,11 @@ Nie serwuje HTML, nie czyta plików/baz — tylko reguły i pośrednictwo.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.clients.data_client import DataClient
|
from app.clients.data_client import DataClient
|
||||||
from app.models import QueryRequest, QueryResponse
|
from app.models import QueryRequest, QueryResponse
|
||||||
@@ -16,6 +19,27 @@ from app.service import QueryService
|
|||||||
app = FastAPI(title="astrololo · warstwa logiczna")
|
app = FastAPI(title="astrololo · warstwa logiczna")
|
||||||
service = QueryService()
|
service = QueryService()
|
||||||
|
|
||||||
|
# --- silnik efemeryd (LOG-24): budowany leniwie, by nie wymagać Skyfielda do startu ---
|
||||||
|
_engine = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
global _engine
|
||||||
|
if _engine is None:
|
||||||
|
from app.engine.factory import build_engine
|
||||||
|
|
||||||
|
_engine = build_engine()
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
class PositionsRequest(BaseModel):
|
||||||
|
when_utc: datetime # moment w UTC (świadomy strefy)
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
objects: list[str] | None = None
|
||||||
|
house_system: str = "whole_sign" # whole_sign | equal | porphyry
|
||||||
|
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/query", response_model=QueryResponse)
|
@app.post("/api/query", response_model=QueryResponse)
|
||||||
def query(req: QueryRequest) -> QueryResponse:
|
def query(req: QueryRequest) -> QueryResponse:
|
||||||
@@ -25,6 +49,190 @@ def query(req: QueryRequest) -> QueryResponse:
|
|||||||
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/positions")
|
||||||
|
def chart_positions(req: PositionsRequest) -> dict:
|
||||||
|
"""Pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty (LOG-06);
|
||||||
|
opcjonalnie stacje planet (LOG-03, stations=true)."""
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
chart = build_chart(engine, moment, req.house_system)
|
||||||
|
if req.stations:
|
||||||
|
from app.engine.stations import find_stations
|
||||||
|
|
||||||
|
for p in chart["positions"]:
|
||||||
|
st = find_stations(engine, moment, p["name"])
|
||||||
|
if st:
|
||||||
|
p["stations"] = st
|
||||||
|
return chart
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/compare")
|
||||||
|
def chart_compare(req: PositionsRequest) -> dict:
|
||||||
|
"""Tryb dwu-silnikowy (LOG-26): policz oboma silnikami i zwróć raport różnic.
|
||||||
|
|
||||||
|
Wymaga skonfigurowanego ENGINE_SWISSEPH_URL (silnik B). W przeciwnym razie
|
||||||
|
zwraca informację, że porównanie jest niedostępne.
|
||||||
|
"""
|
||||||
|
from app.engine.compare import compare_engines
|
||||||
|
from app.engine.factory import build_engine
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
try:
|
||||||
|
report = compare_engines(build_engine("own"), build_engine("swisseph"), moment)
|
||||||
|
except (RuntimeError, httpx.HTTPError) as e:
|
||||||
|
raise HTTPException(status_code=503, detail=f"Silnik B niedostępny: {e}")
|
||||||
|
return report.summary()
|
||||||
|
|
||||||
|
|
||||||
|
class ReportRequest(BaseModel):
|
||||||
|
when_utc: datetime
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
limit: int = 5000
|
||||||
|
group: bool = False # grupowanie identycznych opisów
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/report")
|
||||||
|
def chart_report(req: ReportRequest) -> dict:
|
||||||
|
"""Wynik obliczeń szukany w bazie: z pozycji + domów + aspektów generuje
|
||||||
|
sygnifikatory (fasety znak/dom/aspekt) i pyta warstwę danych o interpretacje."""
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.significators import build_report
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
chart = build_chart(engine, moment) # pozycje z domami + aspekty
|
||||||
|
try:
|
||||||
|
report = build_report(
|
||||||
|
chart["positions"], DataClient(),
|
||||||
|
aspects=chart.get("aspects"), per_object_limit=req.limit, group=req.group,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"}
|
||||||
|
return {"engine": engine.name, **report}
|
||||||
|
|
||||||
|
|
||||||
|
class ProfectionsRequest(BaseModel):
|
||||||
|
when_utc: datetime # moment urodzenia (UTC)
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
start_age: int = 0
|
||||||
|
count: int = 13 # domyślnie pełny cykl 12 lat + rok startowy
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/profections")
|
||||||
|
def chart_profections(req: ProfectionsRequest) -> dict:
|
||||||
|
"""Profekcje roczne (LOG-10): wiek, profektowany Asc, Władca Roku (+MC/Su/Mo)."""
|
||||||
|
from app.engine import houses as H
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.profections import profection_rows
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
ramc, eps = engine.sidereal(natal)
|
||||||
|
points = {
|
||||||
|
"Asc": H.compute_asc(ramc, eps, natal.lat),
|
||||||
|
"MC": H.compute_mc(ramc, eps),
|
||||||
|
}
|
||||||
|
for p in engine.positions(natal, ["Sun", "Moon"]):
|
||||||
|
points[p.name] = p.longitude
|
||||||
|
rows = profection_rows(points, req.when_utc, req.start_age, req.count)
|
||||||
|
return {"engine": engine.name, "rows": rows}
|
||||||
|
|
||||||
|
|
||||||
|
class ReturnRequest(BaseModel):
|
||||||
|
when_utc: datetime # moment urodzenia (UTC)
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
kind: str = "solar" # solar | lunar
|
||||||
|
around: datetime | None = None # data, wokół której szukać powrotu
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/return")
|
||||||
|
def chart_return(req: ReturnRequest) -> dict:
|
||||||
|
"""Solar/Lunar Return (LOG-12): moment powrotu + pełny horoskop na ten moment."""
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.returns import find_return
|
||||||
|
|
||||||
|
if req.kind not in ("solar", "lunar"):
|
||||||
|
raise HTTPException(status_code=422, detail="kind: solar albo lunar")
|
||||||
|
engine = get_engine()
|
||||||
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
around = req.around or req.when_utc
|
||||||
|
hit = find_return(engine, req.kind, natal, around)
|
||||||
|
if hit is None:
|
||||||
|
raise HTTPException(status_code=404, detail="nie znaleziono powrotu w oknie skanu")
|
||||||
|
chart = build_chart(engine, ChartMoment(when_utc=hit, lat=req.lat, lon=req.lon))
|
||||||
|
return {"engine": engine.name, "kind": req.kind,
|
||||||
|
"return_utc": hit.isoformat(), **chart}
|
||||||
|
|
||||||
|
|
||||||
|
class FirdariaRequest(BaseModel):
|
||||||
|
when_utc: datetime # moment urodzenia (UTC)
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/firdaria")
|
||||||
|
def chart_firdaria(req: FirdariaRequest) -> dict:
|
||||||
|
"""Firdaria (LOG-11): sekta + okresy główne i podokresy time-lordów."""
|
||||||
|
from app.engine import houses as H
|
||||||
|
from app.engine.firdaria import firdaria
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
ramc, eps = engine.sidereal(natal)
|
||||||
|
asc, mc = H.compute_asc(ramc, eps, natal.lat), H.compute_mc(ramc, eps)
|
||||||
|
sun = engine.positions(natal, ["Sun"])[0].longitude
|
||||||
|
return {"engine": engine.name, **firdaria(req.when_utc, sun, asc, mc)}
|
||||||
|
|
||||||
|
|
||||||
|
class TimelineRequest(BaseModel):
|
||||||
|
when_utc: datetime # moment urodzenia (UTC)
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
from_date: str # zakres: YYYY-MM-DD
|
||||||
|
to_date: str
|
||||||
|
techniques: list[str] | None = None # profection | solar_return | solar_arc
|
||||||
|
interpret: bool = False # dopnij interpretacje z bazy (1B->2B)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/timeline")
|
||||||
|
def chart_timeline(req: TimelineRequest) -> dict:
|
||||||
|
"""Zbiorcza oś czasu z technik (LOG-14): technique | significator | start | exact | end.
|
||||||
|
|
||||||
|
Z interpret=true dopina do zdarzeń interpretacje z warstwy danych (LOG-19, 1B->2B).
|
||||||
|
"""
|
||||||
|
from app.engine import houses as H
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.timeline import build_timeline
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
ramc, eps = engine.sidereal(natal)
|
||||||
|
points = {"Asc": H.compute_asc(ramc, eps, natal.lat), "MC": H.compute_mc(ramc, eps)}
|
||||||
|
for p in engine.positions(natal):
|
||||||
|
points[p.name] = p.longitude
|
||||||
|
events = build_timeline(engine, natal, points, req.from_date, req.to_date, req.techniques)
|
||||||
|
|
||||||
|
out = {"engine": engine.name, "from": req.from_date, "to": req.to_date}
|
||||||
|
if req.interpret:
|
||||||
|
from app.significators import interpret_events
|
||||||
|
try:
|
||||||
|
interpret_events(events, DataClient())
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
out["data_error"] = f"Warstwa danych niedostępna: {e}"
|
||||||
|
out.update(count=len(events), events=events)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
info = {"status": "ok", "layer": "logic"}
|
info = {"status": "ok", "layer": "logic"}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Punktacja siły trafień — do rankingowania faset (zalążek LOG-21).
|
||||||
|
|
||||||
|
v1 liczy siłę z sygnałów OBLICZALNYCH:
|
||||||
|
- typ fasety (aspekt zwykle mocniejszy od znaku/domu),
|
||||||
|
- rodzaj aspektu (koniunkcja/opozycja mocniejsze od sekstyla),
|
||||||
|
- ciasnota orbu (im bliżej dokładności, tym mocniej).
|
||||||
|
|
||||||
|
Wszystko konfigurowalne. HOOK NA PRZYSZŁOŚĆ: gdy w SIGNIFICATORS KEY zostaną
|
||||||
|
wypełnione kolumny `countas*`/`level*`, można je tu domieszać per sygnifikator.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# bazowe wagi faset (łatwe do strojenia)
|
||||||
|
FACET_BASE = {"sign": 5.0, "house": 5.0, "aspect": 6.0}
|
||||||
|
|
||||||
|
# względna siła aspektów głównych
|
||||||
|
ASPECT_WEIGHT = {
|
||||||
|
"conjunction": 1.0, "opposition": 0.95, "square": 0.85,
|
||||||
|
"trine": 0.85, "sextile": 0.65,
|
||||||
|
}
|
||||||
|
# aspekt aplikacyjny (A) jest silniejszy niż separacyjny (S) — notes3
|
||||||
|
APPLYING_BONUS = 1.15
|
||||||
|
|
||||||
|
|
||||||
|
def score_facet(facet: dict) -> float:
|
||||||
|
"""Siła fasety w skali ~0–12. Deterministyczna, konfigurowalna."""
|
||||||
|
base = FACET_BASE.get(facet.get("type"), 1.0)
|
||||||
|
if facet.get("type") == "aspect":
|
||||||
|
weight = ASPECT_WEIGHT.get(facet.get("aspect"), 0.7)
|
||||||
|
orb = facet.get("orb")
|
||||||
|
allowed = facet.get("allowed") or 10.0
|
||||||
|
tight = max(0.0, 1.0 - orb / allowed) if orb is not None and allowed else 0.0
|
||||||
|
score = base * weight * (1.0 + tight)
|
||||||
|
if facet.get("applying"):
|
||||||
|
score *= APPLYING_BONUS
|
||||||
|
return round(score, 2)
|
||||||
|
return round(base, 2)
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""Most: policzony horoskop → tokeny sygnifikatorów → wyszukiwanie w bazie.
|
||||||
|
|
||||||
|
Zalążek LOG-15/16: z pozycji obiektów (wraz z domami) generujemy tokeny w składni
|
||||||
|
bazy i wyszukujemy pasujące rekordy. Dla każdego obiektu tworzymy kilka **faset**:
|
||||||
|
- „w znaku" — planeta + token znaku (`[Su` + `[Tau`)
|
||||||
|
- „w domu" — planeta + token domu (`[Su` + `11th H.`)
|
||||||
|
Aspekty (`[conj`,`[sq`,`[opp`) wymagają policzenia aspektów (LOG-06) — na później.
|
||||||
|
|
||||||
|
Format skrótów odczytany z realnej bazy: planety `[Su`,`[Mo`,…; znaki
|
||||||
|
`[Ari`,`[Tau`,…; domy `12th H.`; np. `[Sa [conj [Su in 6th H.`.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from app.abbreviations import expand
|
||||||
|
from app.engine.formats import SIGN_ABBR, SIGNS
|
||||||
|
from app.scoring import score_facet
|
||||||
|
|
||||||
|
PLANET_ABBR = {
|
||||||
|
"Sun": "Su", "Moon": "Mo", "Mercury": "Me", "Venus": "Ve", "Mars": "Ma",
|
||||||
|
"Jupiter": "Ju", "Saturn": "Sa", "Uranus": "Ur", "Neptune": "Ne", "Pluto": "Pl",
|
||||||
|
# punkty wirtualne — tokeny wg SIGNIFICATORS KEY ([NN, [SN, [Lilith)
|
||||||
|
"North Node": "NN", "South Node": "SN", "Lilith": "Lilith",
|
||||||
|
# Lots — w bazie Fortuna występuje jako [PF (Part of Fortune)
|
||||||
|
"Fortune": "PF", "Spirit": "Spirit",
|
||||||
|
}
|
||||||
|
SIGN_TO_ABBR = dict(zip(SIGNS, SIGN_ABBR))
|
||||||
|
|
||||||
|
|
||||||
|
class DataSource(Protocol):
|
||||||
|
def search(
|
||||||
|
self, key: str, value: str, exact: bool, limit: int, fields: list[str] | None = None
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
def _effect(row: dict) -> str:
|
||||||
|
for col in ("actioneffect", "topicresult", "bodypart"):
|
||||||
|
v = row.get(col)
|
||||||
|
if v and str(v).strip().lower() not in ("", "nan"):
|
||||||
|
return str(v).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_noise(sig: str, effect: str) -> bool:
|
||||||
|
s = sig.strip().lower()
|
||||||
|
e = effect.strip().lower()
|
||||||
|
return (
|
||||||
|
e in ("", "nan", "x", "x?", "?", "-") # efekt pusty/zastępczy
|
||||||
|
or s.startswith("significator") # wiersz-legenda/nagłówek
|
||||||
|
or "header" in s
|
||||||
|
or s.startswith("*") # *MARKER / *header
|
||||||
|
or s in ("x", "x?", "nan") # znacznik „pomiń rekord"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ordinal(n: int) -> str:
|
||||||
|
if 10 <= n % 100 <= 20:
|
||||||
|
suffix = "th"
|
||||||
|
else:
|
||||||
|
suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
|
||||||
|
return f"{n}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(s: str) -> str:
|
||||||
|
"""Normalizacja do porównań duplikatów: bez skrajnych spacji, jedna spacja, lower."""
|
||||||
|
return " ".join(str(s).strip().lower().split())
|
||||||
|
|
||||||
|
|
||||||
|
def _facet_samples(rows: list[dict], tokens: list[str]) -> list[dict]:
|
||||||
|
"""Rekordy, których sygnifikator zawiera WSZYSTKIE tokeny — bez szumu i bez duplikatów.
|
||||||
|
|
||||||
|
Duplikat = ten sam sygnifikator ORAZ ten sam opis (po normalizacji). Dedup
|
||||||
|
działa na zagregowanym wyniku, więc odsiewa też powtórki między wieloma bazami.
|
||||||
|
"""
|
||||||
|
toks = [t.lower() for t in tokens if t]
|
||||||
|
out: list[dict] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for r in rows:
|
||||||
|
sig = str(r.get("significator") or "").strip()
|
||||||
|
low = sig.lower()
|
||||||
|
if not all(t in low for t in toks):
|
||||||
|
continue
|
||||||
|
eff = _effect(r)
|
||||||
|
if _is_noise(sig, eff):
|
||||||
|
continue
|
||||||
|
key = (_norm(sig), _norm(eff))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append({"significator": sig, "expanded": expand(sig), "effect": eff})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _group_by_effect(samples: list[dict]) -> list[dict]:
|
||||||
|
"""Grupuje próbki po opisie: ten sam efekt = jedna grupa z listą sygnifikatorów."""
|
||||||
|
groups: dict[str, dict] = {}
|
||||||
|
order: list[str] = []
|
||||||
|
for s in samples:
|
||||||
|
key = _norm(s["effect"])
|
||||||
|
g = groups.get(key)
|
||||||
|
if g is None:
|
||||||
|
g = {"effect": s["effect"], "count": 0, "significators": []}
|
||||||
|
groups[key] = g
|
||||||
|
order.append(key)
|
||||||
|
g["count"] += 1
|
||||||
|
g["significators"].append(s["expanded"])
|
||||||
|
result = [groups[k] for k in order]
|
||||||
|
result.sort(key=lambda g: g["count"], reverse=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def build_report(
|
||||||
|
positions: list[dict],
|
||||||
|
data: DataSource,
|
||||||
|
aspects: list[dict] | None = None,
|
||||||
|
per_object_limit: int = 5000,
|
||||||
|
group: bool = False,
|
||||||
|
) -> dict:
|
||||||
|
"""positions: pozycje z build_chart (name, sign, direction, house).
|
||||||
|
|
||||||
|
Dla każdego obiektu fasety: „w znaku", „w domu" oraz „w aspekcie" (dla każdego
|
||||||
|
aspektu głównego z listy `aspects`, jeśli w bazie są dopasowania). Duplikaty
|
||||||
|
(ten sam sygnifikator i opis) są odsiewane wewnątrz każdej fasety.
|
||||||
|
"""
|
||||||
|
from app.engine.aspects import DB_TOKEN as ASP_TOKEN, PL_NAME as ASP_NAME
|
||||||
|
|
||||||
|
items: list[dict] = []
|
||||||
|
provider = None
|
||||||
|
for p in positions:
|
||||||
|
name = p.get("name")
|
||||||
|
if name not in PLANET_ABBR:
|
||||||
|
continue
|
||||||
|
planet_tok = "[" + PLANET_ABBR[name]
|
||||||
|
raw = data.search(
|
||||||
|
key="significator",
|
||||||
|
value=planet_tok,
|
||||||
|
exact=False,
|
||||||
|
limit=per_object_limit,
|
||||||
|
fields=["significator", "actioneffect", "topicresult", "bodypart"],
|
||||||
|
)
|
||||||
|
provider = raw.get("provider", provider)
|
||||||
|
rows = raw.get("rows", [])
|
||||||
|
|
||||||
|
facets: list[dict] = []
|
||||||
|
sign = p.get("sign")
|
||||||
|
sign_tok = "[" + SIGN_TO_ABBR.get(sign, "")
|
||||||
|
sign_samples = _facet_samples(rows, [sign_tok])
|
||||||
|
facets.append({
|
||||||
|
"type": "sign", "label": f"w znaku {sign}", "token": sign_tok,
|
||||||
|
"count": len(sign_samples), "samples": sign_samples,
|
||||||
|
})
|
||||||
|
|
||||||
|
house = p.get("house")
|
||||||
|
if house:
|
||||||
|
ordn = _ordinal(int(house))
|
||||||
|
house_samples = _facet_samples(rows, [f"{ordn} h"]) # matcuje '12th H.'
|
||||||
|
facets.append({
|
||||||
|
"type": "house", "label": f"w {ordn} domu", "token": f"{ordn} H.",
|
||||||
|
"count": len(house_samples), "samples": house_samples,
|
||||||
|
})
|
||||||
|
|
||||||
|
for asp in (aspects or []):
|
||||||
|
if name not in (asp.get("obj1"), asp.get("obj2")):
|
||||||
|
continue
|
||||||
|
other = asp["obj2"] if asp["obj1"] == name else asp["obj1"]
|
||||||
|
asp_tok = ASP_TOKEN.get(asp["aspect"])
|
||||||
|
if other not in PLANET_ABBR or not asp_tok:
|
||||||
|
continue
|
||||||
|
other_tok = "[" + PLANET_ABBR[other]
|
||||||
|
asp_samples = _facet_samples(rows, [asp_tok, other_tok])
|
||||||
|
if not asp_samples: # pokazujemy tylko aspekty z trafieniami
|
||||||
|
continue
|
||||||
|
as_suffix = f" ({asp['as']})" if asp.get("as") else ""
|
||||||
|
facets.append({
|
||||||
|
"type": "aspect", "label": f"{ASP_NAME[asp['aspect']]} z {other}{as_suffix}",
|
||||||
|
"token": f"{asp_tok} + {other_tok}",
|
||||||
|
"aspect": asp["aspect"], "orb": asp.get("orb"), "allowed": asp.get("allowed"),
|
||||||
|
"applying": asp.get("applying"),
|
||||||
|
"count": len(asp_samples), "samples": asp_samples,
|
||||||
|
})
|
||||||
|
|
||||||
|
# punktacja siły (LOG-21), opcjonalne grupowanie po opisie, ranking faset
|
||||||
|
for f in facets:
|
||||||
|
f["score"] = score_facet(f)
|
||||||
|
if group:
|
||||||
|
f["groups"] = _group_by_effect(f["samples"])
|
||||||
|
facets.sort(key=lambda f: f["score"], reverse=True)
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"object": name,
|
||||||
|
"sign": sign,
|
||||||
|
"house": house,
|
||||||
|
"direction": p.get("direction"),
|
||||||
|
"planet_token": planet_tok,
|
||||||
|
"planet_total": raw.get("total", 0),
|
||||||
|
"facets": facets,
|
||||||
|
})
|
||||||
|
return {"provider": provider, "objects": items}
|
||||||
|
|
||||||
|
|
||||||
|
def _event_tokens(event: dict) -> list[str]:
|
||||||
|
"""Tokeny bazy dla zdarzenia osi czasu (spięcie 1B→2B). Pierwszy = planeta."""
|
||||||
|
from app.engine.aspects import DB_TOKEN
|
||||||
|
|
||||||
|
if event.get("technique") == "solar_arc":
|
||||||
|
p = PLANET_ABBR.get(event.get("directed"))
|
||||||
|
a = DB_TOKEN.get(event.get("aspect"))
|
||||||
|
q = PLANET_ABBR.get(event.get("target")) # None dla Asc/MC (nie ma tokenu)
|
||||||
|
toks = []
|
||||||
|
if p:
|
||||||
|
toks.append("[" + p)
|
||||||
|
if a:
|
||||||
|
toks.append(a)
|
||||||
|
if q:
|
||||||
|
toks.append("[" + q)
|
||||||
|
return toks if p else []
|
||||||
|
if event.get("technique") == "profection":
|
||||||
|
lord = PLANET_ABBR.get(event.get("lord"))
|
||||||
|
sign = SIGN_TO_ABBR.get(event.get("sign"))
|
||||||
|
toks = []
|
||||||
|
if lord:
|
||||||
|
toks.append("[" + lord)
|
||||||
|
if sign:
|
||||||
|
toks.append("[" + sign)
|
||||||
|
return toks if lord else []
|
||||||
|
if event.get("technique") == "firdaria":
|
||||||
|
major = PLANET_ABBR.get(event.get("fd_major"))
|
||||||
|
sub = PLANET_ABBR.get(event.get("fd_sub"))
|
||||||
|
toks = []
|
||||||
|
if major:
|
||||||
|
toks.append("[" + major)
|
||||||
|
if sub:
|
||||||
|
toks.append("[" + sub)
|
||||||
|
return toks if major else []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def interpret_events(events: list[dict], data: DataSource, limit: int = 4,
|
||||||
|
per_object_limit: int = 5000) -> list[dict]:
|
||||||
|
"""Dopina interpretacje z bazy do zdarzeń osi czasu (predykcyjne 1B → 2B).
|
||||||
|
|
||||||
|
Wyszukuje po tokenie planety zdarzenia i zawęża do wszystkich tokenów
|
||||||
|
(aspekt/druga planeta lub znak), z odsiewaniem szumu i duplikatów.
|
||||||
|
"""
|
||||||
|
for ev in events:
|
||||||
|
tokens = _event_tokens(ev)
|
||||||
|
if not tokens:
|
||||||
|
continue
|
||||||
|
raw = data.search(
|
||||||
|
key="significator", value=tokens[0], exact=False, limit=per_object_limit,
|
||||||
|
fields=["significator", "actioneffect", "topicresult", "bodypart"],
|
||||||
|
)
|
||||||
|
samples = _facet_samples(raw.get("rows", []), tokens)
|
||||||
|
ev["interpretations"] = samples[:limit]
|
||||||
|
ev["interpretations_count"] = len(samples)
|
||||||
|
return events
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest>=8.0
|
||||||
@@ -2,3 +2,5 @@ fastapi>=0.115
|
|||||||
uvicorn[standard]>=0.34
|
uvicorn[standard]>=0.34
|
||||||
httpx>=0.28
|
httpx>=0.28
|
||||||
pydantic>=2.10
|
pydantic>=2.10
|
||||||
|
# Silnik własny (ścieżka A, permisywny): Skyfield (MIT) + dane JPL (public domain)
|
||||||
|
skyfield>=1.49
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Wspólne fixture'y testów silnika.
|
||||||
|
|
||||||
|
Horoskop referencyjny = przykład z notatek projektu (notes3): 30.04.1984,
|
||||||
|
09:35 CEST = 07:35 UTC, Warszawa. Pozycje znane z astro.com/Swiss Ephemeris
|
||||||
|
służą jako wyrocznia (LOG-25).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
REFERENCE_UTC = dt.datetime(1984, 4, 30, 7, 35, 0, tzinfo=dt.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def reference_moment() -> ChartMoment:
|
||||||
|
return ChartMoment(when_utc=REFERENCE_UTC, lat=52.2333, lon=21.0167)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def own_engine():
|
||||||
|
pytest.importorskip("skyfield")
|
||||||
|
from app.engine.skyfield_engine import SkyfieldEngine
|
||||||
|
|
||||||
|
try:
|
||||||
|
return SkyfieldEngine()
|
||||||
|
except Exception as e: # brak efemeryd / brak sieci
|
||||||
|
# Lokalnie pomijamy (dev bez pobranego jądra), ale w CI to musi być błąd —
|
||||||
|
# inaczej testy referencyjne (walidacja względem astro.com) cicho znikają.
|
||||||
|
if os.getenv("CI"):
|
||||||
|
pytest.fail(f"CI: silnik efemeryd niedostępny — testy referencyjne muszą działać: {e}")
|
||||||
|
pytest.skip(f"Nie można wczytać efemeryd: {e}")
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Testy rozwijania skrótów sygnifikatorów."""
|
||||||
|
from app.abbreviations import expand
|
||||||
|
|
||||||
|
|
||||||
|
def test_planet_in_sign():
|
||||||
|
assert expand("[Su in [Tau") == "Sun in Taurus"
|
||||||
|
|
||||||
|
|
||||||
|
def test_aspect_and_house():
|
||||||
|
assert expand("[Sa [conj [Su in 6th H.") == "Saturn conjunction Sun in 6th house"
|
||||||
|
|
||||||
|
|
||||||
|
def test_affliction_word_and_house():
|
||||||
|
assert expand("[Ne affl. in the 7th H.") == "Neptune afflicted in the 7th house"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_token_kept_without_bracket():
|
||||||
|
assert expand("[Xyz rising") == "Xyz rising"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_passthrough():
|
||||||
|
assert expand("") == ""
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Testy aspektów (LOG-06) — czysta matematyka."""
|
||||||
|
from app.engine.aspects import find_aspects, separation
|
||||||
|
|
||||||
|
|
||||||
|
def test_separation_wraparound():
|
||||||
|
assert separation(10, 350) == 20
|
||||||
|
assert separation(0, 180) == 180
|
||||||
|
assert separation(0, 90) == 90
|
||||||
|
|
||||||
|
|
||||||
|
def test_conjunction_and_opposition():
|
||||||
|
pos = [
|
||||||
|
{"name": "Sun", "decimal": 10.0},
|
||||||
|
{"name": "Moon", "decimal": 12.0}, # 2° od Słońca -> koniunkcja
|
||||||
|
{"name": "Mars", "decimal": 190.0}, # 180° od Słońca -> opozycja
|
||||||
|
]
|
||||||
|
pairs = {(a["obj1"], a["obj2"], a["aspect"]) for a in find_aspects(pos)}
|
||||||
|
assert ("Sun", "Moon", "conjunction") in pairs
|
||||||
|
assert ("Sun", "Mars", "opposition") in pairs
|
||||||
|
|
||||||
|
|
||||||
|
def test_orb_limit_excludes_wide():
|
||||||
|
pos = [{"name": "Mercury", "decimal": 0.0}, {"name": "Venus", "decimal": 100.0}]
|
||||||
|
assert find_aspects(pos, orb=8.0, luminary_bonus=0.0) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_luminary_bonus_widens_orb():
|
||||||
|
# 99.5° -> 9.5° od kwadratury; z bonusem luminarza (8+2) mieści się
|
||||||
|
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Saturn", "decimal": 99.5}]
|
||||||
|
assert any(a["aspect"] == "square" for a in find_aspects(pos))
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_aspect_per_pair():
|
||||||
|
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
|
||||||
|
assert len(find_aspects(pos)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_applying_when_faster_body_catches_up():
|
||||||
|
# Księżyc 5° za Słońcem, szybszy -> koniunkcja aplikacyjna
|
||||||
|
pos = [
|
||||||
|
{"name": "Sun", "decimal": 40.0, "speed": 0.96},
|
||||||
|
{"name": "Moon", "decimal": 35.0, "speed": 13.0},
|
||||||
|
]
|
||||||
|
a = find_aspects(pos)[0]
|
||||||
|
assert a["applying"] is True and a["as"] == "A"
|
||||||
|
|
||||||
|
|
||||||
|
def test_separating_when_moving_apart():
|
||||||
|
# Księżyc 5° przed Słońcem i szybszy -> koniunkcja separacyjna
|
||||||
|
pos = [
|
||||||
|
{"name": "Sun", "decimal": 40.0, "speed": 0.96},
|
||||||
|
{"name": "Moon", "decimal": 45.0, "speed": 13.0},
|
||||||
|
]
|
||||||
|
a = find_aspects(pos)[0]
|
||||||
|
assert a["applying"] is False and a["as"] == "S"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_as_flag_without_speeds():
|
||||||
|
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
|
||||||
|
assert "as" not in find_aspects(pos)[0]
|
||||||
|
|
||||||
|
|
||||||
|
# Referencja A/S z notes3 (astro-seek) dla horoskopu 30.04.1984 07:35 UT, Warszawa.
|
||||||
|
REFERENCE_AS = {
|
||||||
|
("Sun", "Moon", "conjunction"): "A",
|
||||||
|
("Sun", "Jupiter", "trine"): "A",
|
||||||
|
("Sun", "Saturn", "opposition"): "A",
|
||||||
|
("Sun", "Neptune", "trine"): "S",
|
||||||
|
("Sun", "Pluto", "opposition"): "S",
|
||||||
|
("Moon", "Mercury", "conjunction"): "S",
|
||||||
|
("Moon", "Venus", "conjunction"): "S",
|
||||||
|
("Moon", "Neptune", "trine"): "A",
|
||||||
|
("Moon", "Pluto", "opposition"): "S",
|
||||||
|
("Mercury", "Venus", "conjunction"): "S",
|
||||||
|
("Mercury", "Neptune", "trine"): "S",
|
||||||
|
("Mercury", "Pluto", "opposition"): "S",
|
||||||
|
("Venus", "Neptune", "trine"): "A",
|
||||||
|
("Venus", "Pluto", "opposition"): "A",
|
||||||
|
("Jupiter", "Saturn", "sextile"): "A",
|
||||||
|
("Neptune", "Pluto", "sextile"): "S",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_applying_separating_matches_astroseek_reference(own_engine, reference_moment):
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
|
||||||
|
chart = build_chart(own_engine, reference_moment)
|
||||||
|
got = {(a["obj1"], a["obj2"], a["aspect"]): a.get("as") for a in chart["aspects"]}
|
||||||
|
mismatches = {k: (got.get(k), v) for k, v in REFERENCE_AS.items() if got.get(k) != v}
|
||||||
|
assert not mismatches, f"rozbieżności A/S vs astro-seek: {mismatches}"
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Integracja: pełny horoskop (pozycje + osie + domy) przez silnik (LOG-01+LOG-05).
|
||||||
|
|
||||||
|
Waliduje względem astro.com dla horoskopu referencyjnego.
|
||||||
|
"""
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
|
||||||
|
|
||||||
|
def test_chart_angles_match_reference(own_engine, reference_moment):
|
||||||
|
chart = build_chart(own_engine, reference_moment, "whole_sign")
|
||||||
|
assert chart["angles"]["Asc"]["sign"] == "Cancer"
|
||||||
|
assert chart["angles"]["MC"]["sign"] == "Pisces"
|
||||||
|
|
||||||
|
|
||||||
|
def test_chart_house_assignments_match_reference(own_engine, reference_moment):
|
||||||
|
chart = build_chart(own_engine, reference_moment, "whole_sign")
|
||||||
|
by = {p["name"]: p for p in chart["positions"]}
|
||||||
|
expected = {"Sun": 11, "Moon": 11, "Mercury": 10, "Venus": 10, "Mars": 5,
|
||||||
|
"Jupiter": 7, "Saturn": 5, "Uranus": 6, "Neptune": 7, "Pluto": 5}
|
||||||
|
for name, house in expected.items():
|
||||||
|
assert by[name]["house"] == house, f"{name}: dom {by[name]['house']} != {house}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_house_systems_available(own_engine, reference_moment):
|
||||||
|
for system in ("whole_sign", "equal", "porphyry"):
|
||||||
|
chart = build_chart(own_engine, reference_moment, system)
|
||||||
|
assert chart["house_system"] == system
|
||||||
|
assert len(chart["cusps"]) == 12
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Testy harnessu porównawczego (LOG-25) — bez efemeryd."""
|
||||||
|
from app.engine.compare import compare_positions
|
||||||
|
from app.engine.models import ObjectPosition
|
||||||
|
|
||||||
|
|
||||||
|
def test_identical_positions_have_zero_diff():
|
||||||
|
a = [ObjectPosition("Sun", 40.0, 0.0, 0.95, False)]
|
||||||
|
report = compare_positions(a, a, lon_tol_arcsec=1.0)
|
||||||
|
assert report.ok
|
||||||
|
assert report.max_arcsec == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_offset_beyond_tolerance_is_flagged():
|
||||||
|
a = [ObjectPosition("Sun", 40.0, 0.0, 0.95, False)]
|
||||||
|
b = [ObjectPosition("Sun", 40.1, 0.0, 0.95, False)] # 0.1° = 360"
|
||||||
|
report = compare_positions(a, b, lon_tol_arcsec=120.0)
|
||||||
|
assert not report.ok
|
||||||
|
assert report.diffs[0].over_tolerance
|
||||||
|
assert abs(abs(report.diffs[0].delta_arcsec) - 360.0) < 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrograde_mismatch_is_flagged():
|
||||||
|
a = [ObjectPosition("Mars", 234.0, 0.0, -0.2, True)]
|
||||||
|
b = [ObjectPosition("Mars", 234.0, 0.0, 0.2, False)]
|
||||||
|
report = compare_positions(a, b, lon_tol_arcsec=120.0)
|
||||||
|
assert not report.ok
|
||||||
|
assert report.diffs[0].speed_sign_mismatch
|
||||||
|
|
||||||
|
|
||||||
|
def test_wraparound_delta_is_small():
|
||||||
|
a = [ObjectPosition("Sun", 359.99, 0.0, 1.0, False)]
|
||||||
|
b = [ObjectPosition("Sun", 0.01, 0.0, 1.0, False)]
|
||||||
|
report = compare_positions(a, b, lon_tol_arcsec=120.0)
|
||||||
|
assert report.ok # 0.02° ≈ 72" < 120"
|
||||||
|
assert abs(report.diffs[0].delta_arcsec) < 80.0
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Kontrakt parzystości silników (LOG-28).
|
||||||
|
|
||||||
|
Ten sam test musi przejść dla KAŻDEGO silnika. Dziś uruchamiamy go dla silnika
|
||||||
|
własnego; gdy skonfigurowany jest ENGINE_SWISSEPH_URL, ten sam kontrakt sprawdza
|
||||||
|
też silnik B.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.compare import check_engine_contract
|
||||||
|
|
||||||
|
|
||||||
|
def test_own_engine_contract(own_engine, reference_moment):
|
||||||
|
check_engine_contract(own_engine, reference_moment)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
not os.getenv("ENGINE_SWISSEPH_URL"),
|
||||||
|
reason="silnik B (swisseph) nieskonfigurowany — pomijam",
|
||||||
|
)
|
||||||
|
def test_swisseph_engine_contract(reference_moment):
|
||||||
|
from app.engine.remote_engine import RemoteEngine
|
||||||
|
|
||||||
|
check_engine_contract(RemoteEngine(), reference_moment)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Firdaria (LOG-11) — sekta, kolejność, długości okresów, podokresy."""
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from app.engine.firdaria import DAY_ORDER, NIGHT_ORDER, YEARS, firdaria, is_day_birth
|
||||||
|
from app.significators import _event_tokens
|
||||||
|
|
||||||
|
BIRTH = dt.datetime(1984, 4, 30, 7, 35, tzinfo=dt.timezone.utc)
|
||||||
|
# horoskop referencyjny: Asc 112.18, MC 352.59, Sun 40.14 -> Day birth (notes3)
|
||||||
|
ASC, MC, SUN = 112.18, 352.59, 40.14
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_is_day_birth():
|
||||||
|
assert is_day_birth(SUN, ASC, MC) is True # notes3: "Day birth (Diurnal)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_night_birth_uses_night_order():
|
||||||
|
# Słońce po stronie IC (przeciwna półkula) -> noc
|
||||||
|
assert is_day_birth((SUN + 180) % 360, ASC, MC) is False
|
||||||
|
fd = firdaria(BIRTH, (SUN + 180) % 360, ASC, MC)
|
||||||
|
assert fd["sect"] == "night" and fd["order"] == NIGHT_ORDER
|
||||||
|
|
||||||
|
|
||||||
|
def test_diurnal_sequence_and_totals():
|
||||||
|
fd = firdaria(BIRTH, SUN, ASC, MC)
|
||||||
|
assert fd["sect"] == "day" and fd["order"] == DAY_ORDER
|
||||||
|
lords = [p["lord"] for p in fd["periods"]]
|
||||||
|
assert lords == DAY_ORDER + ["North Node", "South Node"]
|
||||||
|
assert sum(p["years"] for p in fd["periods"]) == 75 # 70 + 3 + 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_planet_majors_have_seven_subperiods_summing_to_period():
|
||||||
|
fd = firdaria(BIRTH, SUN, ASC, MC)
|
||||||
|
for p in fd["periods"]:
|
||||||
|
if p["lord"] in YEARS:
|
||||||
|
assert len(p["sub"]) == 7
|
||||||
|
assert p["sub"][0]["lord"] == p["lord"] # sub zaczyna się od władcy okresu
|
||||||
|
assert p["sub"][0]["start"] == p["start"]
|
||||||
|
assert p["sub"][-1]["end"] == p["end"]
|
||||||
|
else:
|
||||||
|
assert "sub" not in p # węzły bez podokresów
|
||||||
|
|
||||||
|
|
||||||
|
def test_periods_are_contiguous():
|
||||||
|
fd = firdaria(BIRTH, SUN, ASC, MC)
|
||||||
|
for a, b in zip(fd["periods"], fd["periods"][1:]):
|
||||||
|
assert a["end"] == b["start"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_age_42_is_saturn_major():
|
||||||
|
# kumulatywnie: Su10 Ve8 Me13 Mo9 -> 40; Saturn 40-51 -> wiek 42 w Saturnie
|
||||||
|
fd = firdaria(BIRTH, SUN, ASC, MC)
|
||||||
|
saturn = next(p for p in fd["periods"] if p["lord"] == "Saturn")
|
||||||
|
assert saturn["start"].startswith("2024") and saturn["end"].startswith("2035")
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_tokens_firdaria():
|
||||||
|
ev = {"technique": "firdaria", "fd_major": "Saturn", "fd_sub": "Jupiter"}
|
||||||
|
assert _event_tokens(ev) == ["[Sa", "[Ju"]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Testy formatowania (czyste, bez efemeryd)."""
|
||||||
|
from app.engine import formats
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_index():
|
||||||
|
assert formats.sign_index(0) == 0 # Aries
|
||||||
|
assert formats.sign_index(35) == 1 # Taurus
|
||||||
|
assert formats.sign_index(359.9) == 11 # Pisces
|
||||||
|
|
||||||
|
|
||||||
|
def test_in_sign_basic():
|
||||||
|
# 40°08'21" absolutnie = Taurus 10°08'21"
|
||||||
|
assert formats.in_sign(40.139166) == "Tau 10°08'21\""
|
||||||
|
|
||||||
|
|
||||||
|
def test_absolute():
|
||||||
|
assert formats.absolute(40.139166) == "40°08'21\""
|
||||||
|
|
||||||
|
|
||||||
|
def test_wraparound_and_norm():
|
||||||
|
assert formats.norm360(370.0) == 10.0
|
||||||
|
assert formats.norm360(-1.0) == 359.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_rounding_carry_into_next_sign():
|
||||||
|
# tuż przy granicy znaku zaokrąglenie nie może dać "30°"
|
||||||
|
s = formats.in_sign(59.99999)
|
||||||
|
assert s.startswith("Gem 0°") or s.startswith("Tau 29°59")
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Testy osi i domów — czysta matematyka (LOG-05, bez efemeryd)."""
|
||||||
|
from app.engine import houses as H
|
||||||
|
|
||||||
|
# RAMC i ε policzone Skyfieldem dla horoskopu referencyjnego (30.04.1984, Warszawa)
|
||||||
|
RAMC, EPS, LAT = 353.1968, 23.44133, 52.2333
|
||||||
|
|
||||||
|
|
||||||
|
def _near(a, b, tol=0.05):
|
||||||
|
return abs(((a - b + 180) % 360) - 180) < tol
|
||||||
|
|
||||||
|
|
||||||
|
def test_asc_mc_match_reference():
|
||||||
|
asc = H.compute_asc(RAMC, EPS, LAT)
|
||||||
|
mc = H.compute_mc(RAMC, EPS)
|
||||||
|
assert _near(asc, 112.18) # Cancer 22°10' (astro.com)
|
||||||
|
assert _near(mc, 352.59) # Pisces 22°35'
|
||||||
|
|
||||||
|
|
||||||
|
def test_whole_sign_starts_on_sign_boundary():
|
||||||
|
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN)
|
||||||
|
assert cusps[0] == 90.0 # dom 1 = 0° Raka
|
||||||
|
assert cusps[1] == 120.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_equal_cusps_are_30_apart_from_asc():
|
||||||
|
cusps = H.cusps(112.18, 352.59, H.EQUAL)
|
||||||
|
assert abs(cusps[0] - 112.18) < 1e-9
|
||||||
|
assert abs(cusps[1] - 142.18) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_porphyry_angles_on_cusps():
|
||||||
|
cusps = H.cusps(112.18, 352.59, H.PORPHYRY)
|
||||||
|
assert abs(cusps[0] - 112.18) < 1e-9 # Asc = dom 1
|
||||||
|
assert abs(cusps[9] - 352.59) < 1e-9 # MC = dom 10
|
||||||
|
assert abs(cusps[6] - (112.18 + 180) % 360) < 1e-9 # Dsc = dom 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_assign_house_whole_sign():
|
||||||
|
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(40.0, cusps) == 11 # Byk -> 11. dom
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Lots / punkty arabskie (LOG-08).
|
||||||
|
|
||||||
|
Wyrocznie z notes3 (astro-seek, horoskop referencyjny 30.04.1984, urodzenie DZIENNE):
|
||||||
|
- Fortuna wprost w tabeli obiektów: Cancer 12°35'24" = 102.5900°
|
||||||
|
- Ducha (Spirit) potwierdza jego antyscja: Taurus 28°14' -> Spirit = 180 − 58.2333
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.formats import sign_index
|
||||||
|
from app.engine.lots import LOT_DEFS, compute_lots
|
||||||
|
|
||||||
|
# natalne długości horoskopu referencyjnego
|
||||||
|
NATAL = {"Asc": 112.1786, "Sun": 40.1392, "Moon": 30.5514, "Mercury": 27.3839,
|
||||||
|
"Venus": 27.6769, "Mars": 234.5411, "Jupiter": 282.9617, "Saturn": 223.3108}
|
||||||
|
|
||||||
|
FORTUNE_REF = 102.5900 # Cancer 12°35'24"
|
||||||
|
SPIRIT_REF = 180.0 - 58.2333 # z antyscji Taurus 28°14'
|
||||||
|
|
||||||
|
|
||||||
|
def _by_name(lots):
|
||||||
|
return {lot["name"]: lot["longitude"] for lot in lots}
|
||||||
|
|
||||||
|
|
||||||
|
def _arcmin(a, b):
|
||||||
|
return abs(((a - b + 180.0) % 360.0) - 180.0) * 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fortune_matches_astroseek():
|
||||||
|
lots = _by_name(compute_lots(NATAL, is_day=True))
|
||||||
|
assert _arcmin(lots["Fortune"], FORTUNE_REF) < 2.0
|
||||||
|
assert sign_index(lots["Fortune"]) == 3 # Cancer
|
||||||
|
|
||||||
|
|
||||||
|
def test_spirit_matches_antiscia_reference():
|
||||||
|
lots = _by_name(compute_lots(NATAL, is_day=True))
|
||||||
|
assert _arcmin(lots["Spirit"], SPIRIT_REF) < 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fortune_and_spirit_reverse_at_night():
|
||||||
|
day = _by_name(compute_lots(NATAL, is_day=True))
|
||||||
|
night = _by_name(compute_lots(NATAL, is_day=False))
|
||||||
|
# nocą formuła się odwraca: Fortuna nocna = Duch dzienny (i odwrotnie)
|
||||||
|
assert _arcmin(night["Fortune"], day["Spirit"]) < 0.01
|
||||||
|
assert _arcmin(night["Spirit"], day["Fortune"]) < 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_seven_hermetic_lots_present():
|
||||||
|
lots = compute_lots(NATAL, is_day=True)
|
||||||
|
assert [lot["name"] for lot in lots] == [d[0] for d in LOT_DEFS]
|
||||||
|
assert len(lots) == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_derived_lots_use_fortune_and_spirit():
|
||||||
|
lots = _by_name(compute_lots(NATAL, is_day=True))
|
||||||
|
# Necessity = Asc + Fortune − Mercury
|
||||||
|
expected = (NATAL["Asc"] + lots["Fortune"] - NATAL["Mercury"]) % 360.0
|
||||||
|
assert _arcmin(lots["Necessity"], expected) < 0.01
|
||||||
|
# Eros = Asc + Venus − Spirit
|
||||||
|
expected_eros = (NATAL["Asc"] + NATAL["Venus"] - lots["Spirit"]) % 360.0
|
||||||
|
assert _arcmin(lots["Eros"], expected_eros) < 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def test_by_sign_method_lands_on_sign_start():
|
||||||
|
lots = compute_lots(NATAL, is_day=True, method="sign")
|
||||||
|
for lot in lots:
|
||||||
|
assert lot["longitude"] % 30.0 == 0.0 # 0° wyliczonego znaku
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_method_rejected():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
compute_lots(NATAL, is_day=True, method="bzdura")
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Testy punktów wirtualnych (LOG-02): mean Node, mean Lilith.
|
||||||
|
|
||||||
|
Referencje dla 30.04.1984 07:35 UT:
|
||||||
|
- astro-seek (notes3): North Node (M) = Gem 8°09'24" = 68.1567°
|
||||||
|
- wyrocznia swisseph (MEAN_NODE / MEAN_APOG, tryb Moshiera):
|
||||||
|
NN = 68.1569°, Lilith = 345.6840°
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.formats import norm360
|
||||||
|
|
||||||
|
|
||||||
|
def _delta_arcmin(a: float, b: float) -> float:
|
||||||
|
return abs(((a - b + 180.0) % 360.0) - 180.0) * 60.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def by_name(own_engine, reference_moment):
|
||||||
|
return {p.name: p for p in own_engine.positions(reference_moment)}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mean_node_matches_references(by_name):
|
||||||
|
nn = by_name["North Node"]
|
||||||
|
assert _delta_arcmin(nn.longitude, 68.1567) < 2.0 # astro-seek
|
||||||
|
assert _delta_arcmin(nn.longitude, 68.1569) < 2.0 # swisseph
|
||||||
|
assert nn.sign == "Gemini"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nodes_always_retrograde_and_opposed(by_name):
|
||||||
|
nn, sn = by_name["North Node"], by_name["South Node"]
|
||||||
|
assert nn.retrograde and sn.retrograde # mean node zawsze Rx
|
||||||
|
assert _delta_arcmin(sn.longitude, norm360(nn.longitude + 180.0)) < 0.01
|
||||||
|
assert abs(nn.speed - sn.speed) < 1e-9 # ta sama prędkość
|
||||||
|
|
||||||
|
|
||||||
|
def test_mean_lilith_matches_swisseph(by_name):
|
||||||
|
li = by_name["Lilith"]
|
||||||
|
assert _delta_arcmin(li.longitude, 345.6840) < 3.0 # wyrocznia swisseph
|
||||||
|
assert li.sign == "Pisces"
|
||||||
|
assert li.speed > 0 and not li.retrograde # mean Lilith zawsze direct
|
||||||
|
|
||||||
|
|
||||||
|
def test_points_join_houses_and_chart(own_engine, reference_moment):
|
||||||
|
from app.engine.chart import build_chart
|
||||||
|
|
||||||
|
chart = build_chart(own_engine, reference_moment)
|
||||||
|
by = {p["name"]: p for p in chart["positions"]}
|
||||||
|
# NN w Gem -> 12. dom Whole Sign (Asc w Raku); zgodnie z tabelą astro-seek w notes3
|
||||||
|
assert by["North Node"]["house"] == 12
|
||||||
|
assert by["Lilith"]["house"] == 9 # Pis -> 9. dom
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Profekcje (LOG-10) — walidacja względem tabeli astro-seek z notes3."""
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from app.engine.profections import DOMICILE_RULERS, age_at, profected_sign, profection_rows
|
||||||
|
|
||||||
|
BIRTH = dt.datetime(1984, 4, 30, 7, 35, tzinfo=dt.timezone.utc)
|
||||||
|
NATAL = {"Asc": 112.18, "MC": 352.59, "Sun": 40.14, "Moon": 30.55}
|
||||||
|
|
||||||
|
# wiek -> (profektowany Asc, Władca Roku) — tabela referencyjna notes3
|
||||||
|
REFERENCE = {
|
||||||
|
0: ("Cancer", "Moon"), 1: ("Leo", "Sun"), 2: ("Virgo", "Mercury"),
|
||||||
|
3: ("Libra", "Venus"), 4: ("Scorpio", "Mars"), 5: ("Sagittarius", "Jupiter"),
|
||||||
|
6: ("Capricorn", "Saturn"), 7: ("Aquarius", "Saturn"), 8: ("Pisces", "Jupiter"),
|
||||||
|
9: ("Aries", "Mars"), 10: ("Taurus", "Venus"), 11: ("Gemini", "Mercury"),
|
||||||
|
12: ("Cancer", "Moon"), 40: ("Scorpio", "Mars"), 41: ("Sagittarius", "Jupiter"),
|
||||||
|
42: ("Capricorn", "Saturn"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_profections_match_astroseek_table():
|
||||||
|
rows = {r["age"]: r for r in profection_rows(NATAL, BIRTH, 0, 43)}
|
||||||
|
for age, (asc, lord) in REFERENCE.items():
|
||||||
|
assert rows[age]["profected_asc"] == asc, f"wiek {age}"
|
||||||
|
assert rows[age]["lord_of_year"] == lord, f"wiek {age}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_profected_secondary_points_match_reference():
|
||||||
|
rows = {r["age"]: r for r in profection_rows(NATAL, BIRTH, 0, 3)}
|
||||||
|
# notes3: wiek 0 -> MC Pis, Sun Tau, Moon Tau; wiek 1 -> MC Ari, Sun Gem
|
||||||
|
assert rows[0]["MC"] == "Pisces" and rows[0]["Sun"] == "Taurus"
|
||||||
|
assert rows[1]["MC"] == "Aries" and rows[1]["Sun"] == "Gemini"
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_dates_are_birthdays():
|
||||||
|
rows = profection_rows(NATAL, BIRTH, 40, 3)
|
||||||
|
assert [r["from"] for r in rows] == ["2024-04-30", "2025-04-30", "2026-04-30"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_age_at_boundaries():
|
||||||
|
assert age_at(BIRTH, dt.datetime(2026, 4, 29, tzinfo=dt.timezone.utc)) == 41
|
||||||
|
assert age_at(BIRTH, dt.datetime(2026, 4, 30, tzinfo=dt.timezone.utc)) == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_rulers_cover_all_signs():
|
||||||
|
assert len(DOMICILE_RULERS) == 12
|
||||||
|
assert profected_sign(112.18, 12) == "Cancer" # pełny cykl wraca
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Solar / Lunar Return (LOG-12) — samospójność i sensowność dat."""
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.returns import find_return
|
||||||
|
|
||||||
|
|
||||||
|
def _lon(engine, body, when):
|
||||||
|
return engine.positions(ChartMoment(when_utc=when), [body])[0].longitude
|
||||||
|
|
||||||
|
|
||||||
|
def _delta_arcmin(a, b):
|
||||||
|
return abs(((a - b + 180.0) % 360.0) - 180.0) * 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_solar_return_hits_natal_sun(own_engine, reference_moment):
|
||||||
|
natal_sun = _lon(own_engine, "Sun", reference_moment.when_utc)
|
||||||
|
# solariusz na 42. urodziny (2026)
|
||||||
|
hit = find_return(own_engine, "solar", reference_moment,
|
||||||
|
dt.datetime(2026, 4, 30, tzinfo=dt.timezone.utc))
|
||||||
|
assert hit is not None
|
||||||
|
assert _delta_arcmin(_lon(own_engine, "Sun", hit), natal_sun) < 0.5
|
||||||
|
assert hit.month == 4 and hit.year == 2026 # w okolicy urodzin
|
||||||
|
|
||||||
|
|
||||||
|
def test_lunar_return_hits_natal_moon(own_engine, reference_moment):
|
||||||
|
natal_moon = _lon(own_engine, "Moon", reference_moment.when_utc)
|
||||||
|
hit = find_return(own_engine, "lunar", reference_moment,
|
||||||
|
dt.datetime(1984, 5, 27, tzinfo=dt.timezone.utc))
|
||||||
|
assert hit is not None
|
||||||
|
assert _delta_arcmin(_lon(own_engine, "Moon", hit), natal_moon) < 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_solar_return_near_birth_is_close_to_birth(own_engine, reference_moment):
|
||||||
|
# powrót szukany wokół samych urodzin = ~moment urodzenia
|
||||||
|
hit = find_return(own_engine, "solar", reference_moment, reference_moment.when_utc)
|
||||||
|
assert hit is not None
|
||||||
|
assert abs(hit - reference_moment.when_utc) < dt.timedelta(days=1)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Testy punktacji siły faset (LOG-21)."""
|
||||||
|
from app.scoring import score_facet
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_and_house_base():
|
||||||
|
assert score_facet({"type": "sign"}) == 5.0
|
||||||
|
assert score_facet({"type": "house"}) == 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_tighter_aspect_scores_higher():
|
||||||
|
tight = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 0.0, "allowed": 10.0})
|
||||||
|
loose = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 9.0, "allowed": 10.0})
|
||||||
|
assert tight > loose
|
||||||
|
assert tight == 12.0 # 6.0 * 1.0 * (1 + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_conjunction_beats_sextile_at_same_orb():
|
||||||
|
conj = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 2.0, "allowed": 10.0})
|
||||||
|
sext = score_facet({"type": "aspect", "aspect": "sextile", "orb": 2.0, "allowed": 10.0})
|
||||||
|
assert conj > sext
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Testy mostu obliczenia → sygnifikatory → wyszukiwanie (bez efemeryd/HTTP)."""
|
||||||
|
from app.engine.models import DEFAULT_OBJECTS
|
||||||
|
from app.significators import PLANET_ABBR, _ordinal, build_report
|
||||||
|
|
||||||
|
|
||||||
|
class FakeData:
|
||||||
|
def __init__(self, rows_by_value: dict) -> None:
|
||||||
|
self.rows_by_value = rows_by_value
|
||||||
|
|
||||||
|
def search(self, key, value, exact, limit, fields=None) -> dict:
|
||||||
|
rows = self.rows_by_value.get(value, [])
|
||||||
|
return {"provider": "fake", "total": len(rows), "rows": rows}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_and_house_facets():
|
||||||
|
positions = [{"name": "Sun", "sign": "Taurus", "direction": "D", "house": 11}]
|
||||||
|
data = FakeData({"[Su": [
|
||||||
|
{"significator": "[Su in [Tau", "actioneffect": "efekt znak"}, # faseta znak
|
||||||
|
{"significator": "[Su in 11th H.", "actioneffect": "efekt dom"}, # faseta dom
|
||||||
|
{"significator": "[Su in [Vir", "actioneffect": "inny"}, # żadna
|
||||||
|
{"significator": "[Su in [Tau", "actioneffect": "nan"}, # szum
|
||||||
|
]})
|
||||||
|
item = build_report(positions, data)["objects"][0]
|
||||||
|
assert item["object"] == "Sun" and item["house"] == 11
|
||||||
|
facets = {f["type"]: f for f in item["facets"]}
|
||||||
|
assert facets["sign"]["count"] == 1
|
||||||
|
assert facets["sign"]["samples"][0]["effect"] == "efekt znak"
|
||||||
|
assert facets["sign"]["samples"][0]["expanded"] == "Sun in Taurus" # rozwinięcie skrótu
|
||||||
|
assert facets["house"]["count"] == 1
|
||||||
|
assert facets["house"]["token"] == "11th H."
|
||||||
|
assert facets["house"]["samples"][0]["effect"] == "efekt dom"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_house_facet_when_house_missing():
|
||||||
|
positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx"}]
|
||||||
|
data = FakeData({"[Ma": [{"significator": "[Ma in [Sco", "actioneffect": "eff"}]})
|
||||||
|
types = [f["type"] for f in build_report(positions, data)["objects"][0]["facets"]]
|
||||||
|
assert "sign" in types and "house" not in types
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedup_by_significator_and_effect():
|
||||||
|
positions = [{"name": "Moon", "sign": "Taurus", "direction": "D", "house": 11}]
|
||||||
|
data = FakeData({"[Mo": [
|
||||||
|
{"significator": "[Mo in 11th H.", "actioneffect": "efekt"},
|
||||||
|
{"significator": "[Mo in 11th H.", "actioneffect": "efekt"}, # duplikat (sig+opis)
|
||||||
|
{"significator": "[Mo in 11th H.", "actioneffect": "inny efekt"}, # ten sam sig, inny opis
|
||||||
|
]})
|
||||||
|
facets = {f["type"]: f for f in build_report(positions, data)["objects"][0]["facets"]}
|
||||||
|
assert facets["house"]["count"] == 2 # duplikat odsiany, różny opis zostaje
|
||||||
|
|
||||||
|
|
||||||
|
def test_aspect_facet():
|
||||||
|
positions = [
|
||||||
|
{"name": "Sun", "sign": "Taurus", "direction": "D", "house": 11},
|
||||||
|
{"name": "Moon", "sign": "Taurus", "direction": "D", "house": 11},
|
||||||
|
]
|
||||||
|
aspects = [{"obj1": "Sun", "obj2": "Moon", "aspect": "conjunction", "orb": 2.0}]
|
||||||
|
data = FakeData({"[Su": [{"significator": "[Su [conj [Mo", "actioneffect": "złączeni"}]})
|
||||||
|
sun = build_report(positions, data, aspects=aspects)["objects"][0]
|
||||||
|
asp = [f for f in sun["facets"] if f["type"] == "aspect"]
|
||||||
|
assert asp and asp[0]["count"] == 1 and "Moon" in asp[0]["label"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_grouping_by_effect():
|
||||||
|
positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx", "house": 5}]
|
||||||
|
data = FakeData({"[Ma": [
|
||||||
|
{"significator": "[Ma in 5th H.", "actioneffect": "miscarriage"},
|
||||||
|
{"significator": "[Sa in 5th H.", "actioneffect": "miscarriage"}, # inny sig, ten sam opis
|
||||||
|
{"significator": "[Ma in 5th H.", "actioneffect": "fever"},
|
||||||
|
]})
|
||||||
|
house = [f for f in build_report(positions, data, group=True)["objects"][0]["facets"]
|
||||||
|
if f["type"] == "house"][0]
|
||||||
|
groups = {g["effect"]: g for g in house["groups"]}
|
||||||
|
assert groups["miscarriage"]["count"] == 2
|
||||||
|
assert len(groups["miscarriage"]["significators"]) == 2
|
||||||
|
assert "score" in house
|
||||||
|
|
||||||
|
|
||||||
|
def test_facets_carry_score():
|
||||||
|
positions = [{"name": "Sun", "sign": "Taurus", "direction": "D", "house": 11}]
|
||||||
|
data = FakeData({"[Su": [{"significator": "[Su in [Tau", "actioneffect": "e"}]})
|
||||||
|
facets = build_report(positions, data)["objects"][0]["facets"]
|
||||||
|
assert all("score" in f for f in facets)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordinal():
|
||||||
|
assert _ordinal(1) == "1st" and _ordinal(2) == "2nd" and _ordinal(3) == "3rd"
|
||||||
|
assert _ordinal(4) == "4th" and _ordinal(11) == "11th" and _ordinal(12) == "12th"
|
||||||
|
|
||||||
|
|
||||||
|
def test_planet_abbr_covers_all_default_objects():
|
||||||
|
for o in DEFAULT_OBJECTS:
|
||||||
|
assert o in PLANET_ABBR
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Walidacja silnika własnego względem wyroczni (LOG-01 + LOG-25).
|
||||||
|
|
||||||
|
Porównujemy pozycje policzone Skyfieldem ze znanymi wartościami Swiss Ephemeris
|
||||||
|
(astro.com) dla horoskopu referencyjnego. To dokładnie mechanizm LOG-25 — tyle
|
||||||
|
że wyrocznią są tu zapisane wcześniej wartości referencyjne zamiast żywej usługi
|
||||||
|
swisseph. Tolerancja 2' (120") z zapasem na różnicę barycentrum/centrum planety.
|
||||||
|
"""
|
||||||
|
from app.engine.compare import compare_positions
|
||||||
|
from app.engine.models import ObjectPosition
|
||||||
|
|
||||||
|
# obiekt -> (długość absolutna w stopniach, retrogradacja) — Swiss Ephemeris / astro.com
|
||||||
|
REFERENCE = {
|
||||||
|
"Sun": (40.139167, False), # Tau 10°08'21"
|
||||||
|
"Moon": (30.551389, False), # Tau 0°33'05"
|
||||||
|
"Mercury": (27.383889, True), # Ari 27°23'02" Rx
|
||||||
|
"Venus": (27.676944, False), # Ari 27°40'37"
|
||||||
|
"Mars": (234.541111, True), # Sco 24°32'28" Rx
|
||||||
|
"Jupiter": (282.961667, True), # Cap 12°57'42" Rx
|
||||||
|
"Saturn": (223.310833, True), # Sco 13°18'39" Rx
|
||||||
|
"Uranus": (252.813333, True), # Sag 12°48'48" Rx
|
||||||
|
"Neptune": (271.220833, True), # Cap 1°13'15" Rx
|
||||||
|
"Pluto": (210.478889, True), # Sco 0°28'44" Rx
|
||||||
|
}
|
||||||
|
|
||||||
|
TOLERANCE_ARCSEC = 120.0
|
||||||
|
|
||||||
|
|
||||||
|
def _reference_positions():
|
||||||
|
return [
|
||||||
|
ObjectPosition(name, lon, 0.0, (-1.0 if retro else 1.0), retro)
|
||||||
|
for name, (lon, retro) in REFERENCE.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_positions_match_swisseph_reference(own_engine, reference_moment):
|
||||||
|
got = own_engine.positions(reference_moment)
|
||||||
|
report = compare_positions(got, _reference_positions(), lon_tol_arcsec=TOLERANCE_ARCSEC)
|
||||||
|
assert report.ok, report.summary()
|
||||||
|
|
||||||
|
|
||||||
|
def test_retrograde_flags_match(own_engine, reference_moment):
|
||||||
|
got = {p.name: p for p in own_engine.positions(reference_moment)}
|
||||||
|
for name, (_lon, retro) in REFERENCE.items():
|
||||||
|
assert got[name].retrograde == retro, f"{name}: kierunek niezgodny"
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Testy wykrywania stacji (LOG-03).
|
||||||
|
|
||||||
|
Fakt historyczny: Mars w horoskopie referencyjnym (30.04.1984) jest w środku
|
||||||
|
retrogradacji — stacja SR ~5.04.1984 (ok. 25 dni wstecz), stacja SD ~19.06.1984
|
||||||
|
(ok. 50 dni w przód). Testy sprawdzają strukturę, klasyfikację SD/SR, przedziały
|
||||||
|
dat i samospójność (prędkość w znalezionym momencie ~0).
|
||||||
|
"""
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
from app.engine.stations import STATION_SOON_DAYS, find_stations
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def mars_stations(own_engine, reference_moment):
|
||||||
|
return find_stations(own_engine, reference_moment, "Mars")
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_stations_for_sun_moon_and_points(own_engine, reference_moment):
|
||||||
|
for name in ("Sun", "Moon", "North Node", "South Node", "Lilith"):
|
||||||
|
assert find_stations(own_engine, reference_moment, name) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_mars_prev_is_sr_next_is_sd(mars_stations):
|
||||||
|
assert mars_stations["prev"]["type"] == "SR" # wszedł w retrogradację
|
||||||
|
assert mars_stations["next"]["type"] == "SD" # wróci do ruchu prostego
|
||||||
|
|
||||||
|
|
||||||
|
def test_mars_station_windows_match_history(mars_stations):
|
||||||
|
# SR ~5.04.1984 -> ok. -25 dni; SD ~19/20.06.1984 -> ok. +50 dni
|
||||||
|
assert -35 < mars_stations["prev"]["days"] < -15
|
||||||
|
assert 40 < mars_stations["next"]["days"] < 60
|
||||||
|
assert mars_stations["prev"]["date"].startswith("1984-04")
|
||||||
|
assert mars_stations["next"]["date"].startswith("1984-06")
|
||||||
|
|
||||||
|
|
||||||
|
def test_station_speed_is_near_zero(own_engine, reference_moment, mars_stations):
|
||||||
|
"""Samospójność: w znalezionym momencie stacji prędkość Marsa ~0."""
|
||||||
|
for key in ("prev", "next"):
|
||||||
|
when = dt.datetime.strptime(mars_stations[key]["date"], "%Y-%m-%d %H:%M").replace(
|
||||||
|
tzinfo=dt.timezone.utc
|
||||||
|
)
|
||||||
|
m = ChartMoment(when_utc=when, lat=reference_moment.lat, lon=reference_moment.lon)
|
||||||
|
speed = own_engine.positions(m, ["Mars"])[0].speed
|
||||||
|
assert abs(speed) < 0.01, f"{key}: speed={speed}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_station_soon_flag_consistent(mars_stations):
|
||||||
|
expected = any(
|
||||||
|
abs(mars_stations[k]["days"]) < STATION_SOON_DAYS
|
||||||
|
for k in ("prev", "next") if k in mars_stations
|
||||||
|
)
|
||||||
|
assert mars_stations["station_soon"] == expected
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Zbiorcza oś czasu z technik (LOG-14)."""
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from app.engine.timeline import (
|
||||||
|
NAIBOD_KEY,
|
||||||
|
build_timeline,
|
||||||
|
profection_events,
|
||||||
|
solar_arc_directions,
|
||||||
|
)
|
||||||
|
|
||||||
|
BIRTH = dt.datetime(1984, 4, 30, 7, 35, tzinfo=dt.timezone.utc)
|
||||||
|
# natalne długości (z horoskopu referencyjnego)
|
||||||
|
NATAL = {
|
||||||
|
"Asc": 112.18, "MC": 352.59, "Sun": 40.14, "Moon": 30.55, "Mercury": 27.38,
|
||||||
|
"Venus": 27.68, "Mars": 234.54, "Jupiter": 282.96, "Saturn": 223.31,
|
||||||
|
"Uranus": 252.81, "Neptune": 271.22, "Pluto": 210.48,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _win(y0, y1):
|
||||||
|
return (dt.datetime(y0, 1, 1, tzinfo=dt.timezone.utc),
|
||||||
|
dt.datetime(y1, 12, 31, tzinfo=dt.timezone.utc))
|
||||||
|
|
||||||
|
|
||||||
|
def test_profection_events_in_window():
|
||||||
|
lo, hi = _win(2024, 2026)
|
||||||
|
rows = profection_events(NATAL["Asc"], BIRTH, lo, hi)
|
||||||
|
# rok profekcyjny wiek 42 zaczyna się 30.04.2026 -> Asc Capricorn
|
||||||
|
ages = [r["significator"] for r in rows]
|
||||||
|
assert any("Asc Capricorn" in a and "wiek 42" in a for a in ages)
|
||||||
|
|
||||||
|
|
||||||
|
def test_solar_arc_exact_matches_arc_over_key():
|
||||||
|
lo, hi = _win(2020, 2030)
|
||||||
|
rows = solar_arc_directions(NATAL, BIRTH, lo, hi, key=NAIBOD_KEY)
|
||||||
|
assert rows, "brak dyrekcji w oknie"
|
||||||
|
# dla każdej dyrekcji: łuk = (wiek * klucz), a data = urodziny + wiek -> spójne
|
||||||
|
for r in rows[:5]:
|
||||||
|
exact = dt.date.fromisoformat(r["exact"])
|
||||||
|
age_years = (dt.datetime(exact.year, exact.month, exact.day, tzinfo=dt.timezone.utc)
|
||||||
|
- BIRTH).days / 365.2422
|
||||||
|
assert 36 <= age_years <= 47 # okno 2020-2030 = wiek ~36-46
|
||||||
|
assert r["technique"] == "solar_arc" and "dyr." in r["significator"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_directed_sun_conjunct_natal_mc_date():
|
||||||
|
# Sun natal 40.14 -> MC natal 352.59: łuk koniunkcji = (352.59-40.14)%360 = 312.45
|
||||||
|
# to > lifespan przy Naibod (~317 lat) -> NIE powinno być w oknie życia
|
||||||
|
lo, hi = _win(1984, 2084)
|
||||||
|
rows = solar_arc_directions(NATAL, BIRTH, lo, hi)
|
||||||
|
sun_mc = [r for r in rows if r["significator"] == "dyr. Sun koniunkcja MC"]
|
||||||
|
assert not sun_mc # łuk 312° = poza życiem
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_timeline_sorted_and_merged():
|
||||||
|
events = build_timeline(_FakeEngine(), _FakeNatal(), NATAL,
|
||||||
|
"2025-01-01", "2027-01-01",
|
||||||
|
techniques=["profection", "solar_arc"])
|
||||||
|
assert events
|
||||||
|
dates = [e["exact"] for e in events]
|
||||||
|
assert dates == sorted(dates) # posortowane po dacie dokładnej
|
||||||
|
techs = {e["technique"] for e in events}
|
||||||
|
assert "profection" in techs and "solar_arc" in techs
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeNatal:
|
||||||
|
when_utc = BIRTH
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeEngine:
|
||||||
|
"""Silnik-atrapa — build_timeline z solar_return by go użył, tu go pomijamy."""
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Spięcie osi czasu z bazą interpretacji (LOG-14 → 1B→2B)."""
|
||||||
|
from app.significators import _event_tokens, interpret_events
|
||||||
|
|
||||||
|
|
||||||
|
class FakeData:
|
||||||
|
def __init__(self, rows_by_value):
|
||||||
|
self.rows_by_value = rows_by_value
|
||||||
|
|
||||||
|
def search(self, key, value, exact, limit, fields=None):
|
||||||
|
rows = self.rows_by_value.get(value, [])
|
||||||
|
return {"provider": "fake", "total": len(rows), "rows": rows}
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_tokens_solar_arc():
|
||||||
|
ev = {"technique": "solar_arc", "directed": "Venus", "aspect": "conjunction", "target": "North Node"}
|
||||||
|
assert _event_tokens(ev) == ["[Ve", "[conj", "[NN"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_tokens_solar_arc_to_angle_has_no_target_token():
|
||||||
|
ev = {"technique": "solar_arc", "directed": "Sun", "aspect": "square", "target": "MC"}
|
||||||
|
assert _event_tokens(ev) == ["[Su", "[sq"] # MC nie ma tokenu planety
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_tokens_profection():
|
||||||
|
ev = {"technique": "profection", "lord": "Saturn", "sign": "Capricorn"}
|
||||||
|
assert _event_tokens(ev) == ["[Sa", "[Cap"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_solar_return_has_no_tokens():
|
||||||
|
assert _event_tokens({"technique": "solar_return"}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_interpret_attaches_matches_with_all_tokens():
|
||||||
|
events = [
|
||||||
|
{"technique": "solar_arc", "directed": "Venus", "aspect": "conjunction", "target": "North Node"},
|
||||||
|
{"technique": "solar_return"},
|
||||||
|
]
|
||||||
|
data = FakeData({"[Ve": [
|
||||||
|
{"significator": "[Ve [conj [NN", "actioneffect": "spotkanie losowe"}, # wszystkie tokeny
|
||||||
|
{"significator": "[Ve [conj [Mo", "actioneffect": "inny"}, # brak [NN
|
||||||
|
{"significator": "[Ve [conj [NN", "actioneffect": "spotkanie losowe"}, # duplikat
|
||||||
|
]})
|
||||||
|
interpret_events(events, data)
|
||||||
|
assert events[0]["interpretations_count"] == 1 # duplikat odsiany, tylko z [NN
|
||||||
|
assert events[0]["interpretations"][0]["expanded"] == "Venus conjunction North Node"
|
||||||
|
assert "interpretations" not in events[1] # solar_return pominięty
|
||||||
@@ -24,11 +24,49 @@ class LogicClient:
|
|||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
def positions(
|
def positions(
|
||||||
self, when_utc_iso: str, lat: float, lon: float, objects: list[str] | None = None
|
self,
|
||||||
|
when_utc_iso: str,
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
objects: list[str] | None = None,
|
||||||
|
house_system: str = "whole_sign",
|
||||||
|
stations: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Pozycje obiektów dla danego momentu — woła logic /chart/positions."""
|
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
||||||
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "objects": objects}
|
payload = {
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
"when_utc": when_utc_iso,
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"objects": objects,
|
||||||
|
"house_system": house_system,
|
||||||
|
"stations": stations,
|
||||||
|
}
|
||||||
|
# stacje wymagają root-findów — dłuższy timeout
|
||||||
|
with httpx.Client(timeout=max(settings.http_timeout, 60.0) if stations else settings.http_timeout) as client:
|
||||||
r = client.post(f"{self.base_url}/chart/positions", json=payload)
|
r = client.post(f"{self.base_url}/chart/positions", json=payload)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
def report(
|
||||||
|
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
|
||||||
|
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
|
||||||
|
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
|
||||||
|
r = client.post(f"{self.base_url}/chart/report", json=payload)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
def timeline(
|
||||||
|
self, when_utc_iso: str, lat: float, lon: float,
|
||||||
|
from_date: str, to_date: str, interpret: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Oś czasu z technik (+interpretacje z bazy) — woła logic /chart/timeline."""
|
||||||
|
payload = {
|
||||||
|
"when_utc": when_utc_iso, "lat": lat, "lon": lon,
|
||||||
|
"from_date": from_date, "to_date": to_date, "interpret": interpret,
|
||||||
|
}
|
||||||
|
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
|
||||||
|
r = client.post(f"{self.base_url}/chart/timeline", json=payload)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|||||||
@@ -15,3 +15,17 @@ class Settings:
|
|||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
|
# Domyślna (wklepana) lokalizacja formularzy — miejsce urodzenia właściciela:
|
||||||
|
# Szpital Barlickiego w Łodzi, 51°46'26.1"N 19°28'58.6"E (potwierdzone reverse-
|
||||||
|
# geokodowaniem OSM: Kopcińskiego 22/28). QoL — nie trzeba wpisywać jej za każdym
|
||||||
|
# razem. Przycisk „Tu i teraz" nadal ją nadpisuje geolokalizacją przeglądarki.
|
||||||
|
# Można nadpisać zmiennymi środowiskowymi (np. inny współpracownik).
|
||||||
|
DEFAULT_LAT: float = float(os.getenv("DEFAULT_LAT", "51.7739"))
|
||||||
|
DEFAULT_LON: float = float(os.getenv("DEFAULT_LON", "19.4829"))
|
||||||
|
DEFAULT_LOCATION_LABEL: str = os.getenv("DEFAULT_LOCATION_LABEL", "Szpital Barlickiego, Łódź")
|
||||||
|
|
||||||
|
|
||||||
|
def default_form() -> dict:
|
||||||
|
"""Wstępnie wypełnione pola lokalizacji na czystym formularzu (GET)."""
|
||||||
|
return {"lat": DEFAULT_LAT, "lon": DEFAULT_LON}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Proxy geokodera (OpenStreetMap / Nominatim) dla wyszukiwarki lokalizacji w UI.
|
||||||
|
|
||||||
|
QoL: pozwala wpisać nazwę / adres / POI i dostać współrzędne (oraz odwrotnie —
|
||||||
|
z punktu na mapie nazwę miejsca). Wołamy Nominatim **po stronie serwera**, nie
|
||||||
|
z przeglądarki, bo:
|
||||||
|
- polityka Nominatim wymaga sensownego User-Agent i max ~1 zapytanie/s — łatwiej
|
||||||
|
to wyegzekwować i cache'ować centralnie niż z wielu klientów,
|
||||||
|
- działa niezależnie od „secure context" przeglądarki (na http://<ip> też).
|
||||||
|
|
||||||
|
Atrybucja „© OpenStreetMap contributors" jest pokazywana w UI (mapa + wyniki).
|
||||||
|
Zależność sieciowa jest OPCJONALNA: brak internetu = wyszukiwarka zwraca błąd,
|
||||||
|
ale ręczne wpisanie lat/lon oraz mapa działają dalej.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
NOMINATIM = "https://nominatim.openstreetmap.org"
|
||||||
|
# Nominatim wymaga identyfikującego User-Agent (inaczej blokuje). Kontakt = właściciel.
|
||||||
|
USER_AGENT = "astrololo/1.0 (self-hosted astrology tool; +mtuszowski@gmail.com)"
|
||||||
|
_MIN_INTERVAL = 1.1 # s — polityka Nominatim: max ~1 zapytanie/s
|
||||||
|
_CACHE_TTL = 3600.0 # s — powtórki tego samego zapytania z pamięci przez godzinę
|
||||||
|
_TIMEOUT = 6.0
|
||||||
|
|
||||||
|
_lock = threading.Lock() # serializuje wołania (throttling globalny w procesie)
|
||||||
|
_last_call = 0.0
|
||||||
|
_cache: dict[str, tuple[float, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _throttle() -> None:
|
||||||
|
global _last_call
|
||||||
|
wait = _MIN_INTERVAL - (time.monotonic() - _last_call)
|
||||||
|
if wait > 0:
|
||||||
|
time.sleep(wait)
|
||||||
|
_last_call = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def _cached(key: str) -> Any | None:
|
||||||
|
hit = _cache.get(key)
|
||||||
|
if hit and (time.monotonic() - hit[0]) < _CACHE_TTL:
|
||||||
|
return hit[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get(path: str, params: dict) -> Any:
|
||||||
|
"""Wołanie Nominatim z throttlingiem; zakłada trzymany `_lock`."""
|
||||||
|
_throttle()
|
||||||
|
r = httpx.get(
|
||||||
|
f"{NOMINATIM}{path}",
|
||||||
|
params=params,
|
||||||
|
headers={"User-Agent": USER_AGENT, "Accept-Language": "pl"},
|
||||||
|
timeout=_TIMEOUT,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def search(query: str, limit: int = 5) -> list[dict]:
|
||||||
|
"""Nazwa/adres/POI → lista kandydatów [{name, lat, lon}] (najlepsze pierwsze)."""
|
||||||
|
query = (query or "").strip()
|
||||||
|
if not query:
|
||||||
|
return []
|
||||||
|
key = f"s:{limit}:{query.lower()}"
|
||||||
|
with _lock:
|
||||||
|
cached = _cached(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
data = _get("/search", {"q": query, "format": "jsonv2",
|
||||||
|
"limit": limit, "addressdetails": 0})
|
||||||
|
out = [
|
||||||
|
{"name": item.get("display_name", ""),
|
||||||
|
"lat": round(float(item["lat"]), 6),
|
||||||
|
"lon": round(float(item["lon"]), 6)}
|
||||||
|
for item in data if "lat" in item and "lon" in item
|
||||||
|
]
|
||||||
|
_cache[key] = (time.monotonic(), out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def reverse(lat: float, lon: float) -> dict:
|
||||||
|
"""Punkt (lat, lon) → nazwa miejsca {name, lat, lon}."""
|
||||||
|
key = f"r:{round(lat, 5)}:{round(lon, 5)}"
|
||||||
|
with _lock:
|
||||||
|
cached = _cached(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
data = _get("/reverse", {"lat": lat, "lon": lon, "format": "jsonv2"})
|
||||||
|
out = {"name": data.get("display_name", ""),
|
||||||
|
"lat": round(lat, 6), "lon": round(lon, 6)}
|
||||||
|
_cache[key] = (time.monotonic(), out)
|
||||||
|
return out
|
||||||
@@ -12,12 +12,14 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, Form, Request
|
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app import geocode
|
||||||
from app.clients.logic_client import LogicClient
|
from app.clients.logic_client import LogicClient
|
||||||
|
from app.config import DEFAULT_LOCATION_LABEL, default_form
|
||||||
|
|
||||||
app = FastAPI(title="astrololo · warstwa prezentacji")
|
app = FastAPI(title="astrololo · warstwa prezentacji")
|
||||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||||
@@ -48,7 +50,10 @@ def _logic_error(e: Exception) -> str:
|
|||||||
# ---------------- Horoskop: pozycje (strona główna) ----------------
|
# ---------------- Horoskop: pozycje (strona główna) ----------------
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def chart_form(request: Request):
|
def chart_form(request: Request):
|
||||||
return templates.TemplateResponse(request, "chart.html", {"result": None, "form": {}})
|
return templates.TemplateResponse(
|
||||||
|
request, "chart.html",
|
||||||
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/", response_class=HTMLResponse)
|
@app.post("/", response_class=HTMLResponse)
|
||||||
@@ -59,13 +64,19 @@ def chart_compute(
|
|||||||
tz_offset: float = Form(0.0),
|
tz_offset: float = Form(0.0),
|
||||||
lat: float = Form(0.0),
|
lat: float = Form(0.0),
|
||||||
lon: float = Form(0.0),
|
lon: float = Form(0.0),
|
||||||
|
house_system: str = Form("whole_sign"),
|
||||||
|
stations: bool = Form(False),
|
||||||
):
|
):
|
||||||
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon}
|
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
||||||
|
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations}
|
||||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||||
try:
|
try:
|
||||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
ctx["moment"] = label
|
ctx["moment"] = label
|
||||||
ctx["result"] = logic.positions(when_utc_iso=iso_utc, lat=lat, lon=lon)
|
ctx["result"] = logic.positions(
|
||||||
|
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
|
house_system=house_system, stations=stations,
|
||||||
|
)
|
||||||
except (httpx.HTTPError,) as e:
|
except (httpx.HTTPError,) as e:
|
||||||
ctx["error"] = _logic_error(e)
|
ctx["error"] = _logic_error(e)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -96,6 +107,95 @@ def significators_search(
|
|||||||
return templates.TemplateResponse(request, "significators.html", ctx)
|
return templates.TemplateResponse(request, "significators.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Interpretacje (wynik obliczeń szukany w bazie) ----------------
|
||||||
|
@app.get("/interpret", response_class=HTMLResponse)
|
||||||
|
def interpret_form(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "interpret.html",
|
||||||
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/interpret", response_class=HTMLResponse)
|
||||||
|
def interpret_run(
|
||||||
|
request: Request,
|
||||||
|
date: str = Form(...),
|
||||||
|
time: str = Form(...),
|
||||||
|
tz_offset: float = Form(0.0),
|
||||||
|
lat: float = Form(0.0),
|
||||||
|
lon: float = Form(0.0),
|
||||||
|
group: bool = Form(False),
|
||||||
|
):
|
||||||
|
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
||||||
|
"lat": lat, "lon": lon, "group": group}
|
||||||
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||||
|
try:
|
||||||
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
|
ctx["moment"] = label
|
||||||
|
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
ctx["error"] = _logic_error(e)
|
||||||
|
except ValueError as e:
|
||||||
|
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||||||
|
return templates.TemplateResponse(request, "interpret.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Kalendarz (oś czasu z technik + interpretacje) ----------------
|
||||||
|
@app.get("/timeline", response_class=HTMLResponse)
|
||||||
|
def timeline_form(request: Request):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "timeline.html",
|
||||||
|
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/timeline", response_class=HTMLResponse)
|
||||||
|
def timeline_run(
|
||||||
|
request: Request,
|
||||||
|
date: str = Form(...),
|
||||||
|
time: str = Form(...),
|
||||||
|
tz_offset: float = Form(0.0),
|
||||||
|
lat: float = Form(0.0),
|
||||||
|
lon: float = Form(0.0),
|
||||||
|
from_date: str = Form(...),
|
||||||
|
to_date: str = Form(...),
|
||||||
|
):
|
||||||
|
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
|
||||||
|
"from_date": from_date, "to_date": to_date}
|
||||||
|
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||||
|
try:
|
||||||
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||||
|
ctx["moment"] = label
|
||||||
|
ctx["result"] = logic.timeline(
|
||||||
|
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
|
from_date=from_date, to_date=to_date, interpret=True,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
ctx["error"] = _logic_error(e)
|
||||||
|
except ValueError as e:
|
||||||
|
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||||||
|
return templates.TemplateResponse(request, "timeline.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
|
||||||
|
@app.get("/geocode")
|
||||||
|
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
|
||||||
|
"""Nazwa miejsca → kandydaci ze współrzędnymi (dla pola „Szukaj miejsca")."""
|
||||||
|
try:
|
||||||
|
return {"results": geocode.search(q)}
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/reverse")
|
||||||
|
def geocode_reverse(lat: float, lon: float):
|
||||||
|
"""Punkt z mapy → nazwa miejsca (po przeciągnięciu pineski / kliknięciu)."""
|
||||||
|
try:
|
||||||
|
return geocode.reverse(lat, lon)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
return {"status": "ok", "layer": "presentation"}
|
return {"status": "ok", "layer": "presentation"}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// Wyszukiwarka lokalizacji + mapa (OpenStreetMap / Leaflet, bez klucza API).
|
||||||
|
// Wpina się do formularza z polami input[name=lat] / input[name=lon]:
|
||||||
|
// - wpisanie nazwy/adresu/POI -> /geocode -> lista trafień -> klik ustawia lat/lon,
|
||||||
|
// - klik na mapie lub przeciągnięcie pineski -> ustawia lat/lon + /reverse pokazuje nazwę,
|
||||||
|
// - „Tu i teraz" (now.js) emituje event 'astrololo:coords' -> mapa się synchronizuje.
|
||||||
|
// Bez internetu wyszukiwarka/kafelki nie działają, ale ręczne lat/lon dalej tak.
|
||||||
|
(function () {
|
||||||
|
function ready(fn) {
|
||||||
|
if (document.readyState !== 'loading') fn();
|
||||||
|
else document.addEventListener('DOMContentLoaded', fn);
|
||||||
|
}
|
||||||
|
|
||||||
|
ready(function () {
|
||||||
|
var picker = document.getElementById('geoPicker');
|
||||||
|
if (!picker || typeof L === 'undefined') return; // brak Leafleta = po cichu pas
|
||||||
|
var latEl = document.querySelector('input[name=lat]');
|
||||||
|
var lonEl = document.querySelector('input[name=lon]');
|
||||||
|
if (!latEl || !lonEl) return;
|
||||||
|
|
||||||
|
var searchEl = document.getElementById('geoSearch');
|
||||||
|
var searchBtn = document.getElementById('geoSearchBtn');
|
||||||
|
var mapToggle = document.getElementById('geoMapToggle');
|
||||||
|
var resultsEl = document.getElementById('geoResults');
|
||||||
|
var mapEl = document.getElementById('map');
|
||||||
|
|
||||||
|
var num = function (el) { var v = parseFloat(el.value); return isNaN(v) ? 0 : v; };
|
||||||
|
var curLat = function () { return num(latEl); };
|
||||||
|
var curLon = function () { return num(lonEl); };
|
||||||
|
|
||||||
|
var pin = L.divIcon({ className: 'geo-pin', html: '📍', iconSize: [24, 24], iconAnchor: [12, 24] });
|
||||||
|
var map = L.map(mapEl).setView([curLat(), curLon()], 12);
|
||||||
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© OpenStreetMap contributors'
|
||||||
|
}).addTo(map);
|
||||||
|
var marker = L.marker([curLat(), curLon()], { draggable: true, icon: pin }).addTo(map);
|
||||||
|
|
||||||
|
// Kontener mógł być renderowany zanim ustaliła się jego szerokość — poprawka.
|
||||||
|
setTimeout(function () { map.invalidateSize(); }, 60);
|
||||||
|
|
||||||
|
function setCoords(lat, lon, fromMap) {
|
||||||
|
latEl.value = lat.toFixed(4);
|
||||||
|
lonEl.value = lon.toFixed(4);
|
||||||
|
marker.setLatLng([lat, lon]);
|
||||||
|
if (!fromMap) map.setView([lat, lon], Math.max(map.getZoom(), 12));
|
||||||
|
if (fromMap) reverseName(lat, lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reverseName(lat, lon) {
|
||||||
|
fetch('/reverse?lat=' + lat + '&lon=' + lon)
|
||||||
|
.then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (d) { if (d && d.name) searchEl.value = d.name; })
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
marker.on('dragend', function () {
|
||||||
|
var p = marker.getLatLng();
|
||||||
|
setCoords(p.lat, p.lng, true);
|
||||||
|
});
|
||||||
|
map.on('click', function (e) { setCoords(e.latlng.lat, e.latlng.lng, true); });
|
||||||
|
|
||||||
|
// ---- wyszukiwanie po nazwie ----
|
||||||
|
function renderResults(items) {
|
||||||
|
resultsEl.innerHTML = '';
|
||||||
|
if (!items.length) { resultsEl.hidden = true; return; }
|
||||||
|
items.forEach(function (it) {
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.textContent = it.name;
|
||||||
|
li.addEventListener('click', function () {
|
||||||
|
setCoords(it.lat, it.lon, false);
|
||||||
|
searchEl.value = it.name;
|
||||||
|
resultsEl.hidden = true;
|
||||||
|
});
|
||||||
|
resultsEl.appendChild(li);
|
||||||
|
});
|
||||||
|
resultsEl.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function doSearch() {
|
||||||
|
var q = searchEl.value.trim();
|
||||||
|
if (q.length < 3) { resultsEl.hidden = true; return; }
|
||||||
|
fetch('/geocode?q=' + encodeURIComponent(q))
|
||||||
|
.then(function (r) { return r.ok ? r.json() : { results: [] }; })
|
||||||
|
.then(function (d) { renderResults(d.results || []); })
|
||||||
|
.catch(function () { renderResults([]); });
|
||||||
|
}
|
||||||
|
|
||||||
|
var timer = null;
|
||||||
|
searchBtn.addEventListener('click', doSearch);
|
||||||
|
searchEl.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); doSearch(); } // nie wysyłaj formularza
|
||||||
|
});
|
||||||
|
searchEl.addEventListener('input', function () {
|
||||||
|
clearTimeout(timer); timer = setTimeout(doSearch, 500); // debounce (limit Nominatim)
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- zwijanie/rozwijanie mapy ----
|
||||||
|
mapToggle.addEventListener('click', function () {
|
||||||
|
var hidden = mapEl.hasAttribute('hidden');
|
||||||
|
if (hidden) {
|
||||||
|
mapEl.removeAttribute('hidden');
|
||||||
|
mapToggle.textContent = 'Ukryj mapę';
|
||||||
|
setTimeout(function () { map.invalidateSize(); map.setView([curLat(), curLon()]); }, 60);
|
||||||
|
} else {
|
||||||
|
mapEl.setAttribute('hidden', '');
|
||||||
|
mapToggle.textContent = 'Pokaż mapę';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- synchronizacja z „Tu i teraz" ----
|
||||||
|
document.addEventListener('astrololo:coords', function () {
|
||||||
|
marker.setLatLng([curLat(), curLon()]);
|
||||||
|
map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// „Tu i teraz": uzupełnia datę/godzinę/strefę z przeglądarki oraz — jeśli to
|
||||||
|
// możliwe — lokalizację (lat/lon).
|
||||||
|
//
|
||||||
|
// UWAGA: geolokalizacja przeglądarki działa tylko w "secure context"
|
||||||
|
// (https:// lub localhost). Na http://<ip> przeglądarka NIE pyta o zgodę,
|
||||||
|
// tylko po cichu odmawia — dlatego pokazujemy jawny komunikat w #geoNote.
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const btn = document.getElementById('nowBtn');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const note = document.getElementById('geoNote');
|
||||||
|
const say = msg => { if (note) note.textContent = msg; };
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
document.querySelector('input[name=date]').value =
|
||||||
|
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
||||||
|
document.querySelector('input[name=time]').value = pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||||
|
document.querySelector('input[name=tz_offset]').value = (-d.getTimezoneOffset() / 60);
|
||||||
|
|
||||||
|
if (!('geolocation' in navigator)) {
|
||||||
|
say('Ta przeglądarka nie udostępnia geolokalizacji — wpisz lat/lon ręcznie.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.isSecureContext) {
|
||||||
|
say('Lokalizacja z przeglądarki wymaga HTTPS lub localhost (otwarto przez http://) — wpisz lat/lon ręcznie.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
say('Pobieram lokalizację…');
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
function (pos) {
|
||||||
|
document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4);
|
||||||
|
document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4);
|
||||||
|
document.dispatchEvent(new CustomEvent('astrololo:coords')); // zsynchronizuj mapę (geo.js)
|
||||||
|
say('Lokalizacja pobrana ✓');
|
||||||
|
},
|
||||||
|
function (err) {
|
||||||
|
say('Nie udało się pobrać lokalizacji: ' + (err && err.message ? err.message : 'odmowa dostępu'));
|
||||||
|
},
|
||||||
|
{ timeout: 8000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,3 +50,34 @@ table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: v
|
|||||||
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
|
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
|
||||||
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
|
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
|
||||||
tr:last-child td { border-bottom: none; }
|
tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.small { font-size: .85rem; }
|
||||||
|
.nowrap { white-space: nowrap; }
|
||||||
|
.sig-item { margin-top: 1.25rem; }
|
||||||
|
.sig-head { padding: .5rem 0; }
|
||||||
|
.samples { margin-top: .3rem; }
|
||||||
|
.samples td { font-size: .92rem; vertical-align: top; }
|
||||||
|
.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
|
||||||
|
.facet { margin: .4rem 0 .6rem 1rem; }
|
||||||
|
.facet-head { color: var(--ink); font-size: .9rem; }
|
||||||
|
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
|
||||||
|
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
|
||||||
|
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
|
||||||
|
|
||||||
|
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
|
||||||
|
.geo { margin-top: .9rem; }
|
||||||
|
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
|
||||||
|
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
|
||||||
|
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
|
||||||
|
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
|
||||||
|
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
|
||||||
|
.geo-results li:last-child { border-bottom: none; }
|
||||||
|
.geo-results li:hover { background: var(--panel); color: #fff; }
|
||||||
|
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
|
||||||
|
.geo-map[hidden] { display: none; }
|
||||||
|
.geo-attrib { margin: .4rem 0 0; }
|
||||||
|
.geo-attrib a { color: var(--accent); }
|
||||||
|
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
|
||||||
|
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
|
||||||
|
.leaflet-control-attribution { font-size: .68rem; }
|
||||||
|
|||||||
@@ -0,0 +1,661 @@
|
|||||||
|
/* required styles */
|
||||||
|
|
||||||
|
.leaflet-pane,
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-tile-container,
|
||||||
|
.leaflet-pane > svg,
|
||||||
|
.leaflet-pane > canvas,
|
||||||
|
.leaflet-zoom-box,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-layer {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile,
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
/* Prevents IE11 from highlighting tiles in blue */
|
||||||
|
.leaflet-tile::selection {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||||
|
.leaflet-safari .leaflet-tile {
|
||||||
|
image-rendering: -webkit-optimize-contrast;
|
||||||
|
}
|
||||||
|
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||||
|
.leaflet-safari .leaflet-tile-container {
|
||||||
|
width: 1600px;
|
||||||
|
height: 1600px;
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||||
|
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||||
|
.leaflet-container .leaflet-overlay-pane svg {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
.leaflet-container .leaflet-marker-pane img,
|
||||||
|
.leaflet-container .leaflet-shadow-pane img,
|
||||||
|
.leaflet-container .leaflet-tile-pane img,
|
||||||
|
.leaflet-container img.leaflet-image-layer,
|
||||||
|
.leaflet-container .leaflet-tile {
|
||||||
|
max-width: none !important;
|
||||||
|
max-height: none !important;
|
||||||
|
width: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container img.leaflet-tile {
|
||||||
|
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||||
|
mix-blend-mode: plus-lighter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: pan-x pan-y;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag {
|
||||||
|
-ms-touch-action: pinch-zoom;
|
||||||
|
/* Fallback for FF which doesn't support pinch-zoom */
|
||||||
|
touch-action: none;
|
||||||
|
touch-action: pinch-zoom;
|
||||||
|
}
|
||||||
|
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||||
|
-ms-touch-action: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.leaflet-container {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tile {
|
||||||
|
filter: inherit;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.leaflet-tile-loaded {
|
||||||
|
visibility: inherit;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
z-index: 800;
|
||||||
|
}
|
||||||
|
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||||
|
.leaflet-overlay-pane svg {
|
||||||
|
-moz-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-pane { z-index: 400; }
|
||||||
|
|
||||||
|
.leaflet-tile-pane { z-index: 200; }
|
||||||
|
.leaflet-overlay-pane { z-index: 400; }
|
||||||
|
.leaflet-shadow-pane { z-index: 500; }
|
||||||
|
.leaflet-marker-pane { z-index: 600; }
|
||||||
|
.leaflet-tooltip-pane { z-index: 650; }
|
||||||
|
.leaflet-popup-pane { z-index: 700; }
|
||||||
|
|
||||||
|
.leaflet-map-pane canvas { z-index: 100; }
|
||||||
|
.leaflet-map-pane svg { z-index: 200; }
|
||||||
|
|
||||||
|
.leaflet-vml-shape {
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
.lvml {
|
||||||
|
behavior: url(#default#VML);
|
||||||
|
display: inline-block;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* control positioning */
|
||||||
|
|
||||||
|
.leaflet-control {
|
||||||
|
position: relative;
|
||||||
|
z-index: 800;
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-top,
|
||||||
|
.leaflet-bottom {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-top {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.leaflet-right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
.leaflet-bottom {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
.leaflet-left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control {
|
||||||
|
float: left;
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
.leaflet-top .leaflet-control {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
.leaflet-right .leaflet-control {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* zoom and fade animations */
|
||||||
|
|
||||||
|
.leaflet-fade-anim .leaflet-popup {
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transition: opacity 0.2s linear;
|
||||||
|
-moz-transition: opacity 0.2s linear;
|
||||||
|
transition: opacity 0.2s linear;
|
||||||
|
}
|
||||||
|
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-animated {
|
||||||
|
-webkit-transform-origin: 0 0;
|
||||||
|
-ms-transform-origin: 0 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
svg.leaflet-zoom-animated {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||||
|
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||||
|
}
|
||||||
|
.leaflet-zoom-anim .leaflet-tile,
|
||||||
|
.leaflet-pan-anim .leaflet-tile {
|
||||||
|
-webkit-transition: none;
|
||||||
|
-moz-transition: none;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* cursors */
|
||||||
|
|
||||||
|
.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.leaflet-grab {
|
||||||
|
cursor: -webkit-grab;
|
||||||
|
cursor: -moz-grab;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.leaflet-crosshair,
|
||||||
|
.leaflet-crosshair .leaflet-interactive {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.leaflet-popup-pane,
|
||||||
|
.leaflet-control {
|
||||||
|
cursor: auto;
|
||||||
|
}
|
||||||
|
.leaflet-dragging .leaflet-grab,
|
||||||
|
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||||
|
.leaflet-dragging .leaflet-marker-draggable {
|
||||||
|
cursor: move;
|
||||||
|
cursor: -webkit-grabbing;
|
||||||
|
cursor: -moz-grabbing;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* marker & overlays interactivity */
|
||||||
|
.leaflet-marker-icon,
|
||||||
|
.leaflet-marker-shadow,
|
||||||
|
.leaflet-image-layer,
|
||||||
|
.leaflet-pane > svg path,
|
||||||
|
.leaflet-tile-container {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-marker-icon.leaflet-interactive,
|
||||||
|
.leaflet-image-layer.leaflet-interactive,
|
||||||
|
.leaflet-pane > svg path.leaflet-interactive,
|
||||||
|
svg.leaflet-image-layer.leaflet-interactive path {
|
||||||
|
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* visual tweaks */
|
||||||
|
|
||||||
|
.leaflet-container {
|
||||||
|
background: #ddd;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-container a {
|
||||||
|
color: #0078A8;
|
||||||
|
}
|
||||||
|
.leaflet-zoom-box {
|
||||||
|
border: 2px dotted #38f;
|
||||||
|
background: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general typography */
|
||||||
|
.leaflet-container {
|
||||||
|
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* general toolbar styles */
|
||||||
|
|
||||||
|
.leaflet-bar {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a {
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
line-height: 26px;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.leaflet-bar a,
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-position: 50% 50%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:hover,
|
||||||
|
.leaflet-bar a:focus {
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 4px;
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
}
|
||||||
|
.leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.leaflet-bar a.leaflet-disabled {
|
||||||
|
cursor: default;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-bar a {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:first-child {
|
||||||
|
border-top-left-radius: 2px;
|
||||||
|
border-top-right-radius: 2px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-bar a:last-child {
|
||||||
|
border-bottom-left-radius: 2px;
|
||||||
|
border-bottom-right-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* zoom control */
|
||||||
|
|
||||||
|
.leaflet-control-zoom-in,
|
||||||
|
.leaflet-control-zoom-out {
|
||||||
|
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||||
|
text-indent: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* layers control */
|
||||||
|
|
||||||
|
.leaflet-control-layers {
|
||||||
|
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers.png);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.leaflet-retina .leaflet-control-layers-toggle {
|
||||||
|
background-image: url(images/layers-2x.png);
|
||||||
|
background-size: 26px 26px;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers-toggle {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers .leaflet-control-layers-list,
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-expanded {
|
||||||
|
padding: 6px 10px 6px 6px;
|
||||||
|
color: #333;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-scrollbar {
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding-right: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-selector {
|
||||||
|
margin-top: 2px;
|
||||||
|
position: relative;
|
||||||
|
top: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
}
|
||||||
|
.leaflet-control-layers-separator {
|
||||||
|
height: 0;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
margin: 5px -10px 5px -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Default icon URLs */
|
||||||
|
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||||
|
background-image: url(images/marker-icon.png);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* attribution and scale controls */
|
||||||
|
|
||||||
|
.leaflet-container .leaflet-control-attribution {
|
||||||
|
background: #fff;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution,
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
padding: 0 5px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.leaflet-control-attribution a:hover,
|
||||||
|
.leaflet-control-attribution a:focus {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.leaflet-attribution-flag {
|
||||||
|
display: inline !important;
|
||||||
|
vertical-align: baseline !important;
|
||||||
|
width: 1em;
|
||||||
|
height: 0.6669em;
|
||||||
|
}
|
||||||
|
.leaflet-left .leaflet-control-scale {
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-bottom .leaflet-control-scale {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line {
|
||||||
|
border: 2px solid #777;
|
||||||
|
border-top: none;
|
||||||
|
line-height: 1.1;
|
||||||
|
padding: 2px 5px 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(255, 255, 255, 0.8);
|
||||||
|
text-shadow: 1px 1px #fff;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child) {
|
||||||
|
border-top: 2px solid #777;
|
||||||
|
border-bottom: none;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
|
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||||
|
border-bottom: 2px solid #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-touch .leaflet-control-attribution,
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.leaflet-touch .leaflet-control-layers,
|
||||||
|
.leaflet-touch .leaflet-bar {
|
||||||
|
border: 2px solid rgba(0,0,0,0.2);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* popup */
|
||||||
|
|
||||||
|
.leaflet-popup {
|
||||||
|
position: absolute;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper {
|
||||||
|
padding: 1px;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content {
|
||||||
|
margin: 13px 24px 13px 20px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: 13px;
|
||||||
|
font-size: 1.08333em;
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
.leaflet-popup-content p {
|
||||||
|
margin: 17px 0;
|
||||||
|
margin: 1.3em 0;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip-container {
|
||||||
|
width: 40px;
|
||||||
|
height: 20px;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
margin-top: -1px;
|
||||||
|
margin-left: -20px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
padding: 1px;
|
||||||
|
|
||||||
|
margin: -10px auto 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
-webkit-transform: rotate(45deg);
|
||||||
|
-moz-transform: rotate(45deg);
|
||||||
|
-ms-transform: rotate(45deg);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
.leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||||
|
color: #757575;
|
||||||
|
text-decoration: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||||
|
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||||
|
color: #585858;
|
||||||
|
}
|
||||||
|
.leaflet-popup-scrolled {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||||
|
-ms-zoom: 1;
|
||||||
|
}
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
width: 24px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||||
|
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-oldie .leaflet-control-zoom,
|
||||||
|
.leaflet-oldie .leaflet-control-layers,
|
||||||
|
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||||
|
.leaflet-oldie .leaflet-popup-tip {
|
||||||
|
border: 1px solid #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* div icon */
|
||||||
|
|
||||||
|
.leaflet-div-icon {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Tooltip */
|
||||||
|
/* Base styles for the element that has a tooltip */
|
||||||
|
.leaflet-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
padding: 6px;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #fff;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #222;
|
||||||
|
white-space: nowrap;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.leaflet-tooltip.leaflet-interactive {
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before,
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 6px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Directions */
|
||||||
|
|
||||||
|
.leaflet-tooltip-bottom {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top {
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before,
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-top:before {
|
||||||
|
bottom: 0;
|
||||||
|
margin-bottom: -12px;
|
||||||
|
border-top-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-bottom:before {
|
||||||
|
top: 0;
|
||||||
|
margin-top: -12px;
|
||||||
|
margin-left: -6px;
|
||||||
|
border-bottom-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left {
|
||||||
|
margin-left: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before,
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
top: 50%;
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-left:before {
|
||||||
|
right: 0;
|
||||||
|
margin-right: -12px;
|
||||||
|
border-left-color: #fff;
|
||||||
|
}
|
||||||
|
.leaflet-tooltip-right:before {
|
||||||
|
left: 0;
|
||||||
|
margin-left: -12px;
|
||||||
|
border-right-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Printing */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
/* Prevent printers from removing background-images of controls. */
|
||||||
|
.leaflet-control {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
|||||||
|
{# Wyszukiwarka lokalizacji + interaktywna mapa (OpenStreetMap / Leaflet, bez klucza API).
|
||||||
|
Wklejana do formularzy z polami input[name=lat] / input[name=lon]. Samowystarczalna:
|
||||||
|
ładuje CSS/JS Leafleta (vendorowane lokalnie) oraz geo.js. Kafelki mapy lecą z OSM. #}
|
||||||
|
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css">
|
||||||
|
<div class="geo" id="geoPicker">
|
||||||
|
<div class="geo-search">
|
||||||
|
<input type="search" id="geoSearch" autocomplete="off"
|
||||||
|
placeholder="Szukaj miejsca (np. Szpital Barlickiego, Łódź)…">
|
||||||
|
<button type="button" id="geoSearchBtn" class="ghost">Szukaj</button>
|
||||||
|
<button type="button" id="geoMapToggle" class="ghost">Ukryj mapę</button>
|
||||||
|
</div>
|
||||||
|
<ul id="geoResults" class="geo-results" hidden></ul>
|
||||||
|
<div id="map" class="geo-map"></div>
|
||||||
|
<p class="muted small geo-attrib">
|
||||||
|
Wyszukiwarka i mapa: © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>
|
||||||
|
contributors (Nominatim). Kliknij mapę lub przeciągnij pineskę, by ustawić współrzędne.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<script defer src="/static/vendor/leaflet/leaflet.js"></script>
|
||||||
|
<script defer src="/static/geo.js"></script>
|
||||||
@@ -12,6 +12,8 @@
|
|||||||
<h1>astrololo</h1>
|
<h1>astrololo</h1>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/" class="{% block nav_chart %}{% endblock %}">Horoskop</a>
|
<a href="/" class="{% block nav_chart %}{% endblock %}">Horoskop</a>
|
||||||
|
<a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a>
|
||||||
|
<a href="/timeline" class="{% block nav_timeline %}{% endblock %}">Kalendarz</a>
|
||||||
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
|
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% block nav_chart %}active{% endblock %}
|
{% block nav_chart %}active{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="sub">Wpisz podstawowe dane momentu — program policzy pozycje obiektów (silnik efemeryd warstwy logicznej).</p>
|
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
|
||||||
|
|
||||||
<form method="post" action="/">
|
<form method="post" action="/">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
@@ -17,20 +17,32 @@
|
|||||||
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
|
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<details class="loc">
|
<div class="grid">
|
||||||
<summary>Lokalizacja (opcjonalnie — przyda się później do osi i domów)</summary>
|
<label>Szerokość (lat, + N)
|
||||||
<div class="grid">
|
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
|
||||||
<label>Szerokość (lat)
|
</label>
|
||||||
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
|
<label>Długość (lon, + E)
|
||||||
</label>
|
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
||||||
<label>Długość (lon, + na wschód)
|
</label>
|
||||||
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
<label>System domów
|
||||||
</label>
|
<select name="house_system">
|
||||||
</div>
|
{% set hs = form.house_system or 'whole_sign' %}
|
||||||
</details>
|
<option value="whole_sign" {{ 'selected' if hs == 'whole_sign' else '' }}>Whole Sign</option>
|
||||||
|
<option value="equal" {{ 'selected' if hs == 'equal' else '' }}>Equal</option>
|
||||||
|
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% include "_location_picker.html" %}
|
||||||
|
<div class="opts">
|
||||||
|
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
|
||||||
|
licz stacje planet (wolniejsze)</label>
|
||||||
|
</div>
|
||||||
|
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||||
<button type="submit">Policz pozycje</button>
|
<button type="submit">Policz horoskop</button>
|
||||||
|
<span id="geoNote" class="muted small"></span>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -42,12 +54,26 @@
|
|||||||
<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: {{ result.house_system }}{% endif %}
|
||||||
{% if moment %}· moment: {{ moment }}{% endif %}
|
{% if moment %}· moment: {{ moment }}{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if result.angles %}
|
||||||
|
<table class="angles">
|
||||||
|
<thead><tr><th>Oś</th><th>Znak</th><th>W znaku</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for key in ["Asc", "MC", "Dsc", "IC"] %}
|
||||||
|
{% set a = result.angles[key] %}
|
||||||
|
<tr><td>{{ a.name }}</td><td>{{ a.sign }}</td><td class="mono">{{ a.in_sign }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Absolutna</th><th>Kier.</th><th>Prędkość °/d</th>
|
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -56,24 +82,69 @@
|
|||||||
<td>{{ p.name }}</td>
|
<td>{{ p.name }}</td>
|
||||||
<td>{{ p.sign }}</td>
|
<td>{{ p.sign }}</td>
|
||||||
<td class="mono">{{ p.in_sign }}</td>
|
<td class="mono">{{ p.in_sign }}</td>
|
||||||
<td class="mono">{{ p.absolute }}</td>
|
<td>{{ p.house if p.house is defined else '—' }}</td>
|
||||||
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
|
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
|
||||||
<td class="mono">{{ '%+.4f' | format(p.speed) }}</td>
|
<td class="mono">{{ '%+.4f' | format(p.speed) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{% if result.lots %}
|
||||||
|
<div class="meta">Lots hermetyczne ({{ result.lots | length }}) · sekta: <strong>{{ result.sect }}</strong></div>
|
||||||
|
<table class="angles">
|
||||||
|
<thead><tr><th>Lot</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Formuła</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for l in result.lots %}
|
||||||
|
<tr><td>{{ l.name }}</td><td>{{ l.sign }}</td><td class="mono">{{ l.in_sign }}</td><td>{{ l.house }}</td><td class="muted small">{{ l.formula }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% set with_stations = result.positions | selectattr('stations', 'defined') | list %}
|
||||||
|
{% if with_stations %}
|
||||||
|
<div class="meta">Stacje planet (poprzednia / następna; <span class="badge">blisko</span> = mniej niż 7 dni)</div>
|
||||||
|
<table class="angles">
|
||||||
|
<thead><tr><th>Planeta</th><th>Poprzednia</th><th>Następna</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for p in with_stations %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ p.name }}{% if p.stations.station_soon %} <span class="badge">blisko</span>{% endif %}</td>
|
||||||
|
<td class="mono">{% if p.stations.prev %}{{ p.stations.prev.type }} · {{ p.stations.prev.date }} · {{ p.stations.prev.degree }} ({{ p.stations.prev.days }} d){% else %}—{% endif %}</td>
|
||||||
|
<td class="mono">{% if p.stations.next %}{{ p.stations.next.type }} · {{ p.stations.next.date }} · {{ p.stations.next.degree }} (+{{ p.stations.next.days }} d){% else %}—{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if result.aspects %}
|
||||||
|
<div class="meta">Aspekty główne ({{ result.aspects | length }})</div>
|
||||||
|
<table class="angles">
|
||||||
|
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in result.aspects %}
|
||||||
|
<tr><td>{{ a.obj1 }}</td><td>{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if result.cusps %}
|
||||||
|
<details class="loc">
|
||||||
|
<summary>Cusps domów ({{ result.house_system }})</summary>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Dom</th><th>Znak</th><th>Cusp</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for c in result.cusps %}
|
||||||
|
<tr><td>{{ c.house }}</td><td>{{ c.sign }}</td><td class="mono">{{ c.in_sign }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script src="/static/now.js"></script>
|
||||||
// „Tu i teraz": uzupełnia datę/godzinę bieżącą i offset lokalny przeglądarki.
|
|
||||||
document.getElementById('nowBtn').addEventListener('click', function () {
|
|
||||||
const d = new Date();
|
|
||||||
const pad = n => String(n).padStart(2, '0');
|
|
||||||
document.querySelector('input[name=date]').value =
|
|
||||||
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
|
||||||
document.querySelector('input[name=time]').value = pad(d.getHours()) + ':' + pad(d.getMinutes());
|
|
||||||
document.querySelector('input[name=tz_offset]').value = (-d.getTimezoneOffset() / 60);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Interpretacje{% endblock %}
|
||||||
|
{% block nav_interp %}active{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
|
||||||
|
|
||||||
|
<form method="post" action="/interpret">
|
||||||
|
<div class="grid">
|
||||||
|
<label>Data
|
||||||
|
<input type="date" name="date" value="{{ form.date or '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label>Godzina (lokalna)
|
||||||
|
<input type="time" name="time" value="{{ form.time or '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label>Strefa (offset h)
|
||||||
|
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
|
||||||
|
</label>
|
||||||
|
<label>Szerokość (lat)
|
||||||
|
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
|
||||||
|
</label>
|
||||||
|
<label>Długość (lon)
|
||||||
|
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% include "_location_picker.html" %}
|
||||||
|
<div class="opts">
|
||||||
|
<label><input type="checkbox" name="group" value="true" {{ 'checked' if form.group else '' }}> grupuj identyczne opisy</label>
|
||||||
|
</div>
|
||||||
|
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||||
|
<button type="submit">Szukaj interpretacji</button>
|
||||||
|
<span id="geoNote" class="muted small"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||||
|
|
||||||
|
{% if result %}
|
||||||
|
{% if result.data_error %}
|
||||||
|
<div class="error">{{ result.data_error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="meta">
|
||||||
|
Silnik: <strong>{{ result.engine }}</strong>
|
||||||
|
{% if result.provider %}· baza: {{ result.provider }}{% endif %}
|
||||||
|
{% if moment %}· moment: {{ moment }}{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for o in result.objects %}
|
||||||
|
<div class="sig-item">
|
||||||
|
<div class="sig-head">
|
||||||
|
<strong>{{ o.object }}</strong> w <strong>{{ o.sign }}</strong>{% if o.house %}, {{ o.house }}. dom{% endif %}
|
||||||
|
<span class="{{ 'retro' if o.direction == 'Rx' else '' }}">{{ o.direction }}</span>
|
||||||
|
<span class="muted small">(planeta {{ o.planet_token }}: {{ o.planet_total }} rekordów)</span>
|
||||||
|
</div>
|
||||||
|
{% for f in o.facets %}
|
||||||
|
<div class="facet">
|
||||||
|
<div class="facet-head">{{ f.label }} — <strong>{{ f.count }}</strong> dopasowań
|
||||||
|
<span class="badge" title="siła (LOG-21)">siła {{ f.score }}</span>
|
||||||
|
<span class="muted small">[{{ f.token }}]</span></div>
|
||||||
|
{% if f.groups %}
|
||||||
|
<table class="samples">
|
||||||
|
<tbody>
|
||||||
|
{% for g in f.groups %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ g.effect }}{% if g.count > 1 %} <span class="badge">×{{ g.count }}</span>{% endif %}</td>
|
||||||
|
<td class="sig small">{{ g.significators | join('; ') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% elif f.samples %}
|
||||||
|
<table class="samples">
|
||||||
|
<tbody>
|
||||||
|
{% for s in f.samples %}
|
||||||
|
<tr><td class="sig nowrap" title="{{ s.significator }}">{{ s.expanded }}</td><td>{{ s.effect }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script src="/static/now.js"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Kalendarz{% endblock %}
|
||||||
|
{% block nav_timeline %}active{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
|
||||||
|
|
||||||
|
<form method="post" action="/timeline">
|
||||||
|
<div class="grid">
|
||||||
|
<label>Data urodzenia
|
||||||
|
<input type="date" name="date" value="{{ form.date or '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label>Godzina (lokalna)
|
||||||
|
<input type="time" name="time" value="{{ form.time or '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label>Strefa (offset h)
|
||||||
|
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
|
||||||
|
</label>
|
||||||
|
<label>Szerokość (lat)
|
||||||
|
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
|
||||||
|
</label>
|
||||||
|
<label>Długość (lon)
|
||||||
|
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
|
||||||
|
{% include "_location_picker.html" %}
|
||||||
|
<div class="grid">
|
||||||
|
<label>Zakres od
|
||||||
|
<input type="date" name="from_date" value="{{ form.from_date or '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label>Zakres do
|
||||||
|
<input type="date" name="to_date" value="{{ form.to_date or '' }}" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||||
|
<button type="submit">Pokaż kalendarz</button>
|
||||||
|
<span id="geoNote" class="muted small"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||||
|
|
||||||
|
{% if result %}
|
||||||
|
{% if result.data_error %}<div class="error">{{ result.data_error }}</div>{% endif %}
|
||||||
|
<div class="meta">
|
||||||
|
Silnik: <strong>{{ result.engine }}</strong> ·
|
||||||
|
zdarzeń: {{ result.count }} · zakres {{ result.from }} → {{ result.to }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for e in result.events %}
|
||||||
|
<div class="sig-item">
|
||||||
|
<div class="sig-head">
|
||||||
|
<span class="badge">{{ e.exact }}</span>
|
||||||
|
<span class="badge">{{ e.technique }}</span>
|
||||||
|
<strong>{{ e.significator }}</strong>
|
||||||
|
<span class="muted small">(okno {{ e.start }} → {{ e.end }})</span>
|
||||||
|
</div>
|
||||||
|
{% if e.interpretations %}
|
||||||
|
<table class="samples">
|
||||||
|
<tbody>
|
||||||
|
{% for s in e.interpretations %}
|
||||||
|
<tr><td class="sig nowrap" title="{{ s.significator }}">{{ s.expanded }}</td><td>{{ s.effect }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if e.interpretations_count > e.interpretations | length %}
|
||||||
|
<div class="muted small">…i {{ e.interpretations_count - (e.interpretations | length) }} więcej</div>
|
||||||
|
{% endif %}
|
||||||
|
{% elif e.technique != 'solar_return' %}
|
||||||
|
<div class="muted small">brak dopasowań w bazie</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script src="/static/now.js"></script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user