feat(annual): annual data package v1 (#152 ruling, phase 1 of #153)

POST /api/annual with mode=package (or /api/annual/package) returns one
versioned package built from the existing producers: year charts with
ascendant/lord, Muntha, Panchadhikari year lord, annual strength, Sahams,
Tajika yogas, Tripataki, Mudda, native Patyayini, Tajika monthly charts;
natal Vimshottari MD/AD/PD and Narayana (legacy + Rath); slow-planet
ingresses, stations, retrograde loops and natal-point crossings; Tithi
Pravesha as an independent view. Report range, year-chart validity and each
period system's own year are separate, every boundary is an exact instant
with timezone, and every block carries computed/blocked/not_applicable with
a reason. The default /api/annual response is unchanged.

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 17:07:54 +08:00
co-authored by Claude Opus 5.5
parent 52f85a793f
commit 340f0f2b85
8 changed files with 6319 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+57
View File
@@ -7733,6 +7733,8 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
}
def _compute_annual(self, body):
if isinstance(body, dict) and body.get('mode') == 'package':
return self._compute_annual_package(body)
target_year = self._get_int(body, 'target_year', datetime.now().year, 1800, 2400)
birth_dt = self._parse_birth_datetime(body)
lat = self._get_float(body, 'lat', body.get('birth_lat', 0), -90, 90)
@@ -7760,9 +7762,60 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
tz,
target_year,
ayanamsa_name=_request_ayanamsa(body), birth_second=birth_dt.second,
node_mode=_request_node_mode(body),
)
return {'success': True, 'endpoint': 'annual', 'report': report}
def _compute_annual_package(self, body):
"""Annual data package v1 (#152 ruling 2026-11-01): `mode: "package"` or /api/annual/package.
The default `/api/annual` response (basic solar-return report) keeps its shape.
"""
if not isinstance(body, dict):
raise BadRequest('JSON body must be an object')
birth_dt = self._parse_birth_datetime(body)
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, birth_dt.year, birth_dt.month, birth_dt.day,
birth_dt.hour, birth_dt.minute, birth_dt.second,
)
timezone_id = None
for key in ('timezone_id', 'timezone'):
value = body.get(key)
if isinstance(value, str) and '/' in value:
try:
from zoneinfo import ZoneInfo
ZoneInfo(value.strip())
except Exception as exc: # noqa: BLE001 - an unknown IANA name is a bad request
raise BadRequest(f'{key} is not a known IANA timezone') from exc
timezone_id = value.strip()
break
package_mod = _load_local_module('annual_package')
try:
request = package_mod.normalize_request(
birth={
'year': birth_dt.year, 'month': birth_dt.month, 'day': birth_dt.day,
'hour': birth_dt.hour, 'minute': birth_dt.minute, 'second': birth_dt.second,
'lat': lat, 'lon': lon, 'tz': tz, 'timezone_id': timezone_id,
},
ayanamsa=_request_ayanamsa(body),
node_mode=_request_node_mode(body),
report_range=body.get('report_range'),
observation_date=body.get('observation_date') or body.get('today'),
return_location=body.get('return_location'),
)
except package_mod.AnnualPackageInputError as exc:
raise BadRequest(str(exc)) from exc
package = package_mod.build_annual_package(request)
return {
'success': True,
'endpoint': 'annual',
'mode': 'package',
'annual_package_version': package['annual_package_version'],
'package': package,
}
def _compute_tajika(self, body):
result = self._compute_annual(body)
return {
@@ -10644,6 +10697,9 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, JyotishComputeService):
elif path == '/api/annual':
result = self._compute_annual(body)
self._json(result)
elif path == '/api/annual/package':
result = self._compute_annual_package(body)
self._json(result)
elif path == '/api/tajika':
result = self._compute_tajika(body)
self._json(result)
@@ -10960,6 +11016,7 @@ def start_server(port=5200, host='127.0.0.1', allowed_origins=None, allowed_host
print(f' POST /api/career — 事业分析')
print(f' POST /api/relationship — 感情分析')
print(f' POST /api/annual — 年运/Tajika')
print(f' POST /api/annual/package — 年度数据包 v1(同 /api/annual + mode=package)')
print(f' POST /api/muhurta — 择日')
print(f' POST /api/panchanga_range — Panchanga日期范围')
print(f' POST /api/declared_window_chart — 声明出生窗口探针比较')