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