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:
2026-11-01 23:29:30 +08:00
co-authored by Claude Opus 5.5
parent 0f1c179eb6
commit eb5e9adcf9
7 changed files with 332 additions and 104 deletions
+6 -94
View File
@@ -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:
+2
View File
@@ -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 = [
+5
View File
@@ -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``.
"""
+154
View File
@@ -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,
}