Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ced2ddd22 | |||
| 79bce8ae90 | |||
| 85e9182f12 | |||
| c75f8377bf | |||
| 5234e86c95 | |||
| 0ac8df250c | |||
| 9323803cb4 | |||
| 678c3bad76 | |||
| 88a1ecd3c4 | |||
| b28c612bf7 | |||
| 2bda4e311d | |||
| 5d0d86cb90 | |||
| 9d5b65ddbd | |||
| cff0ac194c | |||
| 73f39e7df8 | |||
| 96d983b26a | |||
| c4a181b810 | |||
| 97ad21d2e4 | |||
| 2dbd3410e9 | |||
| ca458fd741 | |||
| b013831492 | |||
| d14a77360a | |||
| a8c3072e62 | |||
| 93932246f3 | |||
| 8d579de34a | |||
| db3d1e5117 | |||
| 4a86d34f5a | |||
| fb317172ff |
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 ~1–2 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`,
|
||||
|
||||
@@ -29,8 +29,14 @@ _PLANETS = {
|
||||
"Sun": swe.SUN, "Moon": swe.MOON, "Mercury": swe.MERCURY, "Venus": swe.VENUS,
|
||||
"Mars": swe.MARS, "Jupiter": swe.JUPITER, "Saturn": swe.SATURN,
|
||||
"Uranus": swe.URANUS, "Neptune": swe.NEPTUNE, "Pluto": swe.PLUTO,
|
||||
# punkty wirtualne — mean, jak w silniku własnym (parzystość LOG-28)
|
||||
"North Node": swe.MEAN_NODE, "Lilith": swe.MEAN_APOG,
|
||||
# "South Node" obsługiwany pochodnie w /positions: NN + 180°
|
||||
}
|
||||
DEFAULT_OBJECTS = list(_PLANETS)
|
||||
DEFAULT_OBJECTS = [
|
||||
"Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn",
|
||||
"Uranus", "Neptune", "Pluto", "North Node", "South Node", "Lilith",
|
||||
]
|
||||
|
||||
|
||||
class PositionsRequest(BaseModel):
|
||||
@@ -48,8 +54,11 @@ def positions(req: PositionsRequest) -> dict:
|
||||
|
||||
rows = []
|
||||
for name in (req.objects or DEFAULT_OBJECTS):
|
||||
xx, _retflag = swe.calc_ut(jd, _PLANETS[name], _FLAGS)
|
||||
lookup = "North Node" if name == "South Node" else name
|
||||
xx, _retflag = swe.calc_ut(jd, _PLANETS[lookup], _FLAGS)
|
||||
lon, lat, _dist, lon_speed = xx[0], xx[1], xx[2], xx[3]
|
||||
if name == "South Node":
|
||||
lon, lat = lon + 180.0, -lat
|
||||
rows.append({
|
||||
"name": name,
|
||||
"longitude": lon % 360.0,
|
||||
|
||||
@@ -6,8 +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 (LOG-06). `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ą)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -34,10 +34,29 @@ def separation(a: float, b: float) -> float:
|
||||
return min(d, 360.0 - d)
|
||||
|
||||
|
||||
def _is_applying(la: float, lb: float, sa: float, sb: float, angle: float, dt: float = 0.01) -> bool | None:
|
||||
"""Czy aspekt aplikuje (dokładność 0° dopiero nastąpi)?
|
||||
|
||||
Porównujemy odchyłkę od dokładnego kąta teraz i po małym kroku czasu
|
||||
(pozycje przesunięte o prędkość·dt). Malejąca odchyłka = applying.
|
||||
dt celowo małe (0,01 doby), by szybki Księżyc nie „przeskoczył" dokładności.
|
||||
Zwraca None, gdy brak prędkości (nie da się rozstrzygnąć).
|
||||
"""
|
||||
if sa is None or sb is None:
|
||||
return None
|
||||
dev_now = abs(separation(la, lb) - angle)
|
||||
dev_next = abs(separation(la + sa * dt, lb + sb * dt) - angle)
|
||||
return dev_next < dev_now
|
||||
|
||||
|
||||
def find_aspects(
|
||||
positions: list[dict], orb: float = DEFAULT_ORB, luminary_bonus: float = LUMINARY_BONUS
|
||||
) -> list[dict]:
|
||||
"""positions: dicty z 'name' i 'decimal' (długość). Zwraca listę aspektów."""
|
||||
"""positions: dicty z 'name', 'decimal' (długość) i opcjonalnie 'speed' (°/dobę).
|
||||
|
||||
Zwraca listę aspektów głównych; gdy znane są prędkości, każdy aspekt ma
|
||||
applying (bool) i skrót 'as': 'A'/'S' (aplikacyjny/separacyjny).
|
||||
"""
|
||||
out: list[dict] = []
|
||||
n = len(positions)
|
||||
for i in range(n):
|
||||
@@ -51,9 +70,16 @@ def find_aspects(
|
||||
for asp, angle in MAJOR.items():
|
||||
dev = abs(sep - angle)
|
||||
if dev <= allowed:
|
||||
out.append({
|
||||
applying = _is_applying(
|
||||
float(la), float(lb), a.get("speed"), b.get("speed"), angle
|
||||
)
|
||||
row = {
|
||||
"obj1": a["name"], "obj2": b["name"],
|
||||
"aspect": asp, "orb": round(dev, 2), "allowed": round(allowed, 2),
|
||||
})
|
||||
}
|
||||
if applying is not None:
|
||||
row["applying"] = applying
|
||||
row["as"] = "A" if applying else "S"
|
||||
out.append(row)
|
||||
break # jedna para = jeden aspekt
|
||||
return out
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 Asc–Dsc 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 Asc–Dsc 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}
|
||||
@@ -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
|
||||
@@ -13,10 +13,12 @@ from typing import Any
|
||||
|
||||
from app.engine import formats
|
||||
|
||||
# kanoniczny zestaw i kolejność obiektów (LOG-02: światła + 7 klasycznych + 3 nowożytne)
|
||||
# kanoniczny zestaw i kolejność obiektów (LOG-02: światła + 7 klasycznych +
|
||||
# 3 nowożytne + punkty wirtualne: węzły mean i mean Lilith)
|
||||
DEFAULT_OBJECTS = [
|
||||
"Sun", "Moon", "Mercury", "Venus", "Mars",
|
||||
"Jupiter", "Saturn", "Uranus", "Neptune", "Pluto",
|
||||
"North Node", "South Node", "Lilith",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Punkty wirtualne liczone analitycznie (LOG-02): mean Node i mean Lilith.
|
||||
|
||||
Wzory Meeusa (Astronomical Algorithms) w stuleciach juliańskich od J2000 (TT):
|
||||
- Ω — średni węzeł wstępujący orbity Księżyca (mean ascending node). Porusza się
|
||||
zawsze wstecz (~−0,053°/dobę) — stąd węzły są wiecznie Rx.
|
||||
- średnie perygeum orbity Księżyca; mean Lilith (Black Moon) = średnie APOGEUM
|
||||
= perygeum + 180° (~+0,111°/dobę).
|
||||
|
||||
Wersje TRUE (oskulacyjne) — osobny, późniejszy krok (notatki: mean to
|
||||
historyczny standard i domyślne ustawienie programów).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.engine.formats import norm360
|
||||
|
||||
_DAYS_PER_CENTURY = 36525.0
|
||||
|
||||
|
||||
def _t(tt_jd: float) -> float:
|
||||
return (tt_jd - 2451545.0) / _DAYS_PER_CENTURY
|
||||
|
||||
|
||||
def mean_lunar_node(tt_jd: float) -> float:
|
||||
"""Długość ekliptyczna średniego Węzła Północnego (Ω) [°]."""
|
||||
t = _t(tt_jd)
|
||||
omega = (125.0445479 - 1934.1362891 * t + 0.0020754 * t * t
|
||||
+ t ** 3 / 467441.0 - t ** 4 / 60616000.0)
|
||||
return norm360(omega)
|
||||
|
||||
|
||||
def mean_lilith(tt_jd: float) -> float:
|
||||
"""Długość ekliptyczna mean Lilith (średnie apogeum Księżyca) [°]."""
|
||||
t = _t(tt_jd)
|
||||
perigee = (83.3532465 + 4069.0137287 * t - 0.0103200 * t * t
|
||||
- t ** 3 / 80053.0 + t ** 4 / 18999000.0)
|
||||
return norm360(perigee + 180.0)
|
||||
|
||||
|
||||
def point_speed(fn, tt_jd: float, dt_days: float = 0.1) -> float:
|
||||
"""Prędkość [°/dobę] punktu analitycznego — różnica po małym kroku."""
|
||||
a = fn(tt_jd)
|
||||
b = fn(tt_jd + dt_days)
|
||||
return (((b - a + 180.0) % 360.0) - 180.0) / dt_days
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Profekcje roczne (LOG-10) — hellenistyczna technika time-lord.
|
||||
|
||||
Zasada (Whole Sign): co każde urodziny profektowany Ascendent przeskakuje o jeden
|
||||
znak do przodu (wiek mod 12). Władca Roku (Lord of Year) = władca domicylowy
|
||||
znaku profektowanego Asc. Profektować można każdy punkt natalny (MC, Słońce…) —
|
||||
wszystkie przeskakują o tyle samo znaków.
|
||||
|
||||
Referencja: tabela profekcji w notes3 (astro-seek) dla horoskopu 30.04.1984
|
||||
(wiek 0: Can/Moon, 1: Leo/Sun, …, 42: Cap/Saturn).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.engine.formats import SIGNS, sign_index
|
||||
|
||||
# władcy domicylowi (tradycyjni) — zgodni z tabelą referencyjną notes3
|
||||
DOMICILE_RULERS = {
|
||||
"Aries": "Mars", "Taurus": "Venus", "Gemini": "Mercury", "Cancer": "Moon",
|
||||
"Leo": "Sun", "Virgo": "Mercury", "Libra": "Venus", "Scorpio": "Mars",
|
||||
"Sagittarius": "Jupiter", "Capricorn": "Saturn", "Aquarius": "Saturn",
|
||||
"Pisces": "Jupiter",
|
||||
}
|
||||
|
||||
|
||||
def age_at(birth_utc: datetime, when_utc: datetime) -> int:
|
||||
"""Pełne lata między urodzeniem a danym momentem (wiek profekcyjny)."""
|
||||
age = when_utc.year - birth_utc.year
|
||||
if (when_utc.month, when_utc.day) < (birth_utc.month, birth_utc.day):
|
||||
age -= 1
|
||||
return max(age, 0)
|
||||
|
||||
|
||||
def profected_sign(natal_lon: float, age: int) -> str:
|
||||
"""Znak, do którego profektował punkt natalny po `age` latach."""
|
||||
return SIGNS[(sign_index(natal_lon) + age) % 12]
|
||||
|
||||
|
||||
def profection_rows(
|
||||
natal_points: dict[str, float],
|
||||
birth_utc: datetime,
|
||||
start_age: int,
|
||||
count: int,
|
||||
) -> list[dict]:
|
||||
"""Tabela profekcji dla zakresu lat życia.
|
||||
|
||||
natal_points: nazwa -> natalna długość ekliptyczna (musi zawierać 'Asc').
|
||||
Każdy wiersz: wiek, data początku roku profekcyjnego (urodziny), znak
|
||||
profektowanego Asc, Władca Roku oraz profekcje pozostałych punktów.
|
||||
"""
|
||||
def _birthday(year: int) -> datetime:
|
||||
try:
|
||||
return birth_utc.replace(year=year)
|
||||
except ValueError: # 29 lutego w roku nieprzestępnym
|
||||
return birth_utc.replace(year=year, day=28)
|
||||
|
||||
rows: list[dict] = []
|
||||
for age in range(start_age, start_age + count):
|
||||
asc_sign = profected_sign(natal_points["Asc"], age)
|
||||
row = {
|
||||
"age": age,
|
||||
"from": _birthday(birth_utc.year + age).strftime("%Y-%m-%d"),
|
||||
"profected_asc": asc_sign,
|
||||
"lord_of_year": DOMICILE_RULERS[asc_sign],
|
||||
}
|
||||
for name, lon in natal_points.items():
|
||||
if name != "Asc":
|
||||
row[name] = profected_sign(lon, age)
|
||||
rows.append(row)
|
||||
return rows
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Solar / Lunar Return (LOG-12) — moment powrotu do pozycji natalnej.
|
||||
|
||||
Solar Return (solariusz): moment, w którym Słońce wraca dokładnie do natalnej
|
||||
długości ekliptycznej (raz na rok, w okolicy urodzin). Lunar Return: to samo
|
||||
dla Księżyca (raz na ~27,3 dnia). Dwa warianty użycia (osobny horoskop vs
|
||||
tranzyt do natalu) obsługujemy zwracając pełny horoskop na znaleziony moment —
|
||||
interpretacja pozostaje po stronie technik wyżej.
|
||||
|
||||
Metoda: podpisana różnica długości Δ = lon − natal (zawinięta do ±180°) rośnie
|
||||
monotonicznie i przechodzi przez 0 dokładnie w momencie powrotu. Skan dobowy
|
||||
wykrywa przejście −→+ (skok +180→−180 to artefakt zawinięcia — pomijany,
|
||||
warunek d_hi − d_lo < 180), potem bisekcja do ~sekundy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.engine.models import ChartMoment
|
||||
|
||||
# szerokość okna skanu wokół `around` [dni]: solar kotwiczymy przy urodzinach,
|
||||
# lunar musi objąć cały okres syderyczny Księżyca (27,3 d)
|
||||
SCAN_WINDOW = {"solar": 6.0, "lunar": 15.0}
|
||||
|
||||
|
||||
def _lon_delta(engine, body: str, natal_lon: float, when: datetime) -> float:
|
||||
m = ChartMoment(when_utc=when)
|
||||
lon = engine.positions(m, [body])[0].longitude
|
||||
return ((lon - natal_lon + 180.0) % 360.0) - 180.0
|
||||
|
||||
|
||||
def find_return(
|
||||
engine, kind: str, natal_moment: ChartMoment, around: datetime
|
||||
) -> datetime | None:
|
||||
"""Moment powrotu (kind: 'solar'/'lunar') najbliższy dacie `around`."""
|
||||
body = "Sun" if kind == "solar" else "Moon"
|
||||
natal_lon = engine.positions(natal_moment, [body])[0].longitude
|
||||
if around.tzinfo is None:
|
||||
around = around.replace(tzinfo=timezone.utc)
|
||||
|
||||
window = SCAN_WINDOW[kind]
|
||||
step = timedelta(days=1.0)
|
||||
t = around - timedelta(days=window)
|
||||
end = around + timedelta(days=window)
|
||||
|
||||
candidates: list[datetime] = []
|
||||
d_prev = _lon_delta(engine, body, natal_lon, t)
|
||||
while t < end:
|
||||
t_next = t + step
|
||||
d_next = _lon_delta(engine, body, natal_lon, t_next)
|
||||
# prawdziwe przejście przez zero: − -> + bez skoku zawinięcia
|
||||
if d_prev < 0 <= d_next and (d_next - d_prev) < 180.0:
|
||||
lo, hi, d_lo = t, t_next, d_prev
|
||||
for _ in range(40): # bisekcja do ułamka sekundy
|
||||
mid = lo + (hi - lo) / 2
|
||||
if (_lon_delta(engine, body, natal_lon, mid) < 0) == (d_lo < 0):
|
||||
lo = mid
|
||||
else:
|
||||
hi = mid
|
||||
candidates.append(lo + (hi - lo) / 2)
|
||||
t, d_prev = t_next, d_next
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
return min(candidates, key=lambda c: abs(c - around))
|
||||
@@ -60,6 +60,27 @@ class SkyfieldEngine(EphemerisEngine):
|
||||
lat, lon, _dist = astrometric.ecliptic_latlon(epoch="date")
|
||||
return lon.degrees, lat.degrees
|
||||
|
||||
def _virtual_point(self, name: str, tt_jd: float) -> ObjectPosition:
|
||||
"""Punkty analityczne (LOG-02): mean Node (NN/SN) i mean Lilith.
|
||||
|
||||
Liczone wzorami Meeusa, nie z jądra JPL. SN = NN + 180° (ta sama prędkość).
|
||||
Punkty leżą na ekliptyce (latitude = 0).
|
||||
"""
|
||||
from app.engine.points import mean_lilith, mean_lunar_node, point_speed
|
||||
|
||||
if name in ("North Node", "South Node"):
|
||||
lon = mean_lunar_node(tt_jd)
|
||||
if name == "South Node":
|
||||
lon = norm360(lon + 180.0)
|
||||
speed = point_speed(mean_lunar_node, tt_jd)
|
||||
else: # Lilith
|
||||
lon = mean_lilith(tt_jd)
|
||||
speed = point_speed(mean_lilith, tt_jd)
|
||||
return ObjectPosition(
|
||||
name=name, longitude=float(lon), latitude=0.0,
|
||||
speed=float(speed), retrograde=bool(speed < 0),
|
||||
)
|
||||
|
||||
def positions(
|
||||
self, moment: ChartMoment, objects: list[str] | None = None
|
||||
) -> list[ObjectPosition]:
|
||||
@@ -70,6 +91,9 @@ class SkyfieldEngine(EphemerisEngine):
|
||||
|
||||
out: list[ObjectPosition] = []
|
||||
for name in names:
|
||||
if name not in _TARGETS: # punkt wirtualny (NN/SN/Lilith)
|
||||
out.append(self._virtual_point(name, t.tt))
|
||||
continue
|
||||
target = self.eph[_TARGETS[name]]
|
||||
lon, lat = self._ecliptic_lon_lat(target, t)
|
||||
lon2, _ = self._ecliptic_lon_lat(target, t2)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Wykrywanie stacji planet (LOG-03): poprzednia/następna stacja, SD/SR, flaga <7 dni.
|
||||
|
||||
Stacja ścisła = moment, w którym prędkość zodiakalna przechodzi przez zero.
|
||||
Metoda: próbki prędkości co 1 dzień w oknie ± SEARCH_DAYS → zmiana znaku →
|
||||
bisekcja do dokładności ~1 minuty. Klasyfikacja: prędkość przed<0 i po>0 → SD
|
||||
(stationary direct), odwrotnie → SR (stationary retrograde).
|
||||
|
||||
Pomijamy Słońce/Księżyc (nigdy Rx) i punkty mean (NN/SN/Lilith — ruch jednostajny).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from app.engine.formats import in_sign
|
||||
from app.engine.models import ChartMoment
|
||||
|
||||
# obiekty bez stacji
|
||||
NO_STATIONS = {"Sun", "Moon", "North Node", "South Node", "Lilith"}
|
||||
|
||||
# Okno musi pokryć najdłuższą przerwę między stacjami (Mars/Wenus: ~700 dni),
|
||||
# a krok skanu musi być krótszy niż najkrótsza retrogradacja (Merkury ~21 dni).
|
||||
SEARCH_DAYS = 800 # okno poszukiwań w każdą stronę
|
||||
SCAN_STEP_DAYS = 4.0 # krok zgrubnego skanu (potem bisekcja)
|
||||
STATION_SOON_DAYS = 7.0 # próg flagi "stacja blisko" (konfigurowalny, notes2)
|
||||
|
||||
|
||||
def _speed_fn(engine, name: str):
|
||||
"""Zwraca funkcję: dni_od_momentu_bazowego -> prędkość [°/dobę]."""
|
||||
def speed(base_moment: ChartMoment, offset_days: float) -> float:
|
||||
m = ChartMoment(
|
||||
when_utc=base_moment.when_utc + timedelta(days=offset_days),
|
||||
lat=base_moment.lat, lon=base_moment.lon,
|
||||
)
|
||||
return engine.positions(m, [name])[0].speed
|
||||
return speed
|
||||
|
||||
|
||||
def _bisect_zero(speed, moment: ChartMoment, lo: float, hi: float, iters: int = 20) -> float:
|
||||
"""Bisekcja miejsca zerowego prędkości między dniami lo i hi."""
|
||||
s_lo = speed(moment, lo)
|
||||
for _ in range(iters):
|
||||
mid = (lo + hi) / 2.0
|
||||
s_mid = speed(moment, mid)
|
||||
if (s_lo < 0) == (s_mid < 0):
|
||||
lo, s_lo = mid, s_mid
|
||||
else:
|
||||
hi = mid
|
||||
return (lo + hi) / 2.0
|
||||
|
||||
|
||||
def _station_info(engine, moment: ChartMoment, name: str, day: float, speed) -> dict:
|
||||
"""Opis stacji w danym dniu (offset od momentu bazowego)."""
|
||||
before = speed(moment, day - 0.5)
|
||||
kind = "SD" if before < 0 else "SR"
|
||||
when = moment.when_utc + timedelta(days=day)
|
||||
m = ChartMoment(when_utc=when, lat=moment.lat, lon=moment.lon)
|
||||
lon = engine.positions(m, [name])[0].longitude
|
||||
return {
|
||||
"type": kind,
|
||||
"date": when.strftime("%Y-%m-%d %H:%M"),
|
||||
"days": round(day, 1), # ujemne = w przeszłości
|
||||
"degree": in_sign(lon),
|
||||
}
|
||||
|
||||
|
||||
def find_stations(engine, moment: ChartMoment, name: str, step_days: float = SCAN_STEP_DAYS) -> dict | None:
|
||||
"""Poprzednia i następna stacja obiektu względem momentu horoskopu."""
|
||||
if name in NO_STATIONS:
|
||||
return None
|
||||
speed = _speed_fn(engine, name)
|
||||
|
||||
prev_day = next_day = None
|
||||
# w przeszłość
|
||||
s_right = speed(moment, 0.0)
|
||||
d = 0.0
|
||||
while d > -SEARCH_DAYS:
|
||||
s_left = speed(moment, d - step_days)
|
||||
if (s_left < 0) != (s_right < 0):
|
||||
prev_day = _bisect_zero(speed, moment, d - step_days, d)
|
||||
break
|
||||
d, s_right = d - step_days, s_left
|
||||
# w przyszłość
|
||||
s_left = speed(moment, 0.0)
|
||||
d = 0.0
|
||||
while d < SEARCH_DAYS:
|
||||
s_right = speed(moment, d + step_days)
|
||||
if (s_left < 0) != (s_right < 0):
|
||||
next_day = _bisect_zero(speed, moment, d, d + step_days)
|
||||
break
|
||||
d, s_left = d + step_days, s_right
|
||||
|
||||
result: dict = {}
|
||||
if prev_day is not None:
|
||||
result["prev"] = _station_info(engine, moment, name, prev_day, speed)
|
||||
if next_day is not None:
|
||||
result["next"] = _station_info(engine, moment, name, next_day, speed)
|
||||
result["station_soon"] = any(
|
||||
abs(x["days"]) < STATION_SOON_DAYS for x in result.values() if isinstance(x, dict)
|
||||
)
|
||||
return result or None
|
||||
@@ -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
|
||||
+128
-2
@@ -38,6 +38,7 @@ class PositionsRequest(BaseModel):
|
||||
lon: float = 0.0
|
||||
objects: list[str] | None = None
|
||||
house_system: str = "whole_sign" # whole_sign | equal | porphyry
|
||||
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
|
||||
|
||||
|
||||
@app.post("/api/query", response_model=QueryResponse)
|
||||
@@ -50,13 +51,22 @@ def query(req: QueryRequest) -> QueryResponse:
|
||||
|
||||
@app.post("/chart/positions")
|
||||
def chart_positions(req: PositionsRequest) -> dict:
|
||||
"""Pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05), aktywnym silnikiem."""
|
||||
"""Pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty (LOG-06);
|
||||
opcjonalnie stacje planet (LOG-03, stations=true)."""
|
||||
from app.engine.chart import build_chart
|
||||
from app.engine.models import ChartMoment
|
||||
|
||||
engine = get_engine()
|
||||
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||
return build_chart(engine, moment, req.house_system)
|
||||
chart = build_chart(engine, moment, req.house_system)
|
||||
if req.stations:
|
||||
from app.engine.stations import find_stations
|
||||
|
||||
for p in chart["positions"]:
|
||||
st = find_stations(engine, moment, p["name"])
|
||||
if st:
|
||||
p["stations"] = st
|
||||
return chart
|
||||
|
||||
|
||||
@app.post("/chart/compare")
|
||||
@@ -107,6 +117,122 @@ def chart_report(req: ReportRequest) -> dict:
|
||||
return {"engine": engine.name, **report}
|
||||
|
||||
|
||||
class ProfectionsRequest(BaseModel):
|
||||
when_utc: datetime # moment urodzenia (UTC)
|
||||
lat: float = 0.0
|
||||
lon: float = 0.0
|
||||
start_age: int = 0
|
||||
count: int = 13 # domyślnie pełny cykl 12 lat + rok startowy
|
||||
|
||||
|
||||
@app.post("/chart/profections")
|
||||
def chart_profections(req: ProfectionsRequest) -> dict:
|
||||
"""Profekcje roczne (LOG-10): wiek, profektowany Asc, Władca Roku (+MC/Su/Mo)."""
|
||||
from app.engine import houses as H
|
||||
from app.engine.models import ChartMoment
|
||||
from app.engine.profections import profection_rows
|
||||
|
||||
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, ["Sun", "Moon"]):
|
||||
points[p.name] = p.longitude
|
||||
rows = profection_rows(points, req.when_utc, req.start_age, req.count)
|
||||
return {"engine": engine.name, "rows": rows}
|
||||
|
||||
|
||||
class ReturnRequest(BaseModel):
|
||||
when_utc: datetime # moment urodzenia (UTC)
|
||||
lat: float = 0.0
|
||||
lon: float = 0.0
|
||||
kind: str = "solar" # solar | lunar
|
||||
around: datetime | None = None # data, wokół której szukać powrotu
|
||||
|
||||
|
||||
@app.post("/chart/return")
|
||||
def chart_return(req: ReturnRequest) -> dict:
|
||||
"""Solar/Lunar Return (LOG-12): moment powrotu + pełny horoskop na ten moment."""
|
||||
from app.engine.chart import build_chart
|
||||
from app.engine.models import ChartMoment
|
||||
from app.engine.returns import find_return
|
||||
|
||||
if req.kind not in ("solar", "lunar"):
|
||||
raise HTTPException(status_code=422, detail="kind: solar albo lunar")
|
||||
engine = get_engine()
|
||||
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||
around = req.around or req.when_utc
|
||||
hit = find_return(engine, req.kind, natal, around)
|
||||
if hit is None:
|
||||
raise HTTPException(status_code=404, detail="nie znaleziono powrotu w oknie skanu")
|
||||
chart = build_chart(engine, ChartMoment(when_utc=hit, lat=req.lat, lon=req.lon))
|
||||
return {"engine": engine.name, "kind": req.kind,
|
||||
"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"}
|
||||
|
||||
@@ -18,6 +18,8 @@ ASPECT_WEIGHT = {
|
||||
"conjunction": 1.0, "opposition": 0.95, "square": 0.85,
|
||||
"trine": 0.85, "sextile": 0.65,
|
||||
}
|
||||
# aspekt aplikacyjny (A) jest silniejszy niż separacyjny (S) — notes3
|
||||
APPLYING_BONUS = 1.15
|
||||
|
||||
|
||||
def score_facet(facet: dict) -> float:
|
||||
@@ -28,5 +30,8 @@ def score_facet(facet: dict) -> float:
|
||||
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)
|
||||
score = base * weight * (1.0 + tight)
|
||||
if facet.get("applying"):
|
||||
score *= APPLYING_BONUS
|
||||
return round(score, 2)
|
||||
return round(base, 2)
|
||||
|
||||
@@ -20,6 +20,10 @@ 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",
|
||||
# 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))
|
||||
|
||||
@@ -167,10 +171,12 @@ def build_report(
|
||||
asp_samples = _facet_samples(rows, [asp_tok, other_tok])
|
||||
if not asp_samples: # pokazujemy tylko aspekty z trafieniami
|
||||
continue
|
||||
as_suffix = f" ({asp['as']})" if asp.get("as") else ""
|
||||
facets.append({
|
||||
"type": "aspect", "label": f"{ASP_NAME[asp['aspect']]} z {other}",
|
||||
"type": "aspect", "label": f"{ASP_NAME[asp['aspect']]} z {other}{as_suffix}",
|
||||
"token": f"{asp_tok} + {other_tok}",
|
||||
"aspect": asp["aspect"], "orb": asp.get("orb"), "allowed": asp.get("allowed"),
|
||||
"applying": asp.get("applying"),
|
||||
"count": len(asp_samples), "samples": asp_samples,
|
||||
})
|
||||
|
||||
@@ -191,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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -33,3 +33,58 @@ def test_luminary_bonus_widens_orb():
|
||||
def test_one_aspect_per_pair():
|
||||
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
|
||||
assert len(find_aspects(pos)) == 1
|
||||
|
||||
|
||||
def test_applying_when_faster_body_catches_up():
|
||||
# Księżyc 5° za Słońcem, szybszy -> koniunkcja aplikacyjna
|
||||
pos = [
|
||||
{"name": "Sun", "decimal": 40.0, "speed": 0.96},
|
||||
{"name": "Moon", "decimal": 35.0, "speed": 13.0},
|
||||
]
|
||||
a = find_aspects(pos)[0]
|
||||
assert a["applying"] is True and a["as"] == "A"
|
||||
|
||||
|
||||
def test_separating_when_moving_apart():
|
||||
# Księżyc 5° przed Słońcem i szybszy -> koniunkcja separacyjna
|
||||
pos = [
|
||||
{"name": "Sun", "decimal": 40.0, "speed": 0.96},
|
||||
{"name": "Moon", "decimal": 45.0, "speed": 13.0},
|
||||
]
|
||||
a = find_aspects(pos)[0]
|
||||
assert a["applying"] is False and a["as"] == "S"
|
||||
|
||||
|
||||
def test_no_as_flag_without_speeds():
|
||||
pos = [{"name": "Sun", "decimal": 0.0}, {"name": "Moon", "decimal": 2.0}]
|
||||
assert "as" not in find_aspects(pos)[0]
|
||||
|
||||
|
||||
# Referencja A/S z notes3 (astro-seek) dla horoskopu 30.04.1984 07:35 UT, Warszawa.
|
||||
REFERENCE_AS = {
|
||||
("Sun", "Moon", "conjunction"): "A",
|
||||
("Sun", "Jupiter", "trine"): "A",
|
||||
("Sun", "Saturn", "opposition"): "A",
|
||||
("Sun", "Neptune", "trine"): "S",
|
||||
("Sun", "Pluto", "opposition"): "S",
|
||||
("Moon", "Mercury", "conjunction"): "S",
|
||||
("Moon", "Venus", "conjunction"): "S",
|
||||
("Moon", "Neptune", "trine"): "A",
|
||||
("Moon", "Pluto", "opposition"): "S",
|
||||
("Mercury", "Venus", "conjunction"): "S",
|
||||
("Mercury", "Neptune", "trine"): "S",
|
||||
("Mercury", "Pluto", "opposition"): "S",
|
||||
("Venus", "Neptune", "trine"): "A",
|
||||
("Venus", "Pluto", "opposition"): "A",
|
||||
("Jupiter", "Saturn", "sextile"): "A",
|
||||
("Neptune", "Pluto", "sextile"): "S",
|
||||
}
|
||||
|
||||
|
||||
def test_applying_separating_matches_astroseek_reference(own_engine, reference_moment):
|
||||
from app.engine.chart import build_chart
|
||||
|
||||
chart = build_chart(own_engine, reference_moment)
|
||||
got = {(a["obj1"], a["obj2"], a["aspect"]): a.get("as") for a in chart["aspects"]}
|
||||
mismatches = {k: (got.get(k), v) for k, v in REFERENCE_AS.items() if got.get(k) != v}
|
||||
assert not mismatches, f"rozbieżności A/S vs astro-seek: {mismatches}"
|
||||
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Testy punktów wirtualnych (LOG-02): mean Node, mean Lilith.
|
||||
|
||||
Referencje dla 30.04.1984 07:35 UT:
|
||||
- astro-seek (notes3): North Node (M) = Gem 8°09'24" = 68.1567°
|
||||
- wyrocznia swisseph (MEAN_NODE / MEAN_APOG, tryb Moshiera):
|
||||
NN = 68.1569°, Lilith = 345.6840°
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app.engine.formats import norm360
|
||||
|
||||
|
||||
def _delta_arcmin(a: float, b: float) -> float:
|
||||
return abs(((a - b + 180.0) % 360.0) - 180.0) * 60.0
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def by_name(own_engine, reference_moment):
|
||||
return {p.name: p for p in own_engine.positions(reference_moment)}
|
||||
|
||||
|
||||
def test_mean_node_matches_references(by_name):
|
||||
nn = by_name["North Node"]
|
||||
assert _delta_arcmin(nn.longitude, 68.1567) < 2.0 # astro-seek
|
||||
assert _delta_arcmin(nn.longitude, 68.1569) < 2.0 # swisseph
|
||||
assert nn.sign == "Gemini"
|
||||
|
||||
|
||||
def test_nodes_always_retrograde_and_opposed(by_name):
|
||||
nn, sn = by_name["North Node"], by_name["South Node"]
|
||||
assert nn.retrograde and sn.retrograde # mean node zawsze Rx
|
||||
assert _delta_arcmin(sn.longitude, norm360(nn.longitude + 180.0)) < 0.01
|
||||
assert abs(nn.speed - sn.speed) < 1e-9 # ta sama prędkość
|
||||
|
||||
|
||||
def test_mean_lilith_matches_swisseph(by_name):
|
||||
li = by_name["Lilith"]
|
||||
assert _delta_arcmin(li.longitude, 345.6840) < 3.0 # wyrocznia swisseph
|
||||
assert li.sign == "Pisces"
|
||||
assert li.speed > 0 and not li.retrograde # mean Lilith zawsze direct
|
||||
|
||||
|
||||
def test_points_join_houses_and_chart(own_engine, reference_moment):
|
||||
from app.engine.chart import build_chart
|
||||
|
||||
chart = build_chart(own_engine, reference_moment)
|
||||
by = {p["name"]: p for p in chart["positions"]}
|
||||
# NN w Gem -> 12. dom Whole Sign (Asc w Raku); zgodnie z tabelą astro-seek w notes3
|
||||
assert by["North Node"]["house"] == 12
|
||||
assert by["Lilith"]["house"] == 9 # Pis -> 9. dom
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Profekcje (LOG-10) — walidacja względem tabeli astro-seek z notes3."""
|
||||
import datetime as dt
|
||||
|
||||
from app.engine.profections import DOMICILE_RULERS, age_at, profected_sign, profection_rows
|
||||
|
||||
BIRTH = dt.datetime(1984, 4, 30, 7, 35, tzinfo=dt.timezone.utc)
|
||||
NATAL = {"Asc": 112.18, "MC": 352.59, "Sun": 40.14, "Moon": 30.55}
|
||||
|
||||
# wiek -> (profektowany Asc, Władca Roku) — tabela referencyjna notes3
|
||||
REFERENCE = {
|
||||
0: ("Cancer", "Moon"), 1: ("Leo", "Sun"), 2: ("Virgo", "Mercury"),
|
||||
3: ("Libra", "Venus"), 4: ("Scorpio", "Mars"), 5: ("Sagittarius", "Jupiter"),
|
||||
6: ("Capricorn", "Saturn"), 7: ("Aquarius", "Saturn"), 8: ("Pisces", "Jupiter"),
|
||||
9: ("Aries", "Mars"), 10: ("Taurus", "Venus"), 11: ("Gemini", "Mercury"),
|
||||
12: ("Cancer", "Moon"), 40: ("Scorpio", "Mars"), 41: ("Sagittarius", "Jupiter"),
|
||||
42: ("Capricorn", "Saturn"),
|
||||
}
|
||||
|
||||
|
||||
def test_profections_match_astroseek_table():
|
||||
rows = {r["age"]: r for r in profection_rows(NATAL, BIRTH, 0, 43)}
|
||||
for age, (asc, lord) in REFERENCE.items():
|
||||
assert rows[age]["profected_asc"] == asc, f"wiek {age}"
|
||||
assert rows[age]["lord_of_year"] == lord, f"wiek {age}"
|
||||
|
||||
|
||||
def test_profected_secondary_points_match_reference():
|
||||
rows = {r["age"]: r for r in profection_rows(NATAL, BIRTH, 0, 3)}
|
||||
# notes3: wiek 0 -> MC Pis, Sun Tau, Moon Tau; wiek 1 -> MC Ari, Sun Gem
|
||||
assert rows[0]["MC"] == "Pisces" and rows[0]["Sun"] == "Taurus"
|
||||
assert rows[1]["MC"] == "Aries" and rows[1]["Sun"] == "Gemini"
|
||||
|
||||
|
||||
def test_from_dates_are_birthdays():
|
||||
rows = profection_rows(NATAL, BIRTH, 40, 3)
|
||||
assert [r["from"] for r in rows] == ["2024-04-30", "2025-04-30", "2026-04-30"]
|
||||
|
||||
|
||||
def test_age_at_boundaries():
|
||||
assert age_at(BIRTH, dt.datetime(2026, 4, 29, tzinfo=dt.timezone.utc)) == 41
|
||||
assert age_at(BIRTH, dt.datetime(2026, 4, 30, tzinfo=dt.timezone.utc)) == 42
|
||||
|
||||
|
||||
def test_rulers_cover_all_signs():
|
||||
assert len(DOMICILE_RULERS) == 12
|
||||
assert profected_sign(112.18, 12) == "Cancer" # pełny cykl wraca
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Solar / Lunar Return (LOG-12) — samospójność i sensowność dat."""
|
||||
import datetime as dt
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.models import ChartMoment
|
||||
from app.engine.returns import find_return
|
||||
|
||||
|
||||
def _lon(engine, body, when):
|
||||
return engine.positions(ChartMoment(when_utc=when), [body])[0].longitude
|
||||
|
||||
|
||||
def _delta_arcmin(a, b):
|
||||
return abs(((a - b + 180.0) % 360.0) - 180.0) * 60.0
|
||||
|
||||
|
||||
def test_solar_return_hits_natal_sun(own_engine, reference_moment):
|
||||
natal_sun = _lon(own_engine, "Sun", reference_moment.when_utc)
|
||||
# solariusz na 42. urodziny (2026)
|
||||
hit = find_return(own_engine, "solar", reference_moment,
|
||||
dt.datetime(2026, 4, 30, tzinfo=dt.timezone.utc))
|
||||
assert hit is not None
|
||||
assert _delta_arcmin(_lon(own_engine, "Sun", hit), natal_sun) < 0.5
|
||||
assert hit.month == 4 and hit.year == 2026 # w okolicy urodzin
|
||||
|
||||
|
||||
def test_lunar_return_hits_natal_moon(own_engine, reference_moment):
|
||||
natal_moon = _lon(own_engine, "Moon", reference_moment.when_utc)
|
||||
hit = find_return(own_engine, "lunar", reference_moment,
|
||||
dt.datetime(1984, 5, 27, tzinfo=dt.timezone.utc))
|
||||
assert hit is not None
|
||||
assert _delta_arcmin(_lon(own_engine, "Moon", hit), natal_moon) < 2.0
|
||||
|
||||
|
||||
def test_solar_return_near_birth_is_close_to_birth(own_engine, reference_moment):
|
||||
# powrót szukany wokół samych urodzin = ~moment urodzenia
|
||||
hit = find_return(own_engine, "solar", reference_moment, reference_moment.when_utc)
|
||||
assert hit is not None
|
||||
assert abs(hit - reference_moment.when_utc) < dt.timedelta(days=1)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Testy wykrywania stacji (LOG-03).
|
||||
|
||||
Fakt historyczny: Mars w horoskopie referencyjnym (30.04.1984) jest w środku
|
||||
retrogradacji — stacja SR ~5.04.1984 (ok. 25 dni wstecz), stacja SD ~19.06.1984
|
||||
(ok. 50 dni w przód). Testy sprawdzają strukturę, klasyfikację SD/SR, przedziały
|
||||
dat i samospójność (prędkość w znalezionym momencie ~0).
|
||||
"""
|
||||
import datetime as dt
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.models import ChartMoment
|
||||
from app.engine.stations import STATION_SOON_DAYS, find_stations
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def mars_stations(own_engine, reference_moment):
|
||||
return find_stations(own_engine, reference_moment, "Mars")
|
||||
|
||||
|
||||
def test_no_stations_for_sun_moon_and_points(own_engine, reference_moment):
|
||||
for name in ("Sun", "Moon", "North Node", "South Node", "Lilith"):
|
||||
assert find_stations(own_engine, reference_moment, name) is None
|
||||
|
||||
|
||||
def test_mars_prev_is_sr_next_is_sd(mars_stations):
|
||||
assert mars_stations["prev"]["type"] == "SR" # wszedł w retrogradację
|
||||
assert mars_stations["next"]["type"] == "SD" # wróci do ruchu prostego
|
||||
|
||||
|
||||
def test_mars_station_windows_match_history(mars_stations):
|
||||
# SR ~5.04.1984 -> ok. -25 dni; SD ~19/20.06.1984 -> ok. +50 dni
|
||||
assert -35 < mars_stations["prev"]["days"] < -15
|
||||
assert 40 < mars_stations["next"]["days"] < 60
|
||||
assert mars_stations["prev"]["date"].startswith("1984-04")
|
||||
assert mars_stations["next"]["date"].startswith("1984-06")
|
||||
|
||||
|
||||
def test_station_speed_is_near_zero(own_engine, reference_moment, mars_stations):
|
||||
"""Samospójność: w znalezionym momencie stacji prędkość Marsa ~0."""
|
||||
for key in ("prev", "next"):
|
||||
when = dt.datetime.strptime(mars_stations[key]["date"], "%Y-%m-%d %H:%M").replace(
|
||||
tzinfo=dt.timezone.utc
|
||||
)
|
||||
m = ChartMoment(when_utc=when, lat=reference_moment.lat, lon=reference_moment.lon)
|
||||
speed = own_engine.positions(m, ["Mars"])[0].speed
|
||||
assert abs(speed) < 0.01, f"{key}: speed={speed}"
|
||||
|
||||
|
||||
def test_station_soon_flag_consistent(mars_stations):
|
||||
expected = any(
|
||||
abs(mars_stations[k]["days"]) < STATION_SOON_DAYS
|
||||
for k in ("prev", "next") if k in mars_stations
|
||||
)
|
||||
assert mars_stations["station_soon"] == expected
|
||||
@@ -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
|
||||
@@ -30,6 +30,7 @@ class LogicClient:
|
||||
lon: float,
|
||||
objects: list[str] | None = None,
|
||||
house_system: str = "whole_sign",
|
||||
stations: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
||||
payload = {
|
||||
@@ -38,8 +39,10 @@ class LogicClient:
|
||||
"lon": lon,
|
||||
"objects": objects,
|
||||
"house_system": house_system,
|
||||
"stations": stations,
|
||||
}
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
# stacje wymagają root-findów — dłuższy timeout
|
||||
with httpx.Client(timeout=max(settings.http_timeout, 60.0) if stations else settings.http_timeout) as client:
|
||||
r = client.post(f"{self.base_url}/chart/positions", json=payload)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
@@ -53,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()
|
||||
|
||||
@@ -60,15 +60,17 @@ def chart_compute(
|
||||
lat: float = Form(0.0),
|
||||
lon: float = Form(0.0),
|
||||
house_system: str = Form("whole_sign"),
|
||||
stations: bool = Form(False),
|
||||
):
|
||||
form = {"date": date, "time": time, "tz_offset": tz_offset,
|
||||
"lat": lat, "lon": lon, "house_system": house_system}
|
||||
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations}
|
||||
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
|
||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
house_system=house_system, stations=stations,
|
||||
)
|
||||
except (httpx.HTTPError,) as e:
|
||||
ctx["error"] = _logic_error(e)
|
||||
@@ -130,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"}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// „Tu i teraz": uzupełnia datę/godzinę/strefę z przeglądarki oraz — jeśli to
|
||||
// możliwe — lokalizację (lat/lon).
|
||||
//
|
||||
// UWAGA: geolokalizacja przeglądarki działa tylko w "secure context"
|
||||
// (https:// lub localhost). Na http://<ip> przeglądarka NIE pyta o zgodę,
|
||||
// tylko po cichu odmawia — dlatego pokazujemy jawny komunikat w #geoNote.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const btn = document.getElementById('nowBtn');
|
||||
if (!btn) return;
|
||||
|
||||
const note = document.getElementById('geoNote');
|
||||
const say = msg => { if (note) note.textContent = msg; };
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
const d = new Date();
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
document.querySelector('input[name=date]').value =
|
||||
d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
||||
document.querySelector('input[name=time]').value = pad(d.getHours()) + ':' + pad(d.getMinutes());
|
||||
document.querySelector('input[name=tz_offset]').value = (-d.getTimezoneOffset() / 60);
|
||||
|
||||
if (!('geolocation' in navigator)) {
|
||||
say('Ta przeglądarka nie udostępnia geolokalizacji — wpisz lat/lon ręcznie.');
|
||||
return;
|
||||
}
|
||||
if (!window.isSecureContext) {
|
||||
say('Lokalizacja z przeglądarki wymaga HTTPS lub localhost (otwarto przez http://) — wpisz lat/lon ręcznie.');
|
||||
return;
|
||||
}
|
||||
say('Pobieram lokalizację…');
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
function (pos) {
|
||||
document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4);
|
||||
document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4);
|
||||
say('Lokalizacja pobrana ✓');
|
||||
},
|
||||
function (err) {
|
||||
say('Nie udało się pobrać lokalizacji: ' + (err && err.message ? err.message : 'odmowa dostępu'));
|
||||
},
|
||||
{ timeout: 8000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -33,9 +33,14 @@
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="opts">
|
||||
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
|
||||
licz stacje planet (wolniejsze)</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||
<button type="submit">Policz horoskop</button>
|
||||
<span id="geoNote" class="muted small"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -83,13 +88,42 @@
|
||||
</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>
|
||||
<table class="angles">
|
||||
<thead><tr><th>Planeta</th><th>Poprzednia</th><th>Następna</th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in with_stations %}
|
||||
<tr>
|
||||
<td>{{ p.name }}{% if p.stations.station_soon %} <span class="badge">blisko</span>{% endif %}</td>
|
||||
<td class="mono">{% if p.stations.prev %}{{ p.stations.prev.type }} · {{ p.stations.prev.date }} · {{ p.stations.prev.degree }} ({{ p.stations.prev.days }} d){% else %}—{% endif %}</td>
|
||||
<td class="mono">{% if p.stations.next %}{{ p.stations.next.type }} · {{ p.stations.next.date }} · {{ p.stations.next.degree }} (+{{ p.stations.next.days }} d){% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% 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>
|
||||
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
|
||||
<tbody>
|
||||
{% for a in result.aspects %}
|
||||
<tr><td>{{ a.obj1 }}</td><td>{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td></tr>
|
||||
<tr><td>{{ a.obj1 }}</td><td>{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -110,22 +144,5 @@
|
||||
{% 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>
|
||||
<script src="/static/now.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<div class="actions">
|
||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||
<button type="submit">Szukaj interpretacji</button>
|
||||
<span id="geoNote" class="muted small"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -82,21 +83,5 @@
|
||||
{% 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>
|
||||
<script src="/static/now.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user