Compare commits

...

21 Commits

Author SHA1 Message Date
gitea 8d579de34a Aspekty: applying/separating (A/S) + bonus siły dla aplikujących (LOG-06)
- aspects.py: _is_applying — odchyłka od dokładnego kąta teraz vs po małym
  kroku czasu (prędkość·dt, dt=0,01 doby by szybki Księżyc nie przeskoczył
  dokładności); aspekt niesie applying (bool) i "as": A/S. Bez prędkości —
  brak flagi (None).
- scoring: aspekt aplikacyjny silniejszy (APPLYING_BONUS 1.15) — zgodnie z
  notatkami projektu ("impact considered more powerful").
- significators: faseta aspektu niesie applying, etykieta z sufiksem (A)/(S).
- widok Horoskop: kolumna A/S w tabeli aspektów (tooltip z objaśnieniem).

Walidacja: flagi A/S wszystkich 16 aspektów horoskopu referencyjnego
(30.04.1984, Warszawa) zgodne z tabelą astro-seek z notes3 (test regresyjny
REFERENCE_AS). 45 testów przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:22:51 +02:00
gitea db3d1e5117 Merge pull request #15 from migatu/fix/geo-secure-context
Geolokalizacja: jawny komunikat gdy brak secure context (http)
2026-07-07 19:01:19 +02:00
gitea 4a86d34f5a Geolokalizacja: jawny komunikat gdy brak secure context (http://)
Przyczyna "nie pyta o zgodę": navigator.geolocation działa tylko w secure
context (https:// lub localhost). Na http://<ip> przeglądarka po cichu
odmawia — bez promptu; kod nie miał callbacku błędu, więc nic nie było widać.

- wspólny static/now.js (deduplikacja skryptu z chart.html i interpret.html)
- jawna detekcja window.isSecureContext + czytelny komunikat w #geoNote
  ("wymaga HTTPS lub localhost — wpisz lat/lon ręcznie")
- callback błędu (odmowa/timeout) też widoczny; status "Pobieram lokalizację…"
  i potwierdzenie po sukcesie

Zweryfikowano: /static/now.js serwowany (200), obie strony referencjonują
skrypt i mają #geoNote.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 18:59:03 +02:00
gitea fb317172ff Merge pull request #14 from migatu/feat/scoring-grouping-geo
Ranking sily (LOG-21) + grupowanie identycznych opisow + geolokalizacja
2026-07-06 19:29:56 +02:00
gitea f6323cac10 Ranking siły (LOG-21) + grupowanie identycznych opisów + geolokalizacja
Punktacja siły (LOG-21):
- scoring.py: siła fasety z sygnałów obliczalnych (typ fasety, rodzaj aspektu,
  ciasnota orbu). Konfigurowalne wagi. Hook na przyszłość: kolumny countas*/level*
  z SIGNIFICATORS KEY (obecnie puste).
- aspekty niosą orb+allowed; fasety dostają "score"; ranking faset malejąco.

Grupowanie:
- opcja group: zwija próbki po opisie (ten sam efekt = jedna grupa z listą
  sygnifikatorów i licznikiem). Checkbox "grupuj identyczne opisy" w /interpret.

Geolokalizacja (bajer):
- "Tu i teraz" (widok Horoskop i Interpretacje) uzupełnia lat/lon z przeglądarki
  (navigator.geolocation; wymaga zgody, https/localhost).

Zweryfikowano na realnym main_base.xlsx: ranking sensowny (ciasna opozycja z
Saturn 9.59 > szeroka koniunkcja z Moon 6.25 > znak/dom 5.0); grupowanie zwija
powtórzone opisy. 41 testów przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 01:16:47 +02:00
gitea 243d02d55b Merge pull request #13 from migatu/feat/aspects
Aspekty (LOG-06) + faseta aspektu, dedup i dopieszczenie wynikow
2026-07-02 10:18:59 +02:00
gitea 166b438f83 Aspekty (LOG-06) + faseta aspektu, dedup i dopieszczenie wyników
Aspekty:
- engine/aspects.py: aspekty główne (conj/sex/sq/tri/opp) z orbami
  (bonus dla luminarzy), separacja z obsługą zawinięcia. Applying/sep na później.
- build_chart zwraca listę aspektów; /chart/positions je udostępnia;
  widok Horoskop pokazuje tabelę aspektów.

Bogatsze sygnifikatory:
- trzecia faseta "w aspekcie": dla każdego aspektu głównego obiektu filtruje
  rekordy po tokenie aspektu + drugiej planety ([conj + [Mo). Cookbook
  komplet: znak + dom + aspekt.

Dopieszczenie wyników:
- ODSIEWANIE DUPLIKATÓW: duplikat = ten sam sygnifikator ORAZ ten sam opis
  (po normalizacji). Dedup wewnątrz fasety, działa też na wynikach z wielu baz.
- _facet_samples przyjmuje wiele tokenów (AND); dedup + istniejące odsiewanie szumu.

Zweryfikowano na realnym main_base.xlsx (30.04.1984): 16 aspektów zgodnych z
astro.com (Sun conj Moon 9.59°, Sun opp Saturn 3.17°); faseta aspektu daje
bogate trafienia (Sun koniunkcja z Moon 84, opozycja z Saturn 43); dedup obniżył
duplikaty (Sun w znaku 46->44). 36 testów przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 08:55:27 +02:00
gitea 41d7ca491d Merge pull request #12 from migatu/feat/noise-and-abbrev
Odsiewanie szumu + rozwijanie skrótów sygnifikatorów
2026-07-01 16:01:02 +02:00
gitea 6acd3546fb Merge pull request #10 from migatu/feat/richer-significators
Bogatsze sygnifikatory: faseta w domu obok w znaku (LOG-15/16)
2026-07-01 15:58:08 +02:00
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
31 changed files with 1433 additions and 104 deletions
+60 -18
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:
@echo "up - uruchom wszystkie 3 warstwy (docker compose)"
@echo "down - zatrzymaj"
@echo "sample - wygeneruj przykładowe pliki .xlsx"
@echo "reindex - zbuduj cache + indeks warstwy bazodanowej"
@echo "migrate - ETL: Excel -> SQL"
@echo "sql - uruchom z warstwą SQL (DATA_PROVIDER=sql)"
@echo "dev-* - uruchom pojedynczą warstwę lokalnie (bez dockera)"
@echo "install - zainstaluj zależności WSZYSTKICH warstw do aktywnego venv"
@echo "sample - wygeneruj przykładowe pliki .xlsx"
@echo "dev-data - uruchom warstwę danych lokalnie (:8002)"
@echo "dev-logic - uruchom warstwę logiczną lokalnie (:8001)"
@echo "dev-presentation - uruchom warstwę prezentacji lokalnie (:8000)"
@echo "test - testy warstwy logicznej (silnik efemeryd)"
@echo "clean-cache - wyczyść regenerowalny cache warstwy danych"
@echo "reindex - zbuduj cache + indeks warstwy bazodanowej"
@echo "migrate - ETL: Excel -> SQL"
@echo "up / down - uruchom / zatrzymaj cały stos (Docker; wykryto: $(COMPOSE))"
@echo "sql - Docker z warstwą SQL (DATA_PROVIDER=sql)"
@echo ""
@echo "Szybki start lokalny (bez Dockera), w 3 terminalach:"
@echo " make install"
@echo " make sample # opcjonalnie: dane przykładowe"
@echo " make dev-data # terminal 1"
@echo " make dev-logic # terminal 2"
@echo " make dev-presentation # terminal 3 -> http://localhost:8000"
up:
docker compose up --build
down:
docker compose down
# --- instalacja zależności do aktywnego środowiska ---
install:
$(PY) -m pip install \
-r services/data/requirements.txt \
-r services/logic/requirements.txt \
-r services/presentation/requirements.txt
@echo "OK. Zależności warstw zainstalowane. (Silnik AGPL 'engine-swisseph' instaluje się osobno.)"
sample:
cd services/data && python scripts/make_sample_data.py
cd services/data && $(PY) scripts/make_sample_data.py
reindex:
cd services/data && python -m app.ingest.build_index
cd services/data && $(PY) -m app.ingest.build_index
migrate:
cd services/data && python -m app.ingest.to_sql
cd services/data && $(PY) -m app.ingest.to_sql
sql:
DATA_PROVIDER=sql docker compose up --build
test:
cd services/logic && PYTHONPATH=. $(PY) -m pytest tests -q
clean-cache:
rm -rf services/data/.cache/* services/logic/.ephemeris/* 2>/dev/null || true
@echo "Cache wyczyszczony."
# --- lokalny dev (3 osobne terminale) ---
dev-data:
@@ -36,3 +61,20 @@ dev-logic:
dev-presentation:
cd services/presentation && LOGIC_URL=http://localhost:8001 uvicorn app.main:app --reload --port 8000
# --- Docker (opcjonalnie) ---
up:
@if [ -z "$(COMPOSE)" ]; then \
echo "Nie znaleziono Docker Compose (ani 'docker compose', ani 'docker-compose')."; \
echo "Użyj trybu lokalnego: make install, potem dev-data / dev-logic / dev-presentation."; \
exit 1; \
fi
$(COMPOSE) up --build
down:
@if [ -z "$(COMPOSE)" ]; then echo "Brak Docker Compose."; exit 1; fi
$(COMPOSE) down
sql:
@if [ -z "$(COMPOSE)" ]; then echo "Brak Docker Compose."; exit 1; fi
DATA_PROVIDER=sql $(COMPOSE) up --build
+10 -3
View File
@@ -28,13 +28,20 @@ make up # zbuduj i uruchom 3 warstwy
# 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
cd services/data && pip install -r requirements.txt && python scripts/make_sample_data.py
make dev-data # terminal 1 -> :8002
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
- Wymień prezentację (np. na SPA/React) → reszta bez zmian, kontrakt `/api/query` stały.
+7 -2
View File
@@ -31,5 +31,10 @@ class FrameCache:
return None
def put(self, fp: str, sheet: str, frame: pd.DataFrame) -> None:
# astype(str) na kolumnach object zapewnia stabilny zapis Parquet
frame.to_parquet(self._path(fp, sheet), index=False)
# Kolumny z heterogenicznych plików Excela bywają mieszane (int + str +
# NaN w jednej kolumnie) — pyarrow tego nie zapisze. Warstwa danych i tak
# operuje na tekście (wyszukiwanie po .astype(str)), więc zapisujemy ramkę
# jako string (brak wartości -> pusty tekst). Gwarantuje to stabilny zapis
# Parquet niezależnie od zawartości pliku źródłowego.
safe = frame.astype("string").fillna("")
safe.to_parquet(self._path(fp, sheet), index=False)
+1 -1
View File
@@ -18,7 +18,7 @@ class SearchQuery(BaseModel):
key: str = Field(..., description="Pole/kolumna kanoniczna, po której szukamy, np. 'name'.")
value: str = Field(..., description="Szukana wartość.")
exact: bool = Field(False, description="Dopasowanie dokładne vs. zawieranie (contains).")
limit: int = Field(50, ge=1, le=1000)
limit: int = Field(50, ge=1, le=50000)
fields: list[str] | None = Field(
None, description="Lista pól kanonicznych do zwrócenia; None = wszystkie."
)
@@ -82,7 +82,10 @@ class ExcelDataProvider(DataProvider):
def warmup(self) -> None:
for path in self._excel_files():
self._ensure_indexed(path)
try:
self._ensure_indexed(path)
except Exception as e: # jeden uszkodzony plik nie może zablokować startu
print(f"[data] pominięto plik przy indeksowaniu: {path}{e}")
def _excel_files(self) -> list[str]:
base = Path(self.s.excel_dir)
@@ -112,7 +115,9 @@ class ExcelDataProvider(DataProvider):
if query.exact:
mask = col.str.lower() == query.value.lower()
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]
if query.fields:
keep = [c for c in query.fields if c in matched.columns]
+2 -1
View File
@@ -6,7 +6,8 @@ i nie w bazie.
## API
- `POST /api/query``QueryRequest``QueryResponse`
- `POST /chart/positions``{when_utc, lat, lon, objects?}` → pozycje obiektów (LOG-01)
- `POST /chart/positions``{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty główne z applying/separating (LOG-06). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`.
- `POST /chart/report``{when_utc, lat, lon, limit?}` → wynik obliczeń wyszukany w bazie: fasety sygnifikatorów **w znaku / w domu / w aspekcie**, z rozwinięciem skrótów, odsiewaniem duplikatów (ten sam sygnifikator i opis), rankingiem siły (LOG-21) oraz opcją group (grupowanie identycznych opisów)
- `POST /chart/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B)
- `GET /health` (sprawdza też warstwę bazodanową)
+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:
self.base_url = (base_url or settings.data_url).rstrip("/")
def search(self, key: str, value: str, exact: bool, limit: int) -> dict[str, Any]:
payload = {"key": key, "value": value, "exact": exact, "limit": limit}
with httpx.Client(timeout=settings.http_timeout) as client:
def search(
self,
key: str,
value: str,
exact: bool,
limit: int,
fields: list[str] | None = None,
) -> dict[str, Any]:
payload = {"key": key, "value": value, "exact": exact, "limit": limit, "fields": fields}
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
r = client.post(f"{self.base_url}/search", json=payload)
r.raise_for_status()
return r.json()
+85
View File
@@ -0,0 +1,85 @@
"""Aspekty — kąty między obiektami (LOG-06, wersja: aspekty główne).
Czysta matematyka na policzonych długościach ekliptycznych. Dla każdej pary
obiektów sprawdzamy, czy ich separacja kątowa mieści się w orbie któregoś z
aspektów głównych. Applying/separating (aplikacja/separacja) — na później.
Tokeny bazy (z SIGNIFICATORS KEY): [conj, [sex, [sq, [tri, [opp.
"""
from __future__ import annotations
MAJOR = {
"conjunction": 0.0,
"sextile": 60.0,
"square": 90.0,
"trine": 120.0,
"opposition": 180.0,
}
DB_TOKEN = {
"conjunction": "[conj", "sextile": "[sex", "square": "[sq",
"trine": "[tri", "opposition": "[opp",
}
PL_NAME = {
"conjunction": "koniunkcja", "sextile": "sekstyl", "square": "kwadratura",
"trine": "trygon", "opposition": "opozycja",
}
LUMINARIES = {"Sun", "Moon"}
DEFAULT_ORB = 8.0
LUMINARY_BONUS = 2.0
def separation(a: float, b: float) -> float:
"""Najmniejsza separacja kątowa [0,180]."""
d = abs(a - b) % 360.0
return min(d, 360.0 - d)
def _is_applying(la: float, lb: float, sa: float, sb: float, angle: float, dt: float = 0.01) -> bool | None:
"""Czy aspekt aplikuje (dokładność 0° dopiero nastąpi)?
Porównujemy odchyłkę od dokładnego kąta teraz i po małym kroku czasu
(pozycje przesunięte o prędkość·dt). Malejąca odchyłka = applying.
dt celowo małe (0,01 doby), by szybki Księżyc nie „przeskoczył" dokładności.
Zwraca None, gdy brak prędkości (nie da się rozstrzygnąć).
"""
if sa is None or sb is None:
return None
dev_now = abs(separation(la, lb) - angle)
dev_next = abs(separation(la + sa * dt, lb + sb * dt) - angle)
return dev_next < dev_now
def find_aspects(
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS
) -> list[dict]:
"""positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę).
Zwraca listę aspektów głównych; gdy znane są prędkości, każdy aspekt ma
applying (bool) i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny).
"""
out: list[dict] = []
n = len(positions)
for i in range(n):
for j in range(i + 1, n):
a, b = positions[i], positions[j]
la, lb = a.get("decimal"), b.get("decimal")
if la is None or lb is None:
continue
sep = separation(float(la), float(lb))
allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0)
for asp, angle in MAJOR.items():
dev = abs(sep - angle)
if dev <= allowed:
applying = _is_applying(
float(la), float(lb), a.get("speed"), b.get("speed"), angle
)
row = {
"obj1": a["name"], "obj2": b["name"],
"aspect": asp, "orb": round(dev, 2), "allowed": round(allowed, 2),
}
if applying is not None:
row["applying"] = applying
row["as"] = "A" if applying else "S"
out.append(row)
break # jedna para = jeden aspekt
return out
+53
View File
@@ -0,0 +1,53 @@
"""Złożenie pełnego horoskopu: pozycje + osie + domy (LOG-01 + LOG-05).
Silnik-agnostyczne: potrzebuje tylko `positions()` oraz (dla osi/domów)
`sidereal()`. Jeśli silnik nie umie policzyć czasu gwiazdowego, zwraca same
pozycje.
"""
from __future__ import annotations
from app.engine import houses as H
from app.engine.base import EphemerisEngine
from app.engine.formats import SIGNS, in_sign, norm360, sign_index
from app.engine.models import ChartMoment
def _fmt(name: str, lon: float) -> dict:
return {
"name": name,
"sign": SIGNS[sign_index(lon)],
"in_sign": in_sign(lon),
"decimal": round(norm360(lon), 6),
}
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN) -> dict:
from app.engine.aspects import find_aspects
positions = engine.positions(moment)
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
if not hasattr(engine, "sidereal"):
return result
ramc, eps = engine.sidereal(moment)
asc = H.compute_asc(ramc, eps, moment.lat)
mc = H.compute_mc(ramc, eps)
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
cusp_list = H.cusps(asc, mc, system)
result["house_system"] = system
result["angles"] = {
"Asc": _fmt("Asc", asc),
"MC": _fmt("MC", mc),
"Dsc": _fmt("Dsc", norm360(asc + 180.0)),
"IC": _fmt("IC", norm360(mc + 180.0)),
}
result["cusps"] = [
{"house": i + 1, "sign": SIGNS[sign_index(c)], "in_sign": in_sign(c)}
for i, c in enumerate(cusp_list)
]
for pdict, obj in zip(result["positions"], positions):
pdict["house"] = H.assign_house(obj.longitude, cusp_list)
return result
+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
@@ -88,5 +88,17 @@ class SkyfieldEngine(EphemerisEngine):
)
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}
+33 -3
View File
@@ -37,6 +37,7 @@ class PositionsRequest(BaseModel):
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)
@@ -49,13 +50,13 @@ def query(req: QueryRequest) -> QueryResponse:
@app.post("/chart/positions")
def chart_positions(req: PositionsRequest) -> dict:
"""Pozycje obiektów dla danego momentu (LOG-01), liczone aktywnym silnikiem."""
"""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)
positions = engine.positions(moment, req.objects)
return {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
return build_chart(engine, moment, req.house_system)
@app.post("/chart/compare")
@@ -77,6 +78,35 @@ def chart_compare(req: PositionsRequest) -> dict:
return report.summary()
class ReportRequest(BaseModel):
when_utc: datetime
lat: float = 0.0
lon: float = 0.0
limit: int = 5000
group: bool = False # grupowanie identycznych opisów
@app.post("/chart/report")
def chart_report(req: ReportRequest) -> dict:
"""Wynik obliczeń szukany w bazie: z pozycji + domów + aspektów generuje
sygnifikatory (fasety znak/dom/aspekt) i pyta warstwę danych o interpretacje."""
from app.engine.chart import build_chart
from app.engine.models import ChartMoment
from app.significators import build_report
engine = get_engine()
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
chart = build_chart(engine, moment) # pozycje z domami + aspekty
try:
report = build_report(
chart["positions"], DataClient(),
aspects=chart.get("aspects"), per_object_limit=req.limit, group=req.group,
)
except httpx.HTTPError as e:
return {"engine": engine.name, "objects": [], "data_error": f"Warstwa danych niedostępna: {e}"}
return {"engine": engine.name, **report}
@app.get("/health")
def health() -> dict:
info = {"status": "ok", "layer": "logic"}
+37
View File
@@ -0,0 +1,37 @@
"""Punktacja siły trafień — do rankingowania faset (zalążek LOG-21).
v1 liczy siłę z sygnałów OBLICZALNYCH:
- typ fasety (aspekt zwykle mocniejszy od znaku/domu),
- rodzaj aspektu (koniunkcja/opozycja mocniejsze od sekstyla),
- ciasnota orbu (im bliżej dokładności, tym mocniej).
Wszystko konfigurowalne. HOOK NA PRZYSZŁOŚĆ: gdy w SIGNIFICATORS KEY zostaną
wypełnione kolumny `countas*`/`level*`, można je tu domieszać per sygnifikator.
"""
from __future__ import annotations
# bazowe wagi faset (łatwe do strojenia)
FACET_BASE = {"sign": 5.0, "house": 5.0, "aspect": 6.0}
# względna siła aspektów głównych
ASPECT_WEIGHT = {
"conjunction": 1.0, "opposition": 0.95, "square": 0.85,
"trine": 0.85, "sextile": 0.65,
}
# aspekt aplikacyjny (A) jest silniejszy niż separacyjny (S) — notes3
APPLYING_BONUS = 1.15
def score_facet(facet: dict) -> float:
"""Siła fasety w skali ~012. Deterministyczna, konfigurowalna."""
base = FACET_BASE.get(facet.get("type"), 1.0)
if facet.get("type") == "aspect":
weight = ASPECT_WEIGHT.get(facet.get("aspect"), 0.7)
orb = facet.get("orb")
allowed = facet.get("allowed") or 10.0
tight = max(0.0, 1.0 - orb / allowed) if orb is not None and allowed else 0.0
score = base * weight * (1.0 + tight)
if facet.get("applying"):
score *= APPLYING_BONUS
return round(score, 2)
return round(base, 2)
+195
View File
@@ -0,0 +1,195 @@
"""Most: policzony horoskop → tokeny sygnifikatorów → wyszukiwanie w bazie.
Zalążek LOG-15/16: z pozycji obiektów (wraz z domami) generujemy tokeny w składni
bazy i wyszukujemy pasujące rekordy. Dla każdego obiektu tworzymy kilka **faset**:
- „w znaku" — planeta + token znaku (`[Su` + `[Tau`)
- „w domu" — planeta + token domu (`[Su` + `11th H.`)
Aspekty (`[conj`,`[sq`,`[opp`) wymagają policzenia aspektów (LOG-06) — na później.
Format skrótów odczytany z realnej bazy: planety `[Su`,`[Mo`,…; znaki
`[Ari`,`[Tau`,…; domy `12th H.`; np. `[Sa [conj [Su in 6th H.`.
"""
from __future__ import annotations
from typing import Any, Protocol
from app.abbreviations import expand
from app.engine.formats import SIGN_ABBR, SIGNS
from app.scoring import score_facet
PLANET_ABBR = {
"Sun": "Su", "Moon": "Mo", "Mercury": "Me", "Venus": "Ve", "Mars": "Ma",
"Jupiter": "Ju", "Saturn": "Sa", "Uranus": "Ur", "Neptune": "Ne", "Pluto": "Pl",
}
SIGN_TO_ABBR = dict(zip(SIGNS, SIGN_ABBR))
class DataSource(Protocol):
def search(
self, key: str, value: str, exact: bool, limit: int, fields: list[str] | None = None
) -> dict[str, Any]: ...
def _effect(row: dict) -> str:
for col in ("actioneffect", "topicresult", "bodypart"):
v = row.get(col)
if v and str(v).strip().lower() not in ("", "nan"):
return str(v).strip()
return ""
def _is_noise(sig: str, effect: str) -> bool:
s = sig.strip().lower()
e = effect.strip().lower()
return (
e in ("", "nan", "x", "x?", "?", "-") # efekt pusty/zastępczy
or s.startswith("significator") # wiersz-legenda/nagłówek
or "header" in s
or s.startswith("*") # *MARKER / *header
or s in ("x", "x?", "nan") # znacznik „pomiń rekord"
)
def _ordinal(n: int) -> str:
if 10 <= n % 100 <= 20:
suffix = "th"
else:
suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
return f"{n}{suffix}"
def _norm(s: str) -> str:
"""Normalizacja do porównań duplikatów: bez skrajnych spacji, jedna spacja, lower."""
return " ".join(str(s).strip().lower().split())
def _facet_samples(rows: list[dict], tokens: list[str]) -> list[dict]:
"""Rekordy, których sygnifikator zawiera WSZYSTKIE tokeny — bez szumu i bez duplikatów.
Duplikat = ten sam sygnifikator ORAZ ten sam opis (po normalizacji). Dedup
działa na zagregowanym wyniku, więc odsiewa też powtórki między wieloma bazami.
"""
toks = [t.lower() for t in tokens if t]
out: list[dict] = []
seen: set[tuple[str, str]] = set()
for r in rows:
sig = str(r.get("significator") or "").strip()
low = sig.lower()
if not all(t in low for t in toks):
continue
eff = _effect(r)
if _is_noise(sig, eff):
continue
key = (_norm(sig), _norm(eff))
if key in seen:
continue
seen.add(key)
out.append({"significator": sig, "expanded": expand(sig), "effect": eff})
return out
def _group_by_effect(samples: list[dict]) -> list[dict]:
"""Grupuje próbki po opisie: ten sam efekt = jedna grupa z listą sygnifikatorów."""
groups: dict[str, dict] = {}
order: list[str] = []
for s in samples:
key = _norm(s["effect"])
g = groups.get(key)
if g is None:
g = {"effect": s["effect"], "count": 0, "significators": []}
groups[key] = g
order.append(key)
g["count"] += 1
g["significators"].append(s["expanded"])
result = [groups[k] for k in order]
result.sort(key=lambda g: g["count"], reverse=True)
return result
def build_report(
positions: list[dict],
data: DataSource,
aspects: list[dict] | None = None,
per_object_limit: int = 5000,
group: bool = False,
) -> dict:
"""positions: pozycje z build_chart (name, sign, direction, house).
Dla każdego obiektu fasety: „w znaku", „w domu" oraz „w aspekcie" (dla każdego
aspektu głównego z listy `aspects`, jeśli w bazie są dopasowania). Duplikaty
(ten sam sygnifikator i opis) są odsiewane wewnątrz każdej fasety.
"""
from app.engine.aspects import DB_TOKEN as ASP_TOKEN, PL_NAME as ASP_NAME
items: list[dict] = []
provider = None
for p in positions:
name = p.get("name")
if name not in PLANET_ABBR:
continue
planet_tok = "[" + PLANET_ABBR[name]
raw = data.search(
key="significator",
value=planet_tok,
exact=False,
limit=per_object_limit,
fields=["significator", "actioneffect", "topicresult", "bodypart"],
)
provider = raw.get("provider", provider)
rows = raw.get("rows", [])
facets: list[dict] = []
sign = p.get("sign")
sign_tok = "[" + SIGN_TO_ABBR.get(sign, "")
sign_samples = _facet_samples(rows, [sign_tok])
facets.append({
"type": "sign", "label": f"w znaku {sign}", "token": sign_tok,
"count": len(sign_samples), "samples": sign_samples,
})
house = p.get("house")
if house:
ordn = _ordinal(int(house))
house_samples = _facet_samples(rows, [f"{ordn} h"]) # matcuje '12th H.'
facets.append({
"type": "house", "label": f"w {ordn} domu", "token": f"{ordn} H.",
"count": len(house_samples), "samples": house_samples,
})
for asp in (aspects or []):
if name not in (asp.get("obj1"), asp.get("obj2")):
continue
other = asp["obj2"] if asp["obj1"] == name else asp["obj1"]
asp_tok = ASP_TOKEN.get(asp["aspect"])
if other not in PLANET_ABBR or not asp_tok:
continue
other_tok = "[" + PLANET_ABBR[other]
asp_samples = _facet_samples(rows, [asp_tok, other_tok])
if not asp_samples: # pokazujemy tylko aspekty z trafieniami
continue
as_suffix = f" ({asp['as']})" if asp.get("as") else ""
facets.append({
"type": "aspect", "label": f"{ASP_NAME[asp['aspect']]} z {other}{as_suffix}",
"token": f"{asp_tok} + {other_tok}",
"aspect": asp["aspect"], "orb": asp.get("orb"), "allowed": asp.get("allowed"),
"applying": asp.get("applying"),
"count": len(asp_samples), "samples": asp_samples,
})
# punktacja siły (LOG-21), opcjonalne grupowanie po opisie, ranking faset
for f in facets:
f["score"] = score_facet(f)
if group:
f["groups"] = _group_by_effect(f["samples"])
facets.sort(key=lambda f: f["score"], reverse=True)
items.append({
"object": name,
"sign": sign,
"house": house,
"direction": p.get("direction"),
"planet_token": planet_tok,
"planet_total": raw.get("total", 0),
"facets": facets,
})
return {"provider": provider, "objects": items}
@@ -0,0 +1,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("") == ""
+90
View File
@@ -0,0 +1,90 @@
"""Testy aspektów (LOG-06) — czysta matematyka."""
from app.engine.aspects import find_aspects, separation
def test_separation_wraparound():
assert separation(10, 350) == 20
assert separation(0, 180) == 180
assert separation(0, 90) == 90
def test_conjunction_and_opposition():
pos = [
{"name": "Sun", "decimal": 10.0},
{"name": "Moon", "decimal": 12.0}, # 2° od Słońca -> koniunkcja
{"name": "Mars", "decimal": 190.0}, # 180° od Słońca -> opozycja
]
pairs = {(a["obj1"], a["obj2"], a["aspect"]) for a in find_aspects(pos)}
assert ("Sun", "Moon", "conjunction") in pairs
assert ("Sun", "Mars", "opposition") in pairs
def test_orb_limit_excludes_wide():
pos = [{"name": "Mercury", "decimal": 0.0}, {"name": "Venus", "decimal": 100.0}]
assert find_aspects(pos, orb=8.0, luminary_bonus=0.0) == []
def test_luminary_bonus_widens_orb():
# 99.5° -> 9.5° od kwadratury; z bonusem luminarza (8+2) mieści się
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Saturn", "decimal": 99.5}]
assert any(a["aspect"] == "square" for a in find_aspects(pos))
def test_one_aspect_per_pair():
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
assert len(find_aspects(pos)) == 1
def test_applying_when_faster_body_catches_up():
# Księżyc 5° za Słońcem, szybszy -> koniunkcja aplikacyjna
pos = [
{"name": "Sun", "decimal": 40.0, "speed": 0.96},
{"name": "Moon", "decimal": 35.0, "speed": 13.0},
]
a = find_aspects(pos)[0]
assert a["applying"] is True and a["as"] == "A"
def test_separating_when_moving_apart():
# Księżyc 5° przed Słońcem i szybszy -> koniunkcja separacyjna
pos = [
{"name": "Sun", "decimal": 40.0, "speed": 0.96},
{"name": "Moon", "decimal": 45.0, "speed": 13.0},
]
a = find_aspects(pos)[0]
assert a["applying"] is False and a["as"] == "S"
def test_no_as_flag_without_speeds():
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
assert "as" not in find_aspects(pos)[0]
# Referencja A/S z notes3 (astro-seek) dla horoskopu 30.04.1984 07:35 UT, Warszawa.
REFERENCE_AS = {
("Sun", "Moon", "conjunction"): "A",
("Sun", "Jupiter", "trine"): "A",
("Sun", "Saturn", "opposition"): "A",
("Sun", "Neptune", "trine"): "S",
("Sun", "Pluto", "opposition"): "S",
("Moon", "Mercury", "conjunction"): "S",
("Moon", "Venus", "conjunction"): "S",
("Moon", "Neptune", "trine"): "A",
("Moon", "Pluto", "opposition"): "S",
("Mercury", "Venus", "conjunction"): "S",
("Mercury", "Neptune", "trine"): "S",
("Mercury", "Pluto", "opposition"): "S",
("Venus", "Neptune", "trine"): "A",
("Venus", "Pluto", "opposition"): "A",
("Jupiter", "Saturn", "sextile"): "A",
("Neptune", "Pluto", "sextile"): "S",
}
def test_applying_separating_matches_astroseek_reference(own_engine, reference_moment):
from app.engine.chart import build_chart
chart = build_chart(own_engine, reference_moment)
got = {(a["obj1"], a["obj2"], a["aspect"]): a.get("as") for a in chart["aspects"]}
mismatches = {k: (got.get(k), v) for k, v in REFERENCE_AS.items() if got.get(k) != v}
assert not mismatches, f"rozbieżności A/S vs astro-seek: {mismatches}"
+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
+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
+20
View File
@@ -0,0 +1,20 @@
"""Testy punktacji siły faset (LOG-21)."""
from app.scoring import score_facet
def test_sign_and_house_base():
assert score_facet({"type": "sign"}) == 5.0
assert score_facet({"type": "house"}) == 5.0
def test_tighter_aspect_scores_higher():
tight = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 0.0, "allowed": 10.0})
loose = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 9.0, "allowed": 10.0})
assert tight > loose
assert tight == 12.0 # 6.0 * 1.0 * (1 + 1)
def test_conjunction_beats_sextile_at_same_orb():
conj = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 2.0, "allowed": 10.0})
sext = score_facet({"type": "aspect", "aspect": "sextile", "orb": 2.0, "allowed": 10.0})
assert conj > sext
@@ -0,0 +1,93 @@
"""Testy mostu obliczenia → sygnifikatory → wyszukiwanie (bez efemeryd/HTTP)."""
from app.engine.models import DEFAULT_OBJECTS
from app.significators import PLANET_ABBR, _ordinal, build_report
class FakeData:
def __init__(self, rows_by_value: dict) -> None:
self.rows_by_value = rows_by_value
def search(self, key, value, exact, limit, fields=None) -> dict:
rows = self.rows_by_value.get(value, [])
return {"provider": "fake", "total": len(rows), "rows": rows}
def test_sign_and_house_facets():
positions = [{"name": "Sun", "sign": "Taurus", "direction": "D", "house": 11}]
data = FakeData({"[Su": [
{"significator": "[Su in [Tau", "actioneffect": "efekt znak"}, # faseta znak
{"significator": "[Su in 11th H.", "actioneffect": "efekt dom"}, # faseta dom
{"significator": "[Su in [Vir", "actioneffect": "inny"}, # żadna
{"significator": "[Su in [Tau", "actioneffect": "nan"}, # szum
]})
item = build_report(positions, data)["objects"][0]
assert item["object"] == "Sun" and item["house"] == 11
facets = {f["type"]: f for f in item["facets"]}
assert facets["sign"]["count"] == 1
assert facets["sign"]["samples"][0]["effect"] == "efekt znak"
assert facets["sign"]["samples"][0]["expanded"] == "Sun in Taurus" # rozwinięcie skrótu
assert facets["house"]["count"] == 1
assert facets["house"]["token"] == "11th H."
assert facets["house"]["samples"][0]["effect"] == "efekt dom"
def test_no_house_facet_when_house_missing():
positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx"}]
data = FakeData({"[Ma": [{"significator": "[Ma in [Sco", "actioneffect": "eff"}]})
types = [f["type"] for f in build_report(positions, data)["objects"][0]["facets"]]
assert "sign" in types and "house" not in types
def test_dedup_by_significator_and_effect():
positions = [{"name": "Moon", "sign": "Taurus", "direction": "D", "house": 11}]
data = FakeData({"[Mo": [
{"significator": "[Mo in 11th H.", "actioneffect": "efekt"},
{"significator": "[Mo in 11th H.", "actioneffect": "efekt"}, # duplikat (sig+opis)
{"significator": "[Mo in 11th H.", "actioneffect": "inny efekt"}, # ten sam sig, inny opis
]})
facets = {f["type"]: f for f in build_report(positions, data)["objects"][0]["facets"]}
assert facets["house"]["count"] == 2 # duplikat odsiany, różny opis zostaje
def test_aspect_facet():
positions = [
{"name": "Sun", "sign": "Taurus", "direction": "D", "house": 11},
{"name": "Moon", "sign": "Taurus", "direction": "D", "house": 11},
]
aspects = [{"obj1": "Sun", "obj2": "Moon", "aspect": "conjunction", "orb": 2.0}]
data = FakeData({"[Su": [{"significator": "[Su [conj [Mo", "actioneffect": "złączeni"}]})
sun = build_report(positions, data, aspects=aspects)["objects"][0]
asp = [f for f in sun["facets"] if f["type"] == "aspect"]
assert asp and asp[0]["count"] == 1 and "Moon" in asp[0]["label"]
def test_grouping_by_effect():
positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx", "house": 5}]
data = FakeData({"[Ma": [
{"significator": "[Ma in 5th H.", "actioneffect": "miscarriage"},
{"significator": "[Sa in 5th H.", "actioneffect": "miscarriage"}, # inny sig, ten sam opis
{"significator": "[Ma in 5th H.", "actioneffect": "fever"},
]})
house = [f for f in build_report(positions, data, group=True)["objects"][0]["facets"]
if f["type"] == "house"][0]
groups = {g["effect"]: g for g in house["groups"]}
assert groups["miscarriage"]["count"] == 2
assert len(groups["miscarriage"]["significators"]) == 2
assert "score" in house
def test_facets_carry_score():
positions = [{"name": "Sun", "sign": "Taurus", "direction": "D", "house": 11}]
data = FakeData({"[Su": [{"significator": "[Su in [Tau", "actioneffect": "e"}]})
facets = build_report(positions, data)["objects"][0]["facets"]
assert all("score" in f for f in facets)
def test_ordinal():
assert _ordinal(1) == "1st" and _ordinal(2) == "2nd" and _ordinal(3) == "3rd"
assert _ordinal(4) == "4th" and _ordinal(11) == "11th" and _ordinal(12) == "12th"
def test_planet_abbr_covers_all_default_objects():
for o in DEFAULT_OBJECTS:
assert o in PLANET_ABBR
+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.
## Trasy
- `GET /`strona z formularzem
- `POST /`wysłanie formularza → warstwa logiczna → render wyników
- `GET /`**Horoskop**: formularz danych momentu (data, godzina, strefa, opcjonalnie lokalizacja)
- `POST /`liczy pozycje obiektów (woła logic `/chart/positions`) i renderuje czytelną tabelę
- `GET /significators`, `POST /significators` — wyszukiwarka w bazach interpretacji (warstwa danych)
- `GET /health`
Strona „Horoskop" to widok do **ręcznego testowania**: podstawowe dane wejściowe →
to, co policzyła warstwa logiczna (znak, pozycja w znaku, absolutna, kierunek, prędkość).
Przycisk „Tu i teraz" uzupełnia bieżącą datę/godzinę i strefę przeglądarki.
## Zależności w dół
Zna wyłącznie `LOGIC_URL` (adres warstwy logicznej).
Zna wyłącznie `LOGIC_URL` (adres warstwy logicznej) i jej kontrakty `/chart/positions`
oraz `/api/query`. Nie wie nic o silniku ani bazach.
> Uwaga: pełne liczenie pozycji wymaga warstwy logicznej z silnikiem efemeryd
> (gałąź `feat/logic-engine`). Bez niego strona „Horoskop" pokaże czytelny komunikat.
## Uruchomienie
```bash
@@ -1,7 +1,7 @@
"""Klient HTTP do warstwy logicznej.
Jedyny punkt styku prezentacji w dół. Przekazuje dane z formularza i odbiera
opracowane wyniki. Prezentacja nie sięga bezpośrednio do bazy.
opracowane wyniki. Prezentacja nie sięga bezpośrednio do bazy ani do silnika.
"""
from __future__ import annotations
@@ -22,3 +22,34 @@ class LogicClient:
r = client.post(f"{self.base_url}/api/query", json=payload)
r.raise_for_status()
return r.json()
def positions(
self,
when_utc_iso: str,
lat: float,
lon: float,
objects: list[str] | None = None,
house_system: str = "whole_sign",
) -> dict[str, Any]:
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
payload = {
"when_utc": when_utc_iso,
"lat": lat,
"lon": lon,
"objects": objects,
"house_system": house_system,
}
with httpx.Client(timeout=settings.http_timeout) as client:
r = client.post(f"{self.base_url}/chart/positions", json=payload)
r.raise_for_status()
return r.json()
def report(
self, when_utc_iso: str, lat: float, lon: float, limit: int = 5000, group: bool = False
) -> dict[str, Any]:
"""Sygnifikatory z obliczeń szukane w bazie — woła logic /chart/report."""
payload = {"when_utc": when_utc_iso, "lat": lat, "lon": lon, "limit": limit, "group": group}
with httpx.Client(timeout=max(settings.http_timeout, 30.0)) as client:
r = client.post(f"{self.base_url}/chart/report", json=payload)
r.raise_for_status()
return r.json()
+94 -5
View File
@@ -2,9 +2,15 @@
W dół: przekazuje dane z formularza do warstwy logicznej i odbiera opracowane
wyniki. Nie zawiera logiki biznesowej ani dostępu do danych — tylko UI.
Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych pozycji
(do ręcznego testowania aplikacji). Wyszukiwarka sygnifikatorów przeniesiona pod
„/significators".
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import httpx
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
@@ -19,13 +25,66 @@ templates = Jinja2Templates(directory="app/templates")
logic = LogicClient()
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
"""Z lokalnej daty/godziny + przesunięcia strefy → moment UTC.
Zwraca (iso_utc, etykieta_czytelna). UTC = czas lokalny offset.
"""
local = datetime.fromisoformat(f"{date}T{time}")
utc = (local - timedelta(hours=tz_offset)).replace(tzinfo=timezone.utc)
label = utc.strftime("%Y-%m-%d %H:%M UTC")
return utc.isoformat(), label
def _logic_error(e: Exception) -> str:
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
return (
"Warstwa logiczna działa, ale nie ma endpointu /chart/positions. "
"Uruchom warstwę logiczną z silnikiem efemeryd (gałąź feat/logic-engine)."
)
return f"Warstwa logiczna niedostępna: {e}"
# ---------------- Horoskop: pozycje (strona główna) ----------------
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
return templates.TemplateResponse(request, "index.html", {"result": None, "form": {}})
def chart_form(request: Request):
return templates.TemplateResponse(request, "chart.html", {"result": None, "form": {}})
@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,
query: str = Form(...),
field: str = Form("name"),
@@ -37,8 +96,38 @@ def search(
try:
ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit)
except httpx.HTTPError as e:
ctx["error"] = f"Warstwa logiczna niedostępna: {e}"
return templates.TemplateResponse(request, "index.html", ctx)
ctx["error"] = _logic_error(e)
return templates.TemplateResponse(request, "significators.html", ctx)
# ---------------- Interpretacje (wynik obliczeń szukany w bazie) ----------------
@app.get("/interpret", response_class=HTMLResponse)
def interpret_form(request: Request):
return templates.TemplateResponse(request, "interpret.html", {"result": None, "form": {}})
@app.post("/interpret", response_class=HTMLResponse)
def interpret_run(
request: Request,
date: str = Form(...),
time: str = Form(...),
tz_offset: float = Form(0.0),
lat: float = Form(0.0),
lon: float = Form(0.0),
group: bool = Form(False),
):
form = {"date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "group": group}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
except httpx.HTTPError as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
return templates.TemplateResponse(request, "interpret.html", ctx)
@app.get("/health")
+43
View File
@@ -0,0 +1,43 @@
// „Tu i teraz": uzupełnia datę/godzinę/strefę z przeglądarki oraz — jeśli to
// możliwe — lokalizację (lat/lon).
//
// UWAGA: geolokalizacja przeglądarki działa tylko w "secure context"
// (https:// lub localhost). Na http://<ip> przeglądarka NIE pyta o zgodę,
// tylko po cichu odmawia — dlatego pokazujemy jawny komunikat w #geoNote.
document.addEventListener('DOMContentLoaded', function () {
const btn = document.getElementById('nowBtn');
if (!btn) return;
const note = document.getElementById('geoNote');
const say = msg => { if (note) note.textContent = msg; };
btn.addEventListener('click', function () {
const d = new Date();
const pad = n => String(n).padStart(2, '0');
document.querySelector('input[name=date]').value =
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
document.querySelector('input[name=time]').value = pad(d.getHours()) + ':' + pad(d.getMinutes());
document.querySelector('input[name=tz_offset]').value = (-d.getTimezoneOffset() / 60);
if (!('geolocation' in navigator)) {
say('Ta przeglądarka nie udostępnia geolokalizacji — wpisz lat/lon ręcznie.');
return;
}
if (!window.isSecureContext) {
say('Lokalizacja z przeglądarki wymaga HTTPS lub localhost (otwarto przez http://) — wpisz lat/lon ręcznie.');
return;
}
say('Pobieram lokalizację…');
navigator.geolocation.getCurrentPosition(
function (pos) {
document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4);
document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4);
say('Lokalizacja pobrana ✓');
},
function (err) {
say('Nie udało się pobrać lokalizacji: ' + (err && err.message ? err.message : 'odmowa dostępu'));
},
{ timeout: 8000 }
);
});
});
@@ -15,6 +15,23 @@ body {
main { width: 100%; max-width: 880px; }
h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
.sub { color: var(--muted); margin: .25rem 0 2rem; }
.topbar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: .25rem; }
.topbar nav { display: flex; gap: .25rem; }
.topbar nav a { color: var(--muted); text-decoration: none; padding: .4rem .8rem; border-radius: 8px; border: 1px solid transparent; }
.topbar nav a:hover { color: var(--ink); }
.topbar nav a.active { color: #fff; background: var(--panel); border-color: var(--line); }
.foot { color: var(--muted); font-size: .8rem; margin-top: 2.5rem; opacity: .7; }
.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
.grid input { width: 100%; }
.loc { margin-top: .8rem; }
.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
.actions { display: flex; gap: .5rem; margin-top: 1rem; }
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
td.retro { color: #ff9b6a; font-weight: 600; }
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
.row { display: flex; gap: .5rem; }
.row input[type=text] { flex: 1; }
@@ -33,3 +50,17 @@ table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: v
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
tr:last-child td { border-bottom: none; }
.muted { color: var(--muted); }
.small { font-size: .85rem; }
.nowrap { white-space: nowrap; }
.sig-item { margin-top: 1.25rem; }
.sig-head { padding: .5rem 0; }
.samples { margin-top: .3rem; }
.samples td { font-size: .92rem; vertical-align: top; }
.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
.facet { margin: .4rem 0 .6rem 1rem; }
.facet-head { color: var(--ink); font-size: .9rem; }
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>astrololo · {% block title %}{% endblock %}</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<main>
<header class="topbar">
<h1>astrololo</h1>
<nav>
<a href="/" class="{% block nav_chart %}{% endblock %}">Horoskop</a>
<a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a>
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
</nav>
</header>
{% block content %}{% endblock %}
<footer class="foot">
prezentacja → logika → dane · widok testowy
</footer>
</main>
</body>
</html>
@@ -0,0 +1,115 @@
{% 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>
<span id="geoNote" class="muted small"></span>
</div>
</form>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
{% if result %}
<div class="meta">
Silnik: <strong>{{ result.engine }}</strong> ·
obiektów: {{ result.positions | length }}
{% if result.house_system %}· domy: {{ result.house_system }}{% endif %}
{% if moment %}· moment: {{ moment }}{% endif %}
</div>
{% if result.angles %}
<table class="angles">
<thead><tr><th></th><th>Znak</th><th>W znaku</th></tr></thead>
<tbody>
{% for key in ["Asc", "MC", "Dsc", "IC"] %}
{% set a = result.angles[key] %}
<tr><td>{{ a.name }}</td><td>{{ a.sign }}</td><td class="mono">{{ a.in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<table>
<thead>
<tr>
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
</tr>
</thead>
<tbody>
{% for p in result.positions %}
<tr>
<td>{{ p.name }}</td>
<td>{{ p.sign }}</td>
<td class="mono">{{ p.in_sign }}</td>
<td>{{ p.house if p.house is defined else '—' }}</td>
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
<td class="mono">{{ '%+.4f' | format(p.speed) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if result.aspects %}
<div class="meta">Aspekty główne ({{ result.aspects | length }})</div>
<table class="angles">
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
<tbody>
{% for a in result.aspects %}
<tr><td>{{ a.obj1 }}</td><td>{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if result.cusps %}
<details class="loc">
<summary>Cusps domów ({{ result.house_system }})</summary>
<table>
<thead><tr><th>Dom</th><th>Znak</th><th>Cusp</th></tr></thead>
<tbody>
{% for c in result.cusps %}
<tr><td>{{ c.house }}</td><td>{{ c.sign }}</td><td class="mono">{{ c.in_sign }}</td></tr>
{% endfor %}
</tbody>
</table>
</details>
{% endif %}
{% endif %}
<script src="/static/now.js"></script>
{% endblock %}
@@ -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,87 @@
{% extends "base.html" %}
{% block title %}Interpretacje{% endblock %}
{% block nav_interp %}active{% endblock %}
{% block content %}
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
<form method="post" action="/interpret">
<div class="grid">
<label>Data
<input type="date" name="date" value="{{ form.date or '' }}" required>
</label>
<label>Godzina (lokalna)
<input type="time" name="time" value="{{ form.time or '' }}" required>
</label>
<label>Strefa (offset h)
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
</label>
<label>Szerokość (lat)
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
</label>
<label>Długość (lon)
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
</label>
</div>
<div class="opts">
<label><input type="checkbox" name="group" value="true" {{ 'checked' if form.group else '' }}> grupuj identyczne opisy</label>
</div>
<div class="actions">
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
<button type="submit">Szukaj interpretacji</button>
<span id="geoNote" class="muted small"></span>
</div>
</form>
{% if error %}<div class="error">{{ error }}</div>{% endif %}
{% if result %}
{% if result.data_error %}
<div class="error">{{ result.data_error }}</div>
{% endif %}
<div class="meta">
Silnik: <strong>{{ result.engine }}</strong>
{% if result.provider %}· baza: {{ result.provider }}{% endif %}
{% if moment %}· moment: {{ moment }}{% endif %}
</div>
{% for o in result.objects %}
<div class="sig-item">
<div class="sig-head">
<strong>{{ o.object }}</strong> w <strong>{{ o.sign }}</strong>{% if o.house %}, {{ o.house }}. dom{% endif %}
<span class="{{ 'retro' if o.direction == 'Rx' else '' }}">{{ o.direction }}</span>
<span class="muted small">(planeta {{ o.planet_token }}: {{ o.planet_total }} rekordów)</span>
</div>
{% for f in o.facets %}
<div class="facet">
<div class="facet-head">{{ f.label }} — <strong>{{ f.count }}</strong> dopasowań
<span class="badge" title="siła (LOG-21)">siła {{ f.score }}</span>
<span class="muted small">[{{ f.token }}]</span></div>
{% if f.groups %}
<table class="samples">
<tbody>
{% for g in f.groups %}
<tr>
<td>{{ g.effect }}{% if g.count > 1 %} <span class="badge">×{{ g.count }}</span>{% endif %}</td>
<td class="sig small">{{ g.significators | join('; ') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% elif f.samples %}
<table class="samples">
<tbody>
{% for s in f.samples %}
<tr><td class="sig nowrap" title="{{ s.significator }}">{{ s.expanded }}</td><td>{{ s.effect }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
{% endif %}
<script src="/static/now.js"></script>
{% endblock %}
@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}Sygnifikatory{% endblock %}
{% block nav_sig %}active{% endblock %}
{% block content %}
<p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p>
<form method="post" action="/significators">
<div class="row">
<input type="text" name="query" placeholder="Szukana fraza…"
value="{{ form.query or '' }}" autofocus required>
<select name="field">
{% for f in ["name", "id", "symbol", "category", "value"] %}
<option value="{{ f }}" {{ 'selected' if form.field == f else '' }}>{{ f }}</option>
{% endfor %}
</select>
<button type="submit">Szukaj</button>
</div>
<div class="opts">
<label><input type="checkbox" name="exact" value="true"
{{ 'checked' if form.exact else '' }}> dokładne</label>
<label>limit
<input type="number" name="limit" min="1" max="200" value="{{ form.limit or 25 }}">
</label>
</div>
</form>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
{% if result %}
<div class="meta">
Znaleziono <strong>{{ result.count }}</strong> ·
provider: {{ result.meta.data_provider }} ·
cache: {{ result.meta.data_cache }} ·
{{ result.meta.data_elapsed_ms }} ms
</div>
{% if result.results %}
<table>
<thead>
<tr>{% for col in result.results[0].keys() %}<th>{{ col }}</th>{% endfor %}</tr>
</thead>
<tbody>
{% for row in result.results %}
<tr>{% for v in row.values() %}<td>{{ v }}</td>{% endfor %}</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="empty">Brak wyników.</p>
{% endif %}
{% endif %}
{% endblock %}