Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6323cac10 | |||
| 243d02d55b | |||
| 166b438f83 | |||
| 41d7ca491d | |||
| 6acd3546fb | |||
| 82809665ff | |||
| 0e74566a78 | |||
| cea6907f84 | |||
| dabbaef9dd | |||
| a81ae698a3 | |||
| eef67d37b5 | |||
| 94c3023d3a | |||
| e4f6a16c9c | |||
| 413c46b5dd | |||
| 340b3058e4 | |||
| 376a4bfded | |||
| 3195d9b003 |
@@ -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 "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 "sql - uruchom z warstwą SQL (DATA_PROVIDER=sql)"
|
||||
@echo "dev-* - uruchom pojedynczą warstwę lokalnie (bez dockera)"
|
||||
@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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
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]
|
||||
|
||||
@@ -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 (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ą)
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Rozwijanie skrótów sygnifikatorów do postaci czytelnej (na bazie SIGNIFICATORS KEY).
|
||||
|
||||
W bazie zapis jest skrótowy z prefiksem `[` (np. `[Su in [Tau`, `[Sa [conj [Su in 6th H.`).
|
||||
Ten moduł zamienia go na tekst czytelny: „Sun in Taurus", „Saturn conjunction Sun
|
||||
in 6th house". Słownik pochodzi z pliku SIGNIFICATORS KEY (mały, stabilny — wpięty
|
||||
jako built-in; można rozszerzać).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# skrót (bez nawiasu) -> pełna nazwa
|
||||
ABBREVIATIONS: dict[str, str] = {
|
||||
# znaki zodiaku
|
||||
"Ari": "Aries", "Tau": "Taurus", "Gem": "Gemini", "Can": "Cancer",
|
||||
"Leo": "Leo", "Vir": "Virgo", "Lib": "Libra", "Sco": "Scorpio",
|
||||
"Sag": "Sagittarius", "Cap": "Capricorn", "Aqu": "Aquarius", "Pis": "Pisces",
|
||||
# planety klasyczne + światła
|
||||
"Su": "Sun", "Mo": "Moon", "Me": "Mercury", "Ve": "Venus",
|
||||
"Ma": "Mars", "Ju": "Jupiter", "Sa": "Saturn",
|
||||
# planety nowożytne
|
||||
"Ur": "Uranus", "Ne": "Neptune", "Pl": "Pluto",
|
||||
# węzły i punkty
|
||||
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
|
||||
# osie
|
||||
"Asc": "Ascendant", "Dsc": "Descendant", "MC": "Midheaven", "IC": "Imum Coeli",
|
||||
# aspekty
|
||||
"conj": "conjunction", "sex": "sextile", "sq": "square", "tri": "trine",
|
||||
"opp": "opposition", "semisex": "semisextile", "semisq": "semisquare",
|
||||
"sesquisq": "sesquisquare", "quincunx": "quincunx", "asp": "aspect",
|
||||
# domy jako tokeny [h1..[h12
|
||||
**{f"h{i}": f"{i}th house" for i in range(1, 13)},
|
||||
# ruch
|
||||
"Rx": "retrograde", "R": "retrograde",
|
||||
}
|
||||
# poprawki nieregularnych liczebników domów
|
||||
ABBREVIATIONS.update({"h1": "1st house", "h2": "2nd house", "h3": "3rd house"})
|
||||
|
||||
# rozwinięcia słów spoza składni `[`
|
||||
_WORDS = {
|
||||
"affl.": "afflicted",
|
||||
"P. Dec.": "parallel of declination",
|
||||
"espec.": "especially",
|
||||
}
|
||||
|
||||
_TOKEN = re.compile(r"\[([A-Za-z]+)")
|
||||
_HOUSE = re.compile(r"(\d+(?:st|nd|rd|th))\s*H\.", re.IGNORECASE)
|
||||
|
||||
|
||||
def expand(text: str) -> str:
|
||||
"""Zamienia skróty na pełne nazwy; nieznane tokeny zostawia bez nawiasu."""
|
||||
if not text:
|
||||
return text
|
||||
out = _TOKEN.sub(lambda m: ABBREVIATIONS.get(m.group(1), m.group(1)), text)
|
||||
out = _HOUSE.sub(lambda m: f"{m.group(1)} house", out)
|
||||
for k, v in _WORDS.items():
|
||||
out = out.replace(k, v)
|
||||
return out
|
||||
@@ -16,9 +16,16 @@ class DataClient:
|
||||
def __init__(self, base_url: str | None = None) -> None:
|
||||
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()
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Aspekty — kąty między obiektami (LOG-06, wersja: aspekty główne).
|
||||
|
||||
Czysta matematyka na policzonych długościach ekliptycznych. Dla każdej pary
|
||||
obiektów sprawdzamy, czy ich separacja kątowa mieści się w orbie któregoś z
|
||||
aspektów głównych. Applying/separating (aplikacja/separacja) — na później.
|
||||
|
||||
Tokeny bazy (z SIGNIFICATORS KEY): [conj, [sex, [sq, [tri, [opp.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
MAJOR = {
|
||||
"conjunction": 0.0,
|
||||
"sextile": 60.0,
|
||||
"square": 90.0,
|
||||
"trine": 120.0,
|
||||
"opposition": 180.0,
|
||||
}
|
||||
DB_TOKEN = {
|
||||
"conjunction": "[conj", "sextile": "[sex", "square": "[sq",
|
||||
"trine": "[tri", "opposition": "[opp",
|
||||
}
|
||||
PL_NAME = {
|
||||
"conjunction": "koniunkcja", "sextile": "sekstyl", "square": "kwadratura",
|
||||
"trine": "trygon", "opposition": "opozycja",
|
||||
}
|
||||
LUMINARIES = {"Sun", "Moon"}
|
||||
DEFAULT_ORB = 8.0
|
||||
LUMINARY_BONUS = 2.0
|
||||
|
||||
|
||||
def separation(a: float, b: float) -> float:
|
||||
"""Najmniejsza separacja kątowa [0,180]."""
|
||||
d = abs(a - b) % 360.0
|
||||
return min(d, 360.0 - d)
|
||||
|
||||
|
||||
def find_aspects(
|
||||
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS
|
||||
) -> list[dict]:
|
||||
"""positions: dicty z 'name' i 'decimal' (długość). Zwraca listę aspektów."""
|
||||
out: list[dict] = []
|
||||
n = len(positions)
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
a, b = positions[i], positions[j]
|
||||
la, lb = a.get("decimal"), b.get("decimal")
|
||||
if la is None or lb is None:
|
||||
continue
|
||||
sep = separation(float(la), float(lb))
|
||||
allowed = orb + (luminary_bonus if (a["name"] in LUMINARIES or b["name"] in LUMINARIES) else 0.0)
|
||||
for asp, angle in MAJOR.items():
|
||||
dev = abs(sep - angle)
|
||||
if dev <= allowed:
|
||||
out.append({
|
||||
"obj1": a["name"], "obj2": b["name"],
|
||||
"aspect": asp, "orb": round(dev, 2), "allowed": round(allowed, 2),
|
||||
})
|
||||
break # jedna para = jeden aspekt
|
||||
return out
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Złożenie pełnego horoskopu: pozycje + osie + domy (LOG-01 + LOG-05).
|
||||
|
||||
Silnik-agnostyczne: potrzebuje tylko `positions()` oraz (dla osi/domów)
|
||||
`sidereal()`. Jeśli silnik nie umie policzyć czasu gwiazdowego, zwraca same
|
||||
pozycje.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.engine import houses as H
|
||||
from app.engine.base import EphemerisEngine
|
||||
from app.engine.formats import SIGNS, in_sign, norm360, sign_index
|
||||
from app.engine.models import ChartMoment
|
||||
|
||||
|
||||
def _fmt(name: str, lon: float) -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"sign": SIGNS[sign_index(lon)],
|
||||
"in_sign": in_sign(lon),
|
||||
"decimal": round(norm360(lon), 6),
|
||||
}
|
||||
|
||||
|
||||
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN) -> dict:
|
||||
from app.engine.aspects import find_aspects
|
||||
|
||||
positions = engine.positions(moment)
|
||||
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
|
||||
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
|
||||
|
||||
if not hasattr(engine, "sidereal"):
|
||||
return result
|
||||
|
||||
ramc, eps = engine.sidereal(moment)
|
||||
asc = H.compute_asc(ramc, eps, moment.lat)
|
||||
mc = H.compute_mc(ramc, eps)
|
||||
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
|
||||
cusp_list = H.cusps(asc, mc, system)
|
||||
|
||||
result["house_system"] = system
|
||||
result["angles"] = {
|
||||
"Asc": _fmt("Asc", asc),
|
||||
"MC": _fmt("MC", mc),
|
||||
"Dsc": _fmt("Dsc", norm360(asc + 180.0)),
|
||||
"IC": _fmt("IC", norm360(mc + 180.0)),
|
||||
}
|
||||
result["cusps"] = [
|
||||
{"house": i + 1, "sign": SIGNS[sign_index(c)], "in_sign": in_sign(c)}
|
||||
for i, c in enumerate(cusp_list)
|
||||
]
|
||||
for pdict, obj in zip(result["positions"], positions):
|
||||
pdict["house"] = H.assign_house(obj.longitude, cusp_list)
|
||||
return result
|
||||
@@ -0,0 +1,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}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Punktacja siły trafień — do rankingowania faset (zalążek LOG-21).
|
||||
|
||||
v1 liczy siłę z sygnałów OBLICZALNYCH:
|
||||
- typ fasety (aspekt zwykle mocniejszy od znaku/domu),
|
||||
- rodzaj aspektu (koniunkcja/opozycja mocniejsze od sekstyla),
|
||||
- ciasnota orbu (im bliżej dokładności, tym mocniej).
|
||||
|
||||
Wszystko konfigurowalne. HOOK NA PRZYSZŁOŚĆ: gdy w SIGNIFICATORS KEY zostaną
|
||||
wypełnione kolumny `countas*`/`level*`, można je tu domieszać per sygnifikator.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# bazowe wagi faset (łatwe do strojenia)
|
||||
FACET_BASE = {"sign": 5.0, "house": 5.0, "aspect": 6.0}
|
||||
|
||||
# względna siła aspektów głównych
|
||||
ASPECT_WEIGHT = {
|
||||
"conjunction": 1.0, "opposition": 0.95, "square": 0.85,
|
||||
"trine": 0.85, "sextile": 0.65,
|
||||
}
|
||||
|
||||
|
||||
def score_facet(facet: dict) -> float:
|
||||
"""Siła fasety w skali ~0–12. Deterministyczna, konfigurowalna."""
|
||||
base = FACET_BASE.get(facet.get("type"), 1.0)
|
||||
if facet.get("type") == "aspect":
|
||||
weight = ASPECT_WEIGHT.get(facet.get("aspect"), 0.7)
|
||||
orb = facet.get("orb")
|
||||
allowed = facet.get("allowed") or 10.0
|
||||
tight = max(0.0, 1.0 - orb / allowed) if orb is not None and allowed else 0.0
|
||||
return round(base * weight * (1.0 + tight), 2)
|
||||
return round(base, 2)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Most: policzony horoskop → tokeny sygnifikatorów → wyszukiwanie w bazie.
|
||||
|
||||
Zalążek LOG-15/16: z pozycji obiektów (wraz z domami) generujemy tokeny w składni
|
||||
bazy i wyszukujemy pasujące rekordy. Dla każdego obiektu tworzymy kilka **faset**:
|
||||
- „w znaku" — planeta + token znaku (`[Su` + `[Tau`)
|
||||
- „w domu" — planeta + token domu (`[Su` + `11th H.`)
|
||||
Aspekty (`[conj`,`[sq`,`[opp`) wymagają policzenia aspektów (LOG-06) — na później.
|
||||
|
||||
Format skrótów odczytany z realnej bazy: planety `[Su`,`[Mo`,…; znaki
|
||||
`[Ari`,`[Tau`,…; domy `12th H.`; np. `[Sa [conj [Su in 6th H.`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
from app.abbreviations import expand
|
||||
from app.engine.formats import SIGN_ABBR, SIGNS
|
||||
from app.scoring import score_facet
|
||||
|
||||
PLANET_ABBR = {
|
||||
"Sun": "Su", "Moon": "Mo", "Mercury": "Me", "Venus": "Ve", "Mars": "Ma",
|
||||
"Jupiter": "Ju", "Saturn": "Sa", "Uranus": "Ur", "Neptune": "Ne", "Pluto": "Pl",
|
||||
}
|
||||
SIGN_TO_ABBR = dict(zip(SIGNS, SIGN_ABBR))
|
||||
|
||||
|
||||
class DataSource(Protocol):
|
||||
def search(
|
||||
self, key: str, value: str, exact: bool, limit: int, fields: list[str] | None = None
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
def _effect(row: dict) -> str:
|
||||
for col in ("actioneffect", "topicresult", "bodypart"):
|
||||
v = row.get(col)
|
||||
if v and str(v).strip().lower() not in ("", "nan"):
|
||||
return str(v).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _is_noise(sig: str, effect: str) -> bool:
|
||||
s = sig.strip().lower()
|
||||
e = effect.strip().lower()
|
||||
return (
|
||||
e in ("", "nan", "x", "x?", "?", "-") # efekt pusty/zastępczy
|
||||
or s.startswith("significator") # wiersz-legenda/nagłówek
|
||||
or "header" in s
|
||||
or s.startswith("*") # *MARKER / *header
|
||||
or s in ("x", "x?", "nan") # znacznik „pomiń rekord"
|
||||
)
|
||||
|
||||
|
||||
def _ordinal(n: int) -> str:
|
||||
if 10 <= n % 100 <= 20:
|
||||
suffix = "th"
|
||||
else:
|
||||
suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
|
||||
return f"{n}{suffix}"
|
||||
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
"""Normalizacja do porównań duplikatów: bez skrajnych spacji, jedna spacja, lower."""
|
||||
return " ".join(str(s).strip().lower().split())
|
||||
|
||||
|
||||
def _facet_samples(rows: list[dict], tokens: list[str]) -> list[dict]:
|
||||
"""Rekordy, których sygnifikator zawiera WSZYSTKIE tokeny — bez szumu i bez duplikatów.
|
||||
|
||||
Duplikat = ten sam sygnifikator ORAZ ten sam opis (po normalizacji). Dedup
|
||||
działa na zagregowanym wyniku, więc odsiewa też powtórki między wieloma bazami.
|
||||
"""
|
||||
toks = [t.lower() for t in tokens if t]
|
||||
out: list[dict] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for r in rows:
|
||||
sig = str(r.get("significator") or "").strip()
|
||||
low = sig.lower()
|
||||
if not all(t in low for t in toks):
|
||||
continue
|
||||
eff = _effect(r)
|
||||
if _is_noise(sig, eff):
|
||||
continue
|
||||
key = (_norm(sig), _norm(eff))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append({"significator": sig, "expanded": expand(sig), "effect": eff})
|
||||
return out
|
||||
|
||||
|
||||
def _group_by_effect(samples: list[dict]) -> list[dict]:
|
||||
"""Grupuje próbki po opisie: ten sam efekt = jedna grupa z listą sygnifikatorów."""
|
||||
groups: dict[str, dict] = {}
|
||||
order: list[str] = []
|
||||
for s in samples:
|
||||
key = _norm(s["effect"])
|
||||
g = groups.get(key)
|
||||
if g is None:
|
||||
g = {"effect": s["effect"], "count": 0, "significators": []}
|
||||
groups[key] = g
|
||||
order.append(key)
|
||||
g["count"] += 1
|
||||
g["significators"].append(s["expanded"])
|
||||
result = [groups[k] for k in order]
|
||||
result.sort(key=lambda g: g["count"], reverse=True)
|
||||
return result
|
||||
|
||||
|
||||
def build_report(
|
||||
positions: list[dict],
|
||||
data: DataSource,
|
||||
aspects: list[dict] | None = None,
|
||||
per_object_limit: int = 5000,
|
||||
group: bool = False,
|
||||
) -> dict:
|
||||
"""positions: pozycje z build_chart (name, sign, direction, house).
|
||||
|
||||
Dla każdego obiektu fasety: „w znaku", „w domu" oraz „w aspekcie" (dla każdego
|
||||
aspektu głównego z listy `aspects`, jeśli w bazie są dopasowania). Duplikaty
|
||||
(ten sam sygnifikator i opis) są odsiewane wewnątrz każdej fasety.
|
||||
"""
|
||||
from app.engine.aspects import DB_TOKEN as ASP_TOKEN, PL_NAME as ASP_NAME
|
||||
|
||||
items: list[dict] = []
|
||||
provider = None
|
||||
for p in positions:
|
||||
name = p.get("name")
|
||||
if name not in PLANET_ABBR:
|
||||
continue
|
||||
planet_tok = "[" + PLANET_ABBR[name]
|
||||
raw = data.search(
|
||||
key="significator",
|
||||
value=planet_tok,
|
||||
exact=False,
|
||||
limit=per_object_limit,
|
||||
fields=["significator", "actioneffect", "topicresult", "bodypart"],
|
||||
)
|
||||
provider = raw.get("provider", provider)
|
||||
rows = raw.get("rows", [])
|
||||
|
||||
facets: list[dict] = []
|
||||
sign = p.get("sign")
|
||||
sign_tok = "[" + SIGN_TO_ABBR.get(sign, "")
|
||||
sign_samples = _facet_samples(rows, [sign_tok])
|
||||
facets.append({
|
||||
"type": "sign", "label": f"w znaku {sign}", "token": sign_tok,
|
||||
"count": len(sign_samples), "samples": sign_samples,
|
||||
})
|
||||
|
||||
house = p.get("house")
|
||||
if house:
|
||||
ordn = _ordinal(int(house))
|
||||
house_samples = _facet_samples(rows, [f"{ordn} h"]) # matcuje '12th H.'
|
||||
facets.append({
|
||||
"type": "house", "label": f"w {ordn} domu", "token": f"{ordn} H.",
|
||||
"count": len(house_samples), "samples": house_samples,
|
||||
})
|
||||
|
||||
for asp in (aspects or []):
|
||||
if name not in (asp.get("obj1"), asp.get("obj2")):
|
||||
continue
|
||||
other = asp["obj2"] if asp["obj1"] == name else asp["obj1"]
|
||||
asp_tok = ASP_TOKEN.get(asp["aspect"])
|
||||
if other not in PLANET_ABBR or not asp_tok:
|
||||
continue
|
||||
other_tok = "[" + PLANET_ABBR[other]
|
||||
asp_samples = _facet_samples(rows, [asp_tok, other_tok])
|
||||
if not asp_samples: # pokazujemy tylko aspekty z trafieniami
|
||||
continue
|
||||
facets.append({
|
||||
"type": "aspect", "label": f"{ASP_NAME[asp['aspect']]} z {other}",
|
||||
"token": f"{asp_tok} + {other_tok}",
|
||||
"aspect": asp["aspect"], "orb": asp.get("orb"), "allowed": asp.get("allowed"),
|
||||
"count": len(asp_samples), "samples": asp_samples,
|
||||
})
|
||||
|
||||
# punktacja siły (LOG-21), opcjonalne grupowanie po opisie, ranking faset
|
||||
for f in facets:
|
||||
f["score"] = score_facet(f)
|
||||
if group:
|
||||
f["groups"] = _group_by_effect(f["samples"])
|
||||
facets.sort(key=lambda f: f["score"], reverse=True)
|
||||
|
||||
items.append({
|
||||
"object": name,
|
||||
"sign": sign,
|
||||
"house": house,
|
||||
"direction": p.get("direction"),
|
||||
"planet_token": planet_tok,
|
||||
"planet_total": raw.get("total", 0),
|
||||
"facets": facets,
|
||||
})
|
||||
return {"provider": provider, "objects": items}
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Testy rozwijania skrótów sygnifikatorów."""
|
||||
from app.abbreviations import expand
|
||||
|
||||
|
||||
def test_planet_in_sign():
|
||||
assert expand("[Su in [Tau") == "Sun in Taurus"
|
||||
|
||||
|
||||
def test_aspect_and_house():
|
||||
assert expand("[Sa [conj [Su in 6th H.") == "Saturn conjunction Sun in 6th house"
|
||||
|
||||
|
||||
def test_affliction_word_and_house():
|
||||
assert expand("[Ne affl. in the 7th H.") == "Neptune afflicted in the 7th house"
|
||||
|
||||
|
||||
def test_unknown_token_kept_without_bracket():
|
||||
assert expand("[Xyz rising") == "Xyz rising"
|
||||
|
||||
|
||||
def test_empty_passthrough():
|
||||
assert expand("") == ""
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Testy aspektów (LOG-06) — czysta matematyka."""
|
||||
from app.engine.aspects import find_aspects, separation
|
||||
|
||||
|
||||
def test_separation_wraparound():
|
||||
assert separation(10, 350) == 20
|
||||
assert separation(0, 180) == 180
|
||||
assert separation(0, 90) == 90
|
||||
|
||||
|
||||
def test_conjunction_and_opposition():
|
||||
pos = [
|
||||
{"name": "Sun", "decimal": 10.0},
|
||||
{"name": "Moon", "decimal": 12.0}, # 2° od Słońca -> koniunkcja
|
||||
{"name": "Mars", "decimal": 190.0}, # 180° od Słońca -> opozycja
|
||||
]
|
||||
pairs = {(a["obj1"], a["obj2"], a["aspect"]) for a in find_aspects(pos)}
|
||||
assert ("Sun", "Moon", "conjunction") in pairs
|
||||
assert ("Sun", "Mars", "opposition") in pairs
|
||||
|
||||
|
||||
def test_orb_limit_excludes_wide():
|
||||
pos = [{"name": "Mercury", "decimal": 0.0}, {"name": "Venus", "decimal": 100.0}]
|
||||
assert find_aspects(pos, orb=8.0, luminary_bonus=0.0) == []
|
||||
|
||||
|
||||
def test_luminary_bonus_widens_orb():
|
||||
# 99.5° -> 9.5° od kwadratury; z bonusem luminarza (8+2) mieści się
|
||||
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Saturn", "decimal": 99.5}]
|
||||
assert any(a["aspect"] == "square" for a in find_aspects(pos))
|
||||
|
||||
|
||||
def test_one_aspect_per_pair():
|
||||
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
|
||||
assert len(find_aspects(pos)) == 1
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Integracja: pełny horoskop (pozycje + osie + domy) przez silnik (LOG-01+LOG-05).
|
||||
|
||||
Waliduje względem astro.com dla horoskopu referencyjnego.
|
||||
"""
|
||||
from app.engine.chart import build_chart
|
||||
|
||||
|
||||
def test_chart_angles_match_reference(own_engine, reference_moment):
|
||||
chart = build_chart(own_engine, reference_moment, "whole_sign")
|
||||
assert chart["angles"]["Asc"]["sign"] == "Cancer"
|
||||
assert chart["angles"]["MC"]["sign"] == "Pisces"
|
||||
|
||||
|
||||
def test_chart_house_assignments_match_reference(own_engine, reference_moment):
|
||||
chart = build_chart(own_engine, reference_moment, "whole_sign")
|
||||
by = {p["name"]: p for p in chart["positions"]}
|
||||
expected = {"Sun": 11, "Moon": 11, "Mercury": 10, "Venus": 10, "Mars": 5,
|
||||
"Jupiter": 7, "Saturn": 5, "Uranus": 6, "Neptune": 7, "Pluto": 5}
|
||||
for name, house in expected.items():
|
||||
assert by[name]["house"] == house, f"{name}: dom {by[name]['house']} != {house}"
|
||||
|
||||
|
||||
def test_house_systems_available(own_engine, reference_moment):
|
||||
for system in ("whole_sign", "equal", "porphyry"):
|
||||
chart = build_chart(own_engine, reference_moment, system)
|
||||
assert chart["house_system"] == system
|
||||
assert len(chart["cusps"]) == 12
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Testy osi i domów — czysta matematyka (LOG-05, bez efemeryd)."""
|
||||
from app.engine import houses as H
|
||||
|
||||
# RAMC i ε policzone Skyfieldem dla horoskopu referencyjnego (30.04.1984, Warszawa)
|
||||
RAMC, EPS, LAT = 353.1968, 23.44133, 52.2333
|
||||
|
||||
|
||||
def _near(a, b, tol=0.05):
|
||||
return abs(((a - b + 180) % 360) - 180) < tol
|
||||
|
||||
|
||||
def test_asc_mc_match_reference():
|
||||
asc = H.compute_asc(RAMC, EPS, LAT)
|
||||
mc = H.compute_mc(RAMC, EPS)
|
||||
assert _near(asc, 112.18) # Cancer 22°10' (astro.com)
|
||||
assert _near(mc, 352.59) # Pisces 22°35'
|
||||
|
||||
|
||||
def test_whole_sign_starts_on_sign_boundary():
|
||||
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN)
|
||||
assert cusps[0] == 90.0 # dom 1 = 0° Raka
|
||||
assert cusps[1] == 120.0
|
||||
|
||||
|
||||
def test_equal_cusps_are_30_apart_from_asc():
|
||||
cusps = H.cusps(112.18, 352.59, H.EQUAL)
|
||||
assert abs(cusps[0] - 112.18) < 1e-9
|
||||
assert abs(cusps[1] - 142.18) < 1e-9
|
||||
|
||||
|
||||
def test_porphyry_angles_on_cusps():
|
||||
cusps = H.cusps(112.18, 352.59, H.PORPHYRY)
|
||||
assert abs(cusps[0] - 112.18) < 1e-9 # Asc = dom 1
|
||||
assert abs(cusps[9] - 352.59) < 1e-9 # MC = dom 10
|
||||
assert abs(cusps[6] - (112.18 + 180) % 360) < 1e-9 # Dsc = dom 7
|
||||
|
||||
|
||||
def test_assign_house_whole_sign():
|
||||
cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN) # dom 1 = Rak (90–120°)
|
||||
assert H.assign_house(100.0, cusps) == 1 # w Raku
|
||||
assert H.assign_house(40.0, cusps) == 11 # Byk -> 11. dom
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Testy punktacji siły faset (LOG-21)."""
|
||||
from app.scoring import score_facet
|
||||
|
||||
|
||||
def test_sign_and_house_base():
|
||||
assert score_facet({"type": "sign"}) == 5.0
|
||||
assert score_facet({"type": "house"}) == 5.0
|
||||
|
||||
|
||||
def test_tighter_aspect_scores_higher():
|
||||
tight = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 0.0, "allowed": 10.0})
|
||||
loose = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 9.0, "allowed": 10.0})
|
||||
assert tight > loose
|
||||
assert tight == 12.0 # 6.0 * 1.0 * (1 + 1)
|
||||
|
||||
|
||||
def test_conjunction_beats_sextile_at_same_orb():
|
||||
conj = score_facet({"type": "aspect", "aspect": "conjunction", "orb": 2.0, "allowed": 10.0})
|
||||
sext = score_facet({"type": "aspect", "aspect": "sextile", "orb": 2.0, "allowed": 10.0})
|
||||
assert conj > sext
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Testy mostu obliczenia → sygnifikatory → wyszukiwanie (bez efemeryd/HTTP)."""
|
||||
from app.engine.models import DEFAULT_OBJECTS
|
||||
from app.significators import PLANET_ABBR, _ordinal, build_report
|
||||
|
||||
|
||||
class FakeData:
|
||||
def __init__(self, rows_by_value: dict) -> None:
|
||||
self.rows_by_value = rows_by_value
|
||||
|
||||
def search(self, key, value, exact, limit, fields=None) -> dict:
|
||||
rows = self.rows_by_value.get(value, [])
|
||||
return {"provider": "fake", "total": len(rows), "rows": rows}
|
||||
|
||||
|
||||
def test_sign_and_house_facets():
|
||||
positions = [{"name": "Sun", "sign": "Taurus", "direction": "D", "house": 11}]
|
||||
data = FakeData({"[Su": [
|
||||
{"significator": "[Su in [Tau", "actioneffect": "efekt znak"}, # faseta znak
|
||||
{"significator": "[Su in 11th H.", "actioneffect": "efekt dom"}, # faseta dom
|
||||
{"significator": "[Su in [Vir", "actioneffect": "inny"}, # żadna
|
||||
{"significator": "[Su in [Tau", "actioneffect": "nan"}, # szum
|
||||
]})
|
||||
item = build_report(positions, data)["objects"][0]
|
||||
assert item["object"] == "Sun" and item["house"] == 11
|
||||
facets = {f["type"]: f for f in item["facets"]}
|
||||
assert facets["sign"]["count"] == 1
|
||||
assert facets["sign"]["samples"][0]["effect"] == "efekt znak"
|
||||
assert facets["sign"]["samples"][0]["expanded"] == "Sun in Taurus" # rozwinięcie skrótu
|
||||
assert facets["house"]["count"] == 1
|
||||
assert facets["house"]["token"] == "11th H."
|
||||
assert facets["house"]["samples"][0]["effect"] == "efekt dom"
|
||||
|
||||
|
||||
def test_no_house_facet_when_house_missing():
|
||||
positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx"}]
|
||||
data = FakeData({"[Ma": [{"significator": "[Ma in [Sco", "actioneffect": "eff"}]})
|
||||
types = [f["type"] for f in build_report(positions, data)["objects"][0]["facets"]]
|
||||
assert "sign" in types and "house" not in types
|
||||
|
||||
|
||||
def test_dedup_by_significator_and_effect():
|
||||
positions = [{"name": "Moon", "sign": "Taurus", "direction": "D", "house": 11}]
|
||||
data = FakeData({"[Mo": [
|
||||
{"significator": "[Mo in 11th H.", "actioneffect": "efekt"},
|
||||
{"significator": "[Mo in 11th H.", "actioneffect": "efekt"}, # duplikat (sig+opis)
|
||||
{"significator": "[Mo in 11th H.", "actioneffect": "inny efekt"}, # ten sam sig, inny opis
|
||||
]})
|
||||
facets = {f["type"]: f for f in build_report(positions, data)["objects"][0]["facets"]}
|
||||
assert facets["house"]["count"] == 2 # duplikat odsiany, różny opis zostaje
|
||||
|
||||
|
||||
def test_aspect_facet():
|
||||
positions = [
|
||||
{"name": "Sun", "sign": "Taurus", "direction": "D", "house": 11},
|
||||
{"name": "Moon", "sign": "Taurus", "direction": "D", "house": 11},
|
||||
]
|
||||
aspects = [{"obj1": "Sun", "obj2": "Moon", "aspect": "conjunction", "orb": 2.0}]
|
||||
data = FakeData({"[Su": [{"significator": "[Su [conj [Mo", "actioneffect": "złączeni"}]})
|
||||
sun = build_report(positions, data, aspects=aspects)["objects"][0]
|
||||
asp = [f for f in sun["facets"] if f["type"] == "aspect"]
|
||||
assert asp and asp[0]["count"] == 1 and "Moon" in asp[0]["label"]
|
||||
|
||||
|
||||
def test_grouping_by_effect():
|
||||
positions = [{"name": "Mars", "sign": "Scorpio", "direction": "Rx", "house": 5}]
|
||||
data = FakeData({"[Ma": [
|
||||
{"significator": "[Ma in 5th H.", "actioneffect": "miscarriage"},
|
||||
{"significator": "[Sa in 5th H.", "actioneffect": "miscarriage"}, # inny sig, ten sam opis
|
||||
{"significator": "[Ma in 5th H.", "actioneffect": "fever"},
|
||||
]})
|
||||
house = [f for f in build_report(positions, data, group=True)["objects"][0]["facets"]
|
||||
if f["type"] == "house"][0]
|
||||
groups = {g["effect"]: g for g in house["groups"]}
|
||||
assert groups["miscarriage"]["count"] == 2
|
||||
assert len(groups["miscarriage"]["significators"]) == 2
|
||||
assert "score" in house
|
||||
|
||||
|
||||
def test_facets_carry_score():
|
||||
positions = [{"name": "Sun", "sign": "Taurus", "direction": "D", "house": 11}]
|
||||
data = FakeData({"[Su": [{"significator": "[Su in [Tau", "actioneffect": "e"}]})
|
||||
facets = build_report(positions, data)["objects"][0]["facets"]
|
||||
assert all("score" in f for f in facets)
|
||||
|
||||
|
||||
def test_ordinal():
|
||||
assert _ordinal(1) == "1st" and _ordinal(2) == "2nd" and _ordinal(3) == "3rd"
|
||||
assert _ordinal(4) == "4th" and _ordinal(11) == "11th" and _ordinal(12) == "12th"
|
||||
|
||||
|
||||
def test_planet_abbr_covers_all_default_objects():
|
||||
for o in DEFAULT_OBJECTS:
|
||||
assert o in PLANET_ABBR
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,131 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Horoskop{% endblock %}
|
||||
{% block nav_chart %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
|
||||
|
||||
<form method="post" action="/">
|
||||
<div class="grid">
|
||||
<label>Data
|
||||
<input type="date" name="date" value="{{ form.date or '' }}" required>
|
||||
</label>
|
||||
<label>Godzina (lokalna)
|
||||
<input type="time" name="time" value="{{ form.time or '' }}" required>
|
||||
</label>
|
||||
<label>Strefa (offset h)
|
||||
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<label>Szerokość (lat, + N)
|
||||
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
|
||||
</label>
|
||||
<label>Długość (lon, + E)
|
||||
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
||||
</label>
|
||||
<label>System domów
|
||||
<select name="house_system">
|
||||
{% set hs = form.house_system or 'whole_sign' %}
|
||||
<option value="whole_sign" {{ 'selected' if hs == 'whole_sign' else '' }}>Whole Sign</option>
|
||||
<option value="equal" {{ 'selected' if hs == 'equal' else '' }}>Equal</option>
|
||||
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||
<button type="submit">Policz horoskop</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result %}
|
||||
<div class="meta">
|
||||
Silnik: <strong>{{ result.engine }}</strong> ·
|
||||
obiektów: {{ result.positions | length }}
|
||||
{% if result.house_system %}· domy: {{ result.house_system }}{% endif %}
|
||||
{% if moment %}· moment: {{ moment }}{% endif %}
|
||||
</div>
|
||||
|
||||
{% if result.angles %}
|
||||
<table class="angles">
|
||||
<thead><tr><th>Oś</th><th>Znak</th><th>W znaku</th></tr></thead>
|
||||
<tbody>
|
||||
{% for key in ["Asc", "MC", "Dsc", "IC"] %}
|
||||
{% set a = result.angles[key] %}
|
||||
<tr><td>{{ a.name }}</td><td>{{ a.sign }}</td><td class="mono">{{ a.in_sign }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in result.positions %}
|
||||
<tr>
|
||||
<td>{{ p.name }}</td>
|
||||
<td>{{ p.sign }}</td>
|
||||
<td class="mono">{{ p.in_sign }}</td>
|
||||
<td>{{ p.house if p.house is defined else '—' }}</td>
|
||||
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
|
||||
<td class="mono">{{ '%+.4f' | format(p.speed) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if result.aspects %}
|
||||
<div class="meta">Aspekty główne ({{ result.aspects | length }})</div>
|
||||
<table class="angles">
|
||||
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th></tr></thead>
|
||||
<tbody>
|
||||
{% for a in result.aspects %}
|
||||
<tr><td>{{ a.obj1 }}</td><td>{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if result.cusps %}
|
||||
<details class="loc">
|
||||
<summary>Cusps domów ({{ result.house_system }})</summary>
|
||||
<table>
|
||||
<thead><tr><th>Dom</th><th>Znak</th><th>Cusp</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in result.cusps %}
|
||||
<tr><td>{{ c.house }}</td><td>{{ c.sign }}</td><td class="mono">{{ c.in_sign }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
// „Tu i teraz": uzupełnia datę/godzinę bieżącą i offset lokalny przeglądarki.
|
||||
document.getElementById('nowBtn').addEventListener('click', function () {
|
||||
const d = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
document.querySelector('input[name=date]').value =
|
||||
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
||||
document.querySelector('input[name=time]').value = pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
document.querySelector('input[name=tz_offset]').value = (-d.getTimezoneOffset() / 60);
|
||||
// bajer: lokalizacja z przeglądarki (wymaga zgody; działa na https/localhost)
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(function (pos) {
|
||||
document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4);
|
||||
document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,62 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>astrololo</title>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>astrololo</h1>
|
||||
<p class="sub">Warstwa prezentacji → logiczna → bazodanowa</p>
|
||||
|
||||
<form method="post" action="/">
|
||||
<div class="row">
|
||||
<input type="text" name="query" placeholder="Szukana fraza…"
|
||||
value="{{ form.query or '' }}" autofocus required>
|
||||
<select name="field">
|
||||
{% for f in ["name", "id", "symbol", "category", "value"] %}
|
||||
<option value="{{ f }}" {{ 'selected' if form.field == f else '' }}>{{ f }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Szukaj</button>
|
||||
</div>
|
||||
<div class="opts">
|
||||
<label><input type="checkbox" name="exact" value="true"
|
||||
{{ 'checked' if form.exact else '' }}> dokładne</label>
|
||||
<label>limit
|
||||
<input type="number" name="limit" min="1" max="200" value="{{ form.limit or 25 }}">
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result %}
|
||||
<div class="meta">
|
||||
Znaleziono <strong>{{ result.count }}</strong> ·
|
||||
provider: {{ result.meta.data_provider }} ·
|
||||
cache: {{ result.meta.data_cache }} ·
|
||||
{{ result.meta.data_elapsed_ms }} ms
|
||||
</div>
|
||||
{% if result.results %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>{% for col in result.results[0].keys() %}<th>{{ col }}</th>{% endfor %}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in result.results %}
|
||||
<tr>{% for v in row.values() %}<td>{{ v }}</td>{% endfor %}</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty">Brak wyników.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,102 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Interpretacje{% endblock %}
|
||||
{% block nav_interp %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
|
||||
|
||||
<form method="post" action="/interpret">
|
||||
<div class="grid">
|
||||
<label>Data
|
||||
<input type="date" name="date" value="{{ form.date or '' }}" required>
|
||||
</label>
|
||||
<label>Godzina (lokalna)
|
||||
<input type="time" name="time" value="{{ form.time or '' }}" required>
|
||||
</label>
|
||||
<label>Strefa (offset h)
|
||||
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
|
||||
</label>
|
||||
<label>Szerokość (lat)
|
||||
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
|
||||
</label>
|
||||
<label>Długość (lon)
|
||||
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
|
||||
</label>
|
||||
</div>
|
||||
<div class="opts">
|
||||
<label><input type="checkbox" name="group" value="true" {{ 'checked' if form.group else '' }}> grupuj identyczne opisy</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||
<button type="submit">Szukaj interpretacji</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||
|
||||
{% if result %}
|
||||
{% if result.data_error %}
|
||||
<div class="error">{{ result.data_error }}</div>
|
||||
{% endif %}
|
||||
<div class="meta">
|
||||
Silnik: <strong>{{ result.engine }}</strong>
|
||||
{% if result.provider %}· baza: {{ result.provider }}{% endif %}
|
||||
{% if moment %}· moment: {{ moment }}{% endif %}
|
||||
</div>
|
||||
|
||||
{% for o in result.objects %}
|
||||
<div class="sig-item">
|
||||
<div class="sig-head">
|
||||
<strong>{{ o.object }}</strong> w <strong>{{ o.sign }}</strong>{% if o.house %}, {{ o.house }}. dom{% endif %}
|
||||
<span class="{{ 'retro' if o.direction == 'Rx' else '' }}">{{ o.direction }}</span>
|
||||
<span class="muted small">(planeta {{ o.planet_token }}: {{ o.planet_total }} rekordów)</span>
|
||||
</div>
|
||||
{% for f in o.facets %}
|
||||
<div class="facet">
|
||||
<div class="facet-head">{{ f.label }} — <strong>{{ f.count }}</strong> dopasowań
|
||||
<span class="badge" title="siła (LOG-21)">siła {{ f.score }}</span>
|
||||
<span class="muted small">[{{ f.token }}]</span></div>
|
||||
{% if f.groups %}
|
||||
<table class="samples">
|
||||
<tbody>
|
||||
{% for g in f.groups %}
|
||||
<tr>
|
||||
<td>{{ g.effect }}{% if g.count > 1 %} <span class="badge">×{{ g.count }}</span>{% endif %}</td>
|
||||
<td class="sig small">{{ g.significators | join('; ') }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% elif f.samples %}
|
||||
<table class="samples">
|
||||
<tbody>
|
||||
{% for s in f.samples %}
|
||||
<tr><td class="sig nowrap" title="{{ s.significator }}">{{ s.expanded }}</td><td>{{ s.effect }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
document.getElementById('nowBtn').addEventListener('click', function () {
|
||||
const d = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
document.querySelector('input[name=date]').value =
|
||||
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
||||
document.querySelector('input[name=time]').value = pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
document.querySelector('input[name=tz_offset]').value = (-d.getTimezoneOffset() / 60);
|
||||
// bajer: lokalizacja z przeglądarki (wymaga zgody; działa na https/localhost)
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(function (pos) {
|
||||
document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4);
|
||||
document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sygnifikatory{% endblock %}
|
||||
{% block nav_sig %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p>
|
||||
|
||||
<form method="post" action="/significators">
|
||||
<div class="row">
|
||||
<input type="text" name="query" placeholder="Szukana fraza…"
|
||||
value="{{ form.query or '' }}" autofocus required>
|
||||
<select name="field">
|
||||
{% for f in ["name", "id", "symbol", "category", "value"] %}
|
||||
<option value="{{ f }}" {{ 'selected' if form.field == f else '' }}>{{ f }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Szukaj</button>
|
||||
</div>
|
||||
<div class="opts">
|
||||
<label><input type="checkbox" name="exact" value="true"
|
||||
{{ 'checked' if form.exact else '' }}> dokładne</label>
|
||||
<label>limit
|
||||
<input type="number" name="limit" min="1" max="200" value="{{ form.limit or 25 }}">
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result %}
|
||||
<div class="meta">
|
||||
Znaleziono <strong>{{ result.count }}</strong> ·
|
||||
provider: {{ result.meta.data_provider }} ·
|
||||
cache: {{ result.meta.data_cache }} ·
|
||||
{{ result.meta.data_elapsed_ms }} ms
|
||||
</div>
|
||||
{% if result.results %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>{% for col in result.results[0].keys() %}<th>{{ col }}</th>{% endfor %}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in result.results %}
|
||||
<tr>{% for v in row.values() %}<td>{{ v }}</td>{% endfor %}</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty">Brak wyników.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user