From eb5e9adcf93c77801a480dce270aba63461fe143 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 23:29:30 +0800 Subject: [PATCH] fix(shadbala): move /api/shadbala into shadbala_service; birth data no longer 500 (BUG-1463) _compute_shadbala called swe.julday with no swe in scope, so any request with year/month/day/lat/lon raised NameError. The endpoint now lives in scripts/shadbala_service/api.py, which imports Swiss Ephemeris itself; the handler only forwards. _shadbala_rule_variants and _compute_shadbala_advanced_layer moved with it (handler methods 217 -> 215, baseline lowered). New real-HTTP regression test with ruff F821 on the server, added to the quick gate. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y --- docs/BUG_HISTORY.md | 18 +-- scripts/jyotish_api_server.py | 100 +-------------- scripts/run_quality_gate.py | 2 + scripts/shadbala_service/__init__.py | 5 + scripts/shadbala_service/api.py | 154 +++++++++++++++++++++++ tests/test_api_server_growth_contract.py | 6 +- tests/test_shadbala_service_api.py | 151 ++++++++++++++++++++++ 7 files changed, 332 insertions(+), 104 deletions(-) create mode 100644 scripts/shadbala_service/__init__.py create mode 100644 scripts/shadbala_service/api.py create mode 100644 tests/test_shadbala_service_api.py diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index ac9a9307..a96ad30d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -19935,19 +19935,19 @@ ## BUG-1463 | 单独调 `/api/shadbala` 带出生资料时 500 -- 状态:investigating +- 状态:resolved - 首次发现:2026-10-27(做 issue #114 时调接口发现) -- 最近更新:2026-10-27 -- 影响面:直接请求 `/api/shadbala` 并带 year/month/day/lat/lon 的调用方。星盘页不受影响:六力取自 `/api/chart` 的 `modules.shadbala`。 +- 最近更新:2026-11-01 +- 影响面:直接请求 `/api/shadbala` 并带 year/month/day/lat/lon 的调用方。星盘页不受影响:六力取自 `/api/chart` 的 `modules.shadbala`。进程内的两处调用(咨询的 `_attach_local_consultation_layers`、专题的 `_derive_thematic_evidence`)只在图里还没有 `modules.shadbala` 时才走这里,以前会被各自的 `except` 记成 `unavailable` / `warning`,不报 500。 - 用户现象:接口返回 `ERR_INTERNAL`。 - 触发条件:请求体同时带 `year`、`month`、`day`、`lat`、`lon`。 -- 根因:`scripts/jyotish_api_server.py` `_compute_shadbala` 调 `swe.julday`,但该文件只在别的函数内部局部 `import swisseph as swe`,模块级没有 `swe`,抛 `NameError`。 -- 修复:未修。主文件受增长冻结约束,修复单需另开(把这段逻辑移到 `scripts/` 下的模块里再改)。 -- 验证:本机 `POST /api/shadbala` 带出生资料 → 500,日志 `NameError: name 'swe' is not defined`。 -- 防复发:待修复单补接口测试。 -- 相关记录:issue #114。 +- 根因:`scripts/jyotish_api_server.py` `_compute_shadbala` 调 `swe.julday`,但该文件只在别的函数内部局部 `import swisseph as swe`,模块级没有 `swe`,抛 `NameError`。整份文件只有这一处(`ruff --select F821` 实测只报 7564 行)。只带星体经度的旧测试走不到这条分支,所以一直没拦住。 +- 修复:新建 `scripts/shadbala_service/api.py`,模块顶部 `import swisseph as swe`,把 `_compute_shadbala` 的出生时刻分支、`_shadbala_rule_variants`、`_compute_shadbala_advanced_layer` 原样移过去。主文件的 `_compute_shadbala` 只转发,并把不支持的 ayanamsa 转成 400。主文件四空格方法 217 → 215,`tests/test_api_server_growth_contract.py` 基线同步降到 215。 +- 验证:`tests/test_shadbala_service_api.py` 在本机端口起真实 `JyotishAPIHandler`,POST 金盘出生资料(1990-06-15 12:00 北京,raman,虚构)→ 200;`method_variants` 为精确 Kala / Chesta;七星 Dig Bala 等于同一时刻 `build_shadbala_context` 的宫中点算出的值。只带星体时仍等于 `chart-insight-golden.json` 的 `chart_modules_shadbala`。坏 ayanamsa、缺日月 → 400。把主文件换回修复前版本,同一测试得到 500 和 `NameError: name 'swe' is not defined`。 +- 防复发:上面的接口测试已加进 `scripts/run_quality_gate.py` 的 quick 清单;同一文件里还对 `jyotish_api_server.py`、`offline_compute_mixins.py`、`scripts/shadbala_service/` 跑 `ruff --select F821`,再有用了没导入的名字就失败。 +- 相关记录:issue #114;Gitea #119 第 46 项。 - 复发自:无 -- 修复版本:分支 `codex/rectification-d60-two-way-20261028`。 +- 修复版本:分支 `codex/outage-shadbala-119`。 ## BUG-1464 | 存过别人的账户进首页即「页面出错了」 diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index d169517e..6816fed4 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -7545,41 +7545,12 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin): } def _compute_shadbala(self, body): - planets, planet_lons, asc_sign_idx = self._normalized_planets_from_body(body) - if not {'Sun', 'Moon'} <= set(planet_lons): - raise BadRequest('planets must include Sun and Moon longitude data') - birth_hour = self._get_float(body, 'birth_hour', body.get('hour', 12), 0, 23) - birth_minute = self._get_float(body, 'birth_minute', body.get('minute', 0), 0, 59) - birth_second = self._get_birth_second(body) - birth_hour_decimal = self._birth_hour_decimal(birth_hour, birth_minute, birth_second) - shadbala_module = _load_local_module('shadbala') - context = None - if all(key in body for key in ('year', 'month', 'day', 'lat', 'lon')): - year = self._get_int(body, 'year', 1990, 1800, 2400) - month = self._get_int(body, 'month', 6, 1, 12) - day = self._get_int(body, 'day', 15, 1, 31) - lat = self._get_float(body, 'lat', 0, -90, 90) - lon = self._get_float(body, 'lon', 0, -180, 180) - tz = self._parse_timezone(body, lat, lon, year, month, day, birth_hour, birth_minute, birth_second) - jd = swe.julday(year, month, day, birth_hour_decimal - tz) - context = shadbala_module.build_shadbala_context(jd, lat, lon, _request_ayanamsa(body), tz) - result = shadbala_module.calc_shadbala( - planets, - SIGNS[asc_sign_idx], - birth_hour_decimal, - planet_lons['Sun'], - planet_lons['Moon'], - birth_minute, - context=context, - ) - advanced = self._compute_shadbala_advanced_layer(body, planets, result) - return { - 'success': True, - 'endpoint': 'shadbala', - 'rule_variants': self._shadbala_rule_variants(bool(advanced)), - 'advanced_layer': advanced, - 'result': result, - } + """Thin registration of scripts/shadbala_service/api.py (BUG-1463).""" + shadbala_api = _load_local_module('shadbala_service.api') + try: + return shadbala_api.compute_shadbala(self, body) + except shadbala_api.ShadbalaInputError as exc: + raise BadRequest(str(exc)) from exc def _compute_yogas_api(self, body): planets, _, asc_sign_idx = self._normalized_planets_from_body(body) @@ -7624,16 +7595,6 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin): 'boundary': '当前按本地 BAV 贡献规则生成 SAV、PAV、Sodhita 与 Yoga Pinda;后续可继续加入 Sarvashtakavarga 规则版本对比。', } - def _shadbala_rule_variants(self, advanced_enabled): - return { - 'selected': ['core_sixfold', 'advanced_evidence'] if advanced_enabled else ['core_sixfold'], - 'available': [ - {'key': 'core_sixfold', 'label': '六重力量主算法', 'status': 'active', 'source': 'scripts/shadbala.py'}, - {'key': 'advanced_evidence', 'label': 'Kala/Yuddha/Sputa 增强证据', 'status': 'active' if advanced_enabled else 'unavailable', 'source': 'scripts/shadbala_advanced.py'}, - ], - 'boundary': '增强层只作为证据补充,不覆盖主 Shadbala 的总分与排名。', - } - def _yoga_rule_variants(self, curse_enabled): return { 'selected': ['extended_algorithm', 'json_rule_engine'] + (['curse_conjunctions'] if curse_enabled else []), @@ -7673,55 +7634,6 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin): legacy[planet] = row return legacy - def _compute_shadbala_advanced_layer(self, body, planets, base_result): - try: - advanced_mod = _load_local_module('shadbala_advanced') - birth_hour = self._get_float(body, 'birth_hour', body.get('hour', 12), 0, 23) - birth_minute = self._get_float(body, 'birth_minute', body.get('minute', 0), 0, 59) - birth_second = self._get_birth_second(body) - year = self._get_int(body, 'year', datetime.now().year, 1800, 2400) - month = self._get_int(body, 'month', 1, 1, 12) - day = self._get_int(body, 'day', 1, 1, 31) - lat = self._get_float(body, 'lat', body.get('birth_lat', 0), -90, 90) - lon = self._get_float(body, 'lon', body.get('birth_lon', 0), -180, 180) - tz = self._parse_timezone(body, lat, lon, year, month, day, birth_hour, birth_minute, birth_second) - hour_decimal = self._birth_hour_decimal(birth_hour, birth_minute, birth_second) - solar_lon = planets.get('Sun', {}).get('lon', 0) - base_planets = base_result.get('planets', {}) if isinstance(base_result, dict) else {} - comparison_planets = {} - kala_additions = {} - drik_sputa = {} - for planet, pdata in planets.items(): - if planet not in base_planets: - continue - comparison_planets[planet] = { - **pdata, - 'degree': pdata.get('lon', pdata.get('degree', 0)), - 'shadbala_total': base_planets.get(planet, {}).get('total_virupas', 0), - } - for planet in comparison_planets: - kala_additions[planet] = round(advanced_mod.calc_varsha_maasa_dina_hora_bala( - planet, year, month, day, hour_decimal, solar_lon, lat, lon, tz - ), 2) - drik_sputa[planet] = round(advanced_mod.calc_drik_bala_sputa(planet, comparison_planets), 2) - yuddha = advanced_mod.calc_yuddha_bala(comparison_planets) - active_yuddha = {planet: value for planet, value in yuddha.items() if abs(value) > 0} - top_kala = sorted(kala_additions.items(), key=lambda item: item[1], reverse=True)[:3] - return { - 'source': 'scripts/shadbala_advanced.py', - 'method': 'Kala Bala完整子项 + Yuddha Bala + Sputa Drishti 证据层', - 'kala_vmdh': kala_additions, - 'top_kala_support': [{'planet': planet, 'virupas': value} for planet, value in top_kala], - 'yuddha_bala': yuddha, - 'active_yuddha': active_yuddha, - 'sputa_drik_bala': drik_sputa, - 'next_action': '若高级层与主排名冲突,先保留主 Shadbala 排名,再把冲突作为需要人工复核的证据。', - } - except Exception as exc: - import logging - logging.warning(f"[api_server] shadbala advanced layer failed: {exc}") - return {} - def _compute_aspects(self, body): planets, planet_lons = self._planet_lons_from_body(body) if len(planet_lons) < 2: diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index 61c3630b..dc4d7e3c 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -137,6 +137,8 @@ CORE_PYTEST_TARGETS = [ "tests/test_runtime_jhora_import_scan.py", # English edition han leak, 12-chart slice (issue #89 / BUG-1413). The 200-chart file stays off this list. "tests/test_report_english_sweep_smoke.py", + # /api/shadbala with the birth moment over real HTTP, plus ruff F821 on the API server (BUG-1463). + "tests/test_shadbala_service_api.py", ] RUNTIME_TRUTH_PYTEST_TARGETS = [ diff --git a/scripts/shadbala_service/__init__.py b/scripts/shadbala_service/__init__.py new file mode 100644 index 00000000..209daf0e --- /dev/null +++ b/scripts/shadbala_service/__init__.py @@ -0,0 +1,5 @@ +"""Shadbala endpoint service (BUG-1463). + +- ``api``: what ``scripts/jyotish_api_server.py`` registers thinly for + ``/api/shadbala`` and the in-process callers of ``_compute_shadbala``. +""" diff --git a/scripts/shadbala_service/api.py b/scripts/shadbala_service/api.py new file mode 100644 index 00000000..df196627 --- /dev/null +++ b/scripts/shadbala_service/api.py @@ -0,0 +1,154 @@ +"""`/api/shadbala`: the six-fold strength core plus the advanced evidence layer. + +Moved out of ``JyotishAPIHandler`` for BUG-1463. The handler method used +``swe.julday`` with no ``swe`` in scope, so every request carrying +year/month/day/lat/lon failed with ``NameError`` (HTTP 500). This module +imports Swiss Ephemeris itself; the handler only forwards the body. + +The handler is passed in for its existing body parsers (``_get_float``, +``_parse_timezone`` ...). They raise the server's ``BadRequest`` and that +propagates unchanged. This module never imports the HTTP server. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import swisseph as swe + +from ayanamsa_utils import DEFAULT_AYANAMSA_NAME, UnsupportedAyanamsaError, normalize_ayanamsa_name + +SIGNS = ( + "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", +) +BIRTH_KEYS = ("year", "month", "day", "lat", "lon") + +logger = logging.getLogger(__name__) + + +class ShadbalaInputError(ValueError): + """The request names an ayanamsa the engine does not support.""" + + +def _ayanamsa(body: dict[str, Any]) -> str: + """Same reading as the server's ``_request_ayanamsa(body)``.""" + raw = body.get("ayanamsa") or body.get("ayanamsa_name") or body.get("ayanamsa_policy") + if raw in (None, ""): + return DEFAULT_AYANAMSA_NAME + try: + return normalize_ayanamsa_name(raw) + except UnsupportedAyanamsaError as exc: + raise ShadbalaInputError(str(exc)) from exc + + +def birth_context(handler: Any, body: dict[str, Any], birth_hour: float, birth_minute: float, birth_second: float) -> dict[str, Any] | None: + """Precise Swiss Ephemeris context when the body carries the full birth moment.""" + if not all(key in body for key in BIRTH_KEYS): + return None + import shadbala + + year = handler._get_int(body, "year", 1990, 1800, 2400) + month = handler._get_int(body, "month", 6, 1, 12) + day = handler._get_int(body, "day", 15, 1, 31) + lat = handler._get_float(body, "lat", 0, -90, 90) + lon = handler._get_float(body, "lon", 0, -180, 180) + tz = handler._parse_timezone(body, lat, lon, year, month, day, birth_hour, birth_minute, birth_second) + hour_decimal = handler._birth_hour_decimal(birth_hour, birth_minute, birth_second) + jd_ut = swe.julday(year, month, day, hour_decimal - tz) + return shadbala.build_shadbala_context(jd_ut, lat, lon, _ayanamsa(body), tz) + + +def rule_variants(advanced_enabled: bool) -> dict[str, Any]: + return { + "selected": ["core_sixfold", "advanced_evidence"] if advanced_enabled else ["core_sixfold"], + "available": [ + {"key": "core_sixfold", "label": "六重力量主算法", "status": "active", "source": "scripts/shadbala.py"}, + {"key": "advanced_evidence", "label": "Kala/Yuddha/Sputa 增强证据", "status": "active" if advanced_enabled else "unavailable", "source": "scripts/shadbala_advanced.py"}, + ], + "boundary": "增强层只作为证据补充,不覆盖主 Shadbala 的总分与排名。", + } + + +def advanced_layer(handler: Any, body: dict[str, Any], planets: dict[str, Any], base_result: Any) -> dict[str, Any]: + """Kala sub-items, Yuddha and Sputa Drishti evidence; ``{}`` when it cannot be built.""" + from datetime import datetime + + try: + import shadbala_advanced as advanced_mod + + birth_hour = handler._get_float(body, "birth_hour", body.get("hour", 12), 0, 23) + birth_minute = handler._get_float(body, "birth_minute", body.get("minute", 0), 0, 59) + birth_second = handler._get_birth_second(body) + year = handler._get_int(body, "year", datetime.now().year, 1800, 2400) + month = handler._get_int(body, "month", 1, 1, 12) + day = handler._get_int(body, "day", 1, 1, 31) + lat = handler._get_float(body, "lat", body.get("birth_lat", 0), -90, 90) + lon = handler._get_float(body, "lon", body.get("birth_lon", 0), -180, 180) + tz = handler._parse_timezone(body, lat, lon, year, month, day, birth_hour, birth_minute, birth_second) + hour_decimal = handler._birth_hour_decimal(birth_hour, birth_minute, birth_second) + solar_lon = planets.get("Sun", {}).get("lon", 0) + base_planets = base_result.get("planets", {}) if isinstance(base_result, dict) else {} + comparison_planets = {} + kala_additions = {} + drik_sputa = {} + for planet, pdata in planets.items(): + if planet not in base_planets: + continue + comparison_planets[planet] = { + **pdata, + "degree": pdata.get("lon", pdata.get("degree", 0)), + "shadbala_total": base_planets.get(planet, {}).get("total_virupas", 0), + } + for planet in comparison_planets: + kala_additions[planet] = round(advanced_mod.calc_varsha_maasa_dina_hora_bala( + planet, year, month, day, hour_decimal, solar_lon, lat, lon, tz + ), 2) + drik_sputa[planet] = round(advanced_mod.calc_drik_bala_sputa(planet, comparison_planets), 2) + yuddha = advanced_mod.calc_yuddha_bala(comparison_planets) + active_yuddha = {planet: value for planet, value in yuddha.items() if abs(value) > 0} + top_kala = sorted(kala_additions.items(), key=lambda item: item[1], reverse=True)[:3] + return { + "source": "scripts/shadbala_advanced.py", + "method": "Kala Bala完整子项 + Yuddha Bala + Sputa Drishti 证据层", + "kala_vmdh": kala_additions, + "top_kala_support": [{"planet": planet, "virupas": value} for planet, value in top_kala], + "yuddha_bala": yuddha, + "active_yuddha": active_yuddha, + "sputa_drik_bala": drik_sputa, + "next_action": "若高级层与主排名冲突,先保留主 Shadbala 排名,再把冲突作为需要人工复核的证据。", + } + except Exception as exc: + logger.warning("[api_server] shadbala advanced layer failed: %s", exc) + return {} + + +def compute_shadbala(handler: Any, body: dict[str, Any]) -> dict[str, Any]: + import shadbala + + planets, planet_lons, asc_sign_idx = handler._normalized_planets_from_body(body) + if not {"Sun", "Moon"} <= set(planet_lons): + raise ShadbalaInputError("planets must include Sun and Moon longitude data") + birth_hour = handler._get_float(body, "birth_hour", body.get("hour", 12), 0, 23) + birth_minute = handler._get_float(body, "birth_minute", body.get("minute", 0), 0, 59) + birth_second = handler._get_birth_second(body) + birth_hour_decimal = handler._birth_hour_decimal(birth_hour, birth_minute, birth_second) + context = birth_context(handler, body, birth_hour, birth_minute, birth_second) + result = shadbala.calc_shadbala( + planets, + SIGNS[asc_sign_idx], + birth_hour_decimal, + planet_lons["Sun"], + planet_lons["Moon"], + birth_minute, + context=context, + ) + advanced = advanced_layer(handler, body, planets, result) + return { + "success": True, + "endpoint": "shadbala", + "rule_variants": rule_variants(bool(advanced)), + "advanced_layer": advanced, + "result": result, + } diff --git a/tests/test_api_server_growth_contract.py b/tests/test_api_server_growth_contract.py index 7b3cccf9..d8007a82 100644 --- a/tests/test_api_server_growth_contract.py +++ b/tests/test_api_server_growth_contract.py @@ -38,7 +38,11 @@ JYOTISH_API_SERVER_LINE_COUNT_CAP = JYOTISH_API_SERVER_LINE_COUNT_BASELINE + 300 # 新值:217 # 原因:BUG-1341 删除 JyotishAPIHandler._fallback_dasha_periods,类方法少 1 个,没有新增方法 # 本单数值未变,仍是 217:只把 HTTP 方法换到子类,不增删方法。 -JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE = 217 +# 原值:217 +# 新值:215 +# 原因:BUG-1463 把 /api/shadbala 移到 scripts/shadbala_service/api.py, +# 删除 _shadbala_rule_variants 与 _compute_shadbala_advanced_layer,_compute_shadbala 只转发 +JYOTISH_API_HANDLER_METHOD_COUNT_BASELINE = 215 # 原值:31 # 新值:5 diff --git a/tests/test_shadbala_service_api.py b/tests/test_shadbala_service_api.py new file mode 100644 index 00000000..5426c4ef --- /dev/null +++ b/tests/test_shadbala_service_api.py @@ -0,0 +1,151 @@ +"""BUG-1463: `/api/shadbala` with the birth moment returned 500 (`NameError: swe`). + +The endpoint now lives in scripts/shadbala_service/api.py and the handler only +forwards the body. These tests go through the real HTTP handler on a local +port, so the regression is caught at the same surface the caller saw. +Birth data is the fictional golden profile shared with the frontend tests +(1990-06-15 12:00 Beijing, raman). +""" + +from __future__ import annotations + +import json +import socket +import subprocess +import sys +import threading +import urllib.error +import urllib.request +from http.server import ThreadingHTTPServer +from pathlib import Path +from typing import Any + +import pytest +import swisseph as swe + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +for path in (ROOT, SCRIPTS): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from scripts.api_heavy_compute_gate import reset_heavy_compute_gate # noqa: E402 +from scripts.shadbala import build_shadbala_context, calc_dig_bala_precise # noqa: E402 + +GOLDEN = json.loads((ROOT / "frontend" / "tests" / "fixtures" / "chart-insight-golden.json").read_text(encoding="utf-8")) +PLANETS = GOLDEN["chart"]["planets"] +ASCENDANT = GOLDEN["chart"]["ascendant"] +BIRTH = { + "year": 1990, "month": 6, "day": 15, "hour": 12, "minute": 0, "second": 0, + "lat": 39.9042, "lon": 116.4074, "tz": 8, "ayanamsa": "raman", +} +SEVEN = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn") + + +@pytest.fixture(scope="module") +def api_base(): + import os + + from scripts.jyotish_api_server import DEFAULT_ALLOWED_HOSTS, DEFAULT_ALLOWED_ORIGINS, JyotishAPIHandler + + previous = os.environ.get("JYOTISH_API_RATE_LIMIT_PER_MINUTE") + os.environ["JYOTISH_API_RATE_LIMIT_PER_MINUTE"] = "0" + reset_heavy_compute_gate() + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + sock.close() + server = ThreadingHTTPServer(("127.0.0.1", port), JyotishAPIHandler) + server.daemon_threads = True + base = f"http://127.0.0.1:{port}" + server.allowed_origins = set(DEFAULT_ALLOWED_ORIGINS) | {base} + server.allowed_hosts = set(DEFAULT_ALLOWED_HOSTS) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield base + finally: + server.shutdown() + server.server_close() + reset_heavy_compute_gate() + if previous is None: + os.environ.pop("JYOTISH_API_RATE_LIMIT_PER_MINUTE", None) + else: + os.environ["JYOTISH_API_RATE_LIMIT_PER_MINUTE"] = previous + + +def _post(base: str, body: dict[str, Any]) -> tuple[int, dict[str, Any]]: + request = urllib.request.Request( + f"{base}/api/shadbala", + data=json.dumps(body).encode("utf-8"), + method="POST", + headers={"Content-Type": "application/json", "Origin": base, "Host": base.split("://", 1)[1]}, + ) + try: + with urllib.request.urlopen(request, timeout=120) as response: + return response.status, json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + return error.code, json.loads(error.read().decode("utf-8") or "{}") + + +def test_birth_data_request_returns_precise_shadbala(api_base) -> None: + status, payload = _post(api_base, {**BIRTH, "planets": PLANETS, "ascendant": ASCENDANT}) + + assert status == 200, payload + assert payload["success"] is True + result = payload["result"] + # The birth moment switches Kala and Chesta to the Swiss Ephemeris context. + assert result["method_variants"] == { + "kala": "bphs_local_solar_events_ahargana_declination", + "chesta": "bphs_bounded_surya_mean_motion_seeghrochcha", + } + assert sorted(result["ranking"]) == sorted(SEVEN) + for planet in SEVEN: + row = result["planets"][planet] + assert row["total_rupas"] > 0 + assert row["total_virupas"] == pytest.approx(row["total_rupas"] * 60, abs=0.05) + + # Dig Bala is read from the Placidus bhava midpoints of the same instant (UT = 12:00 - 8h). + context = build_shadbala_context(swe.julday(1990, 6, 15, 4.0), 39.9042, 116.4074, "raman", 8.0) + for planet in SEVEN: + expected = calc_dig_bala_precise(planet, float(PLANETS[planet]["lon"]), context["house_midpoints"]) + assert result["planets"][planet]["dig_bala"] == expected + + assert payload["rule_variants"]["selected"] == ["core_sixfold", "advanced_evidence"] + assert payload["advanced_layer"]["source"] == "scripts/shadbala_advanced.py" + assert set(payload["advanced_layer"]["kala_vmdh"]) == set(SEVEN) + + +def test_birth_data_changes_the_strength_versus_planets_only(api_base) -> None: + status, payload = _post(api_base, {"planets": PLANETS, "ascendant": ASCENDANT, "hour": 12, "minute": 0}) + assert status == 200, payload + legacy = payload["result"] + assert legacy["method_variants"]["kala"] == "legacy_approximation" + # Without the birth moment the answer is the chart page's `modules.shadbala` golden. + golden = GOLDEN["chart_modules_shadbala"]["planets"] + for planet in SEVEN: + assert legacy["planets"][planet]["total_rupas"] == pytest.approx(golden[planet]["total_rupas"], abs=1e-4) + + _, precise = _post(api_base, {**BIRTH, "planets": PLANETS, "ascendant": ASCENDANT}) + assert any( + precise["result"]["planets"][planet]["dig_bala"] != legacy["planets"][planet]["dig_bala"] + for planet in SEVEN + ) + + +def test_bad_inputs_are_400_not_500(api_base) -> None: + status, _ = _post(api_base, {**BIRTH, "planets": PLANETS, "ascendant": ASCENDANT, "ayanamsa": "not-an-ayanamsa"}) + assert status == 400 + status, _ = _post(api_base, {**BIRTH, "planets": {"Mars": PLANETS["Mars"]}, "ascendant": ASCENDANT}) + assert status == 400 + + +def test_shadbala_service_and_api_server_have_no_undefined_names() -> None: + """BUG-1463 was a module name used where it was never imported; ruff F821 sees that statically.""" + ruff = pytest.importorskip("ruff") + targets = ["scripts/jyotish_api_server.py", "scripts/offline_compute_mixins.py", "scripts/shadbala_service"] + completed = subprocess.run( + [ruff.find_ruff_bin(), "check", "--isolated", "--no-cache", "--select", "F821", *targets], + cwd=ROOT, capture_output=True, text=True, check=False, + ) + assert completed.returncode == 0, completed.stdout + completed.stderr