Compare commits

..

21 Commits

Author SHA1 Message Date
gitea 82809665ff Odsiewanie szumu + rozwijanie skrótów sygnifikatorów
- abbreviations.py: słownik skrót -> pełna nazwa (z SIGNIFICATORS KEY, built-in)
  + expand(): [Su in [Tau -> "Sun in Taurus", [Sa in 6th H. -> "...6th house",
  affl. -> afflicted itd.
- significators: każda próbka ma pole "expanded" (postać czytelna); hartowanie
  filtra szumu (efekty zastępcze x/?/-, wiersze *MARKER, legendy/nagłówki).
- prezentacja /interpret: pokazuje rozwiniętą postać, surowy skrót w tooltipie.

Zweryfikowano na realnym main_base.xlsx: "[Sa or [Ma in the 5th H." ->
"Saturn or Mars in the 5th house". 9 testów przechodzi (w tym test_abbreviations).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:42:47 +02:00
gitea 0e74566a78 Bogatsze sygnifikatory: faseta "w domu" obok "w znaku" (LOG-15/16)
Most horoskop -> sygnifikatory generuje teraz dla każdego obiektu dwie fasety:
- "w znaku": planeta + token znaku ([Su + [Tau)
- "w domu":  planeta + token domu ([Su + 11th H.) — używa domów z LOG-05

- significators.build_report: przyjmuje pozycje z build_chart (z numerami domów),
  generuje fasety znak/dom, filtruje szum. Ordinal helper (1st..12th).
- /chart/report: używa build_chart (pozycje z domami).
- Prezentacja /interpret: render faset (znak/dom) per obiekt.

Aspekty ([conj/[sq/[opp) na później — wymagają policzenia aspektów (LOG-06).

Zweryfikowano na realnym main_base.xlsx (53969 wierszy), 30.04.1984:
Mars w 5. domu 10 dopasowań ("[Sa or [Ma in the 5th H." -> "abortion/miscarriage"),
Neptune w 7. domu 6, Uranus w 6. domu 4. 7 testów przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 15:37:20 +02:00
gitea cea6907f84 Merge pull request #9 from migatu/feat/search-integration
Wyszukiwarka: wynik obliczen szukany w bazie interpretacji
2026-07-01 15:03:29 +02:00
gitea dabbaef9dd Merge branch 'master' into feat/search-integration 2026-07-01 15:03:12 +02:00
gitea a81ae698a3 Merge pull request #8 from migatu/feat/engine-houses
Silnik: osie (Asc/MC) i systemy domow (LOG-05)
2026-07-01 15:01:01 +02:00
gitea eef67d37b5 Wyszukiwarka: wynik obliczeń szukany w bazie interpretacji
Pierwsza wersja mostu horoskop -> sygnifikatory -> baza (zalążek LOG-16/18/19).

- logic/significators.py: z pozycji generuje tokeny w składni bazy (planeta
  [Su, znak [Tau...), pyta warstwę danych o rekordy z tokenem planety i zawęża
  do tych, które wspominają też jej znak ("planeta w swoim znaku"); odsiewa szum.
- logic /chart/report: nowy endpoint (pozycje -> raport dopasowań z interpretacjami).
- logic DataClient.search: parametr fields (lżejszy payload).
- data: naprawa str.contains regex=True -> regex=False (sygnifikatory zawierają
  [ + itd., metaznaki regex); podniesiony górny limit zapytania (le=50000).
- prezentacja: strona /interpret (formularz -> wyszukane interpretacje per obiekt)
  + nawigacja.

Zweryfikowano end-to-end na realnym pliku (Encyclopaedia of Medical Astrology,
53969 wierszy): dla horoskopu 30.04.1984 znaleziono m.in. Sun w Taurus 46,
Mars w Scorpio 57, Saturn w Scorpio 61 dopasowań; przykłady: "[Su in [Tau" ->
"the bump of amativeness prominent". 15 testów przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:57:26 +02:00
gitea 94c3023d3a Silnik: osie (Asc/MC) i systemy domów (LOG-05)
Kontynuacja silnika efemeryd o osie i domy.

- engine/houses.py: czysta matematyka sferyczna — Asc, MC (z RAMC + ε + φ),
  cusps dla Whole Sign / Equal / Porphyry, przypisanie obiektu do domu.
- SkyfieldEngine.sidereal(): RAMC (lokalny apparent ST) + średnie nachylenie
  ekliptyki ze Skyfielda.
- engine/chart.py: build_chart() składa pełny horoskop (pozycje + osie + domy).
- Endpoint /chart/positions rozszerzony o house_system i zwraca angles + cusps
  + numer domu per obiekt.
- Prezentacja: lokalizacja i wybór systemu domów w formularzu, tabela osi,
  kolumna Dom, rozwijane cusps.

Walidacja względem astro.com (30.04.1984, Warszawa): Asc Can 22°10'43",
MC Pis 22°35'29" (~1' od referencji); wszystkie przypisania domów Whole Sign
zgodne (Sun 11, Mercury 10, Mars 5, ...). 20 testów przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 14:06:13 +02:00
gitea e4f6a16c9c Merge pull request #7 from migatu/fix/local-dev-env
Napraw lokalne srodowisko dev: Parquet mixed-types, brak deps, compose
2026-07-01 11:24:15 +02:00
gitea 413c46b5dd Napraw lokalne środowisko dev: Parquet mixed-types, brak deps, compose
Trzy usterki uniemożliwiające uruchomienie stosu lokalnie:

1. Warstwa danych wykładała się na starcie przy zapisie Parquet dla realnych
   plików (np. Encyclopaedia of Medical Astrology) — kolumny o mieszanych
   typach (int+str+NaN). frame_cache.put() zapisuje teraz ramkę jako string
   (warstwa i tak wyszukuje po tekście). Dodatkowo warmup() jest odporny:
   pojedynczy uszkodzony plik nie blokuje startu usługi.

2. Brakowało kroku instalacji zależności — dev-logic/dev-presentation padały na
   'No module named httpx'. Nowy cel `make install` instaluje zależności
   WSZYSTKICH warstw do aktywnego venv. README zaktualizowane (instalowało
   wcześniej tylko warstwę danych).

3. `make up` zakładał `docker compose`, którego użytkownik nie ma. Makefile
   wykrywa `docker compose` lub `docker-compose`, a przy braku obu podaje
   czytelną instrukcję trybu lokalnego. Dodano `make test` i `make clean-cache`.

Zweryfikowane end-to-end na realnym środowisku (.env, Python 3.14) i danych:
warmup przechodzi (3 pliki), cały stos wstaje, formularz → logika → silnik
Skyfield zwraca poprawne pozycje.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 10:55:04 +02:00
gitea 340b3058e4 Merge pull request #6 from migatu/feat/presentation-chart
Warstwa prezentacji: widok horoskopu do recznego testowania
2026-06-30 20:33:31 +02:00
gitea 376a4bfded Merge pull request #5 from migatu/feat/logic-engine
Silnik efemeryd: EngineProvider + SkyfieldEngine + harness porównawczy (LOG-24/01/25/27/28)
2026-06-30 20:26:36 +02:00
gitea 3195d9b003 Warstwa prezentacji: widok horoskopu do ręcznego testowania
Strona główna "/" = formularz podstawowych danych momentu (data, godzina,
strefa, opcjonalnie lokalizacja) → tabela policzonych pozycji w formie
human-readable (znak, pozycja w znaku, absolutna, kierunek, prędkość).
Woła logic /chart/positions; przelicza czas lokalny + offset na UTC.
Przycisk "Tu i teraz" uzupełnia bieżącą datę/godzinę i strefę przeglądarki.
Retrogradacja wyróżniona w tabeli.

Wyszukiwarkę sygnifikatorów przeniesiono pod "/significants" -> /significators,
dodano nawigację (base.html). Czytelny komunikat, gdy logika nie ma jeszcze
endpointu silnika.

Zweryfikowano end-to-end: formularz → przeliczenie UTC → render tabeli
(przez stub kontraktu /chart/positions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:08:43 +02:00
gitea e69c0714b9 Silnik efemeryd: EngineProvider + SkyfieldEngine + harness porównawczy
Pierwszy increment implementacji warstwy logicznej (ścieżka A).

LOG-24: interfejs EphemerisEngine z dwoma backendami — SkyfieldEngine
  (własny, permisywny: Skyfield MIT + dane JPL public domain) oraz
  RemoteEngine (klient izolowanej usługi swisseph). Fabryka + leniwa
  inicjalizacja; endpointy /chart/positions i /chart/compare.
LOG-01: pozycje obiektów (długość/szerokość ekliptyczna, prędkość,
  kierunek, formaty: w znaku / absolutny / dziesiętny).
LOG-25/28: harness porównawczy (compare.py) z progami tolerancji oraz
  wspólny kontrakt parzystości; pełen zestaw testów.
LOG-27: services/engine-swisseph — osobna, opcjonalna usługa AGPL
  (pyswisseph, tryb Moshiera), licencjonowana osobno, w compose pod
  profilem "comparison"; nie wchodzi do zamkniętego produktu.

Walidacja: SkyfieldEngine zgadza się ze Swiss Ephemeris co do ~1" dla
wszystkich 10 obiektów na horoskopie referencyjnym (30.04.1984, Warszawa);
12 testów przechodzi (silnik B pomijany gdy nieskonfigurowany).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 19:48:31 +02:00
gitea 9bf297d461 Merge pull request #4 from migatu/docs/plain-language-column
Wymagania: nietechniczna kolumna wyjaśnień (Po ludzku, bez żargonu)
2026-06-27 18:22:03 +02:00
gitea 561d95c7f4 Wymagania: kolumna "Po ludzku (bez żargonu)" — nietechniczne wyjaśnienia
Dodaje per-requirement, kompletnie nietechniczne tłumaczenie do trzech
arkuszy warstw (dane 24, logika 28, prezentacja 13) jako ostatnią kolumnę.
Dla osób nietechnicznych — m.in. współpracownika dostarczającego bazy —
żeby każdy wiersz był zrozumiały bez żargonu. Autofiltr rozszerzony.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 17:16:33 +02:00
gitea 4231f640b8 Merge pull request #3 from migatu/requirements/dual-engine
Wymagania: dwa silniki w warstwie logicznej (własny + AGPL) do porównań
2026-06-27 17:01:16 +02:00
gitea f86ab55b29 Merge pull request #2 from migatu/docs/requirements-spreadsheet
Dodaj arkusz wymagań projektu (model trójwarstwowy)
2026-06-27 16:53:29 +02:00
gitea 21442b35b9 Wymagania: dwa silniki w warstwie logicznej (własny + AGPL) do porównań
Decyzja: warstwa logiczna ma dwie wymienne implementacje silnika obliczeń —
własną/permisywną (ścieżka A) i AGPL (Swiss Ephemeris) — dla wbudowanego
frameworku porównań i walidacji. Prezentacja i dane pozostają wspólne.

- docs/architektura-dwoch-silnikow.md: schemat, izolacja licencyjna silnika
  AGPL jako osobnej usługi (engine-swisseph), mechanizm dual-run, mapowanie
  na wymagania, profile wdrożeniowe.
- astrololo_wymagania.xlsx (Warstwa logiczna): rewizja LOG-24 (EngineProvider
  z dwoma backendami) i LOG-25 (framework porównawczy); nowe LOG-26 (dual-run
  + raport różnic), LOG-27 (izolacja licencyjna silnika AGPL), LOG-28
  (kontrakt parzystości silników). Licznik w Przeglądzie: 28.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:50:56 +02:00
gitea 3655b39acf Rozszerz analizę warstwy logicznej: gotowce, licencje, złożoność + LOG-25
- docs/przeglad-bibliotek-i-licencji.md: przegląd istniejących bibliotek/
  programów z komentarzem licencyjnym i oceną ryzyka praw autorskich;
  rekomendacja ścieżki A (Skyfield/Moshier permisywne) + nota o prawach do
  treści baz.
- docs/warstwa-logiczna-analiza.md: rozwinięcie 24 wymagań LOG, przypisanie
  permisywnych gotowców, ocena złożoności implementacji od zera, rola B/C
  jako wyroczni walidacyjnej; kolejność budowy.
- astrololo_wymagania.xlsx: w arkuszu "Warstwa logiczna" 4 nowe kolumny
  (gotowe rozwiązanie / licencja / złożoność od zera / rola B/C) oraz nowy
  wiersz LOG-25 (harness walidacyjny). Licznik w Przeglądzie zaktualizowany.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 12:31:37 +02:00
gitea 7bda39ab46 Dodaj arkusz wymagań projektu (model trójwarstwowy)
docs/astrololo_wymagania.xlsx — spójna lista wymagań zsyntetyzowana z
dokumentów astroparser_notes2, astroparser notes3 i astro19 version2026.

Sześć arkuszy: Przegląd, Warstwa danych (24), Warstwa logiczna (24),
Warstwa prezentacji (13), Pytania otwarte (11), Słownik (25). Każde
wymaganie: ID, kategoria, opis, szczegóły, priorytet (MoSCoW), status,
źródło. Dokument roboczy do iteracyjnego przeglądu przed implementacją.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:18:46 +02:00
gitea 269c89152e Merge pull request #1 from migatu/scaffold/three-tier-app
Szkielet aplikacji trójwarstwowej (prezentacja / logika / dane)
2026-06-26 17:36:22 +02:00
52 changed files with 2190 additions and 100 deletions
+4
View File
@@ -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
+57 -15
View File
@@ -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
+10 -3
View File
@@ -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.
+12
View File
@@ -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:
+84
View File
@@ -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** (engineagnostic). 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 = defacto 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 sidebyside 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 (dualrun)
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 sidebyside** w prezentacji na żądanie.
---
## 5. Wpływ na warstwy i mapowanie na wymagania
| Warstwa | Zmiana |
|---|---|
| Prezentacja | brak (opcjonalnie: widok porównawczy sidebyside) |
| Dane | brak |
| **Logika** | `EngineProvider` + 2 backendy (EngineA inproc, EngineB jako klient usługi AGPL) + komparator |
| **Nowa usługa** | `engine-swisseph` (AGPL, osobno, opcjonalna) |
Wymagania (arkusz „Warstwa logiczna"):
- **LOG24** — interfejs `EngineProvider` z **dwoma** backendami (zrewidowane).
- **LOG25** — framework walidacyjnoporównawczy (zrewidowane).
- **LOG26** — tryb dualrun + raport różnic (nowe).
- **LOG27** — izolacja licencyjna silnika AGPL jako osobnej usługi (nowe).
- **LOG28** — 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.
+110
View File
@@ -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ą **AGPL3.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`) | Defacto standard: pozycje obiektów, domy, aspekty, gwiazdy stałe, lots — pełna astrologia, najwyższa dokładność | **Dual: AGPL3.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 | **AGPL3.0** | 🔴 To prawdopodobnie „kalkulator z GitHuba", o którym wiesz. Sam wrapper jest AGPL niezależnie od licencji silnika. |
| **swissephwasm** (`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 (researchgrade), 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 15502650; DE441: 13200 do +17191. |
| **Moshier ephemeris** (Steve Moshier, `moshier.net`) | Semianalityczna 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) | **BSD3** | 🟢 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 | **AGPL3.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 | **GPL3.0+** | 🔴 GPL (sieciowo łagodniejsze niż AGPL, ale wciąż copyleft); ciągnie AGPLowy 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 | **AGPL3.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 / frontend (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 | opensource (zweryfikować plik LICENSE) | 🟡 Brak obliczeń = brak ryzyka efemeryd. Sprawdzić dokładnie licencję przed użyciem. |
| **swissephwasm** | (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 (timezoneboundarybuilder, ODbL) — przy dystrybucji bazy wymagana atrybucja i klauzula sharealike **dla samej bazy** (nie dla Twojego kodu). |
| **GeoNames** (baza miejscowości) | Miejscowości + współrzędne + strefy (do dropdowna lokalizacji) | **CCBY 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`) | **GPL2.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** (opensource) | 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ń (**Q01**: produkt zamknięty/hostowany czy nie) i **Q07** (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 longterm)**
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ąć AGPLowy 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ąć AGPLowych 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/) · [MoshierEphemerisJS](https://github.com/0xStarcat/Moshier-Ephemeris-JS)
- [CircularNatalHoroscopeJS](https://github.com/0xStarcat/CircularNatalHoroscopeJS) · [AstroChart (`Kibo/AstroChart`)](https://github.com/Kibo/AstroChart) · [swissephwasm](https://github.com/prolaxu/swisseph-wasm)
- [timezonefinder (PyPI, MIT)](https://pypi.org/project/timezonefinder/) · [Astrolog (Wikipedia)](https://en.wikipedia.org/wiki/Astrolog)
+174
View File
@@ -0,0 +1,174 @@
# Warstwa logiczna — analiza, rozwinięcie wymagań i przypisanie rozwiązań
Dokument roboczy. Rozwinięcie 24 wymagań warstwy logicznej (LOG01…LOG24 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: **~7080% 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 |
|----|-----------|-------------------------------|-------------------|----------|
| LOG01 | Pozycje obiektów | **Skyfield** (komplet) | Niska | walidacja pozycji |
| LOG02 | Taksonomia obiektów | Skyfield (planety, Księżyc), Hipparcos (gwiazdy) | **Wysoka** (true Node/Lilith, gwiazdy) | walidacja węzłów/Lilith |
| LOG03 | Kierunek i stacje | Skyfield (find_discrete) | Średnia | walidacja dat stacji |
| LOG04 | Systemy zodiaku / ayanamsy | Skyfield (tropikalny, RA), pyerfa (precesja) | Średnia | walidacja ayanams |
| LOG05 | Systemy domów (wiele) | pyerfa (LST, obliquity); reszta własna | **Wysoka** | walidacja cusps (kluczowa) |
| LOG06 | Aspekty + orby | własne (czysta matematyka) | Niska | walidacja list aspektów |
| LOG07 | Aspekty pozazodiakalne | Skyfield (deklinacja) | Niska | walidacja |
| LOG08 | Lots | własne (arytmetyka + DSL formuł) | Niska–Średnia | porównanie wartości |
| LOG09 | **Primary Directions** | brak permisywnego | **Bardzo wysoka (XL)** | **krytyczna** (Morinus) |
| LOG10 | Profekcje | własne (arytmetyka + tabele władców) | Niska–Średnia | tabela kontrolna |
| LOG11 | Zod. Releasing / Firdaria / Decennials | brak permisywnego | Średnia (tabledriven) | walidacja sekwencji |
| LOG12 | Returns (Solar/Lunar) | Skyfield (rootfinding) | Niska–Średnia | walidacja czasu powrotu |
| LOG13 | Ascensional Times | pyerfa/własne (oblique ascension) | Średnia | tabela kontrolna |
| LOG14 | Zbiorcza tabela dat | własne (orkiestracja) | Średnia | — |
| LOG15 | **Parser sygnifikatorów** | Lark/pyparsing (infrastruktura) | **Wysoka** (unikalne IP) | brak referencji |
| LOG16 | Precompute „atomów" | własne | Średnia | — |
| LOG17 | Interpretacja „asp"/„asp±" | własne (config + LOG06) | Niska–Średnia | — |
| LOG18 | Warstwy 1A/1B | własne (orkiestracja ↔ warstwa danych) | Średnia | — |
| LOG19 | Wypis interpretacji 2B | własne (↔ DataProvider) | Średnia | — |
| LOG20 | Kolumny sygnif. + „no of hits" | własne | Niska–Średnia | — |
| LOG21 | Scoring siły efektu | własne (rule/datadriven) | Średnia | — |
| LOG22 | Konwersja tekst ↔ symbol | własne (tablice z notes3) | Niska | — |
| LOG23 | Pozostałe wyliczenia | Skyfield (zdarzenia) | Niska | walidacja |
| LOG24 | Abstrakcja silnika efemeryd | architektura | Niska | — |
**Tylko 3 pozycje są naprawdę trudne:** LOG09 (primary directions, XL), LOG05 (pełny zestaw domów, Wysoka), LOG15 (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 (LOG01, 02, 03, 24)
**LOG01 · Pozycje obiektów.**
*Rozwinięcie:* długość i szerokość ekliptyczna (ofdate), prędkość (różniczkowanie pozycji), kierunek; formaty: DMS w znaku (Tau 28°12'57''), absolutne 0360°, 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″.
**LOG02 · 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; **ŚredniaWysoka**).
- **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 LOG05.
*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!).
**LOG03 · 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.
**LOG24 · Abstrakcja silnika.**
*Rozwinięcie:* interfejs `EphemerisEngine` z implementacjami `SkyfieldEngine` (główna, MIT+JPL), `MoshierEngine` (fallback w pełni publicdomain, 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 (LOG04, 05)
**LOG04 · Systemy zodiaku / ayanamsy.**
*Rozwinięcie:* tropikalny (domyślny, 🟢), syderyczny = tropikalny ayanamsa(t) (Lahiri, FaganBradley… ~40 w SE; my zaczynamy od kilku + tabela rozszerzalna), draconic = tropikalny długość NN (🟢 po LOG02), 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.
**LOG05 · 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 (LOG06, 07)
**LOG06 · 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, ±%, perplaneta/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.
**LOG07 · Aspekty pozazodiakalne.**
*Rozwinięcie:* parallel/contraparallel (po deklinacji — Skyfield 🟢), antiscia/contraantiscia (lustro względem osi przesileń — arytmetyka). *Złożoność: Niska.*
### Grupa D — Lots (LOG08)
**LOG08 · 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 Q03). *B/C:* porównanie wartości stopni.
### Grupa E — Techniki predykcyjne (LOG09…14)
**LOG09 · Primary Directions.** ⚠️ **najtrudniejsze (XL).**
*Rozwinięcie:* łuk dyrekcji (semiarc/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 58 (Q02), 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).
**LOG10 · 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).
**LOG11 · 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.
**LOG12 · Returns (Solar/Lunar).**
*Rozwinięcie:* rootfinding 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 LOG01…06. *Złożoność: Niska–Średnia.* *B/C:* czas powrotu z astro.com.
**LOG13 · 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 datadriven (tabela DAN07). *Ścieżka A:* wzory powiązane z LOG05 (trygonometria sferyczna) + pyerfa. *Złożoność: Średnia.*
**LOG14 · 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 rootfinding z LOG12). *Ścieżka A:* orkiestracja/glue. *Złożoność: Średnia.*
### Grupa F — Parser sygnifikatorów i dopasowanie (LOG15…20) — **rdzeń unikalnego IP**
**LOG15 · 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, „rAsc", whole sign+orb); 7 poziomów złożoności — od poziomu „obiekt" po sygnifikatory opisowe/niejasne (poziomy 57 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.
**LOG16 · 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, rAsc Ari…); dopasowanie rekordu bazy = przynależność do zbioru. Zależy od LOG01…08 (policzony horoskop) + LOG15 (słownik). *Złożoność: Średnia* (generacja kombinatoryczna + indeks; wrażliwe wydajnościowo — łączy się z DAN22).
**LOG17 · Interpretacja „asp"/„asp+"/„asp"/„asp malefic".**
*Rozwinięcie:* rozwinięcie wg konfigurowalnej tabeli pomocniczej (jakie aspekty, orby, active) + LOG06. *Złożoność: Niska–Średnia.*
**LOG18/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 / asisinchart), „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 (LOG21, 22, 23)
**LOG21 · 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).
**LOG22 · 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 Q05 (prezentacja).
**LOG23 · 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 SuMo), syzygia prenatalna (rootfind 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):** LOG24 (interfejs) → LOG01 → LOG02 (bez gwiazd/trueLilith) → LOG04 (tropikalny+RA) → LOG06/07 → LOG05 (Whole/Equal/Porphyry+Placidus) → LOG23 (podstawy). *Tu Skyfield daje ogromny skok.*
2. **Astrologia rdzenia:** LOG08 (Lots) → LOG15 (parser) → LOG16 → LOG18/19/20 (dopasowanie ↔ warstwa danych). *Tu powstaje unikalna wartość i pełny przepływ „horoskop → interpretacje".*
3. **Predykcja prosta:** LOG10 (profekcje) → LOG12 (returns) → LOG14 (agregacja) → LOG11 (timelordy) → LOG13.
4. **Predykcja trudna i dopieszczenie:** LOG09 (primary directions, XL) → LOG02 dokończenie (gwiazdy, true Node/Lilith) → LOG04 (ayanamsy) → LOG21 (scoring) → LOG05 (pełny zestaw domów).
Logika: najpierw to, co Skyfield daje tanio i co odblokowuje przepływ endtoend; 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):** **LOG25 — 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
View File
@@ -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)
+1 -1
View File
@@ -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]
+10
View File
@@ -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"]
+19
View File
@@ -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.
+24
View File
@@ -0,0 +1,24 @@
# engine-swisseph (silnik B — AGPL, izolowany)
Osobna, **opcjonalna** usługa będąca drugim silnikiem efemeryd (LOG27). Liczy
pozycje przez **pyswisseph / Swiss Ephemeris** i służy jako **wyrocznia
walidacyjna / tryb porównawczy** (LOG25/26) dla naszego silnika własnego.
## ⚠️ Licencja
Ten komponent jest **AGPL3.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).
+65
View File
@@ -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
+20
View File
@@ -6,8 +6,28 @@ i nie w bazie.
## API ## API
- `POST /api/query``QueryRequest``QueryResponse` - `POST /api/query``QueryRequest``QueryResponse`
- `POST /chart/positions``{when_utc, lat, lon, objects?}` → pozycje obiektów (LOG-01)
- `POST /chart/report``{when_utc, lat, lon, limit?}` → wynik obliczeń wyszukany w bazie: z pozycji + domów generuje sygnifikatory (fasety: planeta w znaku i w domu) i zwraca pasujące interpretacje z warstwy danych (zalążek LOG-16/18/19)
- `POST /chart/positions``{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`.
- `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.
+58
View File
@@ -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
+10 -3
View File
@@ -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()
+24
View File
@@ -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"}
+50
View File
@@ -0,0 +1,50 @@
"""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:
positions = engine.positions(moment)
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
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
+104
View File
@@ -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"
+30
View File
@@ -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
+52
View File
@@ -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 0360°. 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 0360°."""
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)
+77
View File
@@ -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
+64
View File
@@ -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 0360° (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}
+78
View File
@@ -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,61 @@ 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
@app.post("/chart/report")
def chart_report(req: ReportRequest) -> dict:
"""Wynik obliczeń szukany w bazie: z pozycji + domów generuje sygnifikatory
(fasety znak/dom) i pyta warstwę danych o pasujące 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 numerami domów
try:
report = build_report(chart["positions"], DataClient(), per_object_limit=req.limit)
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"}
+125
View File
@@ -0,0 +1,125 @@
"""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
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 _facet_samples(rows: list[dict], token: str, limit: int = 4) -> list[dict]:
"""Rekordy, których sygnifikator zawiera token — bez szumu."""
out: list[dict] = []
tok = token.lower()
for r in rows:
sig = str(r.get("significator") or "")
if tok not in sig.lower():
continue
eff = _effect(r)
if _is_noise(sig, eff):
continue
out.append({"significator": sig.strip(), "expanded": expand(sig.strip()), "effect": eff})
return out
def build_report(positions: list[dict], data: DataSource, per_object_limit: int = 5000) -> dict:
"""positions: pozycje z build_chart (name, sign, direction, house).
Dla każdego obiektu: jedno zapytanie o token planety, potem faseta „w znaku"
i „w domu" (jeśli dom policzony).
"""
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,
})
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}
+2
View File
@@ -0,0 +1,2 @@
-r requirements.txt
pytest>=8.0
+2
View File
@@ -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
+31
View File
@@ -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("") == ""
+27
View File
@@ -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
+35
View File
@@ -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)
+28
View File
@@ -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")
+41
View File
@@ -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 (90120°)
assert H.assign_house(100.0, cusps) == 1 # w Raku
assert H.assign_house(40.0, cusps) == 11 # Byk -> 11. dom
@@ -0,0 +1,48 @@
"""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_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"
+12 -3
View File
@@ -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,32 @@ 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) -> 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}
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()
+92 -5
View File
@@ -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,36 @@ 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),
):
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon}
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)
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,15 @@ 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; }
.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,112 @@
{% 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></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.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);
});
</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,80 @@
{% 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="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="muted small">[{{ f.token }}]</span></div>
{% if 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);
});
</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 %}