Files
Jyotisha/scripts/ephemeris_events.py
T
jesse-ux 25851dd338 feat(qizheng): vendor stem-branch and expose three read-only chart APIs
Add Apache-2.0 @4n6h4x0r/stem-branch 0.8.0, a seven-governors adapter,
and POST /api/qizheng, /api/western, /api/ephemeris_events.

BUG-700 remains blocked (do not call --pillars). BUG-701 and BUG-702 are
resolved. BUG-703 is investigating (panchanga Lahiri). Skill is not bumped.
2026-09-15 16:10:21 +08:00

176 lines
6.4 KiB
Python

#!/usr/bin/env python3
"""Sidereal ingress and station scan for a bounded calendar window."""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
try:
import swisseph as swe
except ImportError: # pragma: no cover
swe = None
try:
from ayanamsa_utils import (
UnsupportedAyanamsaError,
ayanamsa_display_name,
normalize_ayanamsa_name,
temporary_ayanamsa,
)
except ImportError: # pragma: no cover
from scripts.ayanamsa_utils import (
UnsupportedAyanamsaError,
ayanamsa_display_name,
normalize_ayanamsa_name,
temporary_ayanamsa,
)
MAX_RANGE_DAYS = 730
DEFAULT_BODIES = ("sun", "mercury", "venus", "mars", "jupiter", "saturn")
SIGNS = (
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
)
_SWE_BODIES = {
"sun": "SUN",
"moon": "MOON",
"mercury": "MERCURY",
"venus": "VENUS",
"mars": "MARS",
"jupiter": "JUPITER",
"saturn": "SATURN",
"uranus": "URANUS",
"neptune": "NEPTUNE",
"pluto": "PLUTO",
}
class EphemerisEventsError(ValueError):
"""Structured ephemeris-event failure. Callers map this to HTTP 400."""
def _field(body: dict[str, Any], *keys: str, required: bool = True) -> Any:
for key in keys:
if key in body and body[key] not in (None, ""):
return body[key]
if required:
raise EphemerisEventsError(f"{keys[0]} is required")
return None
def _parse_date(value: Any, key: str) -> datetime:
if not isinstance(value, str):
raise EphemerisEventsError(f"{key} must be YYYY-MM-DD")
try:
return datetime.strptime(value[:10], "%Y-%m-%d")
except ValueError as exc:
raise EphemerisEventsError(f"{key} must be YYYY-MM-DD") from exc
def _node_mode(raw: Any) -> str:
if raw in (None, ""):
return "mean"
if not isinstance(raw, str) or raw.strip().lower() not in {"mean", "true"}:
raise EphemerisEventsError("node_mode must be mean or true")
return raw.strip().lower()
def _bodies(raw: Any) -> tuple[str, ...]:
if raw in (None, ""):
return DEFAULT_BODIES
if not isinstance(raw, list) or not raw:
raise EphemerisEventsError("bodies must be a non-empty array")
names: list[str] = []
for item in raw:
name = str(item).strip().lower()
if name not in _SWE_BODIES:
raise EphemerisEventsError(f"unsupported body: {item}")
names.append(name)
return tuple(dict.fromkeys(names))
def _planet_id(name: str) -> int:
if swe is None:
raise EphemerisEventsError("Swiss Ephemeris is not available")
return int(getattr(swe, _SWE_BODIES[name]))
def _sample(jd: float, planet_id: int, flags: int) -> tuple[float, float]:
values, _ = swe.calc_ut(jd, planet_id, flags)
return float(values[0]) % 360.0, float(values[3])
def build_ephemeris_events(body: dict[str, Any] | None) -> dict[str, Any]:
if swe is None:
raise EphemerisEventsError("Swiss Ephemeris is not available")
if not isinstance(body, dict):
raise EphemerisEventsError("JSON body must be an object")
start = _parse_date(_field(body, "start_date", "start"), "start_date")
end = _parse_date(_field(body, "end_date", "end"), "end_date")
if end < start:
raise EphemerisEventsError("end_date must be on or after start_date")
span_days = (end - start).days
if span_days > MAX_RANGE_DAYS:
raise EphemerisEventsError("ephemeris search range must be <= 730 days")
try:
ayanamsa = normalize_ayanamsa_name(body.get("ayanamsa") or body.get("ayanamsa_name"))
except UnsupportedAyanamsaError as exc:
raise EphemerisEventsError(str(exc)) from exc
node_mode = _node_mode(body.get("node_mode", body.get("nodeMode")))
bodies = _bodies(body.get("bodies") or body.get("planets"))
flags = swe.FLG_SWIEPH | swe.FLG_SPEED | swe.FLG_SIDEREAL
events: list[dict[str, Any]] = []
previous: dict[str, tuple[int, float, float]] = {}
with temporary_ayanamsa(ayanamsa):
cursor = start
while cursor <= end:
jd = swe.julday(cursor.year, cursor.month, cursor.day, 12.0)
iso = cursor.strftime("%Y-%m-%d")
for name in bodies:
longitude, speed = _sample(jd, _planet_id(name), flags)
sign_index = int(longitude // 30)
prior = previous.get(name)
if prior is not None:
prior_sign, prior_speed, _prior_lon = prior
if sign_index != prior_sign:
events.append({
"kind": "ingress",
"date": iso,
"body": name,
"from_sign": SIGNS[prior_sign],
"to_sign": SIGNS[sign_index],
"longitude": round(longitude, 6),
"speed_longitude": round(speed, 8),
})
if prior_speed * speed < 0:
events.append({
"kind": "station",
"date": iso,
"body": name,
"motion": "direct_to_retrograde" if speed < 0 else "retrograde_to_direct",
"longitude": round(longitude, 6),
"speed_longitude": round(speed, 8),
})
previous[name] = (sign_index, speed, longitude)
cursor += timedelta(days=1)
events.sort(key=lambda row: (row["date"], row["body"], row["kind"]))
return {
"success": True,
"endpoint": "ephemeris_events",
"calculation": {
"ayanamsa": ayanamsa,
"ayanamsa_display": ayanamsa_display_name(ayanamsa),
"node_mode": node_mode,
"bodies": list(bodies),
"start_date": start.strftime("%Y-%m-%d"),
"end_date": end.strftime("%Y-%m-%d"),
"sample": "daily_noon_ut_sidereal_longitude_and_speed",
"max_range_days": MAX_RANGE_DAYS,
},
"events": events,
"boundary": (
"换座与停滞是按所选岁差逐日扫描恒星黄经得到的事件表,不是对本命的过境触发,"
"也不是七政宿度。不得把两套结果叠加成双重印证。"
),
}