Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6323cac10 | |||
| 243d02d55b | |||
| 166b438f83 | |||
| 41d7ca491d | |||
| 6acd3546fb | |||
| 82809665ff | |||
| 0e74566a78 | |||
| cea6907f84 | |||
| dabbaef9dd | |||
| a81ae698a3 | |||
| eef67d37b5 | |||
| 94c3023d3a | |||
| e4f6a16c9c | |||
| 413c46b5dd | |||
| 340b3058e4 | |||
| 376a4bfded | |||
| 3195d9b003 | |||
| e69c0714b9 | |||
| 9bf297d461 | |||
| 561d95c7f4 | |||
| 4231f640b8 | |||
| f86ab55b29 | |||
| 21442b35b9 | |||
| 3655b39acf | |||
| 7bda39ab46 | |||
| 269c89152e |
@@ -10,6 +10,10 @@ services/data/.cache/
|
|||||||
*.parquet
|
*.parquet
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
|
# Cache efemeryd silnika własnego (pobierane jądra JPL, regenerowalne)
|
||||||
|
services/logic/.ephemeris/
|
||||||
|
*.bsp
|
||||||
|
|
||||||
# Dane wejściowe (duże pliki Excela trzymane poza repo)
|
# Dane wejściowe (duże pliki Excela trzymane poza repo)
|
||||||
services/data/data_files/*.xlsx
|
services/data/data_files/*.xlsx
|
||||||
!services/data/data_files/.gitkeep
|
!services/data/data_files/.gitkeep
|
||||||
|
|||||||
@@ -1,31 +1,56 @@
|
|||||||
.PHONY: help up down sample reindex migrate sql dev-data dev-logic dev-presentation
|
.PHONY: help install up down sample reindex migrate sql test clean-cache \
|
||||||
|
dev-data dev-logic dev-presentation
|
||||||
|
|
||||||
|
# Autodetekcja narzędzia compose: nowe "docker compose" albo stare "docker-compose".
|
||||||
|
COMPOSE := $(shell if docker compose version >/dev/null 2>&1; then echo "docker compose"; \
|
||||||
|
elif command -v docker-compose >/dev/null 2>&1; then echo "docker-compose"; fi)
|
||||||
|
|
||||||
|
# Python aktywnego środowiska (venv). Nadpisz: make install PY=python3.12
|
||||||
|
PY ?= python
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "up - uruchom wszystkie 3 warstwy (docker compose)"
|
@echo "install - zainstaluj zależności WSZYSTKICH warstw do aktywnego venv"
|
||||||
@echo "down - zatrzymaj"
|
|
||||||
@echo "sample - wygeneruj przykładowe pliki .xlsx"
|
@echo "sample - wygeneruj przykładowe pliki .xlsx"
|
||||||
|
@echo "dev-data - uruchom warstwę danych lokalnie (:8002)"
|
||||||
|
@echo "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 "reindex - zbuduj cache + indeks warstwy bazodanowej"
|
||||||
@echo "migrate - ETL: Excel -> SQL"
|
@echo "migrate - ETL: Excel -> SQL"
|
||||||
@echo "sql - uruchom z warstwą SQL (DATA_PROVIDER=sql)"
|
@echo "up / down - uruchom / zatrzymaj cały stos (Docker; wykryto: $(COMPOSE))"
|
||||||
@echo "dev-* - uruchom pojedynczą warstwę lokalnie (bez dockera)"
|
@echo "sql - Docker z warstwą SQL (DATA_PROVIDER=sql)"
|
||||||
|
@echo ""
|
||||||
|
@echo "Szybki start lokalny (bez Dockera), w 3 terminalach:"
|
||||||
|
@echo " make install"
|
||||||
|
@echo " make sample # opcjonalnie: dane przykładowe"
|
||||||
|
@echo " make dev-data # terminal 1"
|
||||||
|
@echo " make dev-logic # terminal 2"
|
||||||
|
@echo " make dev-presentation # terminal 3 -> http://localhost:8000"
|
||||||
|
|
||||||
up:
|
# --- instalacja zależności do aktywnego środowiska ---
|
||||||
docker compose up --build
|
install:
|
||||||
|
$(PY) -m pip install \
|
||||||
down:
|
-r services/data/requirements.txt \
|
||||||
docker compose down
|
-r services/logic/requirements.txt \
|
||||||
|
-r services/presentation/requirements.txt
|
||||||
|
@echo "OK. Zależności warstw zainstalowane. (Silnik AGPL 'engine-swisseph' instaluje się osobno.)"
|
||||||
|
|
||||||
sample:
|
sample:
|
||||||
cd services/data && python scripts/make_sample_data.py
|
cd services/data && $(PY) scripts/make_sample_data.py
|
||||||
|
|
||||||
reindex:
|
reindex:
|
||||||
cd services/data && python -m app.ingest.build_index
|
cd services/data && $(PY) -m app.ingest.build_index
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
cd services/data && python -m app.ingest.to_sql
|
cd services/data && $(PY) -m app.ingest.to_sql
|
||||||
|
|
||||||
sql:
|
test:
|
||||||
DATA_PROVIDER=sql docker compose up --build
|
cd services/logic && PYTHONPATH=. $(PY) -m pytest tests -q
|
||||||
|
|
||||||
|
clean-cache:
|
||||||
|
rm -rf services/data/.cache/* services/logic/.ephemeris/* 2>/dev/null || true
|
||||||
|
@echo "Cache wyczyszczony."
|
||||||
|
|
||||||
# --- lokalny dev (3 osobne terminale) ---
|
# --- lokalny dev (3 osobne terminale) ---
|
||||||
dev-data:
|
dev-data:
|
||||||
@@ -36,3 +61,20 @@ dev-logic:
|
|||||||
|
|
||||||
dev-presentation:
|
dev-presentation:
|
||||||
cd services/presentation && LOGIC_URL=http://localhost:8001 uvicorn app.main:app --reload --port 8000
|
cd services/presentation && LOGIC_URL=http://localhost:8001 uvicorn app.main:app --reload --port 8000
|
||||||
|
|
||||||
|
# --- Docker (opcjonalnie) ---
|
||||||
|
up:
|
||||||
|
@if [ -z "$(COMPOSE)" ]; then \
|
||||||
|
echo "Nie znaleziono Docker Compose (ani 'docker compose', ani 'docker-compose')."; \
|
||||||
|
echo "Użyj trybu lokalnego: make install, potem dev-data / dev-logic / dev-presentation."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
$(COMPOSE) up --build
|
||||||
|
|
||||||
|
down:
|
||||||
|
@if [ -z "$(COMPOSE)" ]; then echo "Brak Docker Compose."; exit 1; fi
|
||||||
|
$(COMPOSE) down
|
||||||
|
|
||||||
|
sql:
|
||||||
|
@if [ -z "$(COMPOSE)" ]; then echo "Brak Docker Compose."; exit 1; fi
|
||||||
|
DATA_PROVIDER=sql $(COMPOSE) up --build
|
||||||
|
|||||||
@@ -28,13 +28,20 @@ make up # zbuduj i uruchom 3 warstwy
|
|||||||
# otwórz http://localhost:8000
|
# otwórz http://localhost:8000
|
||||||
```
|
```
|
||||||
|
|
||||||
## Szybki start (lokalnie, 3 terminale)
|
## Szybki start (lokalnie, bez Dockera — zalecane)
|
||||||
|
```bash
|
||||||
|
python -m venv .env && source .env/bin/activate # venv (jednorazowo)
|
||||||
|
make install # zależności WSZYSTKICH warstw
|
||||||
|
make sample # opcjonalnie: dane przykładowe
|
||||||
|
```
|
||||||
|
Potem w 3 osobnych terminalach (w każdym `source .env/bin/activate`):
|
||||||
```bash
|
```bash
|
||||||
cd services/data && pip install -r requirements.txt && python scripts/make_sample_data.py
|
|
||||||
make dev-data # terminal 1 -> :8002
|
make dev-data # terminal 1 -> :8002
|
||||||
make dev-logic # terminal 2 -> :8001
|
make dev-logic # terminal 2 -> :8001
|
||||||
make dev-presentation # terminal 3 -> :8000
|
make dev-presentation # terminal 3 -> :8000 -> http://localhost:8000
|
||||||
```
|
```
|
||||||
|
> `make install` instaluje zależności wszystkich trzech warstw do aktywnego venv.
|
||||||
|
> Testy silnika: `make test`. Wyczyszczenie cache: `make clean-cache`.
|
||||||
|
|
||||||
## Modułowość — dowód
|
## Modułowość — dowód
|
||||||
- Wymień prezentację (np. na SPA/React) → reszta bez zmian, kontrakt `/api/query` stały.
|
- Wymień prezentację (np. na SPA/React) → reszta bez zmian, kontrakt `/api/query` stały.
|
||||||
|
|||||||
@@ -16,11 +16,23 @@ services:
|
|||||||
build: ./services/logic
|
build: ./services/logic
|
||||||
environment:
|
environment:
|
||||||
DATA_URL: http://data:8002
|
DATA_URL: http://data:8002
|
||||||
|
EPHEMERIS_ENGINE: own # silnik własny (permisywny) — domyślny
|
||||||
|
# adres silnika B (AGPL) używany tylko w trybie porównawczym (profil comparison)
|
||||||
|
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
|
||||||
depends_on:
|
depends_on:
|
||||||
- data
|
- data
|
||||||
ports:
|
ports:
|
||||||
- "8001:8001"
|
- "8001:8001"
|
||||||
|
|
||||||
|
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
|
||||||
|
# docker compose --profile comparison up
|
||||||
|
# Nie wchodzi do domyślnego (zamkniętego) produktu — patrz services/engine-swisseph/LICENSE.
|
||||||
|
engine-swisseph:
|
||||||
|
build: ./services/engine-swisseph
|
||||||
|
profiles: ["comparison"]
|
||||||
|
ports:
|
||||||
|
- "8003:8003"
|
||||||
|
|
||||||
presentation:
|
presentation:
|
||||||
build: ./services/presentation
|
build: ./services/presentation
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -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,110 @@
|
|||||||
|
# Przegląd istniejących rozwiązań + analiza licencji
|
||||||
|
|
||||||
|
Dokument roboczy. Przegląd bibliotek i programów, które mogą być przydatne dla projektu **astrololo**, ze szczególnym naciskiem na **licencje i ryzyko praw autorskich** (zgodnie z prośbą). Stan: czerwiec 2026.
|
||||||
|
|
||||||
|
> ⚠️ **Najważniejszy wniosek (TL;DR).** Branżowy standard obliczeń — **Swiss Ephemeris** — oraz **wszystkie** popularne biblioteki astrologiczne, które na nim bazują (pyswisseph, kerykeion, immanuel, flatlib, swisseph-wasm), są objęte licencją **AGPL‑3.0 albo płatną licencją komercyjną**. AGPL wymusza udostępnienie **całego kodu źródłowego aplikacji** — i to także wtedy, gdy aplikacja jest tylko udostępniana przez sieć (SaaS / API / hosting online, o którym mowa w notatkach). Dla produktu zamkniętego z unikalnymi bazami kolaboratora oznacza to albo (a) zakup licencji komercyjnej Swiss Ephemeris, albo (b) zbudowanie warstwy obliczeń na permisywnym fundamencie (Skyfield MIT + dane NASA JPL = public domain) i samodzielną implementację części astrologicznej. Szczegóły i rekomendacja: sekcja 6.
|
||||||
|
|
||||||
|
Legenda ryzyka: 🟢 permisywne (MIT/BSD/PD) · 🟡 wymaga uwagi/atrybucji · 🔴 copyleft/AGPL — ryzykowne dla produktu zamkniętego.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Silniki efemeryd / obliczenia astronomiczne (fundament warstwy logicznej)
|
||||||
|
|
||||||
|
| Rozwiązanie | Co robi | Licencja | Komentarz / ryzyko |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Swiss Ephemeris** (C, Astrodienst — `aloistr/swisseph`) | De‑facto standard: pozycje obiektów, domy, aspekty, gwiazdy stałe, lots — pełna astrologia, najwyższa dokładność | **Dual: AGPL‑3.0 LUB komercyjna** (CHF 750 za pierwszą licencję + CHF 400 za każdą kolejną; ważna 99 lat) | 🔴 AGPL: użycie w usłudze sieciowej = obowiązek otwarcia całego kodu. Licencja komercyjna zdejmuje copyleft z **silnika**, ale wrappery językowe (poniżej) mają **własne** licencje — trzeba je sprawdzić osobno. Decyzję trzeba podjąć **przed** dystrybucją/uruchomieniem usługi. |
|
||||||
|
| **pyswisseph** (`astrorigin/pyswisseph`) | Wrapper Pythona do Swiss Ephemeris | **AGPL‑3.0** | 🔴 To prawdopodobnie „kalkulator z GitHuba", o którym wiesz. Sam wrapper jest AGPL niezależnie od licencji silnika. |
|
||||||
|
| **swisseph‑wasm** (`prolaxu/swisseph-wasm`) | Swiss Ephemeris skompilowany do WebAssembly (JS) | dziedziczy po Swiss Ephemeris (**AGPL/komercyjna**) | 🔴 Ta sama pułapka AGPL, tyle że w przeglądarce. |
|
||||||
|
| **Skyfield** (`skyfielders/python-skyfield`) | Czysto astronomiczne pozycje planet (research‑grade), oparte o dane NASA JPL | **MIT** | 🟢 Permisywne, idealne dla produktu zamkniętego. **ALE**: tylko astronomia — **nie liczy domów, aspektów, lots ani technik**. Warstwę astrologiczną trzeba dopisać samemu. |
|
||||||
|
| **Dane NASA JPL DE440 / DE441** | Współrzędne efemeryd (Chebyshev), źródło dla Skyfielda | Dane rządu USA — **faktycznie public domain**, swobodnie dystrybuowane | 🟢 Permisywny fundament danych. DE440: lata 1550–2650; DE441: −13200 do +17191. |
|
||||||
|
| **Moshier ephemeris** (Steve Moshier, `moshier.net`) | Semi‑analityczna teoria, ~0,1″ dokładności, bez plików danych | **Public domain** | 🟢 Wolny od jakichkolwiek zobowiązań. Mniej dokładny niż JPL, ale dla astrologii w zupełności wystarcza. Istnieje też reimplementacja JS (`0xStarcat/Moshier-Ephemeris-JS`, MIT). |
|
||||||
|
| **Astropy** | Astronomia ogólna (układy współrzędnych, czas) | **BSD‑3** | 🟢 Permisywne; pomocnicze (czas, transformacje), nie astrologia. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Biblioteki astrologiczne wyższego poziomu (domy, aspekty, lots, techniki)
|
||||||
|
|
||||||
|
| Rozwiązanie | Co robi | Licencja | Komentarz / ryzyko |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **kerykeion** (`g-battaglia/kerykeion`) | Nowoczesna, utrzymywana: pozycje, domy, aspekty, wykresy SVG, synastria/tranzyty/composite | **AGPL‑3.0** | 🔴 Wymaga otwarcia projektu. Autor oferuje hostowane **Astrologer API** (REST) — korzystanie z niego **nie** narzuca copyleft na Twój kod (ale to usługa zewnętrzna, płatna, wysyłasz dane na zewnątrz). |
|
||||||
|
| **immanuel** (`theriftlab/immanuel-python`) | Dane czytelne dla człowieka + JSON, wzorowane na astro.com / Astro Gold; na pyswisseph | **GPL‑3.0+** | 🔴 GPL (sieciowo łagodniejsze niż AGPL, ale wciąż copyleft); ciągnie AGPL‑owy pyswisseph. |
|
||||||
|
| **flatlib** (`flatangle/flatlib`) | Astrologia tradycyjna (domy, aspekty, godności) | **kod własny: MIT** 🟢, ale **zależy od Swiss Ephemeris** 🔴 | 🟡→🔴 Sam kod flatlib jest permisywny (MIT), lecz obliczenia robi Swiss Ephemeris — więc realnie obowiązuje Cię AGPL/komercyjna SE. Słabo utrzymywane. Dobra **referencja** projektowa. |
|
||||||
|
| **libephemeris** (`g-battaglia/libephemeris`) | API zgodne z pyswisseph, ale liczy Skyfieldem (NASA); 25 systemów domów, weryfikowane względem pyswisseph | **AGPL‑3.0** | 🔴 Ciekawe technicznie (permisywny fundament: Skyfield+JPL), ale autor wybrał AGPL → i tak copyleft. Można potraktować jako **wzorzec**, jak dołożyć domy do Skyfielda. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. JavaScript / front‑end (jeśli warstwa prezentacji będzie webowa)
|
||||||
|
|
||||||
|
| Rozwiązanie | Co robi | Licencja | Komentarz / ryzyko |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **CircularNatalHoroscopeJS** (`0xStarcat/...`) | Liczy natalny wykres (Asc/MC, domy, tropikalny/syderyczny) — **na Moshierze, bez Swiss Ephemeris** | **MIT** | 🟢 Rzadki przypadek: obliczenia astrologiczne bez „skażenia" AGPL. Dobry wzorzec/komponent dla JS. |
|
||||||
|
| **AstroChart** (`Kibo/AstroChart`) | Renderowanie kosmogramu (SVG) — tylko rysowanie, bez efemeryd | open‑source (zweryfikować plik LICENSE) | 🟡 Brak obliczeń = brak ryzyka efemeryd. Sprawdzić dokładnie licencję przed użyciem. |
|
||||||
|
| **swisseph‑wasm** | (jak w sekcji 1) | AGPL/komercyjna | 🔴 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Geolokalizacja i strefy czasowe (dla pola „miejscowość → timezone → Asc/MC")
|
||||||
|
|
||||||
|
Notatki opisują obliczenie strefy czasowej z większej miejscowości, potem przeliczenie dla dokładnej lokalizacji. Przydatne:
|
||||||
|
|
||||||
|
| Rozwiązanie | Co robi | Licencja | Komentarz / ryzyko |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **timezonefinder** (`jannikmi/timezonefinder`) | Strefa czasowa z lat/long, offline | **kod: MIT** 🟢 / **dane: ODbL** 🟡 | 🟡 Dane (timezone‑boundary‑builder, ODbL) — przy dystrybucji bazy wymagana atrybucja i klauzula share‑alike **dla samej bazy** (nie dla Twojego kodu). |
|
||||||
|
| **GeoNames** (baza miejscowości) | Miejscowości + współrzędne + strefy (do dropdowna lokalizacji) | **CC‑BY 4.0** | 🟡 Wolne, ale **wymaga atrybucji** „GeoNames". Idealne do tabeli miejscowości z notatek. |
|
||||||
|
| **IANA tz database (tzdata)** | Reguły stref i czasu letniego (DST) | **Public domain** | 🟢 W Pythonie przez `zoneinfo` (stdlib) — bez zależności. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Pełne programy referencyjne (do nauki algorytmów, nie do kopiowania kodu)
|
||||||
|
|
||||||
|
| Program | Licencja | Komentarz / ryzyko |
|
||||||
|
|---|---|---|
|
||||||
|
| **Astrolog** (Walter Pullen, `astrolog.org`) | **GPL‑2.0+** (od wersji 6.00) | 🔴 do kopiowania kodu (GPL), ale **bezcenny jako referencja** algorytmów (domy, dyrekcje, techniki). Opcjonalnie używa Swiss Ephemeris. Można czytać i uczyć się metod, nie wklejać kodu do produktu zamkniętego. |
|
||||||
|
| **Maitreya / Morinus** (open‑source) | GPL (zweryfikować) | 🟡 Jw. — referencja, nie źródło kodu do zamkniętego produktu. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Strategia licencyjna — trzy ścieżki (z rekomendacją)
|
||||||
|
|
||||||
|
Wybór zależy od jednej decyzji z arkusza wymagań (**Q‑01**: produkt zamknięty/hostowany czy nie) i **Q‑07** (SQL/architektura). Przy założeniu **produktu zamkniętego z prywatnymi bazami** (co wynika z notatek — hosting online, własność kolaboratora):
|
||||||
|
|
||||||
|
**Ścieżka A — permisywna, „zbuduj sam" 🟢 (rekomendowana long‑term)**
|
||||||
|
Skyfield (MIT) + dane JPL DE440 (public domain) lub Moshier (PD) do pozycji; domy/aspekty/lots/dyrekcje implementujemy sami (wzorując się na Astrologu i libephemeris — czytając, nie kopiując).
|
||||||
|
- ➕ Pełna swoboda licencyjna, produkt może być zamknięty i hostowany.
|
||||||
|
- ➖ Najwięcej pracy w warstwie logicznej (ale dokładnie tę warstwę i tak projektujemy modułowo).
|
||||||
|
|
||||||
|
**Ścieżka B — Swiss Ephemeris na licencji komercyjnej 🟡 (najszybsza „pełna dokładność")**
|
||||||
|
Kupujemy licencję komercyjną SE (CHF 750) i wołamy silnik C bezpośrednio (z cienkim własnym wrapperem, by ominąć AGPL‑owy pyswisseph).
|
||||||
|
- ➕ Od razu komplet funkcji i najwyższa dokładność; produkt zamknięty OK.
|
||||||
|
- ➖ Koszt + formalności licencyjne; trzeba uważać, żeby nie wciągnąć AGPL‑owych wrapperów.
|
||||||
|
|
||||||
|
**Ścieżka C — AGPL „na całość" 🔴 (tylko jeśli produkt może być otwarty)**
|
||||||
|
kerykeion/immanuel/flatlib+pyswisseph — najszybszy development.
|
||||||
|
- ➕ Gotowiec, mało kodu.
|
||||||
|
- ➖ Wymusza **otwarcie całej aplikacji** (w tym kodu obsługującego prywatne bazy). Przy hostingu online (AGPL!) praktycznie wykluczone dla tego projektu.
|
||||||
|
|
||||||
|
**Ścieżka D — hostowane API (np. Astrologer API)** jako uzupełnienie: korzystanie z REST nie narzuca copyleft, ale to zależność zewnętrzna, koszt, prywatność danych urodzeniowych i może nie pokrywać egzotycznych technik (firdaria, zodiacal releasing, warianty primary directions).
|
||||||
|
|
||||||
|
> **Rekomendacja:** zarezerwować w architekturze warstwy logicznej **interfejs silnika efemeryd** (jak `DataProvider` w warstwie danych), tak by dało się podmienić backend: `MoshierEngine` / `SkyfieldEngine` (ścieżka A) ↔ `SwissEphCommercialEngine` (ścieżka B). Wtedy decyzję A vs B można podjąć później bez przepisywania logiki.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Osobna kwestia: prawa autorskie do TREŚCI baz (to NIE są licencje software!)
|
||||||
|
|
||||||
|
To dotyczy zawartości baz interpretacji, nie kodu — i jest tu realne ryzyko do rozważenia:
|
||||||
|
|
||||||
|
- **Bazy kolaboratora.** Jeśli to **oryginalna twórczość** współpracownika, to on jest dysponentem praw — potrzebne jasne ustalenie (umowa/licencja) na wykorzystanie ich w produkcie. To załatwia sprawę po stronie tych danych.
|
||||||
|
- **Źródła z opublikowanych książek.** Przykładowy plik „The Encyclopaedia of Medical Astrology" to **wydana książka** (H. L. Cornell, 1933). Cyfrowy przedruk obszernych opisów interpretacyjnych z chronionego dzieła = **ryzyko naruszenia praw autorskich**, niezależnie od licencji oprogramowania. Status zależy od jurysdykcji i daty (część dawnych dzieł może być już w domenie publicznej — wymaga sprawdzenia per tytuł).
|
||||||
|
- **Fakt vs. twórczość.** Krótkie formuły sygnifikatorów (np. „Ma Ari = …") to raczej **fakty/metoda** — trudniej objąć je prawem autorskim. Natomiast rozbudowana **proza interpretacyjna** (kolumna Effect) jest chroniona jako utwór.
|
||||||
|
- **Zalecenie:** dla każdej bazy odnotować **proweniencję** (autor/źródło/status praw) — najlepiej jako kolumnę/metadane w warstwie danych (pasuje do `book-#per-txt`, `author`, `lang` z notatek). Oddzielić materiał: własny kolaboratora / public domain / cytowany za zgodą / wymagający usunięcia.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Źródła
|
||||||
|
- [Swiss Ephemeris — licencja (astro.com)](https://www.astro.com/swisseph/swephinfo_e.htm) · [LICENSE](https://www.astro.com/ftp/swisseph/LICENSE) · [repo `aloistr/swisseph`](https://github.com/aloistr/swisseph)
|
||||||
|
- [pyswisseph (`astrorigin/pyswisseph`)](https://github.com/astrorigin/pyswisseph)
|
||||||
|
- [kerykeion (PyPI)](https://pypi.org/project/kerykeion/) · [immanuel (PyPI)](https://pypi.org/project/immanuel/) · [flatlib LICENSE (MIT)](https://raw.githubusercontent.com/flatangle/flatlib/master/LICENSE)
|
||||||
|
- [Skyfield (PyPI, MIT)](https://pypi.org/project/skyfield/) · [libephemeris (`g-battaglia/libephemeris`)](https://github.com/g-battaglia/libephemeris)
|
||||||
|
- [NASA JPL DE440/DE441](https://ssd.jpl.nasa.gov/doc/de440_de441.html) · [Moshier ephemeris](http://www.moshier.net/) · [Moshier‑Ephemeris‑JS](https://github.com/0xStarcat/Moshier-Ephemeris-JS)
|
||||||
|
- [CircularNatalHoroscopeJS](https://github.com/0xStarcat/CircularNatalHoroscopeJS) · [AstroChart (`Kibo/AstroChart`)](https://github.com/Kibo/AstroChart) · [swisseph‑wasm](https://github.com/prolaxu/swisseph-wasm)
|
||||||
|
- [timezonefinder (PyPI, MIT)](https://pypi.org/project/timezonefinder/) · [Astrolog (Wikipedia)](https://en.wikipedia.org/wiki/Astrolog)
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# Warstwa logiczna — analiza, rozwinięcie wymagań i przypisanie rozwiązań
|
||||||
|
|
||||||
|
Dokument roboczy. Rozwinięcie 24 wymagań warstwy logicznej (LOG‑01…LOG‑24 z `astrololo_wymagania.xlsx`) wraz z: przypisaniem gotowych **permisywnych** rozwiązań, oceną złożoności implementacji **od zera** (bo idziemy **ścieżką A**) oraz wskazaniem, gdzie narzędzia AGPL/komercyjne (B/C) służą jako **referencja i walidacja**. Stan: czerwiec 2026.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Założenia ścieżki A
|
||||||
|
|
||||||
|
**Fundament permisywny (co dostajemy „za darmo"):**
|
||||||
|
- **Skyfield** (MIT) + dane **NASA JPL DE440/DE441** (public domain) → geocentryczne pozycje planet i Księżyca, RA/Dec, długość/szerokość ekliptyczna, prędkości, wschody/zachody, wyszukiwanie zdarzeń (stacje, powroty, syzygia).
|
||||||
|
- **pyerfa / ERFA** (BSD; pochodna IAU SOFA) → precesja, nutacja, czas gwiazdowy (LST), nachylenie ekliptyki (obliquity) — prymitywy potrzebne do osi (Asc/MC) i ayanams.
|
||||||
|
- **Katalog Hipparcos** (ESA, swobodnie dostępny; ładuje go Skyfield) → gwiazdy stałe.
|
||||||
|
- **Lark / pyparsing** (MIT) → infrastruktura parsera składni sygnifikatorów.
|
||||||
|
- **numpy / pandas** (BSD) → cała matematyka i tabele.
|
||||||
|
|
||||||
|
Wniosek: **~70–80% astronomicznego fundamentu jest pokryte permisywnie**. Sami budujemy **warstwę astrologiczną** (domy, dyrekcje, techniki, parser, dopasowanie) — a jej algorytmy/wzory są **wiedzą publiczną** (opisane w literaturze astronomicznej i astrologicznej), więc **nie podlegają prawu autorskiemu** — chronione są tylko konkretne implementacje kodu. Czytamy więc Astrolog/Morinus/dokumentację Swiss Ephemeris, by **zrozumieć metodę**, a piszemy **własny** kod permisywny.
|
||||||
|
|
||||||
|
**Rola ścieżek B/C (Swiss Ephemeris, Morinus, astro.com):**
|
||||||
|
1. **Wyrocznia walidacyjna** — nasz silnik porównujemy liczbowo (do łuku sekundy) z SE/astro.com. Użycie pyswisseph **w samych testach/CI** (nie dystrybuowane, nie wystawiane użytkownikom przez sieć) **nie uruchamia obowiązków AGPL** — to legalne i bardzo przydatne.
|
||||||
|
2. **Dane referencyjne** — tabele kontrolne (np. dla profekcji, primary directions) do testów regresyjnych.
|
||||||
|
3. **Porównanie funkcjonalne** — które warianty technik liczy „konkurencja".
|
||||||
|
|
||||||
|
Skala złożoności (implementacja od zera): **Trywialna · Niska · Średnia · Wysoka · Bardzo wysoka (XL)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Macierz zbiorcza
|
||||||
|
|
||||||
|
| ID | Wymaganie | Gotowe permisywne (ścieżka A) | Złożoność od zera | Rola B/C |
|
||||||
|
|----|-----------|-------------------------------|-------------------|----------|
|
||||||
|
| LOG‑01 | Pozycje obiektów | **Skyfield** (komplet) | Niska | walidacja pozycji |
|
||||||
|
| LOG‑02 | Taksonomia obiektów | Skyfield (planety, Księżyc), Hipparcos (gwiazdy) | **Wysoka** (true Node/Lilith, gwiazdy) | walidacja węzłów/Lilith |
|
||||||
|
| LOG‑03 | Kierunek i stacje | Skyfield (find_discrete) | Średnia | walidacja dat stacji |
|
||||||
|
| LOG‑04 | Systemy zodiaku / ayanamsy | Skyfield (tropikalny, RA), pyerfa (precesja) | Średnia | walidacja ayanams |
|
||||||
|
| LOG‑05 | Systemy domów (wiele) | pyerfa (LST, obliquity); reszta własna | **Wysoka** | walidacja cusps (kluczowa) |
|
||||||
|
| LOG‑06 | Aspekty + orby | własne (czysta matematyka) | Niska | walidacja list aspektów |
|
||||||
|
| LOG‑07 | Aspekty pozazodiakalne | Skyfield (deklinacja) | Niska | walidacja |
|
||||||
|
| LOG‑08 | Lots | własne (arytmetyka + DSL formuł) | Niska–Średnia | porównanie wartości |
|
||||||
|
| LOG‑09 | **Primary Directions** | brak permisywnego | **Bardzo wysoka (XL)** | **krytyczna** (Morinus) |
|
||||||
|
| LOG‑10 | Profekcje | własne (arytmetyka + tabele władców) | Niska–Średnia | tabela kontrolna |
|
||||||
|
| LOG‑11 | Zod. Releasing / Firdaria / Decennials | brak permisywnego | Średnia (table‑driven) | walidacja sekwencji |
|
||||||
|
| LOG‑12 | Returns (Solar/Lunar) | Skyfield (root‑finding) | Niska–Średnia | walidacja czasu powrotu |
|
||||||
|
| LOG‑13 | Ascensional Times | pyerfa/własne (oblique ascension) | Średnia | tabela kontrolna |
|
||||||
|
| LOG‑14 | Zbiorcza tabela dat | własne (orkiestracja) | Średnia | — |
|
||||||
|
| LOG‑15 | **Parser sygnifikatorów** | Lark/pyparsing (infrastruktura) | **Wysoka** (unikalne IP) | brak referencji |
|
||||||
|
| LOG‑16 | Precompute „atomów" | własne | Średnia | — |
|
||||||
|
| LOG‑17 | Interpretacja „asp"/„asp±" | własne (config + LOG‑06) | Niska–Średnia | — |
|
||||||
|
| LOG‑18 | Warstwy 1A/1B | własne (orkiestracja ↔ warstwa danych) | Średnia | — |
|
||||||
|
| LOG‑19 | Wypis interpretacji 2B | własne (↔ DataProvider) | Średnia | — |
|
||||||
|
| LOG‑20 | Kolumny sygnif. + „no of hits" | własne | Niska–Średnia | — |
|
||||||
|
| LOG‑21 | Scoring siły efektu | własne (rule/data‑driven) | Średnia | — |
|
||||||
|
| LOG‑22 | Konwersja tekst ↔ symbol | własne (tablice z notes3) | Niska | — |
|
||||||
|
| LOG‑23 | Pozostałe wyliczenia | Skyfield (zdarzenia) | Niska | walidacja |
|
||||||
|
| LOG‑24 | Abstrakcja silnika efemeryd | architektura | Niska | — |
|
||||||
|
|
||||||
|
**Tylko 3 pozycje są naprawdę trudne:** LOG‑09 (primary directions, XL), LOG‑05 (pełny zestaw domów, Wysoka), LOG‑15 (parser — Wysoka, ale to nasze unikalne IP, więc gotowca i tak nie ma). Reszta jest Niska/Średnia, w dużej części pokryta przez Skyfield.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Rozwinięcia pogrupowane
|
||||||
|
|
||||||
|
### Grupa A — Fundament astronomiczny (LOG‑01, 02, 03, 24)
|
||||||
|
|
||||||
|
**LOG‑01 · Pozycje obiektów.**
|
||||||
|
*Rozwinięcie:* długość i szerokość ekliptyczna (of‑date), prędkość (różniczkowanie pozycji), kierunek; formaty: DMS w znaku (Tau 28°12'57''), absolutne 0–360°, dziesiętne. *Ścieżka A:* Skyfield daje to wprost (`ecliptic_latlon`, prędkość z dwóch chwil). Formatowanie/podział na znaki = trywialne. *Złożoność: Niska.* *B/C:* porównać długości z astro.com do ~0,1″.
|
||||||
|
|
||||||
|
**LOG‑02 · Taksonomia obiektów.**
|
||||||
|
*Rozwinięcie:* światła+planety (Skyfield 🟢), planety nowożytne (🟢), gwiazdy stałe (precesja+ruch własny — Hipparcos w Skyfield, 🟢 średnia), obiekty wirtualne:
|
||||||
|
- **Węzły księżycowe** — *mean* (wzór analityczny, Niska) i *true/osculating* (z wektora stanu Księżyca → elementy oskulacyjne → węzeł wstępujący; **Średnia–Wysoka**).
|
||||||
|
- **Lilith (Black Moon = apogeum Księżyca)** — *mean* (Niska–Średnia) i *true* (oskulacyjne apogeum; **Wysoka** — tu implementacje notorycznie się różnią, walidacja względem SE obowiązkowa).
|
||||||
|
- **Osie (Asc/MC → Dsc/IC)** — patrz LOG‑05.
|
||||||
|
*Złożoność całości: Wysoka* (przez gwiazdy + true Node/Lilith; reszta Niska). *B/C:* SE jako wzorzec dla true Node/Lilith (różne definicje!).
|
||||||
|
|
||||||
|
**LOG‑03 · Kierunek i stacje.**
|
||||||
|
*Rozwinięcie:* znak prędkości → D/Rx; stacja = przejście prędkości przez 0 (SD vs SR z przyspieszenia); „dni od stacji ścisłej" + flaga <7 dni; „stacja umowna" jako konfigurowalny próg % ruchu typowego. *Ścieżka A:* Skyfield `find_discrete`/`find_maxima` znajduje momenty stacji; średnie prędkości precompute. *Złożoność: Średnia.* *B/C:* daty stacji z SE.
|
||||||
|
|
||||||
|
**LOG‑24 · Abstrakcja silnika.**
|
||||||
|
*Rozwinięcie:* interfejs `EphemerisEngine` z implementacjami `SkyfieldEngine` (główna, MIT+JPL), `MoshierEngine` (fallback w pełni public‑domain, bez plików danych) oraz — opcjonalnie po decyzji — `SwissEphCommercialEngine`. *Złożoność: Niska* (architektura, analogicznie do `DataProvider`). To wymaganie **spina ścieżki A/B** bez przepisywania logiki.
|
||||||
|
|
||||||
|
### Grupa B — Układy odniesienia (LOG‑04, 05)
|
||||||
|
|
||||||
|
**LOG‑04 · Systemy zodiaku / ayanamsy.**
|
||||||
|
*Rozwinięcie:* tropikalny (domyślny, 🟢), syderyczny = tropikalny − ayanamsa(t) (Lahiri, Fagan‑Bradley… ~40 w SE; my zaczynamy od kilku + tabela rozszerzalna), draconic = tropikalny − długość NN (🟢 po LOG‑02), RA (Skyfield daje wprost). *Ścieżka A:* precesja z pyerfa; ayanamsy to znane stałe odniesienia + tempo precesji. *Złożoność: Średnia* (głównie tabela ayanams). *B/C:* wartości ayanams z SE.
|
||||||
|
|
||||||
|
**LOG‑05 · Systemy domów (wiele, równolegle).** ⚠️ najcięższa matematyka astronomiczna.
|
||||||
|
*Rozwinięcie:*
|
||||||
|
- Whole Sign / Equal (Asc/MC) / Porphyry → **Niska** (trywialne podziały).
|
||||||
|
- Placidus, Koch, Regiomontanus, Campanus, Topocentric, Alcabitus, Meridian, Morinus, Vehlow → znane wzory trygonometrii sferycznej, każdy **Średnia**, w komplecie **Wysoka** (przypadki brzegowe na wysokich szerokościach — Placidus/Koch nieokreślone za kołem podbiegunowym).
|
||||||
|
*Ścieżka A:* Asc/MC z LST (pyerfa) + obliquity + szerokość geo (standardowe wzory). `libephemeris` (AGPL) dowodzi, że pełny zestaw 25 systemów **da się** policzyć na Skyfieldzie — czytamy go jako dowód wykonalności, ale piszemy własny kod. *Złożoność: Wysoka.* *B/C:* **walidacja cusps krytyczna** — tu kryją się błędy, szczególnie na dużych szerokościach.
|
||||||
|
|
||||||
|
### Grupa C — Aspekty (LOG‑06, 07)
|
||||||
|
|
||||||
|
**LOG‑06 · Aspekty + orby.**
|
||||||
|
*Rozwinięcie:* separacja kątowa vs kąt aspektu ± orb; aplikacja/separacja z różnicy prędkości; schematy orbów (domyślne, bonus dla luminarzy, fixed, ±%, per‑planeta/szkoła); aspekt „przez znak" vs „przez stopień"; major/minor/harmonic. *Ścieżka A:* czysta matematyka na policzonych długościach — **żadna biblioteka nie jest potrzebna**. *Złożoność: Niska–Średnia* (szerokość konfiguracji, nie trudność algorytmu). *B/C:* listy aspektów z astro.com do testów.
|
||||||
|
|
||||||
|
**LOG‑07 · Aspekty pozazodiakalne.**
|
||||||
|
*Rozwinięcie:* parallel/contraparallel (po deklinacji — Skyfield 🟢), antiscia/contra‑antiscia (lustro względem osi przesileń — arytmetyka). *Złożoność: Niska.*
|
||||||
|
|
||||||
|
### Grupa D — Lots (LOG‑08)
|
||||||
|
|
||||||
|
**LOG‑08 · Lots.**
|
||||||
|
*Rozwinięcie:* silnik formuł `Lot = C + A − B` z odwracaniem dzień/noc (sekta); By Degree (domyślnie) i By Sign; 7 Hermetic Lots; cały znak działa jak Lot. *Ścieżka A:* mały DSL/parser formuł + ewaluacja na policzonych pozycjach. *Złożoność: Niska–Średnia* (logika sekty prosta; trudność tkwi w **tożsamości/wariantach** Lots — to kuracja danych, nie obliczenia; patrz Q‑03). *B/C:* porównanie wartości stopni.
|
||||||
|
|
||||||
|
### Grupa E — Techniki predykcyjne (LOG‑09…14)
|
||||||
|
|
||||||
|
**LOG‑09 · Primary Directions.** ⚠️ **najtrudniejsze (XL).**
|
||||||
|
*Rozwinięcie:* łuk dyrekcji (semi‑arc/Placidean, Regiomontanus, …) między sygnifikatorem a promisorem w ruchu dobowym; klucz łuk→czas (Ptolemeusz/Naibod/Cardan/Solar arc); direct/converse; mundo/zodiacal; z szerokością/bez. ~160 wariantów — robimy 5–8 (Q‑02), framework rozszerzalny. *Ścieżka A:* brak gotowca permisywnego — **w całości własna trygonometria sferyczna** + obsługa wariantów. *Złożoność: Bardzo wysoka.* *B/C:* **Morinus (GPL) słynie z dokładnych primary directions** — kluczowa wyrocznia; uwaga: nawet komercyjne programy różnią się w wariantach (do udokumentowania, który wariant odwzorowujemy).
|
||||||
|
|
||||||
|
**LOG‑10 · Profekcje.**
|
||||||
|
*Rozwinięcie:* annual (Asc +1 znak/rok, mod 12), monthly/daily, continuous (klucz, domyślnie 30°/rok), Lord of Year, Lord of Orb (kolejność chaldejska, mod 84/12). *Ścieżka A:* arytmetyka dat + tabele władców domicilnych — **bez bibliotek**. *Złożoność: Niska* (annual) *– Średnia* (continuous + Lord of Orb). *B/C:* tabela kontrolna (jak ta w notes3).
|
||||||
|
|
||||||
|
**LOG‑11 · Zodiacal Releasing / Firdaria / Decennials.**
|
||||||
|
*Rozwinięcie:* deterministyczne sekwencje okresów z tabel hellenistycznych/perskich (ZR: długości okresów Valensa + „loosing of the bond"; Firdaria: stałe sekwencje diurnal/nocturnal z podokresami; Decennials: kolejność planetarna). *Ścieżka A:* logika sekwencjonowania na tabelach — algorytmy publiczne, niepodlegające prawu autorskiemu. *Złożoność: Średnia.* *B/C:* walidacja sekwencji z SE/Astrolog.
|
||||||
|
|
||||||
|
**LOG‑12 · Returns (Solar/Lunar).**
|
||||||
|
*Rozwinięcie:* root‑finding momentu powrotu Słońca/Księżyca do długości natalnej; 2 warianty (osobny horoskop vs tranzyt do natalu); monthly revolutions (Słońce + n×30°). *Ścieżka A:* Skyfield znajduje moment (🟢), reszta = ponowne użycie LOG‑01…06. *Złożoność: Niska–Średnia.* *B/C:* czas powrotu z astro.com.
|
||||||
|
|
||||||
|
**LOG‑13 · Ascensional Times.**
|
||||||
|
*Rozwinięcie:* oblique ascension znaków wg szerokości (różnica ascensjonalna z RA) → lata życia; sumy i ułamki dla dodatkowych dat. Częściowo data‑driven (tabela DAN‑07). *Ścieżka A:* wzory powiązane z LOG‑05 (trygonometria sferyczna) + pyerfa. *Złożoność: Średnia.*
|
||||||
|
|
||||||
|
**LOG‑14 · Zbiorcza tabela dat z technik.**
|
||||||
|
*Rozwinięcie:* uruchom wszystkie techniki w zakresie czasu → jedna datowana tabela (`technique | significator | start | exact | end`), sortowanie, deduplikacja, daty dokładne (wspólny root‑finding z LOG‑12). *Ścieżka A:* orkiestracja/glue. *Złożoność: Średnia.*
|
||||||
|
|
||||||
|
### Grupa F — Parser sygnifikatorów i dopasowanie (LOG‑15…20) — **rdzeń unikalnego IP**
|
||||||
|
|
||||||
|
**LOG‑15 · Parser składni „Significator".** ⚠️ **Wysoka — i tak nie ma gotowca.**
|
||||||
|
*Rozwinięcie:* gramatyka skrótów (obiekty, znaki, żywioły, jakości, domy, aspekty+cel, retro/combust/waxing/heliacal, opcje A/B, warunki, „r‑Asc", whole sign+orb); 7 poziomów złożoności — od poziomu „obiekt" po sygnifikatory opisowe/niejasne (poziomy 5–7 mogą wymagać fallbacku człowiek/AI). *Ścieżka A:* tokenizer + gramatyka PEG (Lark, MIT) + słownik SIGNIFICATORS KEY (warstwa danych) jako mapa znaczeń tokenów. *Złożoność: Wysoka* — tu idzie najwięcej oryginalnej inżynierii; to **wartość intelektualna projektu**. *B/C:* brak — nie istnieje zewnętrzny parser tej składni.
|
||||||
|
|
||||||
|
**LOG‑16 · Precompute „atomów" obecnych w horoskopie.**
|
||||||
|
*Rozwinięcie:* wygeneruj zbiór wszystkich cząstek prawdziwych dla danego horoskopu (Ma Ari, Ma fire, Ma h2, Ma opp Sa, r‑Asc Ari…); dopasowanie rekordu bazy = przynależność do zbioru. Zależy od LOG‑01…08 (policzony horoskop) + LOG‑15 (słownik). *Złożoność: Średnia* (generacja kombinatoryczna + indeks; wrażliwe wydajnościowo — łączy się z DAN‑22).
|
||||||
|
|
||||||
|
**LOG‑17 · Interpretacja „asp"/„asp+"/„asp‑"/„asp malefic".**
|
||||||
|
*Rozwinięcie:* rozwinięcie wg konfigurowalnej tabeli pomocniczej (jakie aspekty, orby, active) + LOG‑06. *Złożoność: Niska–Średnia.*
|
||||||
|
|
||||||
|
**LOG‑18/19/20 · Dopasowanie i wypis.**
|
||||||
|
*Rozwinięcie:* silnik dopasowania: atomy → zapytanie do **warstwy danych (nasz DataProvider!)** → złożenie outputu: 1A (natal, unikalny), 1B (predykcyjne z datami), 2B (wypis interpretacji tylko dla obecnych rekordów), 2 kolumny sygnifikatora (z bazy / as‑is‑in‑chart), „no of hits" (1/2/3/…/?/2+?). To **styk warstwy logicznej z danych** — pasuje do istniejącej architektury. *Złożoność: Średnia.*
|
||||||
|
|
||||||
|
### Grupa G — Scoring, symbole, pozostałe (LOG‑21, 22, 23)
|
||||||
|
|
||||||
|
**LOG‑21 · Scoring siły efektu.**
|
||||||
|
*Rozwinięcie:* punktacja wg tabel relacyjnych (np. „no air" poziomy */**/***), punkty za domniemaną prawdziwość w syntezie; logika indywidualna (nie zawsze rzadkość = siła). *Ścieżka A:* silnik reguł na danych (tabele relacyjne — warstwa danych). *Złożoność: Średnia*, otwarte (autor przygotuje punktację indywidualnie).
|
||||||
|
|
||||||
|
**LOG‑22 · Konwersja tekst ↔ symbol.**
|
||||||
|
*Rozwinięcie:* dwukierunkowe mapowanie z tablic Unicode (kompletne w notes3); przechowywanie tekstem, wyświetlanie symbolami; tylko „text" Unicode, nigdy emoji; ℞ dla retro. *Złożoność: Niska* (tablice lookup). Ligatury vs 2 kolumny — patrz Q‑05 (prezentacja).
|
||||||
|
|
||||||
|
**LOG‑23 · Pozostałe wyliczenia.**
|
||||||
|
*Rozwinięcie:* tally żywiołów/jakości (trywialne), planetary day & hours (wschód/zachód ze Skyfielda + podział na 12 godzin dnia/nocy + władcy chaldejscy), stopnie krytyczne/wrażliwe (lookup), faza Księżyca (elongacja Su‑Mo), syzygia prenatalna (root‑find ostatniej pełni/nowiu przed urodzeniem), divisional (12th/9th parts — arytmetyka). *Ścieżka A:* Skyfield obsługuje zdarzenia astronomiczne. *Złożoność: Niska* (lokalnie Niska–Średnia).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Proponowana kolejność budowy (fazy)
|
||||||
|
|
||||||
|
1. **Fundament (MVP silnika):** LOG‑24 (interfejs) → LOG‑01 → LOG‑02 (bez gwiazd/true‑Lilith) → LOG‑04 (tropikalny+RA) → LOG‑06/07 → LOG‑05 (Whole/Equal/Porphyry+Placidus) → LOG‑23 (podstawy). *Tu Skyfield daje ogromny skok.*
|
||||||
|
2. **Astrologia rdzenia:** LOG‑08 (Lots) → LOG‑15 (parser) → LOG‑16 → LOG‑18/19/20 (dopasowanie ↔ warstwa danych). *Tu powstaje unikalna wartość i pełny przepływ „horoskop → interpretacje".*
|
||||||
|
3. **Predykcja prosta:** LOG‑10 (profekcje) → LOG‑12 (returns) → LOG‑14 (agregacja) → LOG‑11 (time‑lordy) → LOG‑13.
|
||||||
|
4. **Predykcja trudna i dopieszczenie:** LOG‑09 (primary directions, XL) → LOG‑02 dokończenie (gwiazdy, true Node/Lilith) → LOG‑04 (ayanamsy) → LOG‑21 (scoring) → LOG‑05 (pełny zestaw domów).
|
||||||
|
|
||||||
|
Logika: najpierw to, co Skyfield daje tanio i co odblokowuje przepływ end‑to‑end; najtrudniejsze (primary directions, pełne domy) na koniec, gdy mamy już solidną wyrocznię walidacyjną.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Stos technologiczny (permisywny) i walidacja
|
||||||
|
|
||||||
|
**Produkcyjne (ścieżka A):** Skyfield (MIT), pyerfa (BSD), dane JPL DE440 (PD) lub Moshier (PD), Lark (MIT), numpy/pandas (BSD). Zero copyleft → produkt może być zamknięty i hostowany.
|
||||||
|
|
||||||
|
**Tylko dev/test (B/C jako wyrocznia — NIE w produkcie, NIE wystawiane przez sieć):** pyswisseph (AGPL) i Morinus (GPL) do porównań liczbowych w testach regresyjnych. To legalne, bo nie dystrybuujemy ich i nie udostępniamy użytkownikom — AGPL/GPL nie zostają uruchomione.
|
||||||
|
|
||||||
|
**Wymaganie pochodne (do dopisania w arkuszu):** **LOG‑25 — harness walidacyjny**: zestaw testów porównujących nasz silnik z SE/astro.com/Morinus na bazie znanych horoskopów (np. przykład z notes3: 30.04.1984, Warszawa), z progami tolerancji per wielkość. To nasza polisa ubezpieczeniowa przy budowie od zera.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Wniosek
|
||||||
|
|
||||||
|
Ścieżka A jest **w pełni wykonalna**. Skyfield + pyerfa + JPL/Moshier zdejmują z nas całą trudną astronomię (pozycje, zdarzenia, czas gwiazdowy, precesja). Realny ciężar własnej implementacji to **trzy obszary**: primary directions (XL), pełny zestaw domów (Wysoka) i parser sygnifikatorów (Wysoka — ale unikalny, gotowca i tak nie ma). Wszystkie pozostałe wymagania są Niskie/Średnie. Narzędzia AGPL/komercyjne zostają jako **wyrocznia walidacyjna i dane referencyjne** — używane legalnie poza produktem.
|
||||||
+7
-2
@@ -31,5 +31,10 @@ class FrameCache:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def put(self, fp: str, sheet: str, frame: pd.DataFrame) -> None:
|
def put(self, fp: str, sheet: str, frame: pd.DataFrame) -> None:
|
||||||
# astype(str) na kolumnach object zapewnia stabilny zapis Parquet
|
# Kolumny z heterogenicznych plików Excela bywają mieszane (int + str +
|
||||||
frame.to_parquet(self._path(fp, sheet), index=False)
|
# NaN w jednej kolumnie) — pyarrow tego nie zapisze. Warstwa danych i tak
|
||||||
|
# operuje na tekście (wyszukiwanie po .astype(str)), więc zapisujemy ramkę
|
||||||
|
# jako string (brak wartości -> pusty tekst). Gwarantuje to stabilny zapis
|
||||||
|
# Parquet niezależnie od zawartości pliku źródłowego.
|
||||||
|
safe = frame.astype("string").fillna("")
|
||||||
|
safe.to_parquet(self._path(fp, sheet), index=False)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class SearchQuery(BaseModel):
|
|||||||
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
|
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
|
||||||
value: str = Field(..., description="Szukana wartość.")
|
value: str = Field(..., description="Szukana wartość.")
|
||||||
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
|
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
|
||||||
limit: int = Field(50, ge=1, le=1000)
|
limit: int = Field(50, ge=1, le=50000)
|
||||||
fields: list[str] | None = Field(
|
fields: list[str] | None = Field(
|
||||||
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
|
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -82,7 +82,10 @@ class ExcelDataProvider(DataProvider):
|
|||||||
|
|
||||||
def warmup(self) -> None:
|
def warmup(self) -> None:
|
||||||
for path in self._excel_files():
|
for path in self._excel_files():
|
||||||
|
try:
|
||||||
self._ensure_indexed(path)
|
self._ensure_indexed(path)
|
||||||
|
except Exception as e: # jeden uszkodzony plik nie może zablokować startu
|
||||||
|
print(f"[data] pominięto plik przy indeksowaniu: {path} — {e}")
|
||||||
|
|
||||||
def _excel_files(self) -> list[str]:
|
def _excel_files(self) -> list[str]:
|
||||||
base = Path(self.s.excel_dir)
|
base = Path(self.s.excel_dir)
|
||||||
@@ -112,7 +115,9 @@ class ExcelDataProvider(DataProvider):
|
|||||||
if query.exact:
|
if query.exact:
|
||||||
mask = col.str.lower() == query.value.lower()
|
mask = col.str.lower() == query.value.lower()
|
||||||
else:
|
else:
|
||||||
mask = col.str.lower().str.contains(query.value.lower(), na=False)
|
# regex=False: wartości sygnifikatorów zawierają znaki [ + itd.,
|
||||||
|
# które są metaznakami regex — szukamy dosłownie.
|
||||||
|
mask = col.str.lower().str.contains(query.value.lower(), na=False, regex=False)
|
||||||
matched = frame[mask]
|
matched = frame[mask]
|
||||||
if query.fields:
|
if query.fields:
|
||||||
keep = [c for c in query.fields if c in matched.columns]
|
keep = [c for c in query.fields if c in matched.columns]
|
||||||
|
|||||||
@@ -0,0 +1,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,65 @@
|
|||||||
|
"""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,
|
||||||
|
}
|
||||||
|
DEFAULT_OBJECTS = list(_PLANETS)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
xx, _retflag = swe.calc_ut(jd, _PLANETS[name], _FLAGS)
|
||||||
|
lon, lat, _dist, lon_speed = xx[0], xx[1], xx[2], xx[3]
|
||||||
|
rows.append({
|
||||||
|
"name": name,
|
||||||
|
"longitude": lon % 360.0,
|
||||||
|
"latitude": lat,
|
||||||
|
"speed": lon_speed,
|
||||||
|
"retrograde": lon_speed < 0,
|
||||||
|
})
|
||||||
|
return {"engine": "swisseph", "positions": rows}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict:
|
||||||
|
return {"engine": "swisseph", "status": "ok", "mode": "moshier", "license": "AGPL-3.0"}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# UWAGA: pyswisseph (Swiss Ephemeris) jest na licencji AGPL-3.0 — dlatego ta
|
||||||
|
# usługa jest wydzielona i licencjonowana osobno (patrz LICENSE). Nie instaluj
|
||||||
|
# tego w obrazie permisywnego produktu.
|
||||||
|
fastapi>=0.115
|
||||||
|
uvicorn[standard]>=0.34
|
||||||
|
pyswisseph>=2.10
|
||||||
@@ -6,8 +6,27 @@ i nie w bazie.
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
- `POST /api/query` → `QueryRequest` → `QueryResponse`
|
- `POST /api/query` → `QueryRequest` → `QueryResponse`
|
||||||
|
- `POST /chart/positions` → `{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty główne (LOG-06). `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/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B)
|
||||||
- `GET /health` (sprawdza też warstwę bazodanową)
|
- `GET /health` (sprawdza też warstwę bazodanową)
|
||||||
|
|
||||||
|
## Silnik efemeryd (LOG-24, „wymienny silnik liczący")
|
||||||
|
W `app/engine/` żyje pluggable silnik za interfejsem `EphemerisEngine`:
|
||||||
|
- **`SkyfieldEngine`** — własny, permisywny (Skyfield MIT + dane JPL public domain). Domyślny.
|
||||||
|
- **`RemoteEngine`** — klient OSOBNEJ, izolowanej usługi `engine-swisseph` (AGPL), używany tylko w trybie porównawczym.
|
||||||
|
|
||||||
|
Wybór: `EPHEMERIS_ENGINE=own|swisseph`. Silnik B włącza się przez `ENGINE_SWISSEPH_URL`.
|
||||||
|
|
||||||
|
Walidacja (LOG-25/28): `app/engine/compare.py` zestawia oba silniki z progiem tolerancji;
|
||||||
|
ten sam kontrakt parzystości obowiązuje każdy silnik. Nasz `SkyfieldEngine` zgadza się
|
||||||
|
ze Swiss Ephemeris **co do ~1″** na horoskopie referencyjnym (patrz `tests/`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements-dev.txt
|
||||||
|
PYTHONPATH=. pytest tests -q # silnik B pomijany, jeśli ENGINE_SWISSEPH_URL nieustawiony
|
||||||
|
```
|
||||||
|
|
||||||
## Zależności w dół
|
## Zależności w dół
|
||||||
Zna wyłącznie `DATA_URL` (adres warstwy bazodanowej) i jej kontrakt `/search`.
|
Zna wyłącznie `DATA_URL` (adres warstwy bazodanowej) i jej kontrakt `/search`.
|
||||||
Nie wie, czy pod spodem jest Excel czy SQL.
|
Nie wie, czy pod spodem jest Excel czy SQL.
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
@@ -16,9 +16,16 @@ class DataClient:
|
|||||||
def __init__(self, base_url: str | None = None) -> None:
|
def __init__(self, base_url: str | None = None) -> None:
|
||||||
self.base_url = (base_url or settings.data_url).rstrip("/")
|
self.base_url = (base_url or settings.data_url).rstrip("/")
|
||||||
|
|
||||||
def search(self, key: str, value: str, exact: bool, limit: int) -> dict[str, Any]:
|
def search(
|
||||||
payload = {"key": key, "value": value, "exact": exact, "limit": limit}
|
self,
|
||||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
key: str,
|
||||||
|
value: str,
|
||||||
|
exact: bool,
|
||||||
|
limit: int,
|
||||||
|
fields: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields}
|
||||||
|
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
|
||||||
r = client.post(f"{self.base_url}/search", json=payload)
|
r = client.post(f"{self.base_url}/search", json=payload)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""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 find_aspects(
|
||||||
|
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS
|
||||||
|
) -> list[dict]:
|
||||||
|
"""positions: dicty z 'name' i 'decimal' (długość). Zwraca listę aspektów."""
|
||||||
|
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:
|
||||||
|
out.append({
|
||||||
|
"obj1": a["name"], "obj2": b["name"],
|
||||||
|
"aspect": asp, "orb": round(dev, 2), "allowed": round(allowed, 2),
|
||||||
|
})
|
||||||
|
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,64 @@
|
|||||||
|
"""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)
|
||||||
|
DEFAULT_OBJECTS = [
|
||||||
|
"Sun", "Moon", "Mercury", "Venus", "Mars",
|
||||||
|
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@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,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,104 @@
|
|||||||
|
"""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 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:
|
||||||
|
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}
|
||||||
@@ -6,8 +6,11 @@ Nie serwuje HTML, nie czyta plików/baz — tylko reguły i pośrednictwo.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.clients.data_client import DataClient
|
from app.clients.data_client import DataClient
|
||||||
from app.models import QueryRequest, QueryResponse
|
from app.models import QueryRequest, QueryResponse
|
||||||
@@ -16,6 +19,26 @@ from app.service import QueryService
|
|||||||
app = FastAPI(title="astrololo · warstwa logiczna")
|
app = FastAPI(title="astrololo · warstwa logiczna")
|
||||||
service = QueryService()
|
service = QueryService()
|
||||||
|
|
||||||
|
# --- silnik efemeryd (LOG-24): budowany leniwie, by nie wymagać Skyfielda do startu ---
|
||||||
|
_engine = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
global _engine
|
||||||
|
if _engine is None:
|
||||||
|
from app.engine.factory import build_engine
|
||||||
|
|
||||||
|
_engine = build_engine()
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
class PositionsRequest(BaseModel):
|
||||||
|
when_utc: datetime # moment w UTC (świadomy strefy)
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
objects: list[str] | None = None
|
||||||
|
house_system: str = "whole_sign" # whole_sign | equal | porphyry
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/query", response_model=QueryResponse)
|
@app.post("/api/query", response_model=QueryResponse)
|
||||||
def query(req: QueryRequest) -> QueryResponse:
|
def query(req: QueryRequest) -> QueryResponse:
|
||||||
@@ -25,6 +48,65 @@ def query(req: QueryRequest) -> QueryResponse:
|
|||||||
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/positions")
|
||||||
|
def chart_positions(req: PositionsRequest) -> dict:
|
||||||
|
"""Pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05), aktywnym silnikiem."""
|
||||||
|
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)
|
||||||
|
return build_chart(engine, moment, req.house_system)
|
||||||
|
|
||||||
|
|
||||||
|
@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}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
info = {"status": "ok", "layer": "logic"}
|
info = {"status": "ok", "layer": "logic"}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
return round(base * weight * (1.0 + tight), 2)
|
||||||
|
return round(base, 2)
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""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",
|
||||||
|
}
|
||||||
|
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
|
||||||
|
facets.append({
|
||||||
|
"type": "aspect", "label": f"{ASP_NAME[asp['aspect']]} z {other}",
|
||||||
|
"token": f"{asp_tok} + {other_tok}",
|
||||||
|
"aspect": asp["aspect"], "orb": asp.get("orb"), "allowed": asp.get("allowed"),
|
||||||
|
"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
|
||||||
@@ -2,3 +2,5 @@ fastapi>=0.115
|
|||||||
uvicorn[standard]>=0.34
|
uvicorn[standard]>=0.34
|
||||||
httpx>=0.28
|
httpx>=0.28
|
||||||
pydantic>=2.10
|
pydantic>=2.10
|
||||||
|
# Silnik własny (ścieżka A, permisywny): Skyfield (MIT) + dane JPL (public domain)
|
||||||
|
skyfield>=1.49
|
||||||
|
|||||||
@@ -0,0 +1,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,35 @@
|
|||||||
|
"""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
|
||||||
@@ -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,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"
|
||||||
@@ -5,12 +5,21 @@ przekazuje dane z formularza do warstwy logicznej i renderuje opracowane wyniki.
|
|||||||
Brak logiki biznesowej i dostępu do danych.
|
Brak logiki biznesowej i dostępu do danych.
|
||||||
|
|
||||||
## Trasy
|
## Trasy
|
||||||
- `GET /` — strona z formularzem
|
- `GET /` — **Horoskop**: formularz danych momentu (data, godzina, strefa, opcjonalnie lokalizacja)
|
||||||
- `POST /` — wysłanie formularza → warstwa logiczna → render wyników
|
- `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`
|
- `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ół
|
## Zależności w dół
|
||||||
Zna wyłącznie `LOGIC_URL` (adres warstwy logicznej).
|
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
|
## Uruchomienie
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Klient HTTP do warstwy logicznej.
|
"""Klient HTTP do warstwy logicznej.
|
||||||
|
|
||||||
Jedyny punkt styku prezentacji w dół. Przekazuje dane z formularza i odbiera
|
Jedyny punkt styku prezentacji w dół. Przekazuje dane z formularza i odbiera
|
||||||
opracowane wyniki. Prezentacja nie sięga bezpośrednio do bazy.
|
opracowane wyniki. Prezentacja nie sięga bezpośrednio do bazy ani do silnika.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -22,3 +22,34 @@ class LogicClient:
|
|||||||
r = client.post(f"{self.base_url}/api/query", json=payload)
|
r = client.post(f"{self.base_url}/api/query", json=payload)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
def positions(
|
||||||
|
self,
|
||||||
|
when_utc_iso: str,
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
objects: list[str] | None = None,
|
||||||
|
house_system: str = "whole_sign",
|
||||||
|
) -> 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,
|
||||||
|
}
|
||||||
|
with httpx.Client(timeout=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()
|
||||||
|
|||||||
@@ -2,9 +2,15 @@
|
|||||||
|
|
||||||
W dół: przekazuje dane z formularza do warstwy logicznej i odbiera opracowane
|
W dół: przekazuje dane z formularza do warstwy logicznej i odbiera opracowane
|
||||||
wyniki. Nie zawiera logiki biznesowej ani dostępu do danych — tylko UI.
|
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 __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, Form, Request
|
from fastapi import FastAPI, Form, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
@@ -19,13 +25,66 @@ templates = Jinja2Templates(directory="app/templates")
|
|||||||
logic = LogicClient()
|
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)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
def index(request: Request):
|
def chart_form(request: Request):
|
||||||
return templates.TemplateResponse(request, "index.html", {"result": None, "form": {}})
|
return templates.TemplateResponse(request, "chart.html", {"result": None, "form": {}})
|
||||||
|
|
||||||
|
|
||||||
@app.post("/", response_class=HTMLResponse)
|
@app.post("/", response_class=HTMLResponse)
|
||||||
def search(
|
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"),
|
||||||
|
):
|
||||||
|
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
||||||
|
"lat": lat, "lon": lon, "house_system": house_system}
|
||||||
|
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
|
||||||
|
)
|
||||||
|
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,
|
request: Request,
|
||||||
query: str = Form(...),
|
query: str = Form(...),
|
||||||
field: str = Form("name"),
|
field: str = Form("name"),
|
||||||
@@ -37,8 +96,38 @@ def search(
|
|||||||
try:
|
try:
|
||||||
ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit)
|
ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit)
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
ctx["error"] = f"Warstwa logiczna niedostępna: {e}"
|
ctx["error"] = _logic_error(e)
|
||||||
return templates.TemplateResponse(request, "index.html", ctx)
|
return templates.TemplateResponse(request, "significators.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Interpretacje (wynik obliczeń szukany w bazie) ----------------
|
||||||
|
@app.get("/interpret", response_class=HTMLResponse)
|
||||||
|
def interpret_form(request: Request):
|
||||||
|
return templates.TemplateResponse(request, "interpret.html", {"result": None, "form": {}})
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -15,6 +15,23 @@ body {
|
|||||||
main { width: 100%; max-width: 880px; }
|
main { width: 100%; max-width: 880px; }
|
||||||
h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
|
h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
|
||||||
.sub { color: var(--muted); margin: .25rem 0 2rem; }
|
.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; }
|
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
||||||
.row { display: flex; gap: .5rem; }
|
.row { display: flex; gap: .5rem; }
|
||||||
.row input[type=text] { flex: 1; }
|
.row input[type=text] { flex: 1; }
|
||||||
@@ -33,3 +50,17 @@ table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: v
|
|||||||
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
|
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
|
||||||
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
|
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
|
||||||
tr:last-child td { border-bottom: none; }
|
tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.small { font-size: .85rem; }
|
||||||
|
.nowrap { white-space: nowrap; }
|
||||||
|
.sig-item { margin-top: 1.25rem; }
|
||||||
|
.sig-head { padding: .5rem 0; }
|
||||||
|
.samples { margin-top: .3rem; }
|
||||||
|
.samples td { font-size: .92rem; vertical-align: top; }
|
||||||
|
.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
|
||||||
|
.facet { margin: .4rem 0 .6rem 1rem; }
|
||||||
|
.facet-head { color: var(--ink); font-size: .9rem; }
|
||||||
|
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
|
||||||
|
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
|
||||||
|
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
|
||||||
|
|||||||
@@ -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,131 @@
|
|||||||
|
{% 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="actions">
|
||||||
|
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||||
|
<button type="submit">Policz horoskop</button>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{% 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></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></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>
|
||||||
|
// „Tu i teraz": uzupełnia datę/godzinę bieżącą i offset lokalny przeglądarki.
|
||||||
|
document.getElementById('nowBtn').addEventListener('click', function () {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
document.querySelector('input[name=date]').value =
|
||||||
|
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
||||||
|
document.querySelector('input[name=time]').value = pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||||
|
document.querySelector('input[name=tz_offset]').value = (-d.getTimezoneOffset() / 60);
|
||||||
|
// bajer: lokalizacja z przeglądarki (wymaga zgody; działa na https/localhost)
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="pl">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>astrololo</title>
|
|
||||||
<link rel="stylesheet" href="/static/styles.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<h1>astrololo</h1>
|
|
||||||
<p class="sub">Warstwa prezentacji → logiczna → bazodanowa</p>
|
|
||||||
|
|
||||||
<form method="post" action="/">
|
|
||||||
<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 %}
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
{% 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>
|
||||||
|
</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>
|
||||||
|
document.getElementById('nowBtn').addEventListener('click', function () {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
document.querySelector('input[name=date]').value =
|
||||||
|
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
||||||
|
document.querySelector('input[name=time]').value = pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||||
|
document.querySelector('input[name=tz_offset]').value = (-d.getTimezoneOffset() / 60);
|
||||||
|
// bajer: lokalizacja z przeglądarki (wymaga zgody; działa na https/localhost)
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</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 %}
|
||||||
Reference in New Issue
Block a user