Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
+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,75 @@
|
||||
# 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.
|
||||
|
||||
@@ -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,10 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
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,24 @@
|
||||
# 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.
|
||||
|
||||
## Uruchomienie (tylko profil porównawczy / dev / CI)
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
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,45 @@
|
||||
# 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). 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/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,58 @@
|
||||
"""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",
|
||||
# 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,53 @@
|
||||
"""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) -> 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)
|
||||
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,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,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,183 @@
|
||||
"""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}
|
||||
|
||||
|
||||
@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,197 @@
|
||||
"""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",
|
||||
}
|
||||
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}
|
||||
@@ -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,31 @@
|
||||
"""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 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
|
||||
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,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,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,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,58 @@
|
||||
"""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()
|
||||
@@ -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,137 @@
|
||||
"""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)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok", "layer": "presentation"}
|
||||
@@ -0,0 +1,43 @@
|
||||
// „Tu i teraz": uzupełnia datę/godzinę/strefę z przeglądarki oraz — jeśli to
|
||||
// możliwe — lokalizację (lat/lon).
|
||||
//
|
||||
// UWAGA: geolokalizacja przeglądarki działa tylko w "secure context"
|
||||
// (https:// lub localhost). Na http://<ip> przeglądarka NIE pyta o zgodę,
|
||||
// tylko po cichu odmawia — dlatego pokazujemy jawny komunikat w #geoNote.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const btn = document.getElementById('nowBtn');
|
||||
if (!btn) return;
|
||||
|
||||
const note = document.getElementById('geoNote');
|
||||
const say = msg => { if (note) note.textContent = msg; };
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
const d = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
document.querySelector('input[name=date]').value =
|
||||
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
||||
document.querySelector('input[name=time]').value = pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
document.querySelector('input[name=tz_offset]').value = (-d.getTimezoneOffset() / 60);
|
||||
|
||||
if (!('geolocation' in navigator)) {
|
||||
say('Ta przeglądarka nie udostępnia geolokalizacji — wpisz lat/lon ręcznie.');
|
||||
return;
|
||||
}
|
||||
if (!window.isSecureContext) {
|
||||
say('Lokalizacja z przeglądarki wymaga HTTPS lub localhost (otwarto przez http://) — wpisz lat/lon ręcznie.');
|
||||
return;
|
||||
}
|
||||
say('Pobieram lokalizację…');
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
function (pos) {
|
||||
document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4);
|
||||
document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4);
|
||||
say('Lokalizacja pobrana ✓');
|
||||
},
|
||||
function (err) {
|
||||
say('Nie udało się pobrać lokalizacji: ' + (err && err.message ? err.message : 'odmowa dostępu'));
|
||||
},
|
||||
{ timeout: 8000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
:root {
|
||||
--bg: #0f1020;
|
||||
--panel: #1a1c33;
|
||||
--ink: #e8e8f0;
|
||||
--muted: #9aa0c0;
|
||||
--accent: #8b7bf0;
|
||||
--line: #2a2d4a;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink);
|
||||
font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
display: flex; justify-content: center; padding: 3rem 1rem;
|
||||
}
|
||||
main { width: 100%; max-width: 880px; }
|
||||
h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
|
||||
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
||||
|
||||
.topbar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: .25rem; }
|
||||
.topbar nav { display: flex; gap: .25rem; }
|
||||
.topbar nav a { color: var(--muted); text-decoration: none; padding: .4rem .8rem; border-radius: 8px; border: 1px solid transparent; }
|
||||
.topbar nav a:hover { color: var(--ink); }
|
||||
.topbar nav a.active { color: #fff; background: var(--panel); border-color: var(--line); }
|
||||
.foot { color: var(--muted); font-size: .8rem; margin-top: 2.5rem; opacity: .7; }
|
||||
|
||||
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
|
||||
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
|
||||
.grid input { width: 100%; }
|
||||
.loc { margin-top: .8rem; }
|
||||
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
|
||||
.actions { display: flex; gap: .5rem; margin-top: 1rem; }
|
||||
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||
td.retro { color: #ff9b6a; font-weight: 600; }
|
||||
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
||||
.row { display: flex; gap: .5rem; }
|
||||
.row input[type=text] { flex: 1; }
|
||||
input, select, button {
|
||||
font: inherit; padding: .6rem .75rem; border-radius: 8px;
|
||||
border: 1px solid var(--line); background: #12132a; color: var(--ink);
|
||||
}
|
||||
button { background: var(--accent); color: #fff; border: none; cursor: pointer; padding-inline: 1.25rem; }
|
||||
button:hover { filter: brightness(1.1); }
|
||||
.opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; }
|
||||
.opts input[type=number] { width: 5rem; }
|
||||
.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
|
||||
.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
|
||||
.empty { color: var(--muted); }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; }
|
||||
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
|
||||
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: .85rem; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.sig-item { margin-top: 1.25rem; }
|
||||
.sig-head { padding: .5rem 0; }
|
||||
.samples { margin-top: .3rem; }
|
||||
.samples td { font-size: .92rem; vertical-align: top; }
|
||||
.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
|
||||
.facet { margin: .4rem 0 .6rem 1rem; }
|
||||
.facet-head { color: var(--ink); font-size: .9rem; }
|
||||
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
|
||||
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
|
||||
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
|
||||
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>astrololo · {% block title %}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header class="topbar">
|
||||
<h1>astrololo</h1>
|
||||
<nav>
|
||||
<a href="/" class="{% block nav_chart %}{% endblock %}">Horoskop</a>
|
||||
<a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a>
|
||||
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
|
||||
</nav>
|
||||
</header>
|
||||
{% block content %}{% endblock %}
|
||||
<footer class="foot">
|
||||
prezentacja → logika → dane · widok testowy
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,136 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Horoskop{% endblock %}
|
||||
{% block nav_chart %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
|
||||
|
||||
<form method="post" action="/">
|
||||
<div class="grid">
|
||||
<label>Data
|
||||
<input type="date" name="date" value="{{ form.date or '' }}" required>
|
||||
</label>
|
||||
<label>Godzina (lokalna)
|
||||
<input type="time" name="time" value="{{ form.time or '' }}" required>
|
||||
</label>
|
||||
<label>Strefa (offset h)
|
||||
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<label>Szerokość (lat, + N)
|
||||
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
|
||||
</label>
|
||||
<label>Długość (lon, + E)
|
||||
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
||||
</label>
|
||||
<label>System domów
|
||||
<select name="house_system">
|
||||
{% set hs = form.house_system or 'whole_sign' %}
|
||||
<option value="whole_sign" {{ 'selected' if hs == 'whole_sign' else '' }}>Whole Sign</option>
|
||||
<option value="equal" {{ 'selected' if hs == 'equal' else '' }}>Equal</option>
|
||||
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="opts">
|
||||
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
|
||||
licz stacje planet (wolniejsze)</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||
<button type="submit">Policz horoskop</button>
|
||||
<span id="geoNote" class="muted small"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result %}
|
||||
<div class="meta">
|
||||
Silnik: <strong>{{ result.engine }}</strong> ·
|
||||
obiektów: {{ result.positions | length }}
|
||||
{% if result.house_system %}· domy: {{ result.house_system }}{% endif %}
|
||||
{% if moment %}· moment: {{ moment }}{% endif %}
|
||||
</div>
|
||||
|
||||
{% if result.angles %}
|
||||
<table class="angles">
|
||||
<thead><tr><th>Oś</th><th>Znak</th><th>W znaku</th></tr></thead>
|
||||
<tbody>
|
||||
{% for key in ["Asc", "MC", "Dsc", "IC"] %}
|
||||
{% set a = result.angles[key] %}
|
||||
<tr><td>{{ a.name }}</td><td>{{ a.sign }}</td><td class="mono">{{ a.in_sign }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in result.positions %}
|
||||
<tr>
|
||||
<td>{{ p.name }}</td>
|
||||
<td>{{ p.sign }}</td>
|
||||
<td class="mono">{{ p.in_sign }}</td>
|
||||
<td>{{ p.house if p.house is defined else '—' }}</td>
|
||||
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
|
||||
<td class="mono">{{ '%+.4f' | format(p.speed) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% set with_stations = result.positions | selectattr('stations', 'defined') | list %}
|
||||
{% if with_stations %}
|
||||
<div class="meta">Stacje planet (poprzednia / następna; <span class="badge">blisko</span> = mniej niż 7 dni)</div>
|
||||
<table class="angles">
|
||||
<thead><tr><th>Planeta</th><th>Poprzednia</th><th>Następna</th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in with_stations %}
|
||||
<tr>
|
||||
<td>{{ p.name }}{% if p.stations.station_soon %} <span class="badge">blisko</span>{% endif %}</td>
|
||||
<td class="mono">{% if p.stations.prev %}{{ p.stations.prev.type }} · {{ p.stations.prev.date }} · {{ p.stations.prev.degree }} ({{ p.stations.prev.days }} d){% else %}—{% endif %}</td>
|
||||
<td class="mono">{% if p.stations.next %}{{ p.stations.next.type }} · {{ p.stations.next.date }} · {{ p.stations.next.degree }} (+{{ p.stations.next.days }} d){% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if result.aspects %}
|
||||
<div class="meta">Aspekty główne ({{ result.aspects | length }})</div>
|
||||
<table class="angles">
|
||||
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
|
||||
<tbody>
|
||||
{% for a in result.aspects %}
|
||||
<tr><td>{{ a.obj1 }}</td><td>{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if result.cusps %}
|
||||
<details class="loc">
|
||||
<summary>Cusps domów ({{ result.house_system }})</summary>
|
||||
<table>
|
||||
<thead><tr><th>Dom</th><th>Znak</th><th>Cusp</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in result.cusps %}
|
||||
<tr><td>{{ c.house }}</td><td>{{ c.sign }}</td><td class="mono">{{ c.in_sign }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<script src="/static/now.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,87 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Interpretacje{% endblock %}
|
||||
{% block nav_interp %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
|
||||
|
||||
<form method="post" action="/interpret">
|
||||
<div class="grid">
|
||||
<label>Data
|
||||
<input type="date" name="date" value="{{ form.date or '' }}" required>
|
||||
</label>
|
||||
<label>Godzina (lokalna)
|
||||
<input type="time" name="time" value="{{ form.time or '' }}" required>
|
||||
</label>
|
||||
<label>Strefa (offset h)
|
||||
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
|
||||
</label>
|
||||
<label>Szerokość (lat)
|
||||
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
|
||||
</label>
|
||||
<label>Długość (lon)
|
||||
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
||||
</label>
|
||||
</div>
|
||||
<div class="opts">
|
||||
<label><input type="checkbox" name="group" value="true" {{ 'checked' if form.group else '' }}> grupuj identyczne opisy</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||
<button type="submit">Szukaj interpretacji</button>
|
||||
<span id="geoNote" class="muted small"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||
|
||||
{% if result %}
|
||||
{% if result.data_error %}
|
||||
<div class="error">{{ result.data_error }}</div>
|
||||
{% endif %}
|
||||
<div class="meta">
|
||||
Silnik: <strong>{{ result.engine }}</strong>
|
||||
{% if result.provider %}· baza: {{ result.provider }}{% endif %}
|
||||
{% if moment %}· moment: {{ moment }}{% endif %}
|
||||
</div>
|
||||
|
||||
{% for o in result.objects %}
|
||||
<div class="sig-item">
|
||||
<div class="sig-head">
|
||||
<strong>{{ o.object }}</strong> w <strong>{{ o.sign }}</strong>{% if o.house %}, {{ o.house }}. dom{% endif %}
|
||||
<span class="{{ 'retro' if o.direction == 'Rx' else '' }}">{{ o.direction }}</span>
|
||||
<span class="muted small">(planeta {{ o.planet_token }}: {{ o.planet_total }} rekordów)</span>
|
||||
</div>
|
||||
{% for f in o.facets %}
|
||||
<div class="facet">
|
||||
<div class="facet-head">{{ f.label }} — <strong>{{ f.count }}</strong> dopasowań
|
||||
<span class="badge" title="siła (LOG-21)">siła {{ f.score }}</span>
|
||||
<span class="muted small">[{{ f.token }}]</span></div>
|
||||
{% if f.groups %}
|
||||
<table class="samples">
|
||||
<tbody>
|
||||
{% for g in f.groups %}
|
||||
<tr>
|
||||
<td>{{ g.effect }}{% if g.count > 1 %} <span class="badge">×{{ g.count }}</span>{% endif %}</td>
|
||||
<td class="sig small">{{ g.significators | join('; ') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% elif f.samples %}
|
||||
<table class="samples">
|
||||
<tbody>
|
||||
{% for s in f.samples %}
|
||||
<tr><td class="sig nowrap" title="{{ s.significator }}">{{ s.expanded }}</td><td>{{ s.effect }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<script src="/static/now.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sygnifikatory{% endblock %}
|
||||
{% block nav_sig %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p>
|
||||
|
||||
<form method="post" action="/significators">
|
||||
<div class="row">
|
||||
<input type="text" name="query" placeholder="Szukana fraza…"
|
||||
value="{{ form.query or '' }}" autofocus required>
|
||||
<select name="field">
|
||||
{% for f in ["name", "id", "symbol", "category", "value"] %}
|
||||
<option value="{{ f }}" {{ 'selected' if form.field == f else '' }}>{{ f }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Szukaj</button>
|
||||
</div>
|
||||
<div class="opts">
|
||||
<label><input type="checkbox" name="exact" value="true"
|
||||
{{ 'checked' if form.exact else '' }}> dokładne</label>
|
||||
<label>limit
|
||||
<input type="number" name="limit" min="1" max="200" value="{{ form.limit or 25 }}">
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result %}
|
||||
<div class="meta">
|
||||
Znaleziono <strong>{{ result.count }}</strong> ·
|
||||
provider: {{ result.meta.data_provider }} ·
|
||||
cache: {{ result.meta.data_cache }} ·
|
||||
{{ result.meta.data_elapsed_ms }} ms
|
||||
</div>
|
||||
{% if result.results %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>{% for col in result.results[0].keys() %}<th>{{ col }}</th>{% endfor %}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in result.results %}
|
||||
<tr>{% for v in row.values() %}<td>{{ v }}</td>{% endfor %}</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty">Brak wyników.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,5 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.34
|
||||
httpx>=0.28
|
||||
jinja2>=3.1
|
||||
python-multipart>=0.0.20
|
||||
Reference in New Issue
Block a user