feat(logic): systemy zodiaku — syderyczny, draconic (LOG-04)
Jedyne wymaganie Must bez implementacji. Nowy zodiac.py + wpiecie w horoskop: - zodiac.py: ayanamsy (Lahiri, Fagan-Bradley, Krishnamurti) modelem ayan(jd)=ayan0+B*x+C*x^2 (wspolna precesja, rozna stala) — skalibrowanym do Swiss Ephemeris jako WYROCZNI: zgodnosc do ~0,02" w latach 1900-2100. Draconic = wzgledem wzla wznoszacego (wzel = 0 Barana). RA: konwersja ekliptyka->rownik (to_equatorial) na przyszly widok rownikowy. - chart.py: build_chart(..., zodiac): offset jednolicie przesuwa etykiety znakow/dlugosci obiektow, osi, cusps i Lots; DOMY licza sie po dlugosci tropikalnej (geometria niezmiennicza wzgledem obrotu -> numery domow bez zmian). Domyslnie tropical -> sciezka i wyniki bez zmian. - main.py: /chart/positions przyjmuje `zodiac`; bledny -> 422. - prezentacja: dropdown „Zodiak" + pokazanie ayanamshy w wynikach. Testy (14): ayanamsy vs wyrocznia swisseph (<0.1"), julian_day, draconic (wzel=0 Barana), niezmienniczosc domow, RA w punktach charakterystycznych, odrzucenie bledow. Cala logika: 99 passed, 1 skipped. Zweryfikowane e2e w przegladarce (horoskop syderyczny Lahiri: Slonce Aries 16°34', ayan 23.6382). RA jako osobny widok zodiaku (per-obiekt, z szerokoscia) — do osobnego PR. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #7.
This commit is contained in:
@@ -7,28 +7,51 @@ pozycje.
|
||||
from __future__ import annotations
|
||||
|
||||
from app.engine import houses as H
|
||||
from app.engine import zodiac as Z
|
||||
from app.engine.base import EphemerisEngine
|
||||
from app.engine.formats import SIGNS, in_sign, norm360, sign_index
|
||||
from app.engine.formats import SIGNS, absolute, decimal, in_sign, norm360, sign_index
|
||||
from app.engine.models import ChartMoment
|
||||
|
||||
|
||||
def _fmt(name: str, lon: float) -> dict:
|
||||
def _fmt(name: str, lon: float, off: float = 0.0) -> dict:
|
||||
lon = norm360(lon - off)
|
||||
return {
|
||||
"name": name,
|
||||
"sign": SIGNS[sign_index(lon)],
|
||||
"in_sign": in_sign(lon),
|
||||
"decimal": round(norm360(lon), 6),
|
||||
"decimal": round(lon, 6),
|
||||
}
|
||||
|
||||
|
||||
def _shift_pos(pdict: dict, off: float) -> None:
|
||||
"""Przelicza etykiety pozycji na wybrany zodiak (in-place). off=0 → bez zmian."""
|
||||
if not off:
|
||||
return
|
||||
lon = norm360(pdict["decimal"] - off)
|
||||
pdict["sign"] = SIGNS[sign_index(lon)]
|
||||
pdict["in_sign"] = in_sign(lon)
|
||||
pdict["absolute"] = absolute(lon)
|
||||
pdict["decimal"] = decimal(lon)
|
||||
|
||||
|
||||
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
|
||||
lots_method: str = "degree") -> dict:
|
||||
lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict:
|
||||
from app.engine.aspects import find_aspects
|
||||
|
||||
positions = engine.positions(moment)
|
||||
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
|
||||
# aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze
|
||||
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
|
||||
|
||||
# offset zodiaku (LOG-04): syderyczny = ayanamsa, draconic = długość węzła
|
||||
node_lon = next((p.longitude for p in positions if p.name == "North Node"), None)
|
||||
off = Z.offset(zodiac, Z.julian_day(moment.when_utc), node_lon)
|
||||
result["zodiac"] = zodiac
|
||||
if zodiac in Z.SIDEREAL:
|
||||
result["ayanamsha"] = round(off, 6)
|
||||
for pdict in result["positions"]:
|
||||
_shift_pos(pdict, off)
|
||||
|
||||
if not hasattr(engine, "sidereal"):
|
||||
return result
|
||||
|
||||
@@ -36,21 +59,22 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
||||
asc = H.compute_asc(ramc, eps, moment.lat)
|
||||
mc = H.compute_mc(ramc, eps)
|
||||
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
|
||||
cusp_list = H.cusps(asc, mc, system)
|
||||
cusp_list = H.cusps(asc, mc, system) # tropikalne — geometria domów jest niezmiennicza
|
||||
|
||||
result["house_system"] = system
|
||||
result["angles"] = {
|
||||
"Asc": _fmt("Asc", asc),
|
||||
"MC": _fmt("MC", mc),
|
||||
"Dsc": _fmt("Dsc", norm360(asc + 180.0)),
|
||||
"IC": _fmt("IC", norm360(mc + 180.0)),
|
||||
"Asc": _fmt("Asc", asc, off),
|
||||
"MC": _fmt("MC", mc, off),
|
||||
"Dsc": _fmt("Dsc", norm360(asc + 180.0), off),
|
||||
"IC": _fmt("IC", norm360(mc + 180.0), off),
|
||||
}
|
||||
result["cusps"] = [
|
||||
{"house": i + 1, "sign": SIGNS[sign_index(c)], "in_sign": in_sign(c)}
|
||||
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
|
||||
"in_sign": in_sign(norm360(c - off))}
|
||||
for i, c in enumerate(cusp_list)
|
||||
]
|
||||
for pdict, obj in zip(result["positions"], positions):
|
||||
pdict["house"] = H.assign_house(obj.longitude, cusp_list)
|
||||
pdict["house"] = H.assign_house(obj.longitude, cusp_list) # dom po długości tropikalnej
|
||||
|
||||
# Lots (LOG-08) — wymagają Asc i sekty (dzień/noc)
|
||||
from app.engine.firdaria import is_day_birth
|
||||
@@ -62,9 +86,10 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
||||
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)}
|
||||
"longitude": decimal(norm360(lot["longitude"] - off)), # w wybranym zodiaku
|
||||
"sign": SIGNS[sign_index(norm360(lot["longitude"] - off))],
|
||||
"in_sign": in_sign(norm360(lot["longitude"] - off)),
|
||||
"house": H.assign_house(lot["longitude"], cusp_list)} # dom po długości tropikalnej
|
||||
for lot in compute_lots(pts, day, lots_method)
|
||||
]
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Systemy zodiaku (LOG-04): tropikalny, syderyczny (ayanamsy), draconic + RA.
|
||||
|
||||
Wszystkie pozycje silnika są liczone **tropikalnie of-date** (kontrakt LOG-28).
|
||||
Zmiana zodiaku to — dla zodiaków ekliptycznych — jednolite przesunięcie długości:
|
||||
|
||||
długość_docelowa = (długość_tropikalna − offset) mod 360
|
||||
|
||||
gdzie offset to:
|
||||
- **syderyczny**: ayanamsa (kąt między tropikalnym a syderycznym punktem Barana),
|
||||
- **draconic**: długość wznoszącego węzła Księżyca (węzeł = 0° draconic),
|
||||
- **tropikalny**: 0.
|
||||
|
||||
Ponieważ to stałe przesunięcie obiektu ORAZ cusps, **numery domów się nie zmieniają**
|
||||
(geometria jest niezmiennicza względem obrotu) — przesuwamy tylko etykiety znaków.
|
||||
|
||||
Model ayanamsy: `ayan(jd) = ayan0 + B·x + C·x²`, gdzie `x = jd − J2000`. Prędkość
|
||||
precesji (B, C) jest **wspólna** dla wszystkich ayanams; różni je tylko stała `ayan0`
|
||||
(wybór syderycznego zera). Stałe skalibrowano do Swiss Ephemeris jako wyroczni —
|
||||
zgodność do ~0,02" w latach 1900–2100 (patrz tests/test_zodiac.py).
|
||||
|
||||
RA (right ascension): konwersja ekliptyka→równik dla przyszłego widoku równikowego.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
from app.engine.formats import norm360
|
||||
|
||||
TROPICAL = "tropical"
|
||||
DRACONIC = "draconic"
|
||||
|
||||
# stała ayanamsy w J2000.0 (°) — skalibrowana do swisseph (get_ayanamsa_ut)
|
||||
_AYAN0 = {
|
||||
"lahiri": 23.857092,
|
||||
"fagan_bradley": 24.740300,
|
||||
"krishnamurti": 23.760240,
|
||||
}
|
||||
_J2000 = 2451545.0
|
||||
_B = 3.824459e-5 # °/dobę — liniowy człon precesji (wspólny)
|
||||
_C = 2.304e-13 # °/dobę² — drobne przyspieszenie (wspólne)
|
||||
|
||||
# nazwy zodiaków akceptowane przez API
|
||||
SIDEREAL = tuple(f"sidereal_{k}" for k in _AYAN0) # sidereal_lahiri, ...
|
||||
SYSTEMS = (TROPICAL, *SIDEREAL, DRACONIC)
|
||||
|
||||
|
||||
def julian_day(dt: datetime) -> float:
|
||||
"""Julian Day (UT) z momentu UTC — algorytm Meeusa (kalendarz gregoriański)."""
|
||||
y, m = dt.year, dt.month
|
||||
day = dt.day + (dt.hour + dt.minute / 60.0 + dt.second / 3600.0
|
||||
+ dt.microsecond / 3.6e9) / 24.0
|
||||
if m <= 2:
|
||||
y -= 1
|
||||
m += 12
|
||||
a = y // 100
|
||||
b = 2 - a + a // 4
|
||||
return math.floor(365.25 * (y + 4716)) + math.floor(30.6001 * (m + 1)) + day + b - 1524.5
|
||||
|
||||
|
||||
def ayanamsha(name: str, jd: float) -> float:
|
||||
"""Ayanamsa [°] danej szkoły dla Julian Day (UT)."""
|
||||
key = name[len("sidereal_"):] if name.startswith("sidereal_") else name
|
||||
if key not in _AYAN0:
|
||||
raise ValueError(f"Nieznana ayanamsa: {name!r} (dostępne: {', '.join(_AYAN0)})")
|
||||
x = jd - _J2000
|
||||
return _AYAN0[key] + _B * x + _C * x * x
|
||||
|
||||
|
||||
def offset(zodiac: str, jd: float, node_lon: float | None = None) -> float:
|
||||
"""Ile odjąć od długości tropikalnej, by dostać wybrany zodiak.
|
||||
|
||||
`node_lon` (tropikalna długość węzła wznoszącego) wymagana tylko dla draconic.
|
||||
"""
|
||||
if zodiac == TROPICAL:
|
||||
return 0.0
|
||||
if zodiac == DRACONIC:
|
||||
if node_lon is None:
|
||||
raise ValueError("draconic wymaga długości węzła (node_lon)")
|
||||
return norm360(node_lon)
|
||||
if zodiac in SIDEREAL:
|
||||
return ayanamsha(zodiac, jd)
|
||||
raise ValueError(f"Nieznany zodiak: {zodiac!r} (dostępne: {', '.join(SYSTEMS)})")
|
||||
|
||||
|
||||
def apply(lon: float, off: float) -> float:
|
||||
"""Długość w docelowym zodiaku."""
|
||||
return norm360(lon - off)
|
||||
|
||||
|
||||
def to_equatorial(lon: float, lat: float, eps: float) -> tuple[float, float]:
|
||||
"""Ekliptyka (λ, β) → równik: (RA, deklinacja) w stopniach. Wszystko w °.
|
||||
|
||||
RA rośnie 0–360°; deklinacja w [−90, 90].
|
||||
"""
|
||||
lam, bet, e = math.radians(lon), math.radians(lat), math.radians(eps)
|
||||
sin_dec = math.sin(bet) * math.cos(e) + math.cos(bet) * math.sin(e) * math.sin(lam)
|
||||
dec = math.asin(max(-1.0, min(1.0, sin_dec)))
|
||||
ra = math.atan2(
|
||||
math.sin(lam) * math.cos(e) - math.tan(bet) * math.sin(e),
|
||||
math.cos(lam),
|
||||
)
|
||||
return norm360(math.degrees(ra)), math.degrees(dec)
|
||||
@@ -39,6 +39,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)
|
||||
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
|
||||
|
||||
|
||||
@app.post("/api/query", response_model=QueryResponse)
|
||||
@@ -58,7 +59,10 @@ def chart_positions(req: PositionsRequest) -> dict:
|
||||
|
||||
engine = get_engine()
|
||||
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||
chart = build_chart(engine, moment, req.house_system)
|
||||
try:
|
||||
chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
if req.stations:
|
||||
from app.engine.stations import find_stations
|
||||
|
||||
|
||||
Reference in New Issue
Block a user