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.
176 lines
6.4 KiB
Python
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": (
|
|
"换座与停滞是按所选岁差逐日扫描恒星黄经得到的事件表,不是对本命的过境触发,"
|
|
"也不是七政宿度。不得把两套结果叠加成双重印证。"
|
|
),
|
|
}
|