Merge origin/master do PRE-16: naprawa regresji strumienia postepu
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m33s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m43s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 33s
Testy / Kontrola składni wszystkich warstw (push) Successful in 15s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m39s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m45s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 26s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 15s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m33s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m43s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 33s
Testy / Kontrola składni wszystkich warstw (push) Successful in 15s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m39s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m45s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 26s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 15s
Konflikt w logic_client.py::positions() rozwiazany biorac OBIE zmiany: routing przez szyfrowane _post() (PRE-16) + dluzszy timeout takze dla tables (LOG-23) — warunek (stations or tables). Wazniejsza rzecz, ktora scalenie ujawnilo: okno postepu (#19/#22) i szyfrowanie lacz (#21) powstaly na rownoleglych galeziach, ktore sie nie widzialy. Po zejsciu razem strumien horoskopu szedl SUROWYM httpx, z pominieciem szyfrowania. Przy wlaczonym LINK_ENCRYPTION_REQUIRED serwer odrzucalby to zadanie (400), a nawet bez wymagania odpowiedz wracalaby jako nieczytelne ramki — okno postepu przestaloby dzialac na produkcji. Naprawa: - nowy link_crypto.stream_lines(): strumieniowe POST przez szyfrowane lacze; pieczetuje zadanie i odszyfrowuje odpowiedz ramka po ramce, sklejajac bufor bo granice ramek nie pokrywaja sie z granicami linii NDJSON. Dostarczanie na zywo zachowane. Bez klucza — jak dotad (dev). - horoscope_stream() w kliencie idzie teraz przez stream_lines zamiast surowego client.stream. - fail-closed takze dla strumienia: bez klucza przy wymaganym szyfrowaniu klient nie wysyla NIC (wczesniej cialo — dane urodzenia — szloby w eter, dopiero potem serwer odmawial). Ujednolica kontrakt z call(). Weryfikacja e2e na prawdziwym uvicornie z podsluchem gniazda: z kluczem strumien dziala (5 etapow + result, na zywo), na kablu ZERO tresci bazy (grep=0; jedyne 'horoscope' to sciezka URL w naglowku, ktory z zalozenia jest jawny); bez klucza klient zatrzymuje sie przed wyslaniem. Testy: +4 na stream_lines (round-trip, sciezka jawna, fail-closed serwera i klienta), niezmiennik strukturalny rozszerzony o stream_lines jako droge w dol (sprawdzone celowym zepsuciem — czerwienieje). Calosc: logika 234 passed / 1 skipped, prezentacja 25 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
"""Tabele pomocnicze horoskopu (LOG-23).
|
||||
|
||||
Zbiór wyliczeń, które astrolog czyta „obok" pozycji: bilans żywiołów i jakości,
|
||||
faza Księżyca, stopnie krytyczne, dzień i godziny planetarne, syzygia prenatalna
|
||||
oraz podziały (dwunastniki i nawamsa).
|
||||
|
||||
Dwie rzeczy wymagają prawdziwego liczenia, nie tabelki:
|
||||
* **godziny planetarne** — są NIERÓWNE: dzień od wschodu do zachodu Słońca dzieli
|
||||
się na 12 części, noc osobno. Bez faktycznego wschodu/zachodu wynik byłby
|
||||
zmyślony, więc szukamy ich numerycznie (przejście wysokości Słońca przez −0°50′);
|
||||
* **syzygia prenatalna** — ostatni nów albo pełnia PRZED urodzeniem; szukamy
|
||||
wstecz momentu, w którym elongacja Księżyca przechodzi przez 0° lub 180°.
|
||||
|
||||
Moduł jest silnik-agnostyczny: potrzebuje tylko `positions()` i `sidereal()`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.engine.formats import SIGNS, in_sign, norm360, sign_index
|
||||
from app.engine.models import ChartMoment
|
||||
from app.engine.zodiac import to_equatorial
|
||||
|
||||
# --- żywioły i jakości ------------------------------------------------------
|
||||
ELEMENTS = ["Fire", "Earth", "Air", "Water"]
|
||||
QUALITIES = ["Cardinal", "Fixed", "Mutable"]
|
||||
ELEMENT_PL = {"Fire": "Ogień", "Earth": "Ziemia", "Air": "Powietrze", "Water": "Woda"}
|
||||
QUALITY_PL = {"Cardinal": "Kardynalny", "Fixed": "Stały", "Mutable": "Zmienny"}
|
||||
|
||||
CLASSICAL = ["Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn"]
|
||||
MODERN = CLASSICAL + ["Uranus", "Neptune", "Pluto"]
|
||||
|
||||
# --- dzień i godziny planetarne --------------------------------------------
|
||||
# Kolejność chaldejska: od najwolniejszej do najszybszej planety
|
||||
CHALDEAN = ["Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury", "Moon"]
|
||||
# Władca dnia wg dnia tygodnia (0 = poniedziałek, jak w datetime.weekday())
|
||||
WEEKDAY_RULER = ["Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Sun"]
|
||||
|
||||
# wysokość środka tarczy Słońca przy wschodzie/zachodzie (refrakcja + promień tarczy)
|
||||
SUNRISE_ALTITUDE = -0.833
|
||||
|
||||
|
||||
def element_of(sign: str) -> str:
|
||||
return ELEMENTS[SIGNS.index(sign) % 4]
|
||||
|
||||
|
||||
def quality_of(sign: str) -> str:
|
||||
return QUALITIES[SIGNS.index(sign) % 3]
|
||||
|
||||
|
||||
def tally(positions: list[dict], asc_sign: str | None = None,
|
||||
modern: bool = True) -> dict:
|
||||
"""Bilans żywiołów i jakości (LOG-23).
|
||||
|
||||
Liczymy w dwóch wariantach naraz, bo szkoły się różnią: 7 planet klasycznych
|
||||
i 10 z nowożytnymi. Ascendent doliczany osobno — bywa traktowany jak punkt
|
||||
równorzędny planetom.
|
||||
"""
|
||||
wanted = MODERN if modern else CLASSICAL
|
||||
by_name = {p.get("name"): p for p in positions}
|
||||
|
||||
def count(names: list[str], with_asc: bool) -> dict:
|
||||
elements = dict.fromkeys(ELEMENTS, 0)
|
||||
qualities = dict.fromkeys(QUALITIES, 0)
|
||||
used = []
|
||||
for name in names:
|
||||
p = by_name.get(name)
|
||||
if not p or not p.get("sign"):
|
||||
continue
|
||||
elements[element_of(p["sign"])] += 1
|
||||
qualities[quality_of(p["sign"])] += 1
|
||||
used.append(name)
|
||||
if with_asc and asc_sign:
|
||||
elements[element_of(asc_sign)] += 1
|
||||
qualities[quality_of(asc_sign)] += 1
|
||||
used.append("Asc")
|
||||
return {"elements": elements, "qualities": qualities,
|
||||
"counted": used, "total": len(used)}
|
||||
|
||||
classical = count(CLASSICAL, False)
|
||||
result = {
|
||||
"classical_7": classical,
|
||||
"with_modern_10": count(wanted, False),
|
||||
"classical_7_plus_asc": count(CLASSICAL, True),
|
||||
"with_modern_10_plus_asc": count(wanted, True),
|
||||
}
|
||||
# brakujące żywioły — klasyczne „no air" itd., podstawa pod scoring (LOG-21)
|
||||
base = result["with_modern_10_plus_asc"]
|
||||
result["missing_elements"] = [e for e, n in base["elements"].items() if n == 0]
|
||||
result["missing_qualities"] = [q for q, n in base["qualities"].items() if n == 0]
|
||||
result["labels"] = {"elements": ELEMENT_PL, "qualities": QUALITY_PL}
|
||||
return result
|
||||
|
||||
|
||||
# --- faza Księżyca ----------------------------------------------------------
|
||||
_PHASES = [
|
||||
(0.0, "New Moon", "Nów"),
|
||||
(45.0, "Waxing Crescent", "Sierp przybywający"),
|
||||
(90.0, "First Quarter", "Pierwsza kwadra"),
|
||||
(135.0, "Waxing Gibbous", "Garb przybywający"),
|
||||
(180.0, "Full Moon", "Pełnia"),
|
||||
(225.0, "Waning Gibbous", "Garb ubywający"),
|
||||
(270.0, "Last Quarter", "Ostatnia kwadra"),
|
||||
(315.0, "Waning Crescent", "Sierp ubywający"),
|
||||
]
|
||||
|
||||
|
||||
def moon_phase(sun_lon: float, moon_lon: float) -> dict:
|
||||
"""Faza Księżyca z elongacji (Księżyc − Słońce)."""
|
||||
angle = norm360(moon_lon - sun_lon)
|
||||
idx = int(((angle + 22.5) % 360.0) // 45.0)
|
||||
_, name, name_pl = _PHASES[idx]
|
||||
illumination = (1.0 - math.cos(math.radians(angle))) / 2.0
|
||||
return {
|
||||
"angle": round(angle, 4),
|
||||
"phase": name,
|
||||
"phase_pl": name_pl,
|
||||
"illumination": round(illumination, 4),
|
||||
"waxing": angle < 180.0,
|
||||
}
|
||||
|
||||
|
||||
# --- stopnie krytyczne ------------------------------------------------------
|
||||
# klasyczne stopnie krytyczne zależą od jakości znaku
|
||||
_CRITICAL = {"Cardinal": (0, 13, 26), "Fixed": (8, 21), "Mutable": (4, 17)}
|
||||
CRITICAL_ORB = 1.0
|
||||
|
||||
|
||||
def critical_degrees(positions: list[dict]) -> list[dict]:
|
||||
"""Obiekty stojące na stopniach krytycznych, 0° albo 29° (anaretycznym)."""
|
||||
out = []
|
||||
for p in positions:
|
||||
lon = p.get("decimal")
|
||||
sign = p.get("sign")
|
||||
if lon is None or not sign:
|
||||
continue
|
||||
deg = norm360(lon) - sign_index(lon) * 30.0
|
||||
flags = []
|
||||
for critical in _CRITICAL[quality_of(sign)]:
|
||||
if abs(deg - critical) <= CRITICAL_ORB:
|
||||
flags.append(f"stopień krytyczny {critical}° ({QUALITY_PL[quality_of(sign)].lower()})")
|
||||
if deg >= 29.0:
|
||||
flags.append("29° — stopień anaretyczny (koniec znaku)")
|
||||
elif deg < 1.0:
|
||||
flags.append("0° — wejście w znak")
|
||||
if flags:
|
||||
out.append({"name": p.get("name"), "sign": sign,
|
||||
"in_sign": p.get("in_sign"), "flags": flags})
|
||||
return out
|
||||
|
||||
|
||||
# --- podziały: dwunastnik i nawamsa ----------------------------------------
|
||||
def dwadasamsa(lon: float) -> float:
|
||||
"""12. część (dwadasamsa): znak dzielony na 12 po 2°30′, licząc od siebie."""
|
||||
lon = norm360(lon)
|
||||
start = sign_index(lon) * 30.0
|
||||
return norm360(start + (lon - start) * 12.0)
|
||||
|
||||
|
||||
def navamsa(lon: float) -> float:
|
||||
"""9. część (nawamsa): 108 podziałów po 3°20′ liczonych od 0° Barana."""
|
||||
lon = norm360(lon)
|
||||
part = int(lon // (30.0 / 9.0))
|
||||
return norm360((part % 12) * 30.0 + (lon % (30.0 / 9.0)) * 9.0)
|
||||
|
||||
|
||||
def divisional(positions: list[dict]) -> list[dict]:
|
||||
"""Pozycje w podziałach 12. i 9. — obie tabele naraz."""
|
||||
out = []
|
||||
for p in positions:
|
||||
lon = p.get("decimal")
|
||||
if lon is None:
|
||||
continue
|
||||
d12, d9 = dwadasamsa(lon), navamsa(lon)
|
||||
out.append({
|
||||
"name": p.get("name"),
|
||||
"d12_sign": SIGNS[sign_index(d12)], "d12_in_sign": in_sign(d12),
|
||||
"d9_sign": SIGNS[sign_index(d9)], "d9_in_sign": in_sign(d9),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
# --- wschód/zachód Słońca i godziny planetarne ------------------------------
|
||||
def sun_altitude(engine, moment: ChartMoment) -> float:
|
||||
"""Wysokość Słońca nad horyzontem [°] dla momentu i miejsca."""
|
||||
ramc, eps = engine.sidereal(moment)
|
||||
sun = engine.positions(moment, ["Sun"])[0]
|
||||
ra, dec = to_equatorial(sun.longitude, sun.latitude, eps)
|
||||
hour_angle = math.radians(norm360(ramc - ra))
|
||||
phi, d = math.radians(moment.lat), math.radians(dec)
|
||||
sin_alt = math.sin(d) * math.sin(phi) + math.cos(d) * math.cos(phi) * math.cos(hour_angle)
|
||||
return math.degrees(math.asin(max(-1.0, min(1.0, sin_alt))))
|
||||
|
||||
|
||||
def _at(moment: ChartMoment, when: datetime) -> ChartMoment:
|
||||
return ChartMoment(when_utc=when, lat=moment.lat, lon=moment.lon)
|
||||
|
||||
|
||||
def _crossings(engine, moment: ChartMoment, start: datetime, end: datetime,
|
||||
step_minutes: int = 20) -> list[tuple[datetime, str]]:
|
||||
"""Momenty przejścia Słońca przez horyzont w oknie [start, end].
|
||||
|
||||
Skan zgrubny + bisekcja — ten sam wzorzec co przy stacjach planet (LOG-03).
|
||||
"""
|
||||
out: list[tuple[datetime, str]] = []
|
||||
step = timedelta(minutes=step_minutes)
|
||||
t0 = start
|
||||
f0 = sun_altitude(engine, _at(moment, t0)) - SUNRISE_ALTITUDE
|
||||
while t0 < end:
|
||||
t1 = min(t0 + step, end)
|
||||
f1 = sun_altitude(engine, _at(moment, t1)) - SUNRISE_ALTITUDE
|
||||
if f0 == 0.0 or (f0 < 0.0) != (f1 < 0.0):
|
||||
lo, hi, flo = t0, t1, f0
|
||||
for _ in range(40): # ~sekundowa dokładność
|
||||
mid = lo + (hi - lo) / 2
|
||||
fmid = sun_altitude(engine, _at(moment, mid)) - SUNRISE_ALTITUDE
|
||||
if (flo < 0.0) != (fmid < 0.0):
|
||||
hi = mid
|
||||
else:
|
||||
lo, flo = mid, fmid
|
||||
out.append((lo + (hi - lo) / 2, "sunrise" if f1 > f0 else "sunset"))
|
||||
t0, f0 = t1, f1
|
||||
return out
|
||||
|
||||
|
||||
def planetary_hours(engine, moment: ChartMoment) -> dict | None:
|
||||
"""Dzień i godziny planetarne w porządku chaldejskim (LOG-23).
|
||||
|
||||
Godziny są NIERÓWNE: dzień (wschód→zachód) i noc (zachód→wschód) dzielą się
|
||||
na 12 części każde. Doba planetarna zaczyna się o WSCHODZIE, nie o północy —
|
||||
dlatego władcę dnia bierzemy z dnia tygodnia tego wschodu, który otworzył
|
||||
bieżący okres.
|
||||
|
||||
Zwraca None dla dnia polarnego/nocy polarnej, gdzie wschód nie występuje.
|
||||
"""
|
||||
now = moment.when_utc
|
||||
events = _crossings(engine, moment, now - timedelta(hours=30), now + timedelta(hours=30))
|
||||
if not events:
|
||||
return None # brak wschodu/zachodu w oknie
|
||||
|
||||
before = [e for e in events if e[0] <= now]
|
||||
after = [e for e in events if e[0] > now]
|
||||
if not before or not after:
|
||||
return None
|
||||
|
||||
last_time, last_kind = before[-1]
|
||||
next_time, _ = after[0]
|
||||
|
||||
daytime = last_kind == "sunrise"
|
||||
period_start, period_end = last_time, next_time
|
||||
# doba planetarna startuje o wschodzie: w nocy to wschód POPRZEDZAJĄCY zachód
|
||||
day_start = last_time if daytime else next((t for t, k in reversed(before)
|
||||
if k == "sunrise"), last_time)
|
||||
|
||||
length = (period_end - period_start) / 12
|
||||
index = int((now - period_start) / length)
|
||||
index = max(0, min(11, index))
|
||||
|
||||
day_ruler = WEEKDAY_RULER[day_start.weekday()]
|
||||
hour_number = index if daytime else index + 12 # 0..23 od wschodu
|
||||
ruler = CHALDEAN[(CHALDEAN.index(day_ruler) + hour_number) % 7]
|
||||
|
||||
hours = []
|
||||
for i in range(12):
|
||||
start = period_start + length * i
|
||||
hours.append({
|
||||
"index": i + 1,
|
||||
"ruler": CHALDEAN[(CHALDEAN.index(day_ruler) + (i if daytime else i + 12)) % 7],
|
||||
"start": start.isoformat(timespec="seconds"),
|
||||
"end": (start + length).isoformat(timespec="seconds"),
|
||||
"current": i == index,
|
||||
})
|
||||
|
||||
return {
|
||||
"day_ruler": day_ruler,
|
||||
"hour_ruler": ruler,
|
||||
"hour_number": hour_number + 1,
|
||||
"daytime": daytime,
|
||||
"period": "dzień" if daytime else "noc",
|
||||
"hour_length_minutes": round(length.total_seconds() / 60.0, 2),
|
||||
"period_start": period_start.isoformat(timespec="seconds"),
|
||||
"period_end": period_end.isoformat(timespec="seconds"),
|
||||
"hours": hours,
|
||||
}
|
||||
|
||||
|
||||
# --- syzygia prenatalna -----------------------------------------------------
|
||||
def prenatal_syzygy(engine, moment: ChartMoment, max_days: float = 32.0) -> dict | None:
|
||||
"""Ostatni nów albo pełnia PRZED podanym momentem (LOG-23).
|
||||
|
||||
Szukamy wstecz przejścia elongacji przez 0° (nów) lub 180° (pełnia); bierzemy
|
||||
to, które wypadło później. Cykl trwa ~29,5 dnia, więc okno 32 dni wystarcza.
|
||||
"""
|
||||
def elongation(when: datetime) -> float:
|
||||
pts = {p.name: p.longitude for p in
|
||||
engine.positions(_at(moment, when), ["Sun", "Moon"])}
|
||||
return norm360(pts["Moon"] - pts["Sun"])
|
||||
|
||||
def signed(when: datetime, target: float) -> float:
|
||||
"""Odległość od celu w [−180, 180] — zeruje się dokładnie w syzygii."""
|
||||
return ((elongation(when) - target + 180.0) % 360.0) - 180.0
|
||||
|
||||
best: tuple[datetime, str] | None = None
|
||||
for target, kind in ((0.0, "new_moon"), (180.0, "full_moon")):
|
||||
step = timedelta(hours=6)
|
||||
t1 = moment.when_utc
|
||||
f1 = signed(t1, target)
|
||||
scanned = timedelta()
|
||||
while scanned < timedelta(days=max_days):
|
||||
t0 = t1 - step
|
||||
f0 = signed(t0, target)
|
||||
if (f0 < 0.0) != (f1 < 0.0) and abs(f0 - f1) < 180.0:
|
||||
lo, hi, flo = t0, t1, f0
|
||||
for _ in range(40):
|
||||
mid = lo + (hi - lo) / 2
|
||||
fmid = signed(mid, target)
|
||||
if (flo < 0.0) != (fmid < 0.0):
|
||||
hi = mid
|
||||
else:
|
||||
lo, flo = mid, fmid
|
||||
found = lo + (hi - lo) / 2
|
||||
if best is None or found > best[0]:
|
||||
best = (found, kind)
|
||||
break
|
||||
t1, f1 = t0, f0
|
||||
scanned += step
|
||||
|
||||
if best is None:
|
||||
return None
|
||||
|
||||
when, kind = best
|
||||
pts = {p.name: p.longitude for p in engine.positions(_at(moment, when), ["Sun", "Moon"])}
|
||||
lon = pts["Sun"] if kind == "new_moon" else pts["Moon"]
|
||||
return {
|
||||
"type": kind,
|
||||
"type_pl": "nów" if kind == "new_moon" else "pełnia",
|
||||
"when_utc": when.isoformat(timespec="seconds"),
|
||||
"days_before_birth": round((moment.when_utc - when).total_seconds() / 86400.0, 3),
|
||||
"sign": SIGNS[sign_index(lon)],
|
||||
"in_sign": in_sign(lon),
|
||||
"decimal": round(norm360(lon), 6),
|
||||
}
|
||||
|
||||
|
||||
# --- złożenie wszystkiego ---------------------------------------------------
|
||||
def build_tables(engine, moment: ChartMoment, chart: dict,
|
||||
heavy: bool = True) -> dict:
|
||||
"""Komplet tabel dla policzonego horoskopu.
|
||||
|
||||
`heavy=False` pomija wyliczenia wymagające szukania numerycznego (godziny
|
||||
planetarne, syzygia) — przydatne, gdy liczy się czas odpowiedzi.
|
||||
"""
|
||||
positions = chart.get("positions") or []
|
||||
by_name = {p.get("name"): p for p in positions}
|
||||
asc_sign = (chart.get("angles") or {}).get("Asc", {}).get("sign")
|
||||
|
||||
out: dict = {
|
||||
"tally": tally(positions, asc_sign),
|
||||
"critical_degrees": critical_degrees(positions),
|
||||
"divisional": divisional(positions),
|
||||
}
|
||||
if "Sun" in by_name and "Moon" in by_name:
|
||||
out["moon_phase"] = moon_phase(by_name["Sun"]["decimal"], by_name["Moon"]["decimal"])
|
||||
if heavy:
|
||||
out["planetary_hours"] = planetary_hours(engine, moment)
|
||||
out["prenatal_syzygy"] = prenatal_syzygy(engine, moment)
|
||||
return out
|
||||
@@ -464,3 +464,58 @@ def open_response_stream(response, link: Link | None) -> Iterator[bytes]:
|
||||
seq += 1
|
||||
if buffer:
|
||||
raise LinkError("strumień urwał się w połowie ramki")
|
||||
|
||||
|
||||
def stream_lines(client, url: str, *, payload, headers: dict[str, str] | None = None,
|
||||
link: Link | None) -> Iterator[str]:
|
||||
"""Strumieniowe POST zwracające kolejne NIEPUSTE linie NDJSON — na żywo.
|
||||
|
||||
Dla okna postępu: linie muszą docierać w trakcie pracy, nie na końcu, więc
|
||||
czytamy strumień, a nie całe ciało. Gdy łącze ma klucz, żądanie jest
|
||||
pieczętowane, a odpowiedź odszyfrowywana ramka po ramce; granice ramek NIE
|
||||
pokrywają się z granicami linii, więc sklejamy bajty w buforze i tniemy je
|
||||
dopiero na znakach nowej linii.
|
||||
|
||||
Bez klucza zachowuje się jak dotąd (surowy strumień), żeby dev bez sekretów
|
||||
działał bez zmian.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
import httpx as _httpx
|
||||
|
||||
request_headers = dict(headers or {})
|
||||
if link is None:
|
||||
if encryption_required():
|
||||
# Ten sam kontrakt co w `call`: nie wypuszczamy jawnego żądania, gdy
|
||||
# szyfrowanie jest wymagane. Bez tego serwer owszem odrzuca (400), ale
|
||||
# ciało żądania — tu dane urodzenia — zdążyłoby już pójść w eter.
|
||||
raise LinkError(
|
||||
f"{ENV_REQUIRED} jest włączone, ale brak klucza łącza — strumień "
|
||||
f"NIE został wysłany, żeby jego treść nie poszła jawnym tekstem"
|
||||
)
|
||||
with client.stream("POST", url, json=payload, headers=request_headers) as response:
|
||||
response.raise_for_status()
|
||||
for text_line in response.iter_lines():
|
||||
if text_line:
|
||||
yield text_line
|
||||
return
|
||||
|
||||
path = _httpx.URL(url).path
|
||||
stamp = stamp_now()
|
||||
body = frame_out(link.seal(REQUEST, path, stamp, 0, _json.dumps(payload).encode("utf-8")))
|
||||
request_headers.update({HEADER_ENC: VERSION, HEADER_TS: stamp, "Content-Type": CONTENT_TYPE})
|
||||
with client.stream("POST", url, content=body, headers=request_headers) as response:
|
||||
response.raise_for_status()
|
||||
buffer = bytearray()
|
||||
for plain in open_response_stream(response, link):
|
||||
buffer += plain
|
||||
while True:
|
||||
nl = buffer.find(b"\n")
|
||||
if nl < 0:
|
||||
break
|
||||
text_line = bytes(buffer[:nl])
|
||||
del buffer[:nl + 1]
|
||||
if text_line:
|
||||
yield text_line.decode("utf-8")
|
||||
if buffer:
|
||||
yield bytes(buffer).decode("utf-8")
|
||||
|
||||
@@ -124,7 +124,14 @@ class _Driver:
|
||||
"""(tekst, czy_ucięta, zużycie, nazwa_modelu, powód_zakończenia)."""
|
||||
raise NotImplementedError
|
||||
|
||||
def generate(self, prompt: str, max_tokens: int) -> Completion:
|
||||
def generate(self, prompt: str, max_tokens: int, on_event=None) -> Completion:
|
||||
"""`on_event(dict)` dostaje zdarzenia postępu — UI pokazuje z nich log.
|
||||
Raportujemy KAŻDĄ turę, bo to ona trwa; bez tego pasek postępu byłby
|
||||
ozdobnikiem, a nie informacją."""
|
||||
def emit(kind: str, message: str, **extra):
|
||||
if on_event:
|
||||
on_event({"type": kind, "message": message, **extra})
|
||||
|
||||
messages: list[dict] = [{"role": "user", "content": prompt}]
|
||||
parts: list[str] = []
|
||||
usage: dict = {}
|
||||
@@ -137,9 +144,29 @@ class _Driver:
|
||||
while turns <= MAX_CONTINUATIONS:
|
||||
turns += 1
|
||||
budget = max(256, min(remaining, TURN_TOKENS_CAP))
|
||||
emit("turn_start",
|
||||
f"Tura {turns}: wysyłam do modelu {self.model} (limit {budget} tokenów)…",
|
||||
turn=turns)
|
||||
started = time.monotonic()
|
||||
text, truncated, turn_usage, model_name, stop = self._turn(messages, budget)
|
||||
took = time.monotonic() - started
|
||||
_merge_usage(usage, turn_usage)
|
||||
remaining -= budget
|
||||
|
||||
# Odejmujemy tokeny FAKTYCZNIE wyprodukowane, nie zamówiony limit tury.
|
||||
# Inaczej pierwsza tura zjadałaby cały budżet i urwana odpowiedź nigdy
|
||||
# nie doczekałaby się kontynuacji — wracałby do użytkownika fragment
|
||||
# udający całość.
|
||||
produced = (turn_usage or {}).get("completion_tokens")
|
||||
if produced is None:
|
||||
produced = (turn_usage or {}).get("output_tokens")
|
||||
if produced is None:
|
||||
produced = max(1, int(len(text) / 3.6))
|
||||
remaining -= max(1, int(produced))
|
||||
|
||||
emit("turn_end",
|
||||
f"Tura {turns}: odebrano {len(text.strip())} znaków w {took:.1f}s"
|
||||
+ (" — odpowiedź urwana, poproszę o dokończenie" if truncated else ""),
|
||||
turn=turns, chars=len(text.strip()), truncated=truncated)
|
||||
|
||||
chunk = text.strip()
|
||||
if chunk:
|
||||
@@ -172,6 +199,8 @@ class _Driver:
|
||||
final = _join(parts)
|
||||
if not final:
|
||||
raise LLMError(_explain_empty(turns, usage, stop))
|
||||
emit("generated", f"Gotowe: {len(final)} znaków w {turns} turach.",
|
||||
chars=len(final), turns=turns)
|
||||
|
||||
usage["turns"] = turns
|
||||
return Completion(text=final, model=model_name, provider=self.name,
|
||||
|
||||
@@ -44,6 +44,7 @@ class PositionsRequest(BaseModel):
|
||||
objects: list[str] | None = None
|
||||
house_system: str = "whole_sign" # whole_sign | equal | porphyry
|
||||
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
|
||||
tables: bool = False # tabele dodatkowe (LOG-23; szuka wschodu/zachodu)
|
||||
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
|
||||
|
||||
|
||||
@@ -75,6 +76,10 @@ def chart_positions(req: PositionsRequest) -> dict:
|
||||
st = find_stations(engine, moment, p["name"])
|
||||
if st:
|
||||
p["stations"] = st
|
||||
if req.tables:
|
||||
from app.engine.tables import build_tables
|
||||
|
||||
chart["tables"] = build_tables(engine, moment, chart)
|
||||
return chart
|
||||
|
||||
|
||||
@@ -268,6 +273,72 @@ def chart_horoscope(req: HoroscopeRequest) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
@app.post("/chart/horoscope/stream")
|
||||
def chart_horoscope_stream(req: HoroscopeRequest):
|
||||
"""To samo co /chart/horoscope, ale strumieniuje POSTĘP w trakcie pracy.
|
||||
|
||||
Pisanie horoskopu trwa minutami — bez sygnału aplikacja wygląda na zawieszoną.
|
||||
Strumień (NDJSON, jedna linia = jedno zdarzenie) niesie RZECZYWISTE etapy:
|
||||
budowę promptu, limity modelu i każdą turę generowania. Ostatnie zdarzenie
|
||||
(`result`) ma identyczny kształt co odpowiedź zwykłego endpointu.
|
||||
"""
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.progress import stream
|
||||
|
||||
def work(emit) -> dict:
|
||||
from app.llm.base import LLMError
|
||||
from app.llm.factory import build_provider
|
||||
from app.llm.limits import plan
|
||||
|
||||
emit({"type": "stage", "message": "Liczę horoskop i szukam wskazań w bazach…"})
|
||||
out = chart_prompt(req)
|
||||
st = out.get("stats", {})
|
||||
emit({"type": "stage", "message":
|
||||
f"Prompt gotowy: {st.get('chars', 0)} znaków, "
|
||||
f"wskazań {st.get('included', 0)}"
|
||||
+ (f", pominięto {st['omitted']}" if st.get("omitted") else "")})
|
||||
if out.get("data_error"):
|
||||
emit({"type": "warn", "message": out["data_error"]})
|
||||
|
||||
try:
|
||||
provider = build_provider(req.provider, req.model)
|
||||
emit({"type": "stage", "message":
|
||||
f"Dostawca: {provider.name}, model: {provider.model}"
|
||||
+ ("" if not provider.leaves_lan else " — dane opuszczają sieć")})
|
||||
|
||||
emit({"type": "stage", "message": "Liczę tokeny promptu…"})
|
||||
prompt_tokens = provider.count_tokens(out["prompt"])
|
||||
budget = plan(provider.name, provider.model, prompt_tokens, req.max_tokens)
|
||||
out["token_plan"] = budget
|
||||
emit({"type": "stage", "message":
|
||||
f"Prompt {prompt_tokens} tok. · okno modelu {budget['context_window']} · "
|
||||
f"na odpowiedź {budget['max_output']}"})
|
||||
for warning in budget["warnings"]:
|
||||
emit({"type": "warn", "message": warning})
|
||||
if budget["warnings"]:
|
||||
out["warnings"] = budget["warnings"]
|
||||
if not budget["fits"]:
|
||||
out["llm_error"] = " ".join(budget["warnings"])
|
||||
return out
|
||||
|
||||
result = provider.generate(out["prompt"], budget["max_output"], on_event=emit)
|
||||
except LLMError as e:
|
||||
emit({"type": "warn", "message": f"Model zawiódł: {e}"})
|
||||
out["llm_error"] = str(e)
|
||||
return out
|
||||
|
||||
out.update(horoscope=result.text, provider=result.provider, model=result.model,
|
||||
leaves_lan=result.leaves_lan, usage=result.usage)
|
||||
return out
|
||||
|
||||
return StreamingResponse(
|
||||
stream(work),
|
||||
media_type="application/x-ndjson",
|
||||
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/llm/models")
|
||||
def llm_models() -> dict:
|
||||
"""Podpowiedzi modeli per dostawca — UI buduje z tego listę wyboru.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Strumień postępu długiej operacji (NDJSON).
|
||||
|
||||
Po co: pisanie horoskopu trwa — czasem minuty. Bez sygnału aplikacja wygląda na
|
||||
zawieszoną. Zamiast udawanego paska postępu strumieniujemy **rzeczywiste**
|
||||
zdarzenia z kolejnych etapów, żeby log pokazywał to, co faktycznie się dzieje.
|
||||
|
||||
Dlaczego NDJSON, a nie SSE: `EventSource` w przeglądarce obsługuje wyłącznie GET,
|
||||
a to jest POST z ciałem. Strumień „jedna linia = jeden obiekt JSON" czyta się
|
||||
zwykłym `fetch()` i jest trywialny do sparsowania.
|
||||
|
||||
Dlaczego wątek: właściwa praca (silnik, baza, model) jest synchroniczna. Puszczamy
|
||||
ją w wątku roboczym, a generator odpompowuje kolejkę zdarzeń — dzięki temu
|
||||
zdarzenia docierają w trakcie pracy, a nie dopiero na końcu.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
import traceback
|
||||
from collections.abc import Iterator
|
||||
from typing import Any, Callable
|
||||
|
||||
_HEARTBEAT_SECONDS = 10.0
|
||||
_DONE = object()
|
||||
|
||||
|
||||
def line(kind: str, message: str, **extra: Any) -> str:
|
||||
return json.dumps({"type": kind, "message": message, **extra}, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def stream(work: Callable[[Callable[[dict], None]], dict]) -> Iterator[str]:
|
||||
"""Uruchamia `work(emit)` w wątku i strumieniuje zdarzenia w czasie rzeczywistym.
|
||||
|
||||
`work` dostaje funkcję `emit(zdarzenie)` i zwraca końcowy wynik, który leci
|
||||
jako ostatnie zdarzenie typu `result`. Wyjątek zamienia się w zdarzenie `error`
|
||||
— połączenie nigdy nie urywa się bez wyjaśnienia.
|
||||
"""
|
||||
events: queue.Queue = queue.Queue()
|
||||
|
||||
def emit(event: dict) -> None:
|
||||
events.put(event)
|
||||
|
||||
def run() -> None:
|
||||
try:
|
||||
result = work(emit)
|
||||
events.put({"type": "result", "message": "Gotowe.", "result": result})
|
||||
except Exception as e: # noqa: BLE001 — zgłaszamy KAŻDY błąd
|
||||
events.put({
|
||||
"type": "error",
|
||||
"message": f"{type(e).__name__}: {e}",
|
||||
"detail": traceback.format_exc(limit=3),
|
||||
})
|
||||
finally:
|
||||
events.put(_DONE)
|
||||
|
||||
worker = threading.Thread(target=run, daemon=True)
|
||||
worker.start()
|
||||
|
||||
while True:
|
||||
try:
|
||||
event = events.get(timeout=_HEARTBEAT_SECONDS)
|
||||
except queue.Empty:
|
||||
# cisza dłuższa niż heartbeat: dajemy znak życia, żeby pośredniki
|
||||
# (proxy, load balancer) nie uznały połączenia za martwe
|
||||
yield line("ping", "…")
|
||||
continue
|
||||
if event is _DONE:
|
||||
break
|
||||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||
@@ -273,6 +273,56 @@ def test_streaming_response_survives_encryption(link):
|
||||
assert all(s["note"] == SECRET for s in steps)
|
||||
|
||||
|
||||
def test_stream_lines_helper_round_trips_ndjson(link):
|
||||
"""Wyższa warstwa (`stream_lines`) — dokładnie tej używa klient okna postępu.
|
||||
|
||||
Regresja, którą pilnuje ten test: strumień horoskopu powstał na gałęzi, która
|
||||
nie widziała szyfrowania, więc szedł surowym httpx. Po scaleniu z PRE-16 przy
|
||||
włączonym łączu żądanie było odrzucane (400) albo odpowiedź wracała jako
|
||||
nieczytelne ramki. Tu sprawdzamy, że helper zwraca CZYSTE linie NDJSON i że
|
||||
tajny opis nie przecieka po drodze."""
|
||||
with TestClient(_app(link)) as client:
|
||||
lines = list(link_crypto.stream_lines(
|
||||
client, "http://testserver/stream", payload=None, link=link))
|
||||
steps = [json.loads(line) for line in lines]
|
||||
assert [s["step"] for s in steps] == [0, 1, 2, 3]
|
||||
assert all(s["note"] == SECRET for s in steps)
|
||||
|
||||
|
||||
def test_stream_lines_plaintext_path_still_works(link):
|
||||
"""Bez klucza (dev) strumień ma działać jak dotąd — surowy, bez szyfrowania."""
|
||||
with TestClient(_app(None)) as client:
|
||||
lines = list(link_crypto.stream_lines(
|
||||
client, "http://testserver/stream", payload=None, link=None))
|
||||
assert [json.loads(line)["step"] for line in lines] == [0, 1, 2, 3]
|
||||
|
||||
|
||||
def test_stream_request_without_key_is_refused_when_required(link):
|
||||
"""Fail-closed obejmuje też strumień: nieszyfrowane żądanie na łączu z kluczem
|
||||
dostaje odmowę, a nie cichy jawny przelot tajnych danych."""
|
||||
with TestClient(_app(link)) as client:
|
||||
response = client.post("http://testserver/stream", json={})
|
||||
assert response.status_code == 400
|
||||
assert SECRET.encode() not in response.content
|
||||
|
||||
|
||||
def test_stream_lines_client_is_fail_closed_when_required(monkeypatch, link):
|
||||
"""Klient strumienia też nie wypuszcza jawnego żądania — jak `call`. Bez tego
|
||||
ciało (dane urodzenia) poszłoby w eter, zanim serwer zdążyłby odmówić."""
|
||||
monkeypatch.setenv(link_crypto.ENV_REQUIRED, "true")
|
||||
sent = []
|
||||
|
||||
class Tripwire:
|
||||
def stream(self, *args, **kwargs):
|
||||
sent.append(args)
|
||||
raise AssertionError("strumień NIE powinien opuścić procesu")
|
||||
|
||||
with pytest.raises(LinkError, match=link_crypto.ENV_REQUIRED):
|
||||
list(link_crypto.stream_lines(Tripwire(), "http://logic/chart/horoscope/stream",
|
||||
payload={"lat": 50.0}, link=None))
|
||||
assert not sent, "żądanie strumienia wyszłoby jawnym tekstem"
|
||||
|
||||
|
||||
def test_incremental_unframing_handles_split_frames(link):
|
||||
"""Ramka potrafi rozjechać się między dwa odczyty z gniazda — składamy ją
|
||||
w buforze, zamiast zakładać, że każdy kawałek to komplet."""
|
||||
|
||||
@@ -405,3 +405,33 @@ def test_max_budget_differs_between_models(monkeypatch):
|
||||
haiku = prompt_token_budget(*factory.resolve_model("anthropic", "claude-haiku-4-5"))
|
||||
local = prompt_token_budget(*factory.resolve_model("local", "llama3.1:8b"))
|
||||
assert opus > haiku > local > 0
|
||||
|
||||
|
||||
def test_turn_budget_counts_produced_not_requested(monkeypatch):
|
||||
"""Regresja: odejmowanie ZAMOWIONEGO limitu tury zamiast wyprodukowanych
|
||||
tokenow konczylo petle po jednej turze — urwany fragment wracal jako calosc."""
|
||||
seq = [_chat("Fragment 1. ", "length"), _chat("Fragment 2. ", "length"),
|
||||
_chat("Zakonczenie. KONIEC", "stop")]
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(200, json=seq.pop(0) if seq else seq[-1])
|
||||
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(handler))
|
||||
# budzet 8000 < TURN_TOKENS_CAP: przy starej logice byla dokladnie jedna tura
|
||||
out = ChatCompletionsProvider("local", "http://x/v1", "m").generate("p", 8000)
|
||||
assert out.usage["turns"] == 3, "urwana odpowiedz musi byc kontynuowana"
|
||||
assert "Fragment 1." in out.text and "Zakonczenie." in out.text
|
||||
|
||||
|
||||
def test_generate_reports_progress_events(monkeypatch):
|
||||
"""Log w UI ma pokazywac RZECZYWISTE tury, nie udawany pasek postepu."""
|
||||
seq = [_chat("Czesc. ", "length"), _chat("Reszta. KONIEC", "stop")]
|
||||
monkeypatch.setattr(httpx, "Client", _mock_client(
|
||||
lambda r: httpx.Response(200, json=seq.pop(0) if seq else seq[-1])))
|
||||
events = []
|
||||
ChatCompletionsProvider("local", "http://x/v1", "m").generate(
|
||||
"p", 8000, on_event=events.append)
|
||||
kinds = [e["type"] for e in events]
|
||||
assert kinds.count("turn_start") == 2 and kinds.count("turn_end") == 2
|
||||
assert kinds[-1] == "generated"
|
||||
assert any("urwana" in e["message"] for e in events if e["type"] == "turn_end")
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Tabele pomocnicze horoskopu (LOG-23).
|
||||
|
||||
Wartości referencyjne dla horoskopu 30.04.1984 09:20 UTC, Kraków (50.0647N, 19.9450E)
|
||||
sprawdzone wobec faktów NIEZALEŻNYCH od naszego kodu:
|
||||
* 30.04.1984 to poniedziałek → władca dnia Księżyc; 5. godzina poniedziałku
|
||||
w porządku chaldejskim to Słońce (Mo, Sa, Ju, Ma, Su),
|
||||
* wschód/zachód dla Krakowa końcem kwietnia ≈ 5:18 / 19:57 czasu lokalnego
|
||||
(CEST = UTC+2), czyli 03:18 / 17:57 UTC,
|
||||
* pełnia poprzedzająca urodzenie: 15.04.1984 ok. 19:11 UTC.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.engine.chart import build_chart
|
||||
from app.engine.models import ChartMoment
|
||||
from app.engine.tables import (
|
||||
build_tables,
|
||||
critical_degrees,
|
||||
dwadasamsa,
|
||||
element_of,
|
||||
moon_phase,
|
||||
navamsa,
|
||||
planetary_hours,
|
||||
prenatal_syzygy,
|
||||
quality_of,
|
||||
tally,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def krakow():
|
||||
return ChartMoment(when_utc=datetime(1984, 4, 30, 9, 20, tzinfo=timezone.utc),
|
||||
lat=50.0647, lon=19.9450)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tables(own_engine, krakow):
|
||||
return build_tables(own_engine, krakow, build_chart(own_engine, krakow))
|
||||
|
||||
|
||||
# ------------------------------------------------------ żywioły i jakości
|
||||
|
||||
def test_element_and_quality_mapping():
|
||||
assert element_of("Aries") == "Fire" and element_of("Cancer") == "Water"
|
||||
assert quality_of("Aries") == "Cardinal" and quality_of("Taurus") == "Fixed"
|
||||
assert quality_of("Gemini") == "Mutable"
|
||||
|
||||
|
||||
def test_tally_matches_hand_count(tables):
|
||||
"""Ręcznie przeliczone dla horoskopu referencyjnego (10 planet + Asc)."""
|
||||
base = tables["tally"]["with_modern_10_plus_asc"]
|
||||
assert base["elements"] == {"Fire": 4, "Earth": 4, "Air": 0, "Water": 3}
|
||||
assert base["qualities"] == {"Cardinal": 4, "Fixed": 6, "Mutable": 1}
|
||||
assert base["total"] == 11
|
||||
|
||||
|
||||
def test_missing_element_detected(tables):
|
||||
"""Klasyczne „no air" — podstawa pod scoring siły (LOG-21)."""
|
||||
assert tables["tally"]["missing_elements"] == ["Air"]
|
||||
assert tables["tally"]["missing_qualities"] == []
|
||||
|
||||
|
||||
def test_tally_variants_differ_by_object_count(tables):
|
||||
t = tables["tally"]
|
||||
assert t["classical_7"]["total"] == 7
|
||||
assert t["with_modern_10"]["total"] == 10
|
||||
assert t["classical_7_plus_asc"]["total"] == 8
|
||||
assert t["with_modern_10_plus_asc"]["total"] == 11
|
||||
|
||||
|
||||
def test_tally_sums_equal_counted_objects(tables):
|
||||
for variant in ("classical_7", "with_modern_10", "with_modern_10_plus_asc"):
|
||||
v = tables["tally"][variant]
|
||||
assert sum(v["elements"].values()) == v["total"]
|
||||
assert sum(v["qualities"].values()) == v["total"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------- faza Księżyca
|
||||
|
||||
def test_moon_phase_reference_is_balsamic_new(tables):
|
||||
"""Nów wypadł 1.05.1984, więc 30.04 Księżyc jest tuż przed nowiem."""
|
||||
mp = tables["moon_phase"]
|
||||
assert mp["phase"] == "New Moon"
|
||||
assert 340.0 < mp["angle"] < 360.0
|
||||
assert mp["illumination"] < 0.05
|
||||
assert mp["waxing"] is False # elongacja > 180 = ubywa
|
||||
|
||||
|
||||
@pytest.mark.parametrize("angle,expected", [
|
||||
(0.0, "New Moon"), (90.0, "First Quarter"), (180.0, "Full Moon"),
|
||||
(270.0, "Last Quarter"), (46.0, "Waxing Crescent"), (300.0, "Waning Crescent"),
|
||||
])
|
||||
def test_moon_phase_buckets(angle, expected):
|
||||
assert moon_phase(0.0, angle)["phase"] == expected
|
||||
|
||||
|
||||
def test_moon_phase_illumination_extremes():
|
||||
assert moon_phase(0.0, 0.0)["illumination"] == 0.0
|
||||
assert moon_phase(0.0, 180.0)["illumination"] == 1.0
|
||||
assert moon_phase(0.0, 90.0)["illumination"] == pytest.approx(0.5)
|
||||
|
||||
|
||||
# ------------------------------------------------------- stopnie krytyczne
|
||||
|
||||
def test_critical_degrees_reference(tables):
|
||||
found = {c["name"]: c["flags"] for c in tables["critical_degrees"]}
|
||||
assert "Jupiter" in found # Cap 12°57' -> 13° kardynalny
|
||||
assert any("13" in f for f in found["Jupiter"])
|
||||
assert "Pluto" in found # Sco 0°28' -> wejście w znak
|
||||
|
||||
|
||||
def test_anaretic_degree_flagged():
|
||||
flags = critical_degrees([{"name": "X", "sign": "Leo", "decimal": 149.5,
|
||||
"in_sign": "Leo 29°30'"}])
|
||||
assert flags and any("anaretyczny" in f for f in flags[0]["flags"])
|
||||
|
||||
|
||||
def test_no_flags_for_ordinary_degree():
|
||||
assert critical_degrees([{"name": "X", "sign": "Leo", "decimal": 135.0,
|
||||
"in_sign": "Leo 15°"}]) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------- podziały
|
||||
|
||||
def test_dwadasamsa_starts_from_own_sign():
|
||||
"""12. część liczy się OD znaku, w którym stoi punkt."""
|
||||
assert dwadasamsa(0.0) == pytest.approx(0.0) # Ari 0 -> Ari
|
||||
assert dwadasamsa(2.5) == pytest.approx(30.0) # Ari 2°30' -> Tau 0
|
||||
assert dwadasamsa(30.0) == pytest.approx(30.0) # Tau 0 -> Tau
|
||||
|
||||
|
||||
def test_navamsa_classic_starts():
|
||||
"""Znaki kardynalne zaczynają od siebie, stałe od 9. znaku."""
|
||||
assert navamsa(0.0) == pytest.approx(0.0) # Ari -> Ari
|
||||
assert navamsa(30.0) == pytest.approx(270.0) # Tau -> Cap (9. od Byka)
|
||||
assert navamsa(60.0) == pytest.approx(180.0) # Gem -> Lib
|
||||
|
||||
|
||||
def test_divisional_covers_all_positions(tables, own_engine, krakow):
|
||||
chart = build_chart(own_engine, krakow)
|
||||
assert len(tables["divisional"]) == len(chart["positions"])
|
||||
|
||||
|
||||
# ------------------------------------------------- dzień i godziny planetarne
|
||||
|
||||
def test_planetary_day_ruler_is_moon_on_monday(tables):
|
||||
"""30.04.1984 to poniedziałek → władcą dnia jest Księżyc."""
|
||||
assert tables["planetary_hours"]["day_ruler"] == "Moon"
|
||||
|
||||
|
||||
def test_planetary_hour_matches_chaldean_sequence(tables):
|
||||
"""Poniedziałek: 1=Mo, 2=Sa, 3=Ju, 4=Ma, 5=Su — urodzenie w 5. godzinie dnia."""
|
||||
ph = tables["planetary_hours"]
|
||||
assert ph["hour_number"] == 5
|
||||
assert ph["hour_ruler"] == "Sun"
|
||||
assert ph["daytime"] is True
|
||||
|
||||
|
||||
def test_sunrise_sunset_match_krakow_late_april(tables):
|
||||
"""Wschód ≈ 03:18 UTC, zachód ≈ 17:57 UTC (5:18 i 19:57 czasu lokalnego)."""
|
||||
ph = tables["planetary_hours"]
|
||||
assert ph["period_start"].startswith("1984-04-30T03:1")
|
||||
assert ph["period_end"].startswith("1984-04-30T17:5")
|
||||
|
||||
|
||||
def test_planetary_hours_are_unequal_and_complete(tables):
|
||||
"""Godziny są nierówne: wiosną dzienna trwa dłużej niż 60 minut."""
|
||||
ph = tables["planetary_hours"]
|
||||
assert ph["hour_length_minutes"] > 60.0
|
||||
assert len(ph["hours"]) == 12
|
||||
assert sum(1 for h in ph["hours"] if h["current"]) == 1
|
||||
|
||||
|
||||
def test_polar_night_returns_none(own_engine):
|
||||
"""Za kołem podbiegunowym w grudniu Słońce nie wschodzi — brak godzin."""
|
||||
polar = ChartMoment(when_utc=datetime(2024, 12, 21, 12, 0, tzinfo=timezone.utc),
|
||||
lat=78.0, lon=15.0)
|
||||
assert planetary_hours(own_engine, polar) is None
|
||||
|
||||
|
||||
# ------------------------------------------------------- syzygia prenatalna
|
||||
|
||||
def test_prenatal_syzygy_is_april_1984_full_moon(tables):
|
||||
"""Rzeczywista pełnia: 15.04.1984 ok. 19:11 UTC."""
|
||||
s = tables["prenatal_syzygy"]
|
||||
assert s["type"] == "full_moon"
|
||||
assert s["when_utc"].startswith("1984-04-15T19:1")
|
||||
|
||||
|
||||
def test_prenatal_syzygy_precedes_birth_within_a_cycle(tables):
|
||||
days = tables["prenatal_syzygy"]["days_before_birth"]
|
||||
assert 0 < days < 29.6, "syzygia musi być w ostatnim cyklu przed urodzeniem"
|
||||
|
||||
|
||||
def test_prenatal_syzygy_elongation_is_at_target(own_engine, krakow):
|
||||
"""W znalezionym momencie elongacja MUSI wynosić 0° albo 180°."""
|
||||
from app.engine.formats import norm360
|
||||
|
||||
s = prenatal_syzygy(own_engine, krakow)
|
||||
when = datetime.fromisoformat(s["when_utc"])
|
||||
pts = {p.name: p.longitude for p in
|
||||
own_engine.positions(ChartMoment(when_utc=when, lat=krakow.lat, lon=krakow.lon),
|
||||
["Sun", "Moon"])}
|
||||
elong = norm360(pts["Moon"] - pts["Sun"])
|
||||
target = 0.0 if s["type"] == "new_moon" else 180.0
|
||||
assert abs(((elong - target + 180.0) % 360.0) - 180.0) < 0.02
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ całość
|
||||
|
||||
def test_build_tables_light_skips_numeric_search(own_engine, krakow):
|
||||
"""heavy=False pomija to, co wymaga szukania numerycznego."""
|
||||
light = build_tables(own_engine, krakow, build_chart(own_engine, krakow), heavy=False)
|
||||
assert "tally" in light and "moon_phase" in light
|
||||
assert "planetary_hours" not in light and "prenatal_syzygy" not in light
|
||||
Reference in New Issue
Block a user