Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 3195d9b003 | |||
| e69c0714b9 | |||
| 9bf297d461 | |||
| 561d95c7f4 | |||
| 4231f640b8 | |||
| f86ab55b29 | |||
| 21442b35b9 | |||
| 269c89152e | |||
| 16d35c16dc |
@@ -0,0 +1,17 @@
|
||||
# Skopiuj do .env i dostosuj.
|
||||
|
||||
# --- warstwa bazodanowa ---
|
||||
DATA_PROVIDER=excel # excel | sql
|
||||
EXCEL_DIR=./services/data/data_files
|
||||
CACHE_DIR=./services/data/.cache
|
||||
INDEXED_KEYS=name,id,symbol
|
||||
HEADER_SCAN_ROWS=15
|
||||
QUERY_CACHE_SIZE=512
|
||||
QUERY_CACHE_TTL=300
|
||||
SQL_URL=sqlite:///./.cache/astrololo.db
|
||||
|
||||
# --- warstwa logiczna ---
|
||||
DATA_URL=http://localhost:8002
|
||||
|
||||
# --- warstwa prezentacji ---
|
||||
LOGIC_URL=http://localhost:8001
|
||||
@@ -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
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
|
||||
# Cache warstwy bazodanowej (regenerowalny)
|
||||
services/data/.cache/
|
||||
*.parquet
|
||||
*.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)
|
||||
services/data/data_files/*.xlsx
|
||||
!services/data/data_files/.gitkeep
|
||||
|
||||
# Narzędzia
|
||||
.env
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -0,0 +1,80 @@
|
||||
.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:
|
||||
@echo "install - zainstaluj zależności WSZYSTKICH warstw do aktywnego venv"
|
||||
@echo "sample - wygeneruj przykładowe pliki .xlsx"
|
||||
@echo "dev-data - uruchom warstwę danych lokalnie (:8002)"
|
||||
@echo "dev-logic - uruchom warstwę logiczną lokalnie (:8001)"
|
||||
@echo "dev-presentation - uruchom warstwę prezentacji lokalnie (:8000)"
|
||||
@echo "test - testy warstwy logicznej (silnik efemeryd)"
|
||||
@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"
|
||||
|
||||
# --- instalacja zależności do aktywnego środowiska ---
|
||||
install:
|
||||
$(PY) -m pip install \
|
||||
-r services/data/requirements.txt \
|
||||
-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:
|
||||
cd services/data && $(PY) scripts/make_sample_data.py
|
||||
|
||||
reindex:
|
||||
cd services/data && $(PY) -m app.ingest.build_index
|
||||
|
||||
migrate:
|
||||
cd services/data && $(PY) -m app.ingest.to_sql
|
||||
|
||||
test:
|
||||
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) ---
|
||||
dev-data:
|
||||
cd services/data && uvicorn app.main:app --reload --port 8002
|
||||
|
||||
dev-logic:
|
||||
cd services/logic && DATA_URL=http://localhost:8002 uvicorn app.main:app --reload --port 8001
|
||||
|
||||
dev-presentation:
|
||||
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
|
||||
@@ -1 +1,76 @@
|
||||
# astrololo
|
||||
# astrololo
|
||||
|
||||
Aplikacja w **modelu trójwarstwowym**, w pełni modułowa: trzy niezależne usługi,
|
||||
każda komunikuje się wyłącznie z sąsiadem (nigdy „przez głowę”).
|
||||
|
||||
```
|
||||
┌──────────────────────┐ formularz (w dół) ┌──────────────────────┐ zapytanie (w dół) ┌──────────────────────┐
|
||||
│ PREZENTACJA (:8000) │ ───────────────────▶ │ LOGICZNA (:8001) │ ───────────────────▶ │ BAZODANOWA (:8002) │
|
||||
│ strona WWW + form │ ◀─────────────────── │ reguły biznesowe │ ◀─────────────────── │ wyszukiwanie danych │
|
||||
└──────────────────────┘ wyniki (w górę) └──────────────────────┘ dane (w górę) └──────────────────────┘
|
||||
HTML/UI pośrednik + logika Excel(+cache) ▸ SQL
|
||||
```
|
||||
|
||||
Każda warstwa to osobny katalog, osobny `requirements.txt`, osobny `Dockerfile`
|
||||
i osobne README. Komunikacja przez HTTP/JSON. Warstwa zna **tylko adres warstwy
|
||||
bezpośrednio pod nią** — nic o jej wnętrzu.
|
||||
|
||||
| Warstwa | Katalog | Zna w dół | Zadanie |
|
||||
|--------|---------|-----------|---------|
|
||||
| Prezentacji | [`services/presentation`](services/presentation) | `LOGIC_URL` | serwuje stronę, przekazuje formularz, renderuje wyniki |
|
||||
| Logiczna | [`services/logic`](services/logic) | `DATA_URL` | reguły biznesowe, tłumaczenie zapytań, opracowanie wyników |
|
||||
| Bazodanowa | [`services/data`](services/data) | pliki Excela / SQL | **tylko** wyszukiwanie danych i podanie ich w górę |
|
||||
|
||||
## Szybki start (Docker)
|
||||
```bash
|
||||
make sample # przykładowe pliki .xlsx do warstwy bazodanowej
|
||||
make up # zbuduj i uruchom 3 warstwy
|
||||
# otwórz http://localhost:8000
|
||||
```
|
||||
|
||||
## 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
|
||||
make dev-data # terminal 1 -> :8002
|
||||
make dev-logic # terminal 2 -> :8001
|
||||
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
|
||||
- Wymień prezentację (np. na SPA/React) → reszta bez zmian, kontrakt `/api/query` stały.
|
||||
- Wymień bazę (Excel → SQL) → prezentacja i logika bez zmian (patrz niżej).
|
||||
- Każdą warstwę da się uruchomić, testować i wdrażać osobno.
|
||||
|
||||
## Wydajność warstwy Excela — cache 4-poziomowy
|
||||
Dziś dane to setki dużych `.xlsx`, przeszukiwanych po **wykrytym nagłówku** i
|
||||
**układzie kolumn**. To kosztowne, więc warstwa bazodanowa ma cache (szczegóły:
|
||||
[`services/data/README.md`](services/data/README.md)):
|
||||
|
||||
1. **Schemat (L1, SQLite)** — wykryty nagłówek + mapowanie kolumn zapisane raz na wersję pliku.
|
||||
2. **Dane (L2, Parquet)** — znormalizowany arkusz; kolejne odczyty 10–100× szybsze niż `.xlsx`.
|
||||
3. **Zapytania (L3, in-memory TTL/LRU)** — powtarzalne wyszukiwania natychmiast (łatwo podmienić na Redis).
|
||||
4. **Odwrócony indeks (L4, SQLite)** — `wartość → plik`; otwieramy tylko trafione pliki zamiast skanu setek.
|
||||
|
||||
Unieważnianie automatyczne: klucz cache = **odcisk pliku** (`mtime+rozmiar`,
|
||||
opcjonalnie `sha256`). Zmiana pliku → przebudowa tylko jego wpisów.
|
||||
|
||||
## Droga na przyszłość — migracja do SQL
|
||||
Warstwa bazodanowa ukrywa źródło za interfejsem `DataProvider` (wzorzec
|
||||
Repository). Migracja:
|
||||
|
||||
```bash
|
||||
make migrate # ETL: tym samym loaderem Excel -> tabela 'records' + indeksy
|
||||
export DATA_PROVIDER=sql # przełącz całą warstwę
|
||||
```
|
||||
|
||||
`SqlDataProvider` realizuje ten sam kontrakt `/search`, więc **warstwa logiczna i
|
||||
prezentacji nie zmieniają ani jednej linii**. Odwrócony indeks z L4 (SQLite) jest
|
||||
już pomostem — rozbudowa o wszystkie kolumny = docelowa baza.
|
||||
# updater test Mon 20 Jul 2026 19:52:08 CEST
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
services:
|
||||
data:
|
||||
build: ./services/data
|
||||
environment:
|
||||
DATA_PROVIDER: ${DATA_PROVIDER:-excel}
|
||||
EXCEL_DIR: /app/data_files
|
||||
CACHE_DIR: /app/.cache
|
||||
INDEXED_KEYS: name,id,symbol
|
||||
volumes:
|
||||
- ./services/data/data_files:/app/data_files
|
||||
- data_cache:/app/.cache
|
||||
ports:
|
||||
- "8002:8002"
|
||||
|
||||
logic:
|
||||
build: ./services/logic
|
||||
environment:
|
||||
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:
|
||||
- data
|
||||
ports:
|
||||
- "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:
|
||||
build: ./services/presentation
|
||||
environment:
|
||||
LOGIC_URL: http://logic:8001
|
||||
depends_on:
|
||||
- logic
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
volumes:
|
||||
data_cache:
|
||||
@@ -0,0 +1,84 @@
|
||||
# Architektura dwóch silników (warstwa logiczna)
|
||||
|
||||
Dokument roboczy. Decyzja: warstwa logiczna ma **dwie wymienne implementacje silnika obliczeń** — jedną **własną/permisywną** (ścieżka A) i jedną **opartą o AGPL** (ścieżka C, Swiss Ephemeris) — żeby od pierwszego dnia mieć **wbudowany framework porównań i walidacji**. Warstwy **prezentacji i danych pozostają wspólne i pojedyncze**; rozdwaja się wyłącznie silnik wewnątrz warstwy logicznej.
|
||||
|
||||
---
|
||||
|
||||
## 1. Schemat
|
||||
|
||||
```
|
||||
(jedna) (jedna, ale 2 silniki) (jedna)
|
||||
┌──────────────────────┐ ┌─────────────────────────────────────┐ ┌──────────────────────┐
|
||||
│ PREZENTACJA │ ─────▶ │ LOGIKA │ ─────▶ │ DANE │
|
||||
│ (bez zmian) │ ◀───── │ EngineProvider (interfejs) │ ◀───── │ (bez zmian) │
|
||||
└──────────────────────┘ │ ├── EngineA: własny (permisywny)│ └──────────────────────┘
|
||||
│ │ Skyfield/Moshier — in-proc │
|
||||
│ └── EngineB: swisseph (AGPL) │
|
||||
│ ⇢ OSOBNY proces/usługa │
|
||||
│ Komparator (dual-run + raport) │
|
||||
└─────────────────────────────────────┘
|
||||
│
|
||||
⇢ (granica sieciowa/IPC)
|
||||
┌─────────────────────────────────┐
|
||||
│ engine-swisseph (AGPL, OSOBNO) │
|
||||
│ pyswisseph / Swiss Ephemeris │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
Prezentacja i dane „widzą" tylko interfejs `EngineProvider` — **nie wiedzą, który silnik liczy** (engine‑agnostic). To ta sama zasada modułowości co `DataProvider` w warstwie danych.
|
||||
|
||||
---
|
||||
|
||||
## 2. Po co dwa silniki
|
||||
|
||||
1. **Wyrocznia walidacyjna od dnia zero.** Każdy wynik EngineA możemy natychmiast porównać z EngineB (Swiss Ephemeris = de‑facto standard). Bezcenne przy budowie własnego silnika od zera (primary directions, domy, węzły/Lilith).
|
||||
2. **Framework porównań.** Tryb „policz oboma i pokaż różnice" — do testów regresyjnych (CI) i do podglądu side‑by‑side w UI.
|
||||
3. **Bezpieczeństwo decyzji.** Gdyby własny silnik gdzieś odstawał, widać to od razu, a nie po miesiącach.
|
||||
|
||||
---
|
||||
|
||||
## 3. Izolacja licencyjna (warunek konieczny) ⚠️
|
||||
|
||||
EngineB jest **AGPL**, więc nie może być wlinkowany w permisywny produkt. Rozwiązanie zgodne z naszą architekturą (osobne usługi jak warstwa danych):
|
||||
|
||||
- **EngineB = osobny proces/usługa `engine-swisseph`**, wołany przez **granicę sieciową/IPC** (HTTP). Brak linkowania = brak „zarażenia" copyleftem reszty.
|
||||
- **Produkt zamknięty (dystrybucja/hosting):** prezentacja + logika(**EngineA**) + dane. **Bez** EngineB. Zero AGPL.
|
||||
- **Profil porównawczy/dev/CI:** dodatkowo uruchamiamy usługę `engine-swisseph` (AGPL, sama w sobie zgodna z AGPL — to w zasadzie cienkie API na pyswisseph). Używana wewnętrznie do porównań, nie wystawiana użytkownikom produktu.
|
||||
- **Konfiguracja** decyduje, czy EngineB jest w ogóle dostępny (`ENGINE_B_URL` ustawione lub nie).
|
||||
|
||||
Efekt: pełny framework porównań **bez** narażania kodu i danych produktu na obowiązki AGPL (patrz `przeglad-bibliotek-i-licencji.md` oraz analiza zakresu AGPL względem baz danych).
|
||||
|
||||
---
|
||||
|
||||
## 4. Jak działa porównanie (dual‑run)
|
||||
|
||||
1. To samo wejście (dane horoskopu + ustawienia) trafia do EngineA i EngineB.
|
||||
2. Komparator zestawia wyniki: pozycje obiektów, cusps domów, aspekty, daty technik.
|
||||
3. Różnice liczone z **progami tolerancji per wielkość** (np. długość ekliptyczna ≤ 1″, cusp ≤ 1″, data zdarzenia ≤ 1 min); przekroczenia oflagowane.
|
||||
4. Raport: (a) jako asercje w **CI** (regresja), (b) jako **podgląd side‑by‑side** w prezentacji na żądanie.
|
||||
|
||||
---
|
||||
|
||||
## 5. Wpływ na warstwy i mapowanie na wymagania
|
||||
|
||||
| Warstwa | Zmiana |
|
||||
|---|---|
|
||||
| Prezentacja | brak (opcjonalnie: widok porównawczy side‑by‑side) |
|
||||
| Dane | brak |
|
||||
| **Logika** | `EngineProvider` + 2 backendy (EngineA in‑proc, EngineB jako klient usługi AGPL) + komparator |
|
||||
| **Nowa usługa** | `engine-swisseph` (AGPL, osobno, opcjonalna) |
|
||||
|
||||
Wymagania (arkusz „Warstwa logiczna"):
|
||||
- **LOG‑24** — interfejs `EngineProvider` z **dwoma** backendami (zrewidowane).
|
||||
- **LOG‑25** — framework walidacyjno‑porównawczy (zrewidowane).
|
||||
- **LOG‑26** — tryb dual‑run + raport różnic (nowe).
|
||||
- **LOG‑27** — izolacja licencyjna silnika AGPL jako osobnej usługi (nowe).
|
||||
- **LOG‑28** — kontrakt parzystości silników: identyczny interfejs, te same testy (nowe).
|
||||
|
||||
---
|
||||
|
||||
## 6. Implikacje wdrożeniowe
|
||||
|
||||
- **Dwa profile `docker-compose`:** `proprietary` (prezentacja+logika+dane, EngineA) i `comparison` (dodaje `engine-swisseph`).
|
||||
- **Testy kontraktowe** uruchamiane na obu silnikach (ten sam zestaw → gwarancja parzystości interfejsu).
|
||||
- **Decyzja A vs B na produkcji** pozostaje po stronie A; B nigdy nie trafia do dystrybucji zamkniętej — służy jako wzorzec i narzędzie QA.
|
||||
Binary file not shown.
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8002
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002"]
|
||||
@@ -0,0 +1,46 @@
|
||||
# Warstwa bazodanowa (`data`)
|
||||
|
||||
Niezależna usługa. **Jedyne zadanie:** wyszukać dane i podać je w górę. Nie zna
|
||||
warstwy logicznej ani prezentacji — komunikacja wyłącznie przez HTTP/JSON
|
||||
(`models.py`).
|
||||
|
||||
## API
|
||||
- `POST /search` → `SearchQuery` → `SearchResult`
|
||||
- `GET /health` → `HealthInfo`
|
||||
|
||||
## Architektura wewnętrzna
|
||||
```
|
||||
providers/ wymienna implementacja (wzorzec Repository)
|
||||
base.py interfejs DataProvider ← kontrakt
|
||||
excel_provider dziś: Excel + 4 poziomy cache
|
||||
sql_provider jutro: SQL (ten sam interfejs)
|
||||
factory.py DATA_PROVIDER=excel|sql
|
||||
excel/ wykrywanie nagłówka + mapowanie układu kolumn (jedyne miejsce znające .xlsx)
|
||||
cache/ fingerprint, schema(L1), frame/parquet(L2), query(L3), index(L4)
|
||||
ingest/ build_index.py (warmup), to_sql.py (migracja ETL)
|
||||
```
|
||||
|
||||
## Cache — dlaczego szybko
|
||||
| Poziom | Co cache'uje | Zysk |
|
||||
|-------|---------------|------|
|
||||
| L1 `schema.db` | wykryty nagłówek + układ kolumn per plik | brak ponownego skanu heurystyką |
|
||||
| L2 Parquet | znormalizowany arkusz | 10–100× szybciej niż parsowanie `.xlsx` |
|
||||
| L3 `QueryCache` | wynik zapytania (TTL/LRU) | powtarzalne zapytania natychmiast |
|
||||
| L4 `index.db` | odwrócony indeks wartość→plik | otwieramy tylko trafione pliki, nie setki |
|
||||
|
||||
Unieważnianie: klucz = odcisk pliku (`mtime+rozmiar`, opcjonalnie `sha256`).
|
||||
Zmiana pliku → inny odcisk → automatyczny przebudowa.
|
||||
|
||||
## Uruchomienie lokalne
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python scripts/make_sample_data.py # przykładowe .xlsx
|
||||
python -m app.ingest.build_index # (opcjonalnie) prebuild indeksu
|
||||
uvicorn app.main:app --port 8002
|
||||
```
|
||||
|
||||
## Migracja do SQL (gdy nadejdzie czas)
|
||||
```bash
|
||||
python -m app.ingest.to_sql # Excel -> tabela 'records' + indeksy
|
||||
export DATA_PROVIDER=sql # przełącz warstwę — reszta systemu bez zmian
|
||||
```
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
"""Odcisk pliku = klucz unieważniania cache.
|
||||
|
||||
Wszystkie warstwy cache są kluczowane odciskiem pliku. Gdy plik Excela się zmieni,
|
||||
zmienia się odcisk -> automatyczny "cache miss" i przebudowa. Domyślnie używamy
|
||||
taniego (mtime + rozmiar); sha256 dostępne, gdy potrzeba pewności co do treści.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
|
||||
def fingerprint(path: str, strong: bool = False) -> str:
|
||||
if strong:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return "sha256:" + h.hexdigest()[:16]
|
||||
|
||||
st = os.stat(path)
|
||||
return f"mt:{int(st.st_mtime)}:{st.st_size}"
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
"""Cache danych: znormalizowany arkusz zapisany jako Parquet.
|
||||
|
||||
POZIOM 2 cache — największy zysk wydajności. Parsowanie .xlsx jest wolne
|
||||
(dziesiątki–setki ms na duży plik). Po pierwszym wczytaniu zapisujemy
|
||||
znormalizowaną ramkę jako Parquet (kolumnowy, kompresowany), kluczowaną odciskiem
|
||||
pliku. Kolejne odczyty ładują Parquet — zwykle 10–100x szybciej niż .xlsx i bez
|
||||
ponownego wykrywania nagłówka.
|
||||
|
||||
Plik Parquet jest też naturalnym formatem pośrednim przy migracji do SQL.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class FrameCache:
|
||||
def __init__(self, cache_dir: Path) -> None:
|
||||
self.dir = cache_dir / "frames"
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _path(self, fp: str, sheet: str) -> Path:
|
||||
safe = fp.replace(":", "_") + "__" + str(sheet).replace("/", "_")
|
||||
return self.dir / f"{safe}.parquet"
|
||||
|
||||
def get(self, fp: str, sheet: str) -> pd.DataFrame | None:
|
||||
p = self._path(fp, sheet)
|
||||
if p.exists():
|
||||
return pd.read_parquet(p)
|
||||
return None
|
||||
|
||||
def put(self, fp: str, sheet: str, frame: pd.DataFrame) -> None:
|
||||
# Kolumny z heterogenicznych plików Excela bywają mieszane (int + str +
|
||||
# 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)
|
||||
Vendored
+68
@@ -0,0 +1,68 @@
|
||||
"""Odwrócony indeks: wartość kanonicznego klucza -> które pliki/arkusze ją mają.
|
||||
|
||||
POZIOM 4 (i najważniejszy przy skali) — pozwala NIE skanować setek plików przy
|
||||
każdym zapytaniu. Budujemy w SQLite indeks: dla wybranych kluczy (np. name, id,
|
||||
symbol) zapisujemy, w którym pliku/arkuszu występuje dana wartość. Wyszukiwanie
|
||||
najpierw pyta indeks (jeden szybki SELECT), a otwiera tylko trafione pliki.
|
||||
|
||||
Ten SQLite indeks jest jednocześnie POMOSTEM do pełnej migracji SQL — rozbudowa
|
||||
go o wszystkie kolumny = de facto baza danych (patrz ingest/to_sql.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class InvertedIndex:
|
||||
def __init__(self, cache_dir: Path) -> None:
|
||||
self._db = sqlite3.connect(str(cache_dir / "index.db"), check_same_thread=False)
|
||||
self._db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
key TEXT, -- kanoniczne pole, np. 'name'
|
||||
value TEXT, -- znormalizowana (lower) wartość
|
||||
file TEXT, -- ścieżka pliku
|
||||
sheet TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._db.execute("CREATE INDEX IF NOT EXISTS ix_kv ON entries(key, value)")
|
||||
self._db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS files (file TEXT PRIMARY KEY, fingerprint TEXT)"
|
||||
)
|
||||
self._db.commit()
|
||||
|
||||
def file_fingerprint(self, file: str) -> str | None:
|
||||
cur = self._db.execute("SELECT fingerprint FROM files WHERE file=?", (file,))
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
def reindex_file(self, file: str, fingerprint: str, rows: list[tuple[str, str, str]]) -> None:
|
||||
"""rows: lista (key, value, sheet) dla jednego pliku."""
|
||||
self._db.execute("DELETE FROM entries WHERE file=?", (file,))
|
||||
self._db.executemany(
|
||||
"INSERT INTO entries(key, value, file, sheet) VALUES (?,?,?,?)",
|
||||
[(k, v.lower(), file, sheet) for (k, v, sheet) in rows],
|
||||
)
|
||||
self._db.execute(
|
||||
"INSERT OR REPLACE INTO files(file, fingerprint) VALUES (?,?)", (file, fingerprint)
|
||||
)
|
||||
self._db.commit()
|
||||
|
||||
def lookup(self, key: str, value: str, exact: bool) -> list[tuple[str, str]]:
|
||||
"""Zwraca listę (file, sheet) kandydatów do przeszukania."""
|
||||
if exact:
|
||||
cur = self._db.execute(
|
||||
"SELECT DISTINCT file, sheet FROM entries WHERE key=? AND value=?",
|
||||
(key, value.lower()),
|
||||
)
|
||||
else:
|
||||
cur = self._db.execute(
|
||||
"SELECT DISTINCT file, sheet FROM entries WHERE key=? AND value LIKE ?",
|
||||
(key, f"%{value.lower()}%"),
|
||||
)
|
||||
return [(r[0], r[1]) for r in cur.fetchall()]
|
||||
|
||||
def count_files(self) -> int:
|
||||
return self._db.execute("SELECT COUNT(*) FROM files").fetchone()[0]
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
"""Cache wyników zapytań: in-memory LRU + TTL.
|
||||
|
||||
POZIOM 3 cache. Te same zapytania powtarzają się (popularne wyszukiwania). Tu
|
||||
trzymamy gotowy wynik przez krótki TTL. Bez zewnętrznych zależności — w produkcji
|
||||
można podmienić na Redis (ten sam interfejs get/put), by współdzielić cache
|
||||
między instancjami.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from typing import Any
|
||||
|
||||
|
||||
class QueryCache:
|
||||
def __init__(self, max_size: int = 512, ttl: int = 300) -> None:
|
||||
self.max_size = max_size
|
||||
self.ttl = ttl
|
||||
self._store: OrderedDict[str, tuple[float, Any]] = OrderedDict()
|
||||
|
||||
def get(self, key: str) -> Any | None:
|
||||
item = self._store.get(key)
|
||||
if item is None:
|
||||
return None
|
||||
ts, value = item
|
||||
if time.time() - ts > self.ttl:
|
||||
del self._store[key]
|
||||
return None
|
||||
self._store.move_to_end(key)
|
||||
return value
|
||||
|
||||
def put(self, key: str, value: Any) -> None:
|
||||
self._store[key] = (time.time(), value)
|
||||
self._store.move_to_end(key)
|
||||
while len(self._store) > self.max_size:
|
||||
self._store.popitem(last=False)
|
||||
|
||||
def clear(self) -> None:
|
||||
self._store.clear()
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
"""Cache schematu: wykryty wiersz nagłówka + mapowanie kolumn, per (plik, arkusz).
|
||||
|
||||
POZIOM 1 cache. Najdroższe jest samo wykrywanie nagłówka i wnioskowanie układu
|
||||
kolumn. Robimy to RAZ na wersję pliku i zapisujemy w SQLite. Kolejne odczyty
|
||||
pomijają skanowanie.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class SchemaCache:
|
||||
def __init__(self, cache_dir: Path) -> None:
|
||||
self._db = sqlite3.connect(str(cache_dir / "schema.db"), check_same_thread=False)
|
||||
self._db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_cache (
|
||||
fingerprint TEXT,
|
||||
sheet TEXT,
|
||||
header_row INTEGER,
|
||||
mapping TEXT,
|
||||
PRIMARY KEY (fingerprint, sheet)
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._db.commit()
|
||||
|
||||
def get(self, fp: str, sheet: str) -> tuple[int, dict[str, str]] | None:
|
||||
cur = self._db.execute(
|
||||
"SELECT header_row, mapping FROM schema_cache WHERE fingerprint=? AND sheet=?",
|
||||
(fp, sheet),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return int(row[0]), json.loads(row[1])
|
||||
|
||||
def put(self, fp: str, sheet: str, header_row: int, mapping: dict[str, str]) -> None:
|
||||
self._db.execute(
|
||||
"INSERT OR REPLACE INTO schema_cache VALUES (?,?,?,?)",
|
||||
(fp, sheet, header_row, json.dumps(mapping, ensure_ascii=False)),
|
||||
)
|
||||
self._db.commit()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Konfiguracja warstwy bazodanowej (z ENV).
|
||||
|
||||
Najważniejsza zmienna: DATA_PROVIDER. Zmiana 'excel' -> 'sql' przełącza całą
|
||||
warstwę na bazę SQL bez dotykania pozostałych modułów (patrz providers/factory.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent # .../services/data
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
# 'excel' (dziś) albo 'sql' (po migracji)
|
||||
provider: str = field(default_factory=lambda: os.getenv("DATA_PROVIDER", "excel"))
|
||||
|
||||
# Warstwa Excel
|
||||
excel_dir: Path = field(
|
||||
default_factory=lambda: Path(os.getenv("EXCEL_DIR", str(BASE_DIR / "data_files")))
|
||||
)
|
||||
cache_dir: Path = field(
|
||||
default_factory=lambda: Path(os.getenv("CACHE_DIR", str(BASE_DIR / ".cache")))
|
||||
)
|
||||
header_scan_rows: int = field(default_factory=lambda: int(os.getenv("HEADER_SCAN_ROWS", "15")))
|
||||
# Klucze kanoniczne, które trafiają do odwróconego indeksu (przyspiesza wyszukiwanie).
|
||||
indexed_keys: tuple[str, ...] = field(
|
||||
default_factory=lambda: tuple(
|
||||
k.strip() for k in os.getenv("INDEXED_KEYS", "name,id,symbol").split(",") if k.strip()
|
||||
)
|
||||
)
|
||||
|
||||
# Cache zapytań (in-memory)
|
||||
query_cache_size: int = field(default_factory=lambda: int(os.getenv("QUERY_CACHE_SIZE", "512")))
|
||||
query_cache_ttl: int = field(default_factory=lambda: int(os.getenv("QUERY_CACHE_TTL", "300")))
|
||||
|
||||
# Warstwa SQL (po migracji)
|
||||
sql_url: str = field(
|
||||
default_factory=lambda: os.getenv("SQL_URL", "sqlite:///./.cache/astrololo.db")
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.excel_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Wykrywanie wiersza nagłówka w arkuszu.
|
||||
|
||||
Pliki Excela w tej domenie nie mają nagłówka zawsze w pierwszym wierszu — bywają
|
||||
puste wiersze, tytuły, metadane. Ta heurystyka skanuje pierwsze N wierszy i
|
||||
wybiera ten, który "wygląda jak nagłówek": dużo niepustych, tekstowych,
|
||||
unikalnych komórek, po którym następują wiersze danych o podobnym wypełnieniu.
|
||||
|
||||
Wynik (indeks wiersza nagłówka) jest CACHE'OWANY per plik (schema_cache), więc
|
||||
ten kosztowny skan robimy raz na wersję pliku.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def _row_score(raw: pd.DataFrame, i: int) -> float:
|
||||
row = raw.iloc[i]
|
||||
non_null = row.notna()
|
||||
filled = float(non_null.mean()) if len(row) else 0.0
|
||||
if filled == 0:
|
||||
return -1.0
|
||||
|
||||
values = [str(v).strip() for v in row[non_null].tolist()]
|
||||
text_like = sum(1 for v in values if v and not _looks_numeric(v))
|
||||
text_ratio = text_like / max(len(values), 1)
|
||||
uniqueness = len(set(values)) / max(len(values), 1)
|
||||
|
||||
# Wiersze danych pod spodem powinny mieć podobną liczbę wypełnionych kolumn.
|
||||
follow_bonus = 0.0
|
||||
if i + 1 < len(raw):
|
||||
below = raw.iloc[i + 1].notna().mean()
|
||||
follow_bonus = 1.0 - abs(filled - float(below))
|
||||
|
||||
return filled * 0.4 + text_ratio * 0.3 + uniqueness * 0.2 + follow_bonus * 0.1
|
||||
|
||||
|
||||
def _looks_numeric(v: str) -> bool:
|
||||
try:
|
||||
float(v.replace(",", "."))
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def detect_header_row(raw: pd.DataFrame, max_scan: int = 15) -> int:
|
||||
"""Zwraca indeks (0-based) wiersza, który najprawdopodobniej jest nagłówkiem."""
|
||||
best_idx, best_score = 0, float("-inf")
|
||||
for i in range(min(max_scan, len(raw))):
|
||||
score = _row_score(raw, i)
|
||||
if score > best_score:
|
||||
best_idx, best_score = i, score
|
||||
return best_idx
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Mapowanie układu kolumn na schemat kanoniczny.
|
||||
|
||||
Setki plików mogą mieć te same dane pod różnymi nagłówkami i w różnej kolejności
|
||||
kolumn ("Imię", "Name", "NAZWA" -> kanoniczne 'name'). Ta warstwa tłumaczy
|
||||
faktyczny układ kolumn pliku na wspólny słownik pól, dzięki czemu reszta systemu
|
||||
(i przyszła baza SQL) operuje na jednej, stabilnej nazwie pola.
|
||||
|
||||
Aliasowanie jest świadomie wydzielone i konfigurowalne — to jedyne miejsce do
|
||||
edycji, gdy pojawi się nowy wariant nagłówka.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# kanoniczne_pole -> zbiór aliasów (po normalizacji)
|
||||
CANONICAL_ALIASES: dict[str, set[str]] = {
|
||||
"id": {"id", "identyfikator", "nr", "no", "number"},
|
||||
"name": {"name", "imie", "nazwa", "nazwisko", "title", "tytul"},
|
||||
"symbol": {"symbol", "znak", "sign", "glyph"},
|
||||
"date": {"date", "data", "datetime", "timestamp"},
|
||||
"value": {"value", "wartosc", "val", "amount", "kwota"},
|
||||
"category": {"category", "kategoria", "type", "typ", "group", "grupa"},
|
||||
}
|
||||
|
||||
|
||||
def _normalize(col: str) -> str:
|
||||
s = str(col).strip().lower()
|
||||
s = re.sub(r"[ąàá]", "a", s)
|
||||
s = s.replace("ł", "l").replace("ż", "z").replace("ź", "z").replace("ć", "c")
|
||||
s = s.replace("ę", "e").replace("ó", "o").replace("ś", "s").replace("ń", "n")
|
||||
s = re.sub(r"[^a-z0-9]+", "", s)
|
||||
return s
|
||||
|
||||
|
||||
def build_column_mapping(header_cells: list[str]) -> dict[str, str]:
|
||||
"""Zwraca mapę pole_kanoniczne -> faktyczna_nazwa_kolumny dla danego pliku.
|
||||
|
||||
Kolumny nierozpoznane są zachowywane pod swoją (znormalizowaną) nazwą, więc
|
||||
nic nie ginie — po prostu nie mają aliasu kanonicznego.
|
||||
"""
|
||||
reverse: dict[str, str] = {}
|
||||
for canonical, aliases in CANONICAL_ALIASES.items():
|
||||
for alias in aliases:
|
||||
reverse[alias] = canonical
|
||||
|
||||
mapping: dict[str, str] = {}
|
||||
for actual in header_cells:
|
||||
norm = _normalize(actual)
|
||||
canonical = reverse.get(norm, norm or "col")
|
||||
# pierwsze trafienie wygrywa (stabilność przy duplikatach)
|
||||
mapping.setdefault(canonical, actual)
|
||||
return mapping
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Wczytanie pojedynczego arkusza do znormalizowanej ramki danych.
|
||||
|
||||
Łączy wykrywanie nagłówka (header_detect) z mapowaniem układu kolumn (layout).
|
||||
Zwraca ramkę o KANONICZNYCH nazwach kolumn — gotową do indeksowania, cache'owania
|
||||
(parquet) i ewentualnego załadowania do SQL.
|
||||
|
||||
To jest jedyne miejsce, które "rozumie" format Excela. Reszta systemu jej nie
|
||||
widzi.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from app.excel.header_detect import detect_header_row
|
||||
from app.excel.layout import build_column_mapping
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoadedSheet:
|
||||
frame: pd.DataFrame # dane z kanonicznymi kolumnami
|
||||
header_row: int # wykryty indeks nagłówka
|
||||
column_mapping: dict[str, str] # pole_kanoniczne -> oryginalna_nazwa
|
||||
|
||||
|
||||
def load_sheet(path: str, sheet: str | int = 0, header_scan_rows: int = 15) -> LoadedSheet:
|
||||
raw = pd.read_excel(path, sheet_name=sheet, header=None, dtype=object)
|
||||
header_row = detect_header_row(raw, max_scan=header_scan_rows)
|
||||
|
||||
header_cells = [str(c) for c in raw.iloc[header_row].tolist()]
|
||||
mapping = build_column_mapping(header_cells)
|
||||
|
||||
data = raw.iloc[header_row + 1 :].copy()
|
||||
data.columns = header_cells
|
||||
data = data.dropna(how="all")
|
||||
|
||||
# przenazwij na kanoniczne pola: {oryginał -> kanoniczne}
|
||||
inverse = {orig: canon for canon, orig in mapping.items()}
|
||||
data = data.rename(columns=inverse)
|
||||
data = data.reset_index(drop=True)
|
||||
|
||||
return LoadedSheet(frame=data, header_row=header_row, column_mapping=mapping)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Wstępne zbudowanie cache + odwróconego indeksu dla wszystkich plików Excela.
|
||||
|
||||
Uruchom raz po wgraniu/aktualizacji plików (albo zostaw warmup przy starcie usługi):
|
||||
|
||||
python -m app.ingest.build_index
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.config import settings
|
||||
from app.providers.excel_provider import ExcelDataProvider
|
||||
|
||||
|
||||
def main() -> None:
|
||||
provider = ExcelDataProvider(settings)
|
||||
files = provider._excel_files()
|
||||
print(f"Indeksuję {len(files)} plików z {settings.excel_dir} ...")
|
||||
for i, path in enumerate(files, 1):
|
||||
provider._ensure_indexed(path)
|
||||
if i % 25 == 0 or i == len(files):
|
||||
print(f" {i}/{len(files)}")
|
||||
print(f"Gotowe. Zaindeksowane pliki: {provider.index.count_files()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,53 @@
|
||||
"""ETL migracji: setki plików Excela -> jedna zoptymalizowana tabela SQL.
|
||||
|
||||
To jest "łatwa droga na przyszłość". Skrypt używa DOKŁADNIE tego samego loadera
|
||||
co warstwa Excela (wykrywanie nagłówka + mapowanie kolumn kanonicznych), więc
|
||||
dane trafiają do SQL już znormalizowane i spójne. Po załadowaniu wystarczy
|
||||
ustawić DATA_PROVIDER=sql.
|
||||
|
||||
python -m app.ingest.to_sql
|
||||
|
||||
Kroki:
|
||||
1. wczytaj każdy plik loaderem -> ramka o kanonicznych kolumnach,
|
||||
2. dołóż kolumnę źródła (_source_file) dla audytu,
|
||||
3. dopisz do tabeli 'records',
|
||||
4. załóż indeksy na kluczach kanonicznych (przyspieszenie zapytań).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
from app.config import settings
|
||||
from app.excel.loader import load_sheet
|
||||
|
||||
|
||||
def main() -> None:
|
||||
engine = create_engine(settings.sql_url, future=True)
|
||||
files = sorted(Path(settings.excel_dir).glob("**/*.xlsx"))
|
||||
print(f"Migruję {len(files)} plików -> {settings.sql_url}")
|
||||
|
||||
first = True
|
||||
for path in files:
|
||||
if path.name.startswith("~$"):
|
||||
continue
|
||||
loaded = load_sheet(str(path), header_scan_rows=settings.header_scan_rows)
|
||||
frame = loaded.frame.copy()
|
||||
frame["_source_file"] = path.name
|
||||
frame.to_sql("records", engine, if_exists="replace" if first else "append", index=False)
|
||||
first = False
|
||||
|
||||
with engine.connect() as conn:
|
||||
for key in settings.indexed_keys:
|
||||
try:
|
||||
conn.execute(text(f"CREATE INDEX IF NOT EXISTS ix_records_{key} ON records({key})"))
|
||||
except Exception as e: # kolumna może nie istnieć w tym zbiorze
|
||||
print(f" (pomijam indeks {key}: {e})")
|
||||
conn.commit()
|
||||
print("Migracja zakończona. Ustaw DATA_PROVIDER=sql aby przełączyć warstwę.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Warstwa BAZODANOWA — usługa HTTP.
|
||||
|
||||
Jedyne zadanie: przyjąć znormalizowane zapytanie z warstwy logicznej, wyszukać
|
||||
dane (w Excelu z cache lub w SQL) i zwrócić je w górę. Nie zna warstwy logicznej
|
||||
ani prezentacji.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.config import settings
|
||||
from app.models import HealthInfo, SearchQuery, SearchResult
|
||||
from app.providers.factory import build_provider
|
||||
|
||||
provider = build_provider(settings)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
provider.warmup() # zbuduj/odśwież indeks i cache przy starcie
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
|
||||
|
||||
|
||||
@app.post("/search", response_model=SearchResult)
|
||||
def search(query: SearchQuery) -> SearchResult:
|
||||
return provider.search(query)
|
||||
|
||||
|
||||
@app.get("/health", response_model=HealthInfo)
|
||||
def health() -> HealthInfo:
|
||||
return provider.health()
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Kontrakt danych warstwy bazodanowej.
|
||||
|
||||
Te modele są JEDYNYM publicznym interfejsem tej warstwy. Warstwa logiczna zna
|
||||
wyłącznie te kształty (poprzez HTTP/JSON) — nie wie nic o Excelu, cache ani SQL.
|
||||
Dzięki temu można podmienić implementację (Excel -> SQL) bez zmiany pozostałych
|
||||
warstw.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SearchQuery(BaseModel):
|
||||
"""Znormalizowane zapytanie wyszukiwania przychodzące z warstwy logicznej."""
|
||||
|
||||
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
|
||||
value: str = Field(..., description="Szukana wartość.")
|
||||
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
|
||||
limit: int = Field(50, ge=1, le=50000)
|
||||
fields: list[str] | None = Field(
|
||||
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
|
||||
)
|
||||
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
"""Wynik wyszukiwania zwracany w górę do warstwy logicznej."""
|
||||
|
||||
rows: list[dict[str, Any]]
|
||||
total: int
|
||||
elapsed_ms: float
|
||||
cache: str = Field("miss", description="hit/miss/partial — skąd pochodzą dane.")
|
||||
provider: str = Field(..., description="Nazwa aktywnej implementacji, np. 'excel' lub 'sql'.")
|
||||
|
||||
|
||||
class HealthInfo(BaseModel):
|
||||
status: str = "ok"
|
||||
provider: str
|
||||
indexed_files: int = 0
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Abstrakcyjny interfejs dostawcy danych (wzorzec Repository/Strategy).
|
||||
|
||||
To jest klucz do "łatwej migracji do SQL". Warstwa bazodanowa udostępnia na
|
||||
zewnątrz tylko ten kontrakt. Dziś realizuje go ExcelDataProvider, jutro
|
||||
SqlDataProvider — bez żadnej zmiany w warstwie logicznej i prezentacji.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from app.models import HealthInfo, SearchQuery, SearchResult
|
||||
|
||||
|
||||
class DataProvider(ABC):
|
||||
name: str = "base"
|
||||
|
||||
@abstractmethod
|
||||
def search(self, query: SearchQuery) -> SearchResult:
|
||||
"""Wyszuka dane i zwróci je w górę. JEDYNE zadanie tej warstwy."""
|
||||
|
||||
@abstractmethod
|
||||
def health(self) -> HealthInfo:
|
||||
...
|
||||
|
||||
def warmup(self) -> None:
|
||||
"""Opcjonalne wstępne zbudowanie cache/indeksu przy starcie."""
|
||||
return None
|
||||
@@ -0,0 +1,148 @@
|
||||
"""ExcelDataProvider — wyszukiwanie w setkach plików .xlsx z 4-poziomowym cache.
|
||||
|
||||
Ścieżka zapytania (od najszybszej):
|
||||
1) QueryCache (in-memory) -> gotowy wynik
|
||||
2) InvertedIndex (SQLite) -> które pliki w ogóle otwierać (zamiast skanu setek)
|
||||
3) FrameCache (Parquet) -> wczytanie pliku bez parsowania .xlsx
|
||||
4) SchemaCache (SQLite) -> bez ponownego wykrywania nagłówka/układu kolumn
|
||||
...dopiero gdy wszystko spudłuje, czytamy .xlsx i wypełniamy cache.
|
||||
|
||||
Cała ta złożoność jest UKRYTA za interfejsem DataProvider.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from app.cache.fingerprint import fingerprint
|
||||
from app.cache.frame_cache import FrameCache
|
||||
from app.cache.index import InvertedIndex
|
||||
from app.cache.query_cache import QueryCache
|
||||
from app.cache.schema_cache import SchemaCache
|
||||
from app.config import Settings
|
||||
from app.excel.header_detect import detect_header_row
|
||||
from app.excel.layout import build_column_mapping
|
||||
from app.models import HealthInfo, SearchQuery, SearchResult
|
||||
from app.providers.base import DataProvider
|
||||
|
||||
|
||||
class ExcelDataProvider(DataProvider):
|
||||
name = "excel"
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.s = settings
|
||||
self.schema = SchemaCache(settings.cache_dir)
|
||||
self.frames = FrameCache(settings.cache_dir)
|
||||
self.index = InvertedIndex(settings.cache_dir)
|
||||
self.queries = QueryCache(settings.query_cache_size, settings.query_cache_ttl)
|
||||
|
||||
# ---- ładowanie pojedynczego arkusza z pełnym cache ----
|
||||
def _load_frame(self, path: str, sheet: str | int = 0) -> pd.DataFrame:
|
||||
fp = fingerprint(path)
|
||||
sheet_key = str(sheet)
|
||||
|
||||
cached = self.frames.get(fp, sheet_key) # poziom 2: Parquet
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
raw = pd.read_excel(path, sheet_name=sheet, header=None, dtype=object)
|
||||
meta = self.schema.get(fp, sheet_key) # poziom 1: schemat
|
||||
if meta is None:
|
||||
header_row = detect_header_row(raw, self.s.header_scan_rows)
|
||||
header_cells = [str(c) for c in raw.iloc[header_row].tolist()]
|
||||
mapping = build_column_mapping(header_cells)
|
||||
self.schema.put(fp, sheet_key, header_row, mapping)
|
||||
else:
|
||||
header_row, mapping = meta
|
||||
header_cells = [str(c) for c in raw.iloc[header_row].tolist()]
|
||||
|
||||
data = raw.iloc[header_row + 1 :].copy()
|
||||
data.columns = header_cells
|
||||
data = data.dropna(how="all")
|
||||
inverse = {orig: canon for canon, orig in mapping.items()}
|
||||
data = data.rename(columns=inverse).reset_index(drop=True)
|
||||
|
||||
self.frames.put(fp, sheet_key, data) # zapisz Parquet na przyszłość
|
||||
return data
|
||||
|
||||
# ---- budowa odwróconego indeksu (warmup / po zmianie pliku) ----
|
||||
def _ensure_indexed(self, path: str) -> None:
|
||||
fp = fingerprint(path)
|
||||
if self.index.file_fingerprint(path) == fp:
|
||||
return # aktualny
|
||||
frame = self._load_frame(path)
|
||||
rows: list[tuple[str, str, str]] = []
|
||||
for key in self.s.indexed_keys:
|
||||
if key in frame.columns:
|
||||
for v in frame[key].dropna().astype(str).unique():
|
||||
rows.append((key, v, "0"))
|
||||
self.index.reindex_file(path, fp, rows)
|
||||
|
||||
def warmup(self) -> None:
|
||||
for path in self._excel_files():
|
||||
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]:
|
||||
base = Path(self.s.excel_dir)
|
||||
return [str(p) for p in sorted(base.glob("**/*.xlsx")) if not p.name.startswith("~$")]
|
||||
|
||||
# ---- publiczne API ----
|
||||
def search(self, query: SearchQuery) -> SearchResult:
|
||||
t0 = time.perf_counter()
|
||||
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}"
|
||||
|
||||
hit = self.queries.get(cache_key) # poziom 3: wynik zapytania
|
||||
if hit is not None:
|
||||
hit = hit.model_copy(update={"cache": "hit", "elapsed_ms": _ms(t0)})
|
||||
return hit
|
||||
|
||||
candidates = self.index.lookup(query.key, query.value, query.exact)
|
||||
if not candidates:
|
||||
# brak w indeksie (np. klucz nieindeksowany) -> przeszukaj wszystkie pliki
|
||||
candidates = [(p, "0") for p in self._excel_files()]
|
||||
|
||||
rows: list[dict] = []
|
||||
for path, _sheet in candidates:
|
||||
frame = self._load_frame(path)
|
||||
if query.key not in frame.columns:
|
||||
continue
|
||||
col = frame[query.key].astype(str)
|
||||
if query.exact:
|
||||
mask = col.str.lower() == query.value.lower()
|
||||
else:
|
||||
# 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]
|
||||
if query.fields:
|
||||
keep = [c for c in query.fields if c in matched.columns]
|
||||
matched = matched[keep]
|
||||
rows.extend(matched.to_dict(orient="records"))
|
||||
if len(rows) >= query.limit:
|
||||
break
|
||||
|
||||
result = SearchResult(
|
||||
rows=rows[: query.limit],
|
||||
total=len(rows),
|
||||
elapsed_ms=_ms(t0),
|
||||
cache="miss",
|
||||
provider=self.name,
|
||||
)
|
||||
self.queries.put(cache_key, result)
|
||||
return result
|
||||
|
||||
def health(self) -> HealthInfo:
|
||||
return HealthInfo(
|
||||
provider=self.name,
|
||||
indexed_files=self.index.count_files(),
|
||||
details={"excel_dir": str(self.s.excel_dir), "files_on_disk": len(self._excel_files())},
|
||||
)
|
||||
|
||||
|
||||
def _ms(t0: float) -> float:
|
||||
return round((time.perf_counter() - t0) * 1000, 2)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Fabryka dostawcy danych — jedyne miejsce, które wie o konkretnych implementacjach.
|
||||
|
||||
Przełączenie Excel <-> SQL: ustaw DATA_PROVIDER w środowisku. Nic poza tym.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.config import Settings
|
||||
from app.providers.base import DataProvider
|
||||
|
||||
|
||||
def build_provider(settings: Settings) -> DataProvider:
|
||||
if settings.provider == "sql":
|
||||
from app.providers.sql_provider import SqlDataProvider
|
||||
|
||||
return SqlDataProvider(settings)
|
||||
|
||||
from app.providers.excel_provider import ExcelDataProvider
|
||||
|
||||
return ExcelDataProvider(settings)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""SqlDataProvider — implementacja docelowa (po migracji z Excela).
|
||||
|
||||
Szkielet. Realizuje TEN SAM interfejs DataProvider, więc przełączenie to tylko
|
||||
zmiana zmiennej środowiskowej DATA_PROVIDER=sql (patrz factory.py). Warstwa
|
||||
logiczna i prezentacji nie zmieniają ani jednej linii.
|
||||
|
||||
Dane ładuje do bazy skrypt ingest/to_sql.py (ten sam loader Excela -> tabele SQL).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
from app.config import Settings
|
||||
from app.models import HealthInfo, SearchQuery, SearchResult
|
||||
from app.providers.base import DataProvider
|
||||
|
||||
|
||||
class SqlDataProvider(DataProvider):
|
||||
name = "sql"
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.s = settings
|
||||
self.engine = create_engine(settings.sql_url, future=True)
|
||||
|
||||
def search(self, query: SearchQuery) -> SearchResult:
|
||||
t0 = time.perf_counter()
|
||||
op = "=" if query.exact else "LIKE"
|
||||
val = query.value if query.exact else f"%{query.value}%"
|
||||
cols = ", ".join(query.fields) if query.fields else "*"
|
||||
# UWAGA: nazwy kolumn/tabel walidować względem białej listy schematu.
|
||||
sql = text(f"SELECT {cols} FROM records WHERE {query.key} {op} :v LIMIT :lim")
|
||||
with self.engine.connect() as conn:
|
||||
rows = [dict(r._mapping) for r in conn.execute(sql, {"v": val, "lim": query.limit})]
|
||||
return SearchResult(
|
||||
rows=rows,
|
||||
total=len(rows),
|
||||
elapsed_ms=round((time.perf_counter() - t0) * 1000, 2),
|
||||
cache="miss",
|
||||
provider=self.name,
|
||||
)
|
||||
|
||||
def health(self) -> HealthInfo:
|
||||
with self.engine.connect() as conn:
|
||||
n = conn.execute(text("SELECT COUNT(*) FROM records")).scalar() or 0
|
||||
return HealthInfo(provider=self.name, indexed_files=0, details={"records": int(n)})
|
||||
@@ -0,0 +1,9 @@
|
||||
# Dolne ograniczenia (>=) — działa zarówno na Pythonie 3.12 (obraz Docker),
|
||||
# jak i na najnowszym 3.14 lokalnie. Przypnij dokładne wersje, gdy ustabilizujesz środowisko.
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.34
|
||||
pandas>=2.2
|
||||
openpyxl>=3.1
|
||||
pyarrow>=18.0
|
||||
SQLAlchemy>=2.0
|
||||
pydantic>=2.10
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Generuje kilka przykładowych plików .xlsx do dema.
|
||||
|
||||
Celowo różnicuje: pozycję nagłówka (puste wiersze/tytuł nad nagłówkiem) oraz
|
||||
kolejność i nazwy kolumn ("Imię"/"Name", "Symbol"/"Znak") — żeby pokazać działanie
|
||||
wykrywania nagłówka i mapowania układu kolumn.
|
||||
|
||||
python scripts/make_sample_data.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
OUT = Path(__file__).resolve().parent.parent / "data_files"
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
SIGNS = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo"]
|
||||
|
||||
|
||||
def file_a() -> None:
|
||||
# nagłówek w 1. wierszu, nazwy PL
|
||||
df = pd.DataFrame(
|
||||
{"id": [1, 2, 3], "Imię": SIGNS[:3], "Symbol": ["♈", "♉", "♊"], "Wartość": [10, 20, 30]}
|
||||
)
|
||||
df.to_excel(OUT / "zodiac_pl.xlsx", index=False)
|
||||
|
||||
|
||||
def file_b() -> None:
|
||||
# tytuł + pusty wiersz nad nagłówkiem, nazwy EN, inna kolejność kolumn
|
||||
with pd.ExcelWriter(OUT / "zodiac_en.xlsx") as xl:
|
||||
meta = pd.DataFrame([["Tabela astrologiczna — wersja 2"], [None]])
|
||||
meta.to_excel(xl, index=False, header=False, startrow=0)
|
||||
df = pd.DataFrame(
|
||||
{"Sign": ["♋", "♌", "♍"], "Name": SIGNS[3:], "No": [4, 5, 6], "Value": [40, 50, 60]}
|
||||
)
|
||||
df.to_excel(xl, index=False, startrow=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
file_a()
|
||||
file_b()
|
||||
print(f"Zapisano przykładowe pliki w {OUT}")
|
||||
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8001
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
@@ -0,0 +1,47 @@
|
||||
# Warstwa logiczna (`logic`)
|
||||
|
||||
Niezależna usługa pośrednicząca. **W górę** udostępnia API dla prezentacji,
|
||||
**w dół** woła warstwę bazodanową. Tu żyją reguły biznesowe — nie w prezentacji
|
||||
i nie w bazie.
|
||||
|
||||
## API
|
||||
- `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ą)
|
||||
|
||||
## 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ół
|
||||
Zna wyłącznie `DATA_URL` (adres warstwy bazodanowej) i jej kontrakt `/search`.
|
||||
Nie wie, czy pod spodem jest Excel czy SQL.
|
||||
|
||||
## Uruchomienie
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
export DATA_URL=http://localhost:8002
|
||||
uvicorn app.main:app --port 8001
|
||||
```
|
||||
|
||||
## Gdzie rozbudowywać domenę
|
||||
`service.py` → `QueryService.handle()`: walidacja wejścia, tłumaczenie zapytania,
|
||||
obliczenia i wzbogacanie wyników.
|
||||
@@ -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
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Klient HTTP do warstwy bazodanowej.
|
||||
|
||||
Jedyny punkt styku w dół. Gdyby warstwa bazodanowa zmieniła implementację
|
||||
(Excel→SQL), tutaj nie zmienia się NIC — kontrakt /search jest stały.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class DataClient:
|
||||
def __init__(self, base_url: str | None = None) -> None:
|
||||
self.base_url = (base_url or settings.data_url).rstrip("/")
|
||||
|
||||
def search(
|
||||
self,
|
||||
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.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
r = client.get(f"{self.base_url}/health")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Konfiguracja warstwy logicznej.
|
||||
|
||||
Zna TYLKO adres warstwy bazodanowej (w dół). Nie wie nic o jej wnętrzu
|
||||
(Excel/SQL/cache).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
data_url: str = field(default_factory=lambda: os.getenv("DATA_URL", "http://localhost:8002"))
|
||||
http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "10")))
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -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
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Warstwa LOGICZNA — usługa HTTP.
|
||||
|
||||
W górę: udostępnia API dla warstwy prezentacji.
|
||||
W dół: woła warstwę bazodanową (DataClient).
|
||||
Nie serwuje HTML, nie czyta plików/baz — tylko reguły i pośrednictwo.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.clients.data_client import DataClient
|
||||
from app.models import QueryRequest, QueryResponse
|
||||
from app.service import QueryService
|
||||
|
||||
app = FastAPI(title="astrololo · warstwa logiczna")
|
||||
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)
|
||||
def query(req: QueryRequest) -> QueryResponse:
|
||||
try:
|
||||
return service.handle(req)
|
||||
except httpx.HTTPError as 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")
|
||||
def health() -> dict:
|
||||
info = {"status": "ok", "layer": "logic"}
|
||||
try:
|
||||
info["data_layer"] = DataClient().health()
|
||||
except httpx.HTTPError as e:
|
||||
info["data_layer"] = {"status": "down", "error": str(e)}
|
||||
return info
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Kontrakt warstwy logicznej (widziany przez warstwę prezentacji)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class QueryRequest(BaseModel):
|
||||
"""To, co przychodzi z formularza (przez warstwę prezentacji)."""
|
||||
|
||||
query: str = Field(..., min_length=1, description="Szukana fraza.")
|
||||
field: str = Field("name", description="Po którym polu szukać.")
|
||||
exact: bool = False
|
||||
limit: int = Field(25, ge=1, le=200)
|
||||
|
||||
|
||||
class QueryResponse(BaseModel):
|
||||
"""To, co wraca w górę do prezentacji."""
|
||||
|
||||
status: str = "ok"
|
||||
query: str
|
||||
count: int
|
||||
results: list[dict[str, Any]]
|
||||
meta: dict[str, Any] = Field(default_factory=dict)
|
||||
@@ -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,43 @@
|
||||
"""Logika biznesowa — serce warstwy logicznej.
|
||||
|
||||
Tu (a nie w prezentacji ani w bazie) żyją reguły: walidacja/normalizacja danych
|
||||
z formularza, tłumaczenie zapytania użytkownika na znormalizowane zapytanie do
|
||||
bazy, oraz opracowanie/wzbogacenie wyników w drodze w górę.
|
||||
|
||||
To jest miejsce do rozbudowy o właściwą domenę (obliczenia, reguły, agregacje).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.clients.data_client import DataClient
|
||||
from app.models import QueryRequest, QueryResponse
|
||||
|
||||
|
||||
class QueryService:
|
||||
def __init__(self, data_client: DataClient | None = None) -> None:
|
||||
self.data = data_client or DataClient()
|
||||
|
||||
def handle(self, req: QueryRequest) -> QueryResponse:
|
||||
# 1) normalizacja wejścia z formularza (reguła biznesowa)
|
||||
value = req.query.strip()
|
||||
key = req.field.strip().lower()
|
||||
|
||||
# 2) zapytanie w dół do warstwy bazodanowej
|
||||
raw = self.data.search(key=key, value=value, exact=req.exact, limit=req.limit)
|
||||
|
||||
# 3) opracowanie wyników w górę (tu można liczyć/wzbogacać/sortować)
|
||||
results = raw.get("rows", [])
|
||||
results = sorted(results, key=lambda r: str(r.get(key, "")))
|
||||
|
||||
return QueryResponse(
|
||||
status="ok",
|
||||
query=value,
|
||||
count=len(results),
|
||||
results=results,
|
||||
meta={
|
||||
"field": key,
|
||||
"exact": req.exact,
|
||||
"data_cache": raw.get("cache"),
|
||||
"data_provider": raw.get("provider"),
|
||||
"data_elapsed_ms": raw.get("elapsed_ms"),
|
||||
},
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.34
|
||||
httpx>=0.28
|
||||
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
|
||||
@@ -0,0 +1,10 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Warstwa prezentacji (`presentation`)
|
||||
|
||||
Niezależna usługa serwująca stronę WWW (formularz + tabela wyników). **W dół**
|
||||
przekazuje dane z formularza do warstwy logicznej i renderuje opracowane wyniki.
|
||||
Brak logiki biznesowej i dostępu do danych.
|
||||
|
||||
## Trasy
|
||||
- `GET /` — **Horoskop**: formularz danych momentu (data, godzina, strefa, opcjonalnie lokalizacja)
|
||||
- `POST /` — liczy pozycje obiektów (woła logic `/chart/positions`) i renderuje czytelną tabelę
|
||||
- `GET /significators`, `POST /significators` — wyszukiwarka w bazach interpretacji (warstwa danych)
|
||||
- `GET /health`
|
||||
|
||||
Strona „Horoskop" to widok do **ręcznego testowania**: podstawowe dane wejściowe →
|
||||
to, co policzyła warstwa logiczna (znak, pozycja w znaku, absolutna, kierunek, prędkość).
|
||||
Przycisk „Tu i teraz" uzupełnia bieżącą datę/godzinę i strefę przeglądarki.
|
||||
|
||||
## Zależności w dół
|
||||
Zna wyłącznie `LOGIC_URL` (adres warstwy logicznej) i jej kontrakty `/chart/positions`
|
||||
oraz `/api/query`. Nie wie nic o silniku ani bazach.
|
||||
|
||||
> Uwaga: pełne liczenie pozycji wymaga warstwy logicznej z silnikiem efemeryd
|
||||
> (gałąź `feat/logic-engine`). Bez niego strona „Horoskop" pokaże czytelny komunikat.
|
||||
|
||||
## Uruchomienie
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
export LOGIC_URL=http://localhost:8001
|
||||
uvicorn app.main:app --port 8000
|
||||
# otwórz http://localhost:8000
|
||||
```
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Klient HTTP do warstwy logicznej.
|
||||
|
||||
Jedyny punkt styku prezentacji w dół. Przekazuje dane z formularza i odbiera
|
||||
opracowane wyniki. Prezentacja nie sięga bezpośrednio do bazy ani do silnika.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class LogicClient:
|
||||
def __init__(self, base_url: str | None = None) -> None:
|
||||
self.base_url = (base_url or settings.logic_url).rstrip("/")
|
||||
|
||||
def query(self, query: str, field: str, exact: bool, limit: int) -> dict[str, Any]:
|
||||
payload = {"query": query, "field": field, "exact": exact, "limit": limit}
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
r = client.post(f"{self.base_url}/api/query", json=payload)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def positions(
|
||||
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]:
|
||||
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
||||
payload = {
|
||||
"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.raise_for_status()
|
||||
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()
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Konfiguracja warstwy prezentacji.
|
||||
|
||||
Zna TYLKO adres warstwy logicznej (w dół). Nie wie nic o bazie/Excelu/SQL.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
logic_url: str = field(default_factory=lambda: os.getenv("LOGIC_URL", "http://localhost:8001"))
|
||||
http_timeout: float = field(default_factory=lambda: float(os.getenv("HTTP_TIMEOUT", "10")))
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Warstwa PREZENTACJI — usługa HTTP serwująca stronę WWW.
|
||||
|
||||
W dół: przekazuje dane z formularza do warstwy logicznej i odbiera opracowane
|
||||
wyniki. Nie zawiera logiki biznesowej ani dostępu do danych — tylko UI.
|
||||
|
||||
Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych pozycji
|
||||
(do ręcznego testowania aplikacji). Wyszukiwarka sygnifikatorów przeniesiona pod
|
||||
„/significators".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Form, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.clients.logic_client import LogicClient
|
||||
|
||||
app = FastAPI(title="astrololo · warstwa prezentacji")
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
logic = LogicClient()
|
||||
|
||||
|
||||
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
||||
"""Z lokalnej daty/godziny + przesunięcia strefy → moment UTC.
|
||||
|
||||
Zwraca (iso_utc, etykieta_czytelna). UTC = czas lokalny − offset.
|
||||
"""
|
||||
local = datetime.fromisoformat(f"{date}T{time}")
|
||||
utc = (local - timedelta(hours=tz_offset)).replace(tzinfo=timezone.utc)
|
||||
label = utc.strftime("%Y-%m-%d %H:%M UTC")
|
||||
return utc.isoformat(), label
|
||||
|
||||
|
||||
def _logic_error(e: Exception) -> str:
|
||||
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
|
||||
return (
|
||||
"Warstwa logiczna działa, ale nie ma endpointu /chart/positions. "
|
||||
"Uruchom warstwę logiczną z silnikiem efemeryd (gałąź feat/logic-engine)."
|
||||
)
|
||||
return f"Warstwa logiczna niedostępna: {e}"
|
||||
|
||||
|
||||
# ---------------- Horoskop: pozycje (strona główna) ----------------
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def chart_form(request: Request):
|
||||
return templates.TemplateResponse(request, "chart.html", {"result": None, "form": {}})
|
||||
|
||||
|
||||
@app.post("/", response_class=HTMLResponse)
|
||||
def chart_compute(
|
||||
request: Request,
|
||||
date: str = Form(...),
|
||||
time: str = Form(...),
|
||||
tz_offset: float = Form(0.0),
|
||||
lat: 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, "house_system": house_system, "stations": stations}
|
||||
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.positions(
|
||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
house_system=house_system, stations=stations,
|
||||
)
|
||||
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, "chart.html", ctx)
|
||||
|
||||
|
||||
# ---------------- Sygnifikatory (wyszukiwarka w bazach) ----------------
|
||||
@app.get("/significators", response_class=HTMLResponse)
|
||||
def significators_form(request: Request):
|
||||
return templates.TemplateResponse(request, "significators.html", {"result": None, "form": {}})
|
||||
|
||||
|
||||
@app.post("/significators", response_class=HTMLResponse)
|
||||
def significators_search(
|
||||
request: Request,
|
||||
query: str = Form(...),
|
||||
field: str = Form("name"),
|
||||
exact: bool = Form(False),
|
||||
limit: int = Form(25),
|
||||
):
|
||||
form = {"query": query, "field": field, "exact": exact, "limit": limit}
|
||||
ctx: dict = {"form": form, "result": None, "error": None}
|
||||
try:
|
||||
ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit)
|
||||
except httpx.HTTPError as e:
|
||||
ctx["error"] = _logic_error(e)
|
||||
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": {}})
|
||||
|
||||
|
||||
@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": {}})
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok", "layer": "presentation"}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user