20 Commits

Author SHA1 Message Date
gitea 6ced2ddd22 fix workflows
build / build (push) Successful in 1m1s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m39s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 6m53s
Testy / Kontrola składni wszystkich warstw (push) Successful in 27s
2026-07-20 18:50:54 +00:00
gitea 79bce8ae90 Lots / punkty arabskie — 7 Lots hermetycznych (LOG-08)
- engine/lots.py: formuła Lot = C + A − B z odwracaniem w horoskopach nocnych
  (zamiana A<->B); 7 Lots hermetycznych (Fortuna, Duch, Eros, Konieczność,
  Odwaga, Zwycięstwo, Nemezis) — Fortuna i Duch liczone pierwsze, bo pozostałe
  się do nich odwołują. Dwa warianty: degree (domyślny) i sign (całe znaki).
- build_chart: liczy sektę (reużyta is_day_birth z Firdarii) i zwraca lots
  ze znakiem, pozycją i domem; parametr lots_method.
- wyszukiwarka: token [PF (tak Fortuna występuje w realnej bazie) + rozwinięcie
  skrótu do "Part of Fortune".
- widok Horoskop: tabela Lots z formułami i sektą.

Walidacja (dwie niezależne wyrocznie z notes3):
- Fortuna = Can 12°35'27" vs astro-seek Can 12°35'24" (3 sekundy różnicy), dom 1;
- Duch potwierdzony przez swoją antyscję (Taurus 28°14' z tabeli antyscji);
- odwracanie nocne: Fortuna nocna == Duch dzienny i odwrotnie;
- Lots pochodne faktycznie używają Fortuny/Ducha; wariant sign trafia w 0° znaku.
85 testów przechodzi (nowy test_lots).

Odblokowuje Zodiacal Releasing (LOG-11), które startuje z Fortuny/Ducha.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:50:54 +00:00
gitea 85e9182f12 fix(swisseph): naprawa builda obrazu silnika B
build / build (push) Successful in 43s
pyswisseph to rozszerzenie C, a na PyPI (2.10.3.2) gotowe wheels koncza sie
na cp311 i obejmuja wylacznie i686/x86_64. Obraz stoi na python:3.12-slim,
wiec pip ZAWSZE kompilowal ze zrodel - a slim nie ma kompilatora. Stad fail.

- Dockerfile wieloetapowy: kompilacja w etapie builder (build-essential),
  do runtime trafia juz tylko gotowy wheel - obraz zostaje czysty i maly.
  Dziala tez na arm64, gdzie wheeli linuksowych nie ma dla zadnej wersji.
- sanity check (import swisseph) na etapie builda, zeby niedzialajacy silnik
  wywracal build, a nie dopiero pierwszy request.
- CI: nowy job swisseph-image - realny docker build + smoke test /health
  i /positions na horoskopie referencyjnym.
- README: udokumentowany powod wieloetapowego builda.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:30:10 +02:00
gitea c75f8377bf kick image updater
build / build (push) Successful in 49s
2026-07-20 19:52:21 +02:00
gitea 5234e86c95 ttt
build / build (push) Successful in 1m18s
2026-07-20 14:50:11 +02:00
gitea 0ac8df250c test image updatera
build / build (push) Successful in 51s
2026-07-19 23:20:39 +02:00
gitea 9323803cb4 tt
build / build (push) Successful in 5m31s
2026-07-18 21:57:33 +02:00
gitea 678c3bad76 test 2 2026-07-18 18:11:42 +02:00
gitea 88a1ecd3c4 test akcji 2026-07-18 18:04:16 +02:00
gitea b28c612bf7 CI: jawne pobranie jądra efemeryd + brak silnika = błąd zamiast pomijania
Pierwszy run CI dał 59 passed / 20 skipped zamiast 78/1: auto-pobranie jądra
przez Skyfield nie powiodło się na runnerze, więc testy referencyjne (walidacja
względem astro.com) cicho znikały.

- workflow: jawne pobranie de421.bsp curlem z ssd.jpl.nasa.gov (naif zwraca 404),
  z retry; EPHEMERIS_DIR wskazany explicite; pytest z -rs (widoczne powody skipów).
- conftest: gdy CI=true, niedostępny silnik kończy się pytest.fail zamiast skip —
  żeby utrata pokrycia nigdy więcej nie przeszła niezauważona. Lokalnie (dev bez
  pobranego jądra) nadal łagodny skip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:47:50 +02:00
gitea 2bda4e311d CI: uruchamianie testów przy każdym pushu (GitHub Actions)
.github/workflows/tests.yml:
- job "Testy warstwy logicznej": Python 3.12 (jak w obrazach Dockera),
  instalacja requirements-dev, pytest na services/logic/tests. Cache pip oraz
  cache jądra efemeryd JPL (de421.bsp ~17 MB), by nie pobierać go co run.
- job "Kontrola składni": compileall na wszystkich warstwach (bez instalowania
  zależności, w tym AGPL-owego silnika swisseph).
- Triggery: każdy push (dowolna gałąź) + pull requesty do master.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:02:37 +02:00
gitea 5d0d86cb90 Merge pull request #22 from migatu/feat/firdaria-log11
Firdaria (LOG-11) — perska technika time-lord
2026-07-17 22:50:39 +02:00
gitea 9d5b65ddbd Firdaria (LOG-11) — perska technika time-lord
- engine/firdaria.py: sekta (dzień = Słońce nad horyzontem, ta sama półkula
  osi Asc-Dsc co MC); kolejność diurnalna/nokturnalna; klasyczne długości
  okresów (Su10 Ve8 Me13 Mo9 Sa11 Ju12 Ma7 + NN3 + SN2 = 75 lat); okresy główne
  planet dzielone na 7 podokresów (sub-lord od władcy okresu), węzły bez sub.
- endpoint /chart/firdaria.
- oś czasu: firdaria_events — starty (pod)okresów w oknie; wpięte w build_timeline
  (domyślnie) + tokeny [major][sub] do dopięcia interpretacji (1B->2B).

Walidacja:
- sekta = day dla horoskopu referencyjnego (zgodnie z notes3 "Day birth");
  night gdy Słońce po stronie IC; sumy i przyleganie okresów; podokresy 7x
  sumujące się do okresu; wiek 42 w okresie Saturna.
- E2E: okresy Sun 1984-1994 ... Saturn 2024-2035; w osi czasu
  "Firdaria: Saturn / Mars" 2027 -> 223 interpretacje ([Sa+[Ma -> "injury").
- 78 testów przechodzi (nowy test_firdaria).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 22:48:53 +02:00
gitea cff0ac194c Merge pull request #21 from migatu/feat/timeline-interpretations
build
2026-07-17 22:39:58 +02:00
gitea 73f39e7df8 build 2026-07-17 19:30:14 +02:00
gitea 96d983b26a Merge pull request #20 from migatu/feat/timeline-interpretations
Spiecie osi czasu z baza interpretacji i widokiem (LOG-14, 1B->2B)
2026-07-11 12:33:46 +02:00
gitea c4a181b810 Spięcie osi czasu z bazą interpretacji i widokiem (LOG-14, 1B->2B)
Realizuje przepływ 1B->2B z notes2: predykcyjne sygnifikatory (z datami)
dopasowane do interpretacji z bazy.

Logika:
- timeline.py: zdarzenia niosą strukturę (directed/aspect/target dla dyrekcji,
  lord/sign dla profekcji) do budowy tokenów.
- significators.interpret_events + _event_tokens: z każdego zdarzenia buduje
  tokeny bazy (dyrekcja: [planeta][aspekt][cel]; profekcja: [władca][znak]) i
  dopina interpretacje reużywając _facet_samples (AND tokenów, dedup, rozwinięcie).
- /chart/timeline: flaga interpret=true.

Prezentacja:
- nowa strona /timeline "Kalendarz": formularz (urodzenie + zakres dat) -> oś
  czasu z technikami, datami i interpretacjami; nawigacja + wspólne now.js.

Walidacja E2E na realnym main_base.xlsx (2025-2026):
- dyr. Saturn kwadratura MC -> 50 interpret. ("...5th house" -> "abortion/miscarriage")
- Władca Roku Saturn (wiek 42) -> 63; strona renderuje badge dat/technik.
71 testów przechodzi (nowy test_timeline_interpret).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:31:50 +02:00
gitea 97ad21d2e4 Merge pull request #19 from migatu/feat/timeline-log14
Zbiorcza tabela dat z technik (LOG-14)
2026-07-10 23:53:10 +02:00
gitea 2dbd3410e9 Zbiorcza tabela dat z technik (LOG-14)
engine/timeline.py: scala w jedną posortowaną oś czasu (technique | significator |
start | exact | end):
- profekcje roczne (LOG-10) — Władca Roku per rok życia,
- Solar Return (LOG-12) — moment powrotu Słońca,
- dyrekcje solar-arc — daty dokładnych aspektów kierowanych planet do punktów
  natalnych (klucz Naiboda 0°59'08"/rok, konfigurowalny; okno orbowe ±1 rok).
Endpoint /chart/timeline (zakres from_date..to_date, wybór technik).

Walidacja:
- profekcje spójne z tabelą notes3; dyrekcja Sun koniunkcja MC (łuk 312°) słusznie
  poza życiem; oś posortowana po dacie dokładnej; wiek = łuk/klucz spójny z datą.
- E2E: dla 2025-2026 zwraca 19 zdarzeń (m.in. Władca Roku 42 Saturn 30.04.2026,
  Solar Return, dyr. Venus koniunkcja North Node 27.05.2025). 66 testów przechodzi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:33:15 +02:00
gitea ca458fd741 Merge pull request #18 from migatu/feat/profections-returns
Profekcje roczne (LOG-10) i Solar/Lunar Return (LOG-12)
2026-07-10 13:40:51 +02:00
24 changed files with 965 additions and 4 deletions
+19
View File
@@ -0,0 +1,19 @@
name: build
on:
push:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
- name: Build & push (data, logic, presentation)
run: |
TAG=${GITHUB_SHA::8}
for SVC in data logic presentation; do
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG
done
echo "Tag: $TAG"
+96
View File
@@ -0,0 +1,96 @@
name: Testy
# Odpala się przy każdym pushu (dowolna gałąź) oraz dla pull requestów do master.
on:
push:
pull_request:
branches: [master]
jobs:
logic-tests:
name: Testy warstwy logicznej (silnik)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Python 3.12 (jak w obrazach Dockera)
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: services/logic/requirements-dev.txt
# Jądro efemeryd JPL (de421.bsp, ~17 MB). Cache'ujemy je między runami.
- name: Cache jądra efemeryd
uses: actions/cache@v4
with:
path: services/logic/.ephemeris
key: ephemeris-de421
# Pobieramy jawnie (a nie licząc na auto-pobranie przez Skyfield), żeby
# brak jądra był twardym błędem, a nie cichym pomijaniem testów.
- name: Pobierz jądro efemeryd (gdy brak w cache)
run: |
mkdir -p services/logic/.ephemeris
if [ ! -s services/logic/.ephemeris/de421.bsp ]; then
curl -fSL --retry 3 --max-time 300 \
-o services/logic/.ephemeris/de421.bsp \
https://ssd.jpl.nasa.gov/ftp/eph/planets/bsp/de421.bsp
fi
ls -lh services/logic/.ephemeris/de421.bsp
- name: Instalacja zależności
run: pip install -r services/logic/requirements-dev.txt
# CI=true (ustawiane przez GitHub) sprawia, że brak silnika = błąd,
# a nie pominięcie — patrz tests/conftest.py.
- name: Testy (pytest)
working-directory: services/logic
env:
PYTHONPATH: .
EPHEMERIS_DIR: ${{ github.workspace }}/services/logic/.ephemeris
run: pytest tests -q -rs
swisseph-image:
name: Build obrazu silnika B (swisseph)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Obraz kompiluje pyswisseph ze źródeł (brak wheeli dla cp312), więc ten
# build jest realnym testem Dockerfile'a — nie tylko pobraniem paczek.
- name: docker build
run: docker build -t astrololo/engine-swisseph:ci services/engine-swisseph
- name: Smoke test (health + pozycje)
run: |
docker run -d --name swe -p 8003:8003 astrololo/engine-swisseph:ci
for i in $(seq 1 30); do
curl -fsS http://localhost:8003/health >/dev/null 2>&1 && break
sleep 1
done
curl -fsS http://localhost:8003/health
echo
# Horoskop referencyjny (30.04.1984) — ten sam, na którym opieramy testy
# silnika własnego; sprawdzamy, że silnik B faktycznie liczy.
curl -fsS -X POST http://localhost:8003/positions \
-H 'Content-Type: application/json' \
-d '{"when_utc":"1984-04-30T09:20:00Z","lat":50.0647,"lon":19.9450}'
echo
- name: Logi kontenera (gdy coś padło)
if: failure()
run: docker logs swe || true
compile-all:
name: Kontrola składni wszystkich warstw
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
# Sam kompilator — bez instalowania zależności warstw (w tym AGPL-owego
# silnika swisseph, który nie wchodzi do produktu).
- name: py_compile
run: python -m compileall -q services
+1
View File
@@ -73,3 +73,4 @@ export DATA_PROVIDER=sql # przełącz całą warstwę
`SqlDataProvider` realizuje ten sam kontrakt `/search`, więc **warstwa logiczna i
prezentacji nie zmieniają ani jednej linii**. Odwrócony indeks z L4 (SQLite) jest
już pomostem — rozbudowa o wszystkie kolumny = docelowa baza.
# updater test Mon 20 Jul 2026 19:52:08 CEST
+25 -1
View File
@@ -1,10 +1,34 @@
# Build wieloetapowy — bo `pyswisseph` to rozszerzenie C bez gotowych wheeli.
#
# Na PyPI (2.10.3.2) wheels kończą się na cp311 i obejmują wyłącznie i686/x86_64.
# Dla Pythona 3.12 oraz dla arm64 pip ZAWSZE kompiluje ze źródeł, a `-slim` nie ma
# kompilatora — dlatego jednoetapowy build tu padał. Kompilujemy w etapie builder,
# a do obrazu finalnego wchodzi już tylko gotowy wheel (bez toolchaina).
FROM python:3.12-slim AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /wheels /wheels
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \
&& rm -rf /wheels
COPY . .
# Sanity check na etapie budowania: brak działającego swissepha ma wywalić build,
# a nie dopiero pierwszy request.
RUN python -c "import swisseph as swe; swe.set_ephe_path(None); print('swisseph OK', swe.version)"
EXPOSE 8003
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8003"]
+15 -1
View File
@@ -15,9 +15,23 @@ permisywnej reszty systemu. **Nie wchodzi do dystrybucji zamkniętego produktu.*
Tryb Moshiera (`FLG_MOSEPH`) — bez plików efemeryd, zero konfiguracji.
## Build obrazu
```bash
docker compose --profile comparison build engine-swisseph
```
Dockerfile jest **wieloetapowy** i to nie jest ozdobnik: `pyswisseph` to rozszerzenie
C, a na PyPI (2.10.3.2) gotowe wheels kończą się na **cp311** i obejmują wyłącznie
**i686/x86_64**. Dla Pythona 3.12 oraz dla arm64 pip musi kompilować ze źródeł, więc
sam `python:3.12-slim` (bez kompilatora) build wywracał. Kompilacja idzie w etapie
`builder` (`build-essential`), a do obrazu finalnego trafia już tylko gotowy wheel —
runtime zostaje czysty i mały. Pierwszy build trwa ~12 min, kolejne idą z cache warstw.
Build kończy się sanity-checkiem (`import swisseph`), żeby niedziałający silnik
wykrzaczył build, a nie dopiero pierwszy request.
## Uruchomienie (tylko profil porównawczy / dev / CI)
```bash
pip install -r requirements.txt
pip install -r requirements.txt # wymaga kompilatora C (patrz wyżej)
uvicorn app.main:app --port 8003
```
Następnie w warstwie logicznej ustaw `ENGINE_SWISSEPH_URL=http://localhost:8003`,
+3 -1
View File
@@ -6,10 +6,12 @@ i nie w bazie.
## API
- `POST /api/query``QueryRequest``QueryResponse`
- `POST /chart/positions``{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty główne z applying/separating (LOG-06) + opcjonalnie stacje planet (`stations:true`, LOG-03). Obiekty: 10 planet + mean NN/SN/Lilith (LOG-02). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`.
- `POST /chart/positions``{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty główne z applying/separating (LOG-06) + opcjonalnie stacje planet (`stations:true`, LOG-03). Zwraca też sektę i 7 Lots hermetycznych z domami (LOG-08). Obiekty: 10 planet + mean NN/SN/Lilith (LOG-02). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`.
- `POST /chart/report``{when_utc, lat, lon, limit?}` → wynik obliczeń wyszukany w bazie: fasety sygnifikatorów **w znaku / w domu / w aspekcie**, z rozwinięciem skrótów, odsiewaniem duplikatów (ten sam sygnifikator i opis), rankingiem siły (LOG-21) oraz opcją group (grupowanie identycznych opisów)
- `POST /chart/profections``{when_utc, lat, lon, start_age?, count?}` → profekcje roczne: wiek, profektowany Asc, Władca Roku (+MC/Su/Mo) (LOG-10)
- `POST /chart/return``{when_utc, lat, lon, kind, around?}` → Solar/Lunar Return: moment powrotu + pełny horoskop na ten moment (LOG-12)
- `POST /chart/firdaria``{when_utc, lat, lon}` → Firdaria: sekta (dzień/noc), okresy główne i podokresy time-lordów (LOG-11)
- `POST /chart/timeline``{when_utc, lat, lon, from_date, to_date, techniques?}` → zbiorcza oś czasu: profekcje + Solar Return + dyrekcje solar-arc + Firdaria, posortowane (technique | significator | start | exact | end); z interpret=true dopina interpretacje z bazy do dat (LOG-14, 1B->2B)
- `POST /chart/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B)
- `GET /health` (sprawdza też warstwę bazodanową)
+2
View File
@@ -24,6 +24,8 @@ ABBREVIATIONS: dict[str, str] = {
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
# osie
"Asc": "Ascendant", "Dsc": "Descendant", "MC": "Midheaven", "IC": "Imum Coeli",
# Lots (punkty arabskie)
"PF": "Part of Fortune", "Fortune": "Part of Fortune", "Spirit": "Lot of Spirit",
# aspekty
"conj": "conjunction", "sex": "sextile", "sq": "square", "tri": "trine",
"opp": "opposition", "semisex": "semisextile", "semisq": "semisquare",
+18 -1
View File
@@ -21,7 +21,8 @@ def _fmt(name: str, lon: float) -> dict:
}
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN) -> dict:
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
lots_method: str = "degree") -> dict:
from app.engine.aspects import find_aspects
positions = engine.positions(moment)
@@ -50,4 +51,20 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
]
for pdict, obj in zip(result["positions"], positions):
pdict["house"] = H.assign_house(obj.longitude, cusp_list)
# Lots (LOG-08) — wymagają Asc i sekty (dzień/noc)
from app.engine.firdaria import is_day_birth
from app.engine.lots import compute_lots
pts = {p.name: p.longitude for p in positions}
pts["Asc"] = asc
day = is_day_birth(pts["Sun"], asc, mc) if "Sun" in pts else True
result["sect"] = "day" if day else "night"
result["lots"] = [
{**lot,
"sign": SIGNS[sign_index(lot["longitude"])],
"in_sign": in_sign(lot["longitude"]),
"house": H.assign_house(lot["longitude"], cusp_list)}
for lot in compute_lots(pts, day, lots_method)
]
return result
+57
View File
@@ -0,0 +1,57 @@
"""Firdaria (LOG-11) — perska technika time-lord.
Sekwencja okresów głównych zależy od sekty (dzień/noc). Sekta: urodzenie dzienne,
gdy Słońce jest nad horyzontem, czyli po tej samej stronie osi AscDsc co MC.
Klasyczne długości okresów (lata): Su 10, Ve 8, Me 13, Mo 9, Sa 11, Ju 12, Ma 7
(razem 70) + Węzeł Północny 3 + Węzeł Południowy 2 = 75 lat. Każdy okres główny
planety dzieli się na 7 podokresów (sub-lord w tej samej kolejności, cyklicznie).
Węzły — bez podokresów (najczęstsza konwencja).
"""
from __future__ import annotations
from datetime import datetime, timedelta
DAY_ORDER = ["Sun", "Venus", "Mercury", "Moon", "Saturn", "Jupiter", "Mars"]
NIGHT_ORDER = ["Moon", "Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury"]
YEARS = {"Sun": 10, "Venus": 8, "Mercury": 13, "Moon": 9,
"Saturn": 11, "Jupiter": 12, "Mars": 7}
NODES = [("North Node", 3), ("South Node", 2)]
DAYS_PER_YEAR = 365.2422
def is_day_birth(sun_lon: float, asc: float, mc: float) -> bool:
"""Słońce nad horyzontem = ta sama półkula osi AscDsc co MC."""
return (((sun_lon - asc) % 360.0) < 180.0) == (((mc - asc) % 360.0) < 180.0)
def _date(birth: datetime, years: float) -> str:
return (birth + timedelta(days=years * DAYS_PER_YEAR)).date().isoformat()
def firdaria(birth: datetime, sun_lon: float, asc: float, mc: float) -> dict:
"""Pełny rozkład Firdarii: sekta, kolejność, okresy główne i podokresy."""
day = is_day_birth(sun_lon, asc, mc)
order = DAY_ORDER if day else NIGHT_ORDER
majors = [(lord, YEARS[lord]) for lord in order] + NODES
periods: list[dict] = []
age = 0.0
for lord, yrs in majors:
period = {"lord": lord, "years": yrs,
"start": _date(birth, age), "end": _date(birth, age + yrs)}
if lord in YEARS: # planeta -> 7 podokresów
sub_len = yrs / 7.0
i = order.index(lord)
sub_age = age
subs: list[dict] = []
for k in range(7):
sub_lord = order[(i + k) % 7]
subs.append({"lord": sub_lord,
"start": _date(birth, sub_age),
"end": _date(birth, sub_age + sub_len)})
sub_age += sub_len
period["sub"] = subs
periods.append(period)
age += yrs
return {"sect": "day" if day else "night", "order": order, "periods": periods}
+60
View File
@@ -0,0 +1,60 @@
"""Lots / punkty arabskie (LOG-08) — 7 Lots hermetycznych.
Formuła: Lot = C + A B (od punktu C odmierzamy odległość między A i B).
Większość Lots **odwraca się w horoskopach nocnych** (zamiana A↔B) — np.
Fortuna: dzień Asc + Mo Su, noc Asc + Su Mo.
Dwa warianty liczenia (notes3):
- `degree` (domyślny) — dokładny stopień,
- `sign` — liczone całymi znakami (Lot wypada na 0° wyliczonego znaku).
Kolejność ma znaczenie: Fortuna i Duch liczone są pierwsze, bo pozostałe Lots
odwołują się do nich.
"""
from __future__ import annotations
from app.engine.formats import norm360, sign_index
# (nazwa, C, A, B, odwracalny w nocy)
LOT_DEFS: list[tuple[str, str, str, str, bool]] = [
("Fortune", "Asc", "Moon", "Sun", True),
("Spirit", "Asc", "Sun", "Moon", True),
("Eros", "Asc", "Venus", "Spirit", True),
("Necessity", "Asc", "Fortune", "Mercury", True),
("Courage", "Asc", "Fortune", "Mars", True),
("Victory", "Asc", "Jupiter", "Spirit", True),
("Nemesis", "Asc", "Fortune", "Saturn", True),
]
METHODS = ("degree", "sign")
def compute_lots(
points: dict[str, float], is_day: bool, method: str = "degree"
) -> list[dict]:
"""points: nazwa → długość natalna (wymagane Asc + planety formuł).
Zwraca listę {name, longitude, formula} w kolejności definicji.
"""
if method not in METHODS:
raise ValueError(f"nieznana metoda liczenia Lots: {method}")
vals = dict(points)
out: list[dict] = []
for name, c, a, b, reversible in LOT_DEFS:
first, second = (a, b) if (is_day or not reversible) else (b, a)
if any(k not in vals for k in (c, first, second)):
continue # brak składnika — pomijamy
if method == "sign":
idx = (sign_index(vals[c]) + sign_index(vals[first])
- sign_index(vals[second])) % 12
lon = idx * 30.0
else:
lon = norm360(vals[c] + vals[first] - vals[second])
vals[name] = lon # dostępny dla kolejnych Lots
out.append({
"name": name,
"longitude": lon,
"formula": f"{c} + {first} {second}",
})
return out
+160
View File
@@ -0,0 +1,160 @@
"""Zbiorcza tabela dat z technik (LOG-14).
Spina w jedną, posortowaną oś czasu daty z kilku technik:
- profekcje roczne (LOG-10) — rok życia,
- Solar Return (LOG-12) — moment powrotu Słońca,
- dyrekcje solar-arc — daty dokładnych aspektów kierowanych planet do punktów
natalnych (wzorzec z notes3: „Profection planet | Aspect | Birth planet |
Exact Date"). Klucz łuku konfigurowalny; domyślnie Naiboda (0°59'08"/rok).
Każdy wiersz ma kształt z notes2: technique | significator | start | exact | end.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
from app.engine.aspects import MAJOR, PL_NAME
from app.engine.profections import DOMICILE_RULERS, profected_sign
from app.engine.returns import find_return
NAIBOD_KEY = 0.9856472 # °/rok (0°59'08") — domyślny klucz solar-arc
DAYS_PER_YEAR = 365.2422
DIRECTED = ["Sun", "Moon", "Mercury", "Venus", "Mars",
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto"]
def _add_years(birth: datetime, years: float) -> datetime:
return birth + timedelta(days=years * DAYS_PER_YEAR)
def _row(technique, significator, start, exact, end) -> dict:
def iso(x):
return x.date().isoformat() if isinstance(x, datetime) else x
return {"technique": technique, "significator": significator,
"start": iso(start), "exact": iso(exact), "end": iso(end)}
def solar_arc_directions(
natal: dict[str, float], birth: datetime, lo: datetime, hi: datetime,
key: float = NAIBOD_KEY, orb_years: float = 1.0,
) -> list[dict]:
"""Daty dyrekcji solar-arc w oknie [lo, hi].
natal: nazwa punktu -> długość natalna (planety + Asc/MC). Kierowane są planety
(DIRECTED), celem każdy punkt natalny. Aspekt dokładny gdy łuk = odległość
kątowa (mod 360). Wiek = łuk/klucz; data = urodziny + wiek.
"""
out: list[dict] = []
lo_age = (lo - birth).days / DAYS_PER_YEAR - orb_years
hi_age = (hi - birth).days / DAYS_PER_YEAR + orb_years
for p in DIRECTED:
if p not in natal:
continue
for q, q_lon in natal.items():
for asp, angle in MAJOR.items():
for target in ({angle, (360.0 - angle) % 360.0}):
arc = (q_lon + target - natal[p]) % 360.0
age = arc / key
if not (lo_age <= age <= hi_age) or (p == q and arc < 1e-6):
continue
exact = _add_years(birth, age)
row = _row(
"solar_arc",
f"dyr. {p} {PL_NAME[asp]} {q}",
_add_years(birth, age - orb_years),
exact,
_add_years(birth, age + orb_years),
)
row.update(directed=p, aspect=asp, target=q) # do budowy tokenów (1B->2B)
out.append(row)
return out
def profection_events(natal_asc: float, birth: datetime, lo: datetime, hi: datetime) -> list[dict]:
"""Lata profekcyjne (LOG-10) nachodzące na okno."""
out: list[dict] = []
for age in range((lo.year - birth.year) - 1, (hi.year - birth.year) + 1):
if age < 0:
continue
try:
start = birth.replace(year=birth.year + age)
end = birth.replace(year=birth.year + age + 1)
except ValueError: # 29 lutego
start = birth.replace(year=birth.year + age, day=28)
end = birth.replace(year=birth.year + age + 1, day=28)
if end < lo or start > hi:
continue
sign = profected_sign(natal_asc, age)
lord = DOMICILE_RULERS[sign]
row = _row(
"profection", f"Władca Roku: {lord} (Asc {sign}, wiek {age})",
start, start, end,
)
row.update(lord=lord, sign=sign) # do budowy tokenów (1B->2B)
out.append(row)
return out
def solar_return_events(engine, natal_moment, birth: datetime, lo: datetime, hi: datetime) -> list[dict]:
"""Solariusze w oknie (LOG-12) — jeden na rok."""
out: list[dict] = []
for year in range(lo.year, hi.year + 1):
try:
around = birth.replace(year=year)
except ValueError:
around = birth.replace(year=year, day=28)
hit = find_return(engine, "solar", natal_moment, around)
if hit and lo <= hit <= hi:
out.append(_row("solar_return", "Solar Return", hit, hit, _add_years(hit, 1)))
return out
def firdaria_events(natal_points: dict[str, float], birth: datetime, lo: datetime, hi: datetime) -> list[dict]:
"""Starty okresów/podokresów Firdarii (LOG-11) nachodzące na okno."""
from app.engine.firdaria import firdaria
fd = firdaria(birth, natal_points["Sun"], natal_points["Asc"], natal_points["MC"])
out: list[dict] = []
for period in fd["periods"]:
if "sub" in period:
for s in period["sub"]:
if lo <= _as_dt(s["start"]) <= hi:
row = _row("firdaria", f"Firdaria: {period['lord']} / {s['lord']}",
s["start"], s["start"], s["end"])
row.update(fd_major=period["lord"], fd_sub=s["lord"])
out.append(row)
elif lo <= _as_dt(period["start"]) <= hi: # węzeł — bez podokresów
row = _row("firdaria", f"Firdaria: {period['lord']}",
period["start"], period["start"], period["end"])
row.update(fd_major=period["lord"])
out.append(row)
return out
def _as_dt(d) -> datetime:
if isinstance(d, datetime):
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
if isinstance(d, date):
return datetime(d.year, d.month, d.day, tzinfo=timezone.utc)
return datetime.fromisoformat(str(d)).replace(tzinfo=timezone.utc)
def build_timeline(
engine, natal_moment, natal_points: dict[str, float],
from_d, to_d, techniques: list[str] | None = None,
) -> list[dict]:
"""Scala wybrane techniki w jedną oś czasu, posortowaną po dacie dokładnej."""
lo, hi = _as_dt(from_d), _as_dt(to_d)
birth = natal_moment.when_utc
want = set(techniques or ["profection", "solar_return", "solar_arc", "firdaria"])
events: list[dict] = []
if "profection" in want:
events += profection_events(natal_points["Asc"], birth, lo, hi)
if "solar_return" in want:
events += solar_return_events(engine, natal_moment, birth, lo, hi)
if "solar_arc" in want:
events += solar_arc_directions(natal_points, birth, lo, hi)
if "firdaria" in want:
events += firdaria_events(natal_points, birth, lo, hi)
events.sort(key=lambda e: e["exact"])
return events
+60
View File
@@ -173,6 +173,66 @@ def chart_return(req: ReturnRequest) -> dict:
"return_utc": hit.isoformat(), **chart}
class FirdariaRequest(BaseModel):
when_utc: datetime # moment urodzenia (UTC)
lat: float = 0.0
lon: float = 0.0
@app.post("/chart/firdaria")
def chart_firdaria(req: FirdariaRequest) -> dict:
"""Firdaria (LOG-11): sekta + okresy główne i podokresy time-lordów."""
from app.engine import houses as H
from app.engine.firdaria import firdaria
from app.engine.models import ChartMoment
engine = get_engine()
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
ramc, eps = engine.sidereal(natal)
asc, mc = H.compute_asc(ramc, eps, natal.lat), H.compute_mc(ramc, eps)
sun = engine.positions(natal, ["Sun"])[0].longitude
return {"engine": engine.name, **firdaria(req.when_utc, sun, asc, mc)}
class TimelineRequest(BaseModel):
when_utc: datetime # moment urodzenia (UTC)
lat: float = 0.0
lon: float = 0.0
from_date: str # zakres: YYYY-MM-DD
to_date: str
techniques: list[str] | None = None # profection | solar_return | solar_arc
interpret: bool = False # dopnij interpretacje z bazy (1B->2B)
@app.post("/chart/timeline")
def chart_timeline(req: TimelineRequest) -> dict:
"""Zbiorcza oś czasu z technik (LOG-14): technique | significator | start | exact | end.
Z interpret=true dopina do zdarzeń interpretacje z warstwy danych (LOG-19, 1B->2B).
"""
from app.engine import houses as H
from app.engine.models import ChartMoment
from app.engine.timeline import build_timeline
engine = get_engine()
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
ramc, eps = engine.sidereal(natal)
points = {"Asc": H.compute_asc(ramc, eps, natal.lat), "MC": H.compute_mc(ramc, eps)}
for p in engine.positions(natal):
points[p.name] = p.longitude
events = build_timeline(engine, natal, points, req.from_date, req.to_date, req.techniques)
out = {"engine": engine.name, "from": req.from_date, "to": req.to_date}
if req.interpret:
from app.significators import interpret_events
try:
interpret_events(events, DataClient())
except httpx.HTTPError as e:
out["data_error"] = f"Warstwa danych niedostępna: {e}"
out.update(count=len(events), events=events)
return out
@app.get("/health")
def health() -> dict:
info = {"status": "ok", "layer": "logic"}
+60
View File
@@ -22,6 +22,8 @@ PLANET_ABBR = {
"Jupiter": "Ju", "Saturn": "Sa", "Uranus": "Ur", "Neptune": "Ne", "Pluto": "Pl",
# punkty wirtualne — tokeny wg SIGNIFICATORS KEY ([NN, [SN, [Lilith)
"North Node": "NN", "South Node": "SN", "Lilith": "Lilith",
# Lots — w bazie Fortuna występuje jako [PF (Part of Fortune)
"Fortune": "PF", "Spirit": "Spirit",
}
SIGN_TO_ABBR = dict(zip(SIGNS, SIGN_ABBR))
@@ -195,3 +197,61 @@ def build_report(
"facets": facets,
})
return {"provider": provider, "objects": items}
def _event_tokens(event: dict) -> list[str]:
"""Tokeny bazy dla zdarzenia osi czasu (spięcie 1B→2B). Pierwszy = planeta."""
from app.engine.aspects import DB_TOKEN
if event.get("technique") == "solar_arc":
p = PLANET_ABBR.get(event.get("directed"))
a = DB_TOKEN.get(event.get("aspect"))
q = PLANET_ABBR.get(event.get("target")) # None dla Asc/MC (nie ma tokenu)
toks = []
if p:
toks.append("[" + p)
if a:
toks.append(a)
if q:
toks.append("[" + q)
return toks if p else []
if event.get("technique") == "profection":
lord = PLANET_ABBR.get(event.get("lord"))
sign = SIGN_TO_ABBR.get(event.get("sign"))
toks = []
if lord:
toks.append("[" + lord)
if sign:
toks.append("[" + sign)
return toks if lord else []
if event.get("technique") == "firdaria":
major = PLANET_ABBR.get(event.get("fd_major"))
sub = PLANET_ABBR.get(event.get("fd_sub"))
toks = []
if major:
toks.append("[" + major)
if sub:
toks.append("[" + sub)
return toks if major else []
return []
def interpret_events(events: list[dict], data: DataSource, limit: int = 4,
per_object_limit: int = 5000) -> list[dict]:
"""Dopina interpretacje z bazy do zdarzeń osi czasu (predykcyjne 1B → 2B).
Wyszukuje po tokenie planety zdarzenia i zawęża do wszystkich tokenów
(aspekt/druga planeta lub znak), z odsiewaniem szumu i duplikatów.
"""
for ev in events:
tokens = _event_tokens(ev)
if not tokens:
continue
raw = data.search(
key="significator", value=tokens[0], exact=False, limit=per_object_limit,
fields=["significator", "actioneffect", "topicresult", "bodypart"],
)
samples = _facet_samples(raw.get("rows", []), tokens)
ev["interpretations"] = samples[:limit]
ev["interpretations_count"] = len(samples)
return events
+5
View File
@@ -7,6 +7,7 @@ służą jako wyrocznia (LOG-25).
from __future__ import annotations
import datetime as dt
import os
import pytest
@@ -28,4 +29,8 @@ def own_engine():
try:
return SkyfieldEngine()
except Exception as e: # brak efemeryd / brak sieci
# Lokalnie pomijamy (dev bez pobranego jądra), ale w CI to musi być błąd —
# inaczej testy referencyjne (walidacja względem astro.com) cicho znikają.
if os.getenv("CI"):
pytest.fail(f"CI: silnik efemeryd niedostępny — testy referencyjne muszą działać: {e}")
pytest.skip(f"Nie można wczytać efemeryd: {e}")
+58
View File
@@ -0,0 +1,58 @@
"""Firdaria (LOG-11) — sekta, kolejność, długości okresów, podokresy."""
import datetime as dt
from app.engine.firdaria import DAY_ORDER, NIGHT_ORDER, YEARS, firdaria, is_day_birth
from app.significators import _event_tokens
BIRTH = dt.datetime(1984, 4, 30, 7, 35, tzinfo=dt.timezone.utc)
# horoskop referencyjny: Asc 112.18, MC 352.59, Sun 40.14 -> Day birth (notes3)
ASC, MC, SUN = 112.18, 352.59, 40.14
def test_reference_is_day_birth():
assert is_day_birth(SUN, ASC, MC) is True # notes3: "Day birth (Diurnal)"
def test_night_birth_uses_night_order():
# Słońce po stronie IC (przeciwna półkula) -> noc
assert is_day_birth((SUN + 180) % 360, ASC, MC) is False
fd = firdaria(BIRTH, (SUN + 180) % 360, ASC, MC)
assert fd["sect"] == "night" and fd["order"] == NIGHT_ORDER
def test_diurnal_sequence_and_totals():
fd = firdaria(BIRTH, SUN, ASC, MC)
assert fd["sect"] == "day" and fd["order"] == DAY_ORDER
lords = [p["lord"] for p in fd["periods"]]
assert lords == DAY_ORDER + ["North Node", "South Node"]
assert sum(p["years"] for p in fd["periods"]) == 75 # 70 + 3 + 2
def test_planet_majors_have_seven_subperiods_summing_to_period():
fd = firdaria(BIRTH, SUN, ASC, MC)
for p in fd["periods"]:
if p["lord"] in YEARS:
assert len(p["sub"]) == 7
assert p["sub"][0]["lord"] == p["lord"] # sub zaczyna się od władcy okresu
assert p["sub"][0]["start"] == p["start"]
assert p["sub"][-1]["end"] == p["end"]
else:
assert "sub" not in p # węzły bez podokresów
def test_periods_are_contiguous():
fd = firdaria(BIRTH, SUN, ASC, MC)
for a, b in zip(fd["periods"], fd["periods"][1:]):
assert a["end"] == b["start"]
def test_age_42_is_saturn_major():
# kumulatywnie: Su10 Ve8 Me13 Mo9 -> 40; Saturn 40-51 -> wiek 42 w Saturnie
fd = firdaria(BIRTH, SUN, ASC, MC)
saturn = next(p for p in fd["periods"] if p["lord"] == "Saturn")
assert saturn["start"].startswith("2024") and saturn["end"].startswith("2035")
def test_event_tokens_firdaria():
ev = {"technique": "firdaria", "fd_major": "Saturn", "fd_sub": "Jupiter"}
assert _event_tokens(ev) == ["[Sa", "[Ju"]
+71
View File
@@ -0,0 +1,71 @@
"""Lots / punkty arabskie (LOG-08).
Wyrocznie z notes3 (astro-seek, horoskop referencyjny 30.04.1984, urodzenie DZIENNE):
- Fortuna wprost w tabeli obiektów: Cancer 12°35'24" = 102.5900°
- Ducha (Spirit) potwierdza jego antyscja: Taurus 28°14' -> Spirit = 180 58.2333
"""
import pytest
from app.engine.formats import sign_index
from app.engine.lots import LOT_DEFS, compute_lots
# natalne długości horoskopu referencyjnego
NATAL = {"Asc": 112.1786, "Sun": 40.1392, "Moon": 30.5514, "Mercury": 27.3839,
"Venus": 27.6769, "Mars": 234.5411, "Jupiter": 282.9617, "Saturn": 223.3108}
FORTUNE_REF = 102.5900 # Cancer 12°35'24"
SPIRIT_REF = 180.0 - 58.2333 # z antyscji Taurus 28°14'
def _by_name(lots):
return {lot["name"]: lot["longitude"] for lot in lots}
def _arcmin(a, b):
return abs(((a - b + 180.0) % 360.0) - 180.0) * 60.0
def test_fortune_matches_astroseek():
lots = _by_name(compute_lots(NATAL, is_day=True))
assert _arcmin(lots["Fortune"], FORTUNE_REF) < 2.0
assert sign_index(lots["Fortune"]) == 3 # Cancer
def test_spirit_matches_antiscia_reference():
lots = _by_name(compute_lots(NATAL, is_day=True))
assert _arcmin(lots["Spirit"], SPIRIT_REF) < 2.0
def test_fortune_and_spirit_reverse_at_night():
day = _by_name(compute_lots(NATAL, is_day=True))
night = _by_name(compute_lots(NATAL, is_day=False))
# nocą formuła się odwraca: Fortuna nocna = Duch dzienny (i odwrotnie)
assert _arcmin(night["Fortune"], day["Spirit"]) < 0.01
assert _arcmin(night["Spirit"], day["Fortune"]) < 0.01
def test_all_seven_hermetic_lots_present():
lots = compute_lots(NATAL, is_day=True)
assert [lot["name"] for lot in lots] == [d[0] for d in LOT_DEFS]
assert len(lots) == 7
def test_derived_lots_use_fortune_and_spirit():
lots = _by_name(compute_lots(NATAL, is_day=True))
# Necessity = Asc + Fortune Mercury
expected = (NATAL["Asc"] + lots["Fortune"] - NATAL["Mercury"]) % 360.0
assert _arcmin(lots["Necessity"], expected) < 0.01
# Eros = Asc + Venus Spirit
expected_eros = (NATAL["Asc"] + NATAL["Venus"] - lots["Spirit"]) % 360.0
assert _arcmin(lots["Eros"], expected_eros) < 0.01
def test_by_sign_method_lands_on_sign_start():
lots = compute_lots(NATAL, is_day=True, method="sign")
for lot in lots:
assert lot["longitude"] % 30.0 == 0.0 # 0° wyliczonego znaku
def test_unknown_method_rejected():
with pytest.raises(ValueError):
compute_lots(NATAL, is_day=True, method="bzdura")
+71
View File
@@ -0,0 +1,71 @@
"""Zbiorcza oś czasu z technik (LOG-14)."""
import datetime as dt
from app.engine.timeline import (
NAIBOD_KEY,
build_timeline,
profection_events,
solar_arc_directions,
)
BIRTH = dt.datetime(1984, 4, 30, 7, 35, tzinfo=dt.timezone.utc)
# natalne długości (z horoskopu referencyjnego)
NATAL = {
"Asc": 112.18, "MC": 352.59, "Sun": 40.14, "Moon": 30.55, "Mercury": 27.38,
"Venus": 27.68, "Mars": 234.54, "Jupiter": 282.96, "Saturn": 223.31,
"Uranus": 252.81, "Neptune": 271.22, "Pluto": 210.48,
}
def _win(y0, y1):
return (dt.datetime(y0, 1, 1, tzinfo=dt.timezone.utc),
dt.datetime(y1, 12, 31, tzinfo=dt.timezone.utc))
def test_profection_events_in_window():
lo, hi = _win(2024, 2026)
rows = profection_events(NATAL["Asc"], BIRTH, lo, hi)
# rok profekcyjny wiek 42 zaczyna się 30.04.2026 -> Asc Capricorn
ages = [r["significator"] for r in rows]
assert any("Asc Capricorn" in a and "wiek 42" in a for a in ages)
def test_solar_arc_exact_matches_arc_over_key():
lo, hi = _win(2020, 2030)
rows = solar_arc_directions(NATAL, BIRTH, lo, hi, key=NAIBOD_KEY)
assert rows, "brak dyrekcji w oknie"
# dla każdej dyrekcji: łuk = (wiek * klucz), a data = urodziny + wiek -> spójne
for r in rows[:5]:
exact = dt.date.fromisoformat(r["exact"])
age_years = (dt.datetime(exact.year, exact.month, exact.day, tzinfo=dt.timezone.utc)
- BIRTH).days / 365.2422
assert 36 <= age_years <= 47 # okno 2020-2030 = wiek ~36-46
assert r["technique"] == "solar_arc" and "dyr." in r["significator"]
def test_directed_sun_conjunct_natal_mc_date():
# Sun natal 40.14 -> MC natal 352.59: łuk koniunkcji = (352.59-40.14)%360 = 312.45
# to > lifespan przy Naibod (~317 lat) -> NIE powinno być w oknie życia
lo, hi = _win(1984, 2084)
rows = solar_arc_directions(NATAL, BIRTH, lo, hi)
sun_mc = [r for r in rows if r["significator"] == "dyr. Sun koniunkcja MC"]
assert not sun_mc # łuk 312° = poza życiem
def test_build_timeline_sorted_and_merged():
events = build_timeline(_FakeEngine(), _FakeNatal(), NATAL,
"2025-01-01", "2027-01-01",
techniques=["profection", "solar_arc"])
assert events
dates = [e["exact"] for e in events]
assert dates == sorted(dates) # posortowane po dacie dokładnej
techs = {e["technique"] for e in events}
assert "profection" in techs and "solar_arc" in techs
class _FakeNatal:
when_utc = BIRTH
class _FakeEngine:
"""Silnik-atrapa — build_timeline z solar_return by go użył, tu go pomijamy."""
@@ -0,0 +1,46 @@
"""Spięcie osi czasu z bazą interpretacji (LOG-14 → 1B→2B)."""
from app.significators import _event_tokens, interpret_events
class FakeData:
def __init__(self, rows_by_value):
self.rows_by_value = rows_by_value
def search(self, key, value, exact, limit, fields=None):
rows = self.rows_by_value.get(value, [])
return {"provider": "fake", "total": len(rows), "rows": rows}
def test_event_tokens_solar_arc():
ev = {"technique": "solar_arc", "directed": "Venus", "aspect": "conjunction", "target": "North Node"}
assert _event_tokens(ev) == ["[Ve", "[conj", "[NN"]
def test_event_tokens_solar_arc_to_angle_has_no_target_token():
ev = {"technique": "solar_arc", "directed": "Sun", "aspect": "square", "target": "MC"}
assert _event_tokens(ev) == ["[Su", "[sq"] # MC nie ma tokenu planety
def test_event_tokens_profection():
ev = {"technique": "profection", "lord": "Saturn", "sign": "Capricorn"}
assert _event_tokens(ev) == ["[Sa", "[Cap"]
def test_solar_return_has_no_tokens():
assert _event_tokens({"technique": "solar_return"}) == []
def test_interpret_attaches_matches_with_all_tokens():
events = [
{"technique": "solar_arc", "directed": "Venus", "aspect": "conjunction", "target": "North Node"},
{"technique": "solar_return"},
]
data = FakeData({"[Ve": [
{"significator": "[Ve [conj [NN", "actioneffect": "spotkanie losowe"}, # wszystkie tokeny
{"significator": "[Ve [conj [Mo", "actioneffect": "inny"}, # brak [NN
{"significator": "[Ve [conj [NN", "actioneffect": "spotkanie losowe"}, # duplikat
]})
interpret_events(events, data)
assert events[0]["interpretations_count"] == 1 # duplikat odsiany, tylko z [NN
assert events[0]["interpretations"][0]["expanded"] == "Venus conjunction North Node"
assert "interpretations" not in events[1] # solar_return pominięty
@@ -56,3 +56,17 @@ class LogicClient:
r = client.post(f"{self.base_url}/chart/report", json=payload)
r.raise_for_status()
return r.json()
def timeline(
self, when_utc_iso: str, lat: float, lon: float,
from_date: str, to_date: str, interpret: bool = True,
) -> dict[str, Any]:
"""Oś czasu z technik (+interpretacje z bazy) — woła logic /chart/timeline."""
payload = {
"when_utc": when_utc_iso, "lat": lat, "lon": lon,
"from_date": from_date, "to_date": to_date, "interpret": interpret,
}
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
r = client.post(f"{self.base_url}/chart/timeline", json=payload)
r.raise_for_status()
return r.json()
+34
View File
@@ -132,6 +132,40 @@ def interpret_run(
return templates.TemplateResponse(request, "interpret.html", ctx)
# ---------------- Kalendarz (oś czasu z technik + interpretacje) ----------------
@app.get("/timeline", response_class=HTMLResponse)
def timeline_form(request: Request):
return templates.TemplateResponse(request, "timeline.html", {"result": None, "form": {}})
@app.post("/timeline", response_class=HTMLResponse)
def timeline_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),
from_date: str = Form(...),
to_date: str = Form(...),
):
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
"from_date": from_date, "to_date": to_date}
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.timeline(
when_utc_iso=iso_utc, lat=lat, lon=lon,
from_date=from_date, to_date=to_date, interpret=True,
)
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, "timeline.html", ctx)
@app.get("/health")
def health() -> dict:
return {"status": "ok", "layer": "presentation"}
@@ -13,6 +13,7 @@
<nav>
<a href="/" class="{% block nav_chart %}{% endblock %}">Horoskop</a>
<a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a>
<a href="/timeline" class="{% block nav_timeline %}{% endblock %}">Kalendarz</a>
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
</nav>
</header>
@@ -88,6 +88,18 @@
</tbody>
</table>
{% if result.lots %}
<div class="meta">Lots hermetyczne ({{ result.lots | length }}) · sekta: <strong>{{ result.sect }}</strong></div>
<table class="angles">
<thead><tr><th>Lot</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Formuła</th></tr></thead>
<tbody>
{% for l in result.lots %}
<tr><td>{{ l.name }}</td><td>{{ l.sign }}</td><td class="mono">{{ l.in_sign }}</td><td>{{ l.house }}</td><td class="muted small">{{ l.formula }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% set with_stations = result.positions | selectattr('stations', 'defined') | list %}
{% if with_stations %}
<div class="meta">Stacje planet (poprzednia / następna; <span class="badge">blisko</span> = mniej niż 7 dni)</div>
@@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}Kalendarz{% endblock %}
{% block nav_timeline %}active{% endblock %}
{% block content %}
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
<form method="post" action="/timeline">
<div class="grid">
<label>Data urodzenia
<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="grid">
<label>Zakres od
<input type="date" name="from_date" value="{{ form.from_date or '' }}" required>
</label>
<label>Zakres do
<input type="date" name="to_date" value="{{ form.to_date or '' }}" required>
</label>
</div>
<div class="actions">
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
<button type="submit">Pokaż kalendarz</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> ·
zdarzeń: {{ result.count }} · zakres {{ result.from }} → {{ result.to }}
</div>
{% for e in result.events %}
<div class="sig-item">
<div class="sig-head">
<span class="badge">{{ e.exact }}</span>
<span class="badge">{{ e.technique }}</span>
<strong>{{ e.significator }}</strong>
<span class="muted small">(okno {{ e.start }} → {{ e.end }})</span>
</div>
{% if e.interpretations %}
<table class="samples">
<tbody>
{% for s in e.interpretations %}
<tr><td class="sig nowrap" title="{{ s.significator }}">{{ s.expanded }}</td><td>{{ s.effect }}</td></tr>
{% endfor %}
</tbody>
</table>
{% if e.interpretations_count > e.interpretations | length %}
<div class="muted small">…i {{ e.interpretations_count - (e.interpretations | length) }} więcej</div>
{% endif %}
{% elif e.technique != 'solar_return' %}
<div class="muted small">brak dopasowań w bazie</div>
{% endif %}
</div>
{% endfor %}
{% endif %}
<script src="/static/now.js"></script>
{% endblock %}
View File