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.
This commit is contained in:
jesse-ux
2026-09-15 16:10:21 +08:00
parent 3e58e85f8f
commit 25851dd338
28 changed files with 94708 additions and 2 deletions
+3
View File
@@ -36,6 +36,9 @@ HEAVY_COMPUTE_PATHS = frozenset(
"/api/vedastro/range_scan",
"/api/vedastro_gateway/run",
"/api/thematic_report",
"/api/qizheng",
"/api/western",
"/api/ephemeris_events",
}
)
+175
View File
@@ -0,0 +1,175 @@
#!/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": (
"换座与停滞是按所选岁差逐日扫描恒星黄经得到的事件表,不是对本命的过境触发,"
"也不是七政宿度。不得把两套结果叠加成双重印证。"
),
}
+21
View File
@@ -3497,6 +3497,27 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
elif path == '/api/chart':
result = self._compute_chart(body)
self._json(result)
elif path == '/api/qizheng':
qizheng = _load_local_module('qizheng_chart_engine')
try:
result = qizheng.build_qizheng_natal_chart(body)
except qizheng.QizhengChartError as exc:
raise BadRequest(str(exc)) from exc
self._json(result)
elif path == '/api/western':
western = _load_local_module('western_chart_engine')
try:
result = western.build_tropical_natal_from_request(body)
except (KeyError, TypeError, ValueError) as exc:
raise BadRequest(str(exc)) from exc
self._json(result)
elif path == '/api/ephemeris_events':
ephemeris = _load_local_module('ephemeris_events')
try:
result = ephemeris.build_ephemeris_events(body)
except ephemeris.EphemerisEventsError as exc:
raise BadRequest(str(exc)) from exc
self._json(result)
elif path == '/api/declared_window_chart':
result = self._compute_declared_window_chart(body)
self._json(result)
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""Native 七政四余 adapter over vendored @4n6h4x0r/stem-branch 0.8.0.
Only the seven-governors library entry is invoked. The CLI subcommands
--pillars, --luck, --polaris, --qimen, and --liuren are never called.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
CLI_PATH = REPO_ROOT / "vendor" / "stem-branch" / "dist" / "cli.cjs"
LIB_PATH = REPO_ROOT / "vendor" / "stem-branch" / "dist" / "index.cjs"
ENGINE_TIMEOUT_SECONDS = 20
COORDINATE_SYSTEM = "qizheng_mansion_degrees_from_jiao"
DEFAULT_KETU_MODE = "apogee"
DEFAULT_SIDEREAL_MODE: dict[str, Any] = {"type": "modern"}
ENGINE_NAME = "@4n6h4x0r/stem-branch"
ENGINE_VERSION = "0.8.0"
REQUIRED_BODIES = (
"sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn",
"rahu", "ketu", "yuebei", "purpleQi",
)
ALLOWED_KETU_MODES = frozenset({"apogee", "descending-node"})
FORBIDDEN_CLI_FLAGS = frozenset({
"--pillars", "--luck", "--polaris", "--qimen", "--liuren", "--chuanren",
})
DIGNITY_CLOSURE = {
"status": "unclosed",
"may_enter_conclusions": False,
"runtime_promotable_count": 0,
"closed_cells": 9,
"total_cells": 132,
"closure_ref": "references/oracle/qizheng_runtime_truth_closure_status_2026_08_29.json",
}
BOUNDARY = (
"已生成七政、四余、二十八宿、十二宫、命宫与相位。"
"宿度自角宿初度起算,不是 Raman/Lahiri 恒星黄经,也不是回归黄道;"
"三套坐标系不得互相换算,也不得叠加成双重印证。"
"庙旺判定未闭合(十一体×十二宫共 132 格仅 9 格有直接证据,"
"runtime_promotable_count = 0),不得进入解读、报告或咨询结论。"
)
_NODE_EVAL = (
"const fs=require('fs');"
"const payload=JSON.parse(fs.readFileSync(0,'utf8'));"
"const lib=require(payload.library);"
"const chart=lib.getSevenGovernorsChart("
"new Date(payload.iso),"
"{lat:payload.lat,lon:payload.lon},"
"{ketuMode:payload.ketuMode,siderealMode:payload.siderealMode}"
");"
"process.stdout.write(JSON.stringify(chart));"
)
class QizhengChartError(ValueError):
"""Structured seven-governors adapter failure. Callers map this to HTTP 400."""
def __init__(self, message: str, *, error_code: str = "ERR_QIZHENG_ENGINE") -> None:
super().__init__(message)
self.error_code = error_code
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 QizhengChartError(f"{keys[0]} is required", error_code="ERR_QIZHENG_INPUT")
return None
def _as_int(value: Any, key: str) -> int:
try:
return int(value)
except (TypeError, ValueError) as exc:
raise QizhengChartError(f"{key} must be an integer", error_code="ERR_QIZHENG_INPUT") from exc
def _as_float(value: Any, key: str) -> float:
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise QizhengChartError(f"{key} must be a number", error_code="ERR_QIZHENG_INPUT") from exc
if number != number or number in {float("inf"), float("-inf")}:
raise QizhengChartError(f"{key} must be finite", error_code="ERR_QIZHENG_INPUT")
return number
def _iso_local(year: int, month: int, day: int, hour: int, minute: int, second: int, tz: float) -> str:
"""ISO local civil time with numeric offset. Correct for astronomy; unsafe for pillars."""
total_minutes = int(round(tz * 60))
sign = "+" if total_minutes >= 0 else "-"
abs_minutes = abs(total_minutes)
offset_hours, offset_minutes = divmod(abs_minutes, 60)
return (
f"{year:04d}-{month:02d}-{day:02d}T{hour:02d}:{minute:02d}:{second:02d}"
f"{sign}{offset_hours:02d}:{offset_minutes:02d}"
)
def _ketu_mode(raw: Any) -> str:
if raw in (None, ""):
return DEFAULT_KETU_MODE
key = str(raw).strip().lower().replace("_", "-")
if key not in ALLOWED_KETU_MODES:
raise QizhengChartError("ketu_mode must be apogee or descending-node", error_code="ERR_QIZHENG_INPUT")
return key
def _sidereal_mode(raw: Any) -> dict[str, Any]:
if raw in (None, ""):
return dict(DEFAULT_SIDEREAL_MODE)
if isinstance(raw, str):
key = raw.strip().lower()
if key == "modern":
return {"type": "modern"}
if key in {"classical", "kaiyuan"}:
return {"type": "classical", "epoch": "kaiyuan"}
if key == "chongzhen":
return {"type": "classical", "epoch": "chongzhen"}
raise QizhengChartError("sidereal_mode is not supported", error_code="ERR_QIZHENG_INPUT")
if not isinstance(raw, dict):
raise QizhengChartError("sidereal_mode is not supported", error_code="ERR_QIZHENG_INPUT")
kind = str(raw.get("type") or "").strip().lower()
if kind == "modern":
return {"type": "modern"}
if kind == "classical":
epoch = raw.get("epoch", "kaiyuan")
if epoch not in {"kaiyuan", "chongzhen"} and not isinstance(epoch, (int, float)):
raise QizhengChartError("sidereal_mode epoch is not supported", error_code="ERR_QIZHENG_INPUT")
return {"type": "classical", "epoch": epoch}
if kind == "ayanamsa":
try:
return {"type": "ayanamsa", "value": float(raw.get("value"))}
except (TypeError, ValueError) as exc:
raise QizhengChartError("sidereal_mode value must be a number", error_code="ERR_QIZHENG_INPUT") from exc
raise QizhengChartError("sidereal_mode is not supported", error_code="ERR_QIZHENG_INPUT")
def _sanitize_raw(value: Any) -> Any:
if isinstance(value, dict):
dropped = {"cwd", "path", "argv", "command", "stderr", "stdout", "library", "cli", "executable"}
return {key: _sanitize_raw(item) for key, item in value.items() if key not in dropped}
if isinstance(value, list):
return [_sanitize_raw(item) for item in value]
if isinstance(value, str) and ("\\" in value or value.startswith("/")) and ("vendor" in value or "stem-branch" in value):
return "[redacted-path]"
return value
def _node_bin() -> str:
found = shutil.which("node") or shutil.which("node.exe")
if not found:
raise QizhengChartError("node runtime is not available", error_code="ERR_QIZHENG_NODE_MISSING")
return found
def _run_engine(payload: dict[str, Any]) -> dict[str, Any]:
if not CLI_PATH.is_file() or not LIB_PATH.is_file():
raise QizhengChartError("vendored seven-governors engine is missing", error_code="ERR_QIZHENG_CLI_MISSING")
node_bin = _node_bin()
argv = [node_bin, "-e", _NODE_EVAL]
if FORBIDDEN_CLI_FLAGS.intersection(argv):
raise QizhengChartError("forbidden engine subcommand blocked", error_code="ERR_QIZHENG_ENGINE")
try:
completed = subprocess.run(
argv,
input=json.dumps(payload, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
timeout=ENGINE_TIMEOUT_SECONDS,
cwd=str(REPO_ROOT),
check=False,
)
except FileNotFoundError as exc:
raise QizhengChartError("node runtime is not available", error_code="ERR_QIZHENG_NODE_MISSING") from exc
except subprocess.TimeoutExpired as exc:
raise QizhengChartError("seven-governors engine timed out", error_code="ERR_QIZHENG_TIMEOUT") from exc
if completed.returncode != 0:
raise QizhengChartError("seven-governors engine exited with an error", error_code="ERR_QIZHENG_ENGINE_EXIT")
stdout = (completed.stdout or "").strip()
if not stdout:
raise QizhengChartError("seven-governors engine returned empty output", error_code="ERR_QIZHENG_BAD_JSON")
try:
parsed = json.loads(stdout)
except json.JSONDecodeError as exc:
raise QizhengChartError("seven-governors engine returned non-JSON output", error_code="ERR_QIZHENG_BAD_JSON") from exc
if not isinstance(parsed, dict):
raise QizhengChartError("seven-governors engine returned non-JSON output", error_code="ERR_QIZHENG_BAD_JSON")
return parsed
def build_qizheng_natal_chart(body: dict[str, Any] | None) -> dict[str, Any]:
if not isinstance(body, dict):
raise QizhengChartError("JSON body must be an object", error_code="ERR_QIZHENG_INPUT")
year = _as_int(_field(body, "year"), "year")
month = _as_int(_field(body, "month"), "month")
day = _as_int(_field(body, "day"), "day")
hour = _as_int(_field(body, "hour"), "hour")
minute = _as_int(_field(body, "minute"), "minute")
second = _as_int(_field(body, "second", required=False) or 0, "second")
lat = _as_float(_field(body, "lat", "latitude"), "lat")
lon = _as_float(_field(body, "lon", "longitude", "lng"), "lon")
tz = _as_float(_field(body, "tz", "timezone"), "tz")
ketu_mode = _ketu_mode(body.get("ketu_mode", body.get("ketuMode")))
sidereal_mode = _sidereal_mode(body.get("sidereal_mode", body.get("siderealMode")))
iso = _iso_local(year, month, day, hour, minute, second, tz)
raw = _run_engine({
"library": os.fspath(LIB_PATH),
"iso": iso,
"lat": lat,
"lon": lon,
"ketuMode": ketu_mode,
"siderealMode": sidereal_mode,
})
bodies = raw.get("bodies") if isinstance(raw.get("bodies"), dict) else {}
palaces = raw.get("palaces") if isinstance(raw.get("palaces"), list) else []
missing = [name for name in REQUIRED_BODIES if name not in bodies]
if missing:
raise QizhengChartError("seven-governors engine returned an incomplete chart", error_code="ERR_QIZHENG_ENGINE")
if len(palaces) != 12:
raise QizhengChartError("seven-governors engine returned an incomplete chart", error_code="ERR_QIZHENG_ENGINE")
if not isinstance(raw.get("ascendant"), dict):
raise QizhengChartError("seven-governors engine returned an incomplete chart", error_code="ERR_QIZHENG_ENGINE")
dignities = dict(DIGNITY_CLOSURE)
dignities["values"] = raw.get("dignities") if isinstance(raw.get("dignities"), dict) else {}
return {
"success": True,
"endpoint": "qizheng",
"coordinate_system": COORDINATE_SYSTEM,
"calculation": {
"engine": ENGINE_NAME,
"engine_version": ENGINE_VERSION,
"ketu_mode": ketu_mode,
"sidereal_mode": sidereal_mode,
"engine_ketu_mode": raw.get("ketuMode"),
"engine_sidereal_mode": raw.get("siderealMode"),
"iso_local": iso,
"timeout_seconds": ENGINE_TIMEOUT_SECONDS,
},
"bodies": bodies,
"palaces": palaces,
"ascendant": raw.get("ascendant"),
"aspects": raw.get("aspects") if isinstance(raw.get("aspects"), list) else [],
"dignities": dignities,
"boundary": BOUNDARY,
"raw_engine_output": _sanitize_raw(raw),
}
+5
View File
@@ -75,6 +75,11 @@ CORE_PYTEST_TARGETS = [
"tests/test_birth_time_journey_contract.py",
# Freeze scripts/jyotish_api_server.py growth; new features must be modules.
"tests/test_api_server_growth_contract.py",
# Native seven-governors adapter and the three read-only chart endpoints.
"tests/test_qizheng_chart_engine.py",
"tests/test_qizheng_api_productization.py",
"tests/test_readonly_chart_endpoints.py",
"tests/test_ephemeris_events.py",
# Fail-fast heavy-compute concurrency gate (429 + Retry-After, health ungated).
"tests/test_api_heavy_compute_gate.py",
# Upstream-sync acceptance regressions must fail the automatic staging gate.
+48
View File
@@ -21,6 +21,10 @@ try:
from western_evidence_packet import build_western_evidence_packet
except ImportError: # pragma: no cover - package import path
from scripts.western_evidence_packet import build_western_evidence_packet
try:
from ayanamsa_utils import DEFAULT_AYANAMSA_NAME, ayanamsa_display_name, temporary_ayanamsa
except ImportError: # pragma: no cover - package import path
from scripts.ayanamsa_utils import DEFAULT_AYANAMSA_NAME, ayanamsa_display_name, temporary_ayanamsa
_PLANETS = {
@@ -213,6 +217,50 @@ def build_tropical_natal_chart(
}
def _request_field(body: dict[str, Any], *keys: str) -> Any:
for key in keys:
if key in body and body[key] not in (None, ""):
return body[key]
raise ValueError(f"{keys[0]} is required")
def build_tropical_natal_from_request(body: dict[str, Any]) -> dict[str, Any]:
"""Read-only tropical natal chart for POST /api/western."""
if not isinstance(body, dict):
raise ValueError("JSON body must be an object")
house_system = str(body.get("house_system") or body.get("houseSystem") or "P").strip() or "P"
chart = build_tropical_natal_chart(
year=int(_request_field(body, "year")),
month=int(_request_field(body, "month")),
day=int(_request_field(body, "day")),
hour=int(_request_field(body, "hour")),
minute=int(_request_field(body, "minute")),
second=int(body.get("second") or 0),
latitude=float(_request_field(body, "lat", "latitude")),
longitude=float(_request_field(body, "lon", "longitude", "lng")),
timezone=_request_field(body, "tz", "timezone"),
house_system=house_system,
)
jd_ut = float(chart["calculation_contract"]["julian_day_ut"])
with temporary_ayanamsa(DEFAULT_AYANAMSA_NAME):
ayanamsa_degrees = round(float(swe.get_ayanamsa_ut(jd_ut)), 6)
chart["coordinate_system"] = "tropical_ecliptic"
chart["sidereal_offset_degrees"] = {
"reference": DEFAULT_AYANAMSA_NAME,
"display": ayanamsa_display_name(DEFAULT_AYANAMSA_NAME),
"degrees": ayanamsa_degrees,
}
chart["boundary"] = (
"这是回归黄道(分点黄经),不是恒星黄道,也不是七政宿度。"
f"{ayanamsa_display_name(DEFAULT_AYANAMSA_NAME)} 岁差在此刻约 {ayanamsa_degrees:.4f}°。"
"三套坐标系不得互相换算,也不得叠加成双重印证。"
" Natal tropical calculation only; it does not calculate transits, progressions, solar arcs, returns, or interpretation."
)
chart["success"] = True
chart["endpoint"] = "western"
return chart
def build_tropical_western_evidence_packet(*, route_packet: dict[str, Any], **birth: Any) -> dict[str, Any]:
"""Wrap direct natal calculation in the existing cross-system packet contract."""
chart = build_tropical_natal_chart(**birth)