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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
This commit is contained in:
+9
-9
@@ -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 | 存过别人的账户进首页即「页面出错了」
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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``.
|
||||
"""
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user