harden calculation contracts and local API boundaries

This commit is contained in:
732642856
2026-07-11 23:24:54 +08:00
parent 4a268c75f2
commit a8f6a57ccc
17 changed files with 1075 additions and 157 deletions
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""Canonical calculation service shared by CLI, REST, and MCP adapters."""
from __future__ import annotations
import hashlib
import json
import math
import threading
from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo
import swisseph as swe
from ayanamsa_utils import apply_ayanamsa, normalize_ayanamsa_name
from dasha_analyzer import build_dasha_timeline, lon_to_nakshatra
from jyotish_engine import SIGNS, compute_chart_data
from sade_sati import calc_sade_sati_complete
CONTRACT_VERSION = "1.0.0"
_SWISSEPH_LOCK = threading.RLock()
_PLANET_IDS = {"Saturn": swe.SATURN}
class CalculationError(ValueError):
pass
class TimezoneInferenceError(CalculationError):
pass
def _canonical_hash(payload: dict[str, Any]) -> str:
encoded = json.dumps(
payload,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _lookup_timezone_name(lat: float, lon: float) -> str | None:
try:
from timezonefinder import TimezoneFinder
except ImportError as exc:
raise TimezoneInferenceError("timezone inference dependency unavailable") from exc
return TimezoneFinder().timezone_at(lng=lon, lat=lat)
def infer_timezone_offset(*, lat: float, lon: float, local_datetime: datetime) -> float:
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
raise TimezoneInferenceError("timezone inference received invalid coordinates")
tz_name = _lookup_timezone_name(lat, lon)
if not tz_name:
raise TimezoneInferenceError("timezone inference returned no IANA zone")
try:
offset = local_datetime.replace(tzinfo=ZoneInfo(tz_name)).utcoffset()
except Exception as exc:
raise TimezoneInferenceError("timezone inference failed for IANA zone") from exc
if offset is None:
raise TimezoneInferenceError("timezone inference returned no UTC offset")
return offset.total_seconds() / 3600.0
def _normalized_request(payload: dict[str, Any]) -> dict[str, Any]:
requested_node = str(payload.get("node_mode", payload.get("nodeMode", "mean"))).lower()
if requested_node not in {"mean", "true"}:
raise CalculationError("node_mode must be mean or true")
ayanamsa = normalize_ayanamsa_name(payload.get("ayanamsa", "lahiri"))
local_dt = datetime(
int(payload["year"]),
int(payload["month"]),
int(payload["day"]),
int(float(payload.get("hour", 0))),
int(float(payload.get("minute", 0))),
int(float(payload.get("second", 0))),
)
lat = float(payload["lat"])
lon = float(payload["lon"])
tz_requested = payload.get("tz")
timezone_source = "explicit_offset"
if tz_requested in {None, ""}:
tz = infer_timezone_offset(lat=lat, lon=lon, local_datetime=local_dt)
timezone_source = "iana_inferred"
else:
tz = float(tz_requested)
if not math.isfinite(tz) or not -14 <= tz <= 14:
raise CalculationError("tz must be a finite offset between -14 and 14")
return {
"year": local_dt.year,
"month": local_dt.month,
"day": local_dt.day,
"hour": int(float(payload.get("hour", 0))),
"minute": int(float(payload.get("minute", 0))),
"second": int(float(payload.get("second", 0))),
"lat": lat,
"lon": lon,
"tz": tz,
"timezone_source": timezone_source,
"ayanamsa": ayanamsa,
"node_mode": requested_node,
}
def _contract(requested: dict[str, Any], effective: dict[str, Any], *, algorithm: str) -> dict[str, Any]:
return {
"contract_version": CONTRACT_VERSION,
"algorithm": algorithm,
"requested": requested,
"effective": effective,
}
def compute_chart(payload: dict[str, Any]) -> dict[str, Any]:
request = _normalized_request(payload)
with _SWISSEPH_LOCK:
chart, _asc_idx, _jd, _ayanamsa = compute_chart_data(
request["year"],
request["month"],
request["day"],
request["hour"],
request["minute"],
request["lat"],
request["lon"],
request["tz"],
node_mode=request["node_mode"],
second=request["second"],
ayanamsa_name=request["ayanamsa"],
)
if not isinstance(chart, dict):
raise CalculationError("canonical chart calculation failed")
for planet in chart.get("planets", {}).values():
if not isinstance(planet, dict) or "error" in planet:
continue
planet.setdefault("lon", planet.get("degree_raw", planet.get("degree")))
if planet.get("sign") in SIGNS:
planet.setdefault("sign_idx", SIGNS.index(planet["sign"]))
birth = chart.get("birth_info", {})
effective = {
"ayanamsa": birth.get("ayanamsa_name", request["ayanamsa"]),
"node_mode": birth.get("node_mode", request["node_mode"]),
"timezone_offset": request["tz"],
"timezone_source": request["timezone_source"],
"ephemeris_source": "swisseph_calc_ut",
"ephemeris_flags_verified": False,
}
requested = {
"ayanamsa": payload.get("ayanamsa", "lahiri"),
"node_mode": payload.get("node_mode", payload.get("nodeMode", "mean")),
"timezone_offset": payload.get("tz"),
}
contract = _contract(requested, effective, algorithm="sidereal_natal_chart")
hash_payload = {
"contract": contract,
"birth": birth,
"ascendant": chart.get("ascendant"),
"planets": chart.get("planets"),
}
chart["calculation_contract"] = contract
chart["result_hash"] = _canonical_hash(hash_payload)
return chart
def compute_vimshottari_timeline(
*, birth_dt: datetime, moon_lon: float, current_date: datetime | None = None
) -> dict[str, Any]:
nak_info, progress, pada = lon_to_nakshatra(float(moon_lon) % 360)
timeline, elapsed, remaining, start_lord = build_dasha_timeline(
birth_dt.strftime("%Y-%m-%d"), nak_info, progress
)
periods = [
{
"lord": period["lord"],
"years": period["years"],
"start": period["start"].strftime("%Y-%m-%d"),
"end": period["end"].strftime("%Y-%m-%d"),
}
for period in timeline
]
contract = _contract(
{"moon_longitude": float(moon_lon) % 360},
{"year_basis_days": 365.25, "nakshatra": nak_info[0], "pada": pada},
algorithm="vimshottari_birth_balance",
)
result = {
"periods": periods,
"birth_balance": {
"lord": start_lord,
"elapsed_years": elapsed,
"remaining_years": remaining,
},
"calculation_contract": contract,
}
result["result_hash"] = _canonical_hash(result)
return result
def compute_transit_longitude(
*, planet: str, reference_date: str, tz: float, ayanamsa: str = "lahiri"
) -> dict[str, Any]:
if planet not in _PLANET_IDS:
raise CalculationError(f"unsupported transit planet: {planet}")
try:
local_dt = datetime.strptime(reference_date[:10], "%Y-%m-%d").replace(hour=12)
except (TypeError, ValueError) as exc:
raise CalculationError("reference_date must be YYYY-MM-DD") from exc
ayanamsa_name = normalize_ayanamsa_name(ayanamsa)
with _SWISSEPH_LOCK:
apply_ayanamsa(ayanamsa_name, swe)
jd = swe.julday(
local_dt.year,
local_dt.month,
local_dt.day,
12.0 - float(tz),
)
ayanamsa_value = swe.get_ayanamsa(jd)
position, flags = swe.calc_ut(jd, _PLANET_IDS[planet])
longitude = (position[0] - ayanamsa_value) % 360
return {
"planet": planet,
"longitude": longitude,
"reference_date": reference_date[:10],
"ayanamsa": ayanamsa_name,
"timezone_offset": float(tz),
"swisseph_return_flags": int(flags),
"data_layer": "true_transit_positions",
}
def compute_sade_sati(
*,
moon_degree: float,
asc_degree: float,
reference_date: str,
tz: float,
ayanamsa: str = "lahiri",
) -> dict[str, Any]:
transit = compute_transit_longitude(
planet="Saturn",
reference_date=reference_date,
tz=tz,
ayanamsa=ayanamsa,
)
result = calc_sade_sati_complete(
float(moon_degree) % 360,
float(asc_degree) % 360,
transit["longitude"],
datetime.strptime(reference_date[:10], "%Y-%m-%d"),
)
result["transit_saturn_lon"] = transit["longitude"]
result["provenance"] = transit
result["calculation_contract"] = _contract(
{"reference_date": reference_date[:10], "ayanamsa": ayanamsa, "tz": tz},
transit,
algorithm="sade_sati_true_saturn_transit",
)
result["result_hash"] = _canonical_hash(result)
return result
+249 -128
View File
@@ -15,10 +15,12 @@ import json, sys, os, math
import importlib.util
import hashlib
import re
import secrets
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta
from http.server import HTTPServer, BaseHTTPRequestHandler
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
@@ -43,6 +45,25 @@ _LOCAL_MODULE_CACHE = {}
_API_CHART_CACHE_SCOPE = 'api_chart_response'
_HIGH_RIGOR_JOB_SCOPE = 'high_rigor_workflow'
_UNIFIED_CONSULTATION_ORCHESTRATOR = UnifiedConsultationOrchestrator()
_ASYNC_JOB_WORKERS = max(int(os.environ.get('JYOTISH_ASYNC_JOB_WORKERS', '2')), 1)
_ASYNC_JOB_QUEUE_SIZE = max(int(os.environ.get('JYOTISH_ASYNC_JOB_QUEUE_SIZE', '8')), 0)
_ASYNC_JOB_EXECUTOR = ThreadPoolExecutor(
max_workers=_ASYNC_JOB_WORKERS,
thread_name_prefix='jyotish-job',
)
_ASYNC_JOB_CAPACITY = threading.BoundedSemaphore(_ASYNC_JOB_WORKERS + _ASYNC_JOB_QUEUE_SIZE)
def _submit_background_job(callback):
if not _ASYNC_JOB_CAPACITY.acquire(blocking=False):
raise JobQueueFull('Async job queue is full')
try:
future = _ASYNC_JOB_EXECUTOR.submit(callback)
except Exception:
_ASYNC_JOB_CAPACITY.release()
raise
future.add_done_callback(lambda _future: _ASYNC_JOB_CAPACITY.release())
return future
def _western_evidence_packet_from_body(body: dict, route_packet: dict) -> dict | None:
@@ -467,29 +488,66 @@ def _async_job_path(scope: str, job_id: str) -> Path:
return _async_job_dir(scope) / f'{job_id}.json'
def _load_high_rigor_job_record(job_id: str) -> dict | None:
return _load_async_job_record(_HIGH_RIGOR_JOB_SCOPE, job_id)
def _async_job_ttl_seconds() -> float:
raw = str(os.environ.get('JYOTISH_ASYNC_JOB_TTL_SECONDS', '3600')).strip()
try:
return max(float(raw), 1.0)
except ValueError:
return 3600.0
def _new_async_job_identity(prefix: str) -> dict:
return {
'job_id': f'{prefix}_{secrets.token_hex(16)}',
'access_token': secrets.token_urlsafe(32),
}
def _access_token_hash(token: str) -> str:
return hashlib.sha256(token.encode('utf-8')).hexdigest()
def _load_high_rigor_job_record(job_id: str, *, access_token: str = '') -> dict | None:
return _load_async_job_record(
_HIGH_RIGOR_JOB_SCOPE,
job_id,
access_token=access_token,
)
def _write_high_rigor_job_record(job_id: str, payload: dict) -> dict:
return _write_async_job_record(_HIGH_RIGOR_JOB_SCOPE, job_id, payload)
def _load_async_job_record(scope: str, job_id: str) -> dict | None:
def _load_async_job_record(scope: str, job_id: str, *, access_token: str = '') -> dict | None:
path = _async_job_path(scope, job_id)
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding='utf-8'))
record = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return None
expires_at = record.get('expires_at_unix')
if isinstance(expires_at, (int, float)) and time.time() >= float(expires_at):
try:
path.unlink()
except OSError:
pass
return None
expected = record.get('access_token_hash')
if not isinstance(expected, str) or not access_token:
raise JobAccessDenied('Async job access token required')
if not secrets.compare_digest(expected, _access_token_hash(access_token)):
raise JobAccessDenied('Async job access token invalid')
return record
def _write_async_job_record(scope: str, job_id: str, payload: dict) -> dict:
_async_job_path(scope, job_id).write_text(
json.dumps(payload, ensure_ascii=False, sort_keys=True),
encoding='utf-8',
)
path = _async_job_path(scope, job_id)
temp_path = path.with_suffix(f'.{secrets.token_hex(8)}.tmp')
temp_path.write_text(json.dumps(payload, ensure_ascii=False, sort_keys=True), encoding='utf-8')
os.chmod(temp_path, 0o600)
os.replace(temp_path, path)
return payload
@@ -809,6 +867,22 @@ class BadRequest(ValueError):
"""Client-side request validation failed."""
class Forbidden(PermissionError):
"""Request failed the local API trust boundary."""
class UnsupportedMediaType(ValueError):
"""Request body media type is not supported."""
class JobAccessDenied(PermissionError):
"""Async job capability token is missing or invalid."""
class JobQueueFull(RuntimeError):
"""Bounded async worker queue has no remaining capacity."""
class JyotishAPIHandler(BaseHTTPRequestHandler):
server_version = 'JyotishAPI/6.9.14'
@@ -832,6 +906,24 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
if origin in allowed:
self.send_header('Access-Control-Allow-Origin', origin)
def _enforce_request_security(self, *, require_json=False):
origin = self.headers.get('Origin')
allowed = getattr(self.server, 'allowed_origins', DEFAULT_ALLOWED_ORIGINS)
if origin and origin not in allowed:
raise Forbidden('Origin is not allowed')
host = (self.headers.get('Host') or '').split(':', 1)[0].strip('[]').lower()
if host and host not in {'localhost', '127.0.0.1', '::1'}:
raise Forbidden('Host is not allowed')
if require_json:
content_type = (self.headers.get('Content-Type') or '').split(';', 1)[0].strip().lower()
if content_type != 'application/json':
raise UnsupportedMediaType('Content-Type must be application/json')
def _job_access_token(self):
authorization = self.headers.get('Authorization') or ''
scheme, _, token = authorization.partition(' ')
return token.strip() if scheme.lower() == 'bearer' else ''
def _vedastro_status(self):
adapter = _load_local_module('vedastro_service_adapter')
endpoint = os.environ.get('VEDASTRO_API_ENDPOINT', '').strip()
@@ -877,11 +969,16 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
}
def do_OPTIONS(self):
self._json({})
try:
self._enforce_request_security()
self._json({})
except Forbidden as exc:
self._error_json(str(exc), 403, 'ERR_FORBIDDEN')
def do_GET(self):
path = urlparse(self.path).path
try:
self._enforce_request_security()
if path == '/api/health':
swisseph_available = False
swisseph_version = None
@@ -937,6 +1034,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
self._json(self._real_case_revalidation())
else:
self._error_json('Not found', 404, 'ERR_NOT_FOUND')
except (Forbidden, JobAccessDenied) as exc:
self._error_json(str(exc), 403, 'ERR_FORBIDDEN')
except Exception:
import logging
logging.exception("[api_server] GET request failed for %s", path)
@@ -945,6 +1044,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
def do_POST(self):
path = urlparse(self.path).path
try:
self._enforce_request_security(require_json=True)
body = self._read_json_body()
if path == '/api/chart':
result = self._compute_chart(body)
@@ -1083,6 +1183,12 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
self._error_json(f'Unknown endpoint: {path}', 404, 'ERR_NOT_FOUND')
except BadRequest as e:
self._error_json(str(e), 400, 'ERR_BAD_REQUEST')
except Forbidden as exc:
self._error_json(str(exc), 403, 'ERR_FORBIDDEN')
except UnsupportedMediaType as exc:
self._error_json(str(exc), 415, 'ERR_UNSUPPORTED_MEDIA_TYPE')
except JobQueueFull as exc:
self._error_json(str(exc), 503, 'ERR_JOB_QUEUE_FULL')
except Exception:
import logging
logging.exception("[api_server] request failed for %s", path)
@@ -1122,13 +1228,20 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
tz = body.get('tz')
if tz is not None and tz != "":
return self._get_float(body, 'tz', 8, -14, 14)
from timezone_utils import infer_timezone
from datetime import datetime
try:
dt = datetime(int(year), int(month), int(day), int(hour), int(minute), int(second))
except Exception:
dt = datetime.utcnow()
return infer_timezone(lat, lon, dt)
except (TypeError, ValueError) as exc:
raise BadRequest('Invalid birth date') from exc
try:
calculation_service = _load_local_module('domain_calculation_service')
return calculation_service.infer_timezone_offset(
lat=lat,
lon=lon,
local_datetime=dt,
)
except ValueError as exc:
raise BadRequest(str(exc)) from exc
def _get_float(self, body, key, default, min_value=None, max_value=None):
value = body.get(key, default)
@@ -1960,7 +2073,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
}
def _enqueue_high_rigor_job(self, body):
job_id = f'hrw_{datetime.utcnow().strftime("%Y%m%d%H%M%S%f")}'
identity = _new_async_job_identity('hrw')
job_id = identity['job_id']
queued_at = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
poll_path = f'/api/high_rigor_workflow/jobs/{job_id}'
record = {
@@ -1972,13 +2086,18 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'queued_at': queued_at,
'poll_path': poll_path,
'scope': _HIGH_RIGOR_JOB_SCOPE,
'access_token': identity['access_token'],
'expires_at_unix': time.time() + _async_job_ttl_seconds(),
}
_write_high_rigor_job_record(job_id, record)
stored_record = dict(record)
stored_record.pop('access_token')
stored_record['access_token_hash'] = _access_token_hash(identity['access_token'])
_write_high_rigor_job_record(job_id, stored_record)
body_copy = dict(body or {})
def _run_job() -> None:
running = dict(record)
running = dict(stored_record)
running['status'] = 'running'
running['started_at'] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
_write_high_rigor_job_record(job_id, running)
@@ -1998,15 +2117,12 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
failed['error'] = str(exc)
_write_high_rigor_job_record(job_id, failed)
threading.Thread(
target=_run_job,
name=f'high-rigor-job-{job_id}',
daemon=True,
).start()
_submit_background_job(_run_job)
return record
def _enqueue_async_job(self, *, scope, endpoint, job_prefix, poll_base, compute_fn):
job_id = f'{job_prefix}_{datetime.utcnow().strftime("%Y%m%d%H%M%S%f")}'
identity = _new_async_job_identity(job_prefix)
job_id = identity['job_id']
queued_at = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
poll_path = f'{poll_base}/{job_id}'
record = {
@@ -2018,11 +2134,16 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'queued_at': queued_at,
'poll_path': poll_path,
'scope': scope,
'access_token': identity['access_token'],
'expires_at_unix': time.time() + _async_job_ttl_seconds(),
}
_write_async_job_record(scope, job_id, record)
stored_record = dict(record)
stored_record.pop('access_token')
stored_record['access_token_hash'] = _access_token_hash(identity['access_token'])
_write_async_job_record(scope, job_id, stored_record)
def _run_job() -> None:
running = dict(record)
running = dict(stored_record)
running['status'] = 'running'
running['started_at'] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
_write_async_job_record(scope, job_id, running)
@@ -2042,18 +2163,18 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
failed['error'] = str(exc)
_write_async_job_record(scope, job_id, failed)
threading.Thread(
target=_run_job,
name=f'{job_prefix}-job-{job_id}',
daemon=True,
).start()
_submit_background_job(_run_job)
return record
def _get_high_rigor_job(self, job_id):
return _load_high_rigor_job_record(job_id)
return _load_high_rigor_job_record(job_id, access_token=self._job_access_token())
def _get_chart_job(self, job_id):
return _load_async_job_record(_API_CHART_CACHE_SCOPE, job_id)
return _load_async_job_record(
_API_CHART_CACHE_SCOPE,
job_id,
access_token=self._job_access_token(),
)
def _high_rigor_birth_payload(self, body):
required = ('year', 'month', 'day', 'hour', 'minute', 'lat', 'lon')
@@ -3913,29 +4034,6 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
errors.append(str(e))
raise BadRequest('PDF has no extractable text; OCR is not supported yet')
def _calc_vimshottari_periods(self, birth_dt, moon_lon):
extended_dashas = _load_local_module('extended_dashas')
DASHA_ORDER = extended_dashas.DASHA_ORDER
YEAR_DAYS = extended_dashas.YEAR_DAYS
dasha_years = [7, 20, 6, 10, 7, 18, 16, 19, 17]
nak_size = 360 / 27
nak_idx = int(moon_lon / nak_size) % 27
start_idx = nak_idx % len(DASHA_ORDER)
current = birth_dt
periods = []
for i in range(len(DASHA_ORDER)):
idx = (start_idx + i) % len(DASHA_ORDER)
years = dasha_years[idx]
end_date = current + timedelta(days=years * YEAR_DAYS)
periods.append({
'lord': DASHA_ORDER[idx],
'years': years,
'start': current.strftime('%Y-%m-%d'),
'end': end_date.strftime('%Y-%m-%d'),
})
current = end_date
return periods
def _compute_chart(self, body):
if body.get('async') or body.get('enqueue'):
return self._enqueue_chart_job(body)
@@ -3975,78 +4073,69 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
raise BadRequest('Invalid birth date') from e
try:
import swisseph as swe
swe.set_ephe_path(os.path.join(SCRIPTS_DIR, '..', 'swiss_ephemeris'))
calculation_service = _load_local_module('domain_calculation_service')
canonical_chart = calculation_service.compute_chart({
'year': year,
'month': month,
'day': day,
'hour': hour,
'minute': minute,
'second': second,
'lat': lat,
'lon': lon,
'tz': tz,
'ayanamsa': body.get('ayanamsa', 'lahiri'),
'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')),
})
canonical_birth = canonical_chart['birth_info']
planets_data = canonical_chart['planets']
ascendant_data = canonical_chart['ascendant']
asc_lon = float(ascendant_data['lon'])
asc_sign = ascendant_data['sign']
asc_sign_idx = SIGNS.index(asc_sign)
birth_hour_decimal = self._birth_hour_decimal(hour, minute, second)
hour_ut = birth_hour_decimal - tz
jd = swe.julday(year, month, day, hour_ut)
ayanamsa_name = body.get('ayanamsa', 'lahiri')
try:
from jyotish_engine import _apply_ayanamsa, _ayanamsa_display_name
_apply_ayanamsa(ayanamsa_name)
ayanamsa_display = _ayanamsa_display_name(ayanamsa_name)
except ImportError:
swe.set_sid_mode(swe.SIDM_LAHIRI, 0, 0)
ayanamsa_name = 'lahiri'
ayanamsa_display = 'Lahiri'
ayanamsa = swe.get_ayanamsa(jd)
jd = float(canonical_birth['julian_day'])
ayanamsa = float(canonical_birth['ayanamsa'])
ayanamsa_name = canonical_birth['ayanamsa_name']
ayanamsa_display = canonical_birth['ayanamsa_display']
planets_data = {}
planet_ids = {'Sun': 0, 'Moon': 1, 'Mars': 4, 'Mercury': 2, 'Jupiter': 5, 'Venus': 3, 'Saturn': 6, 'Rahu': 10, 'Ketu': 20}
planet_names_rev = {v: k for k, v in planet_ids.items()}
for pid, pname in planet_names_rev.items():
if pid == 20:
rahu_result, _ = swe.calc_ut(jd, 10)
planet_lon = (rahu_result[0] - ayanamsa + 180) % 360
else:
result, _ = swe.calc_ut(jd, pid)
planet_lon = (result[0] - ayanamsa) % 360
sign_idx = int(planet_lon / 30) % 12
planets_data[pname] = {'lon': planet_lon, 'sign_idx': sign_idx, 'sign': SIGNS[sign_idx], 'degree': planet_lon % 30}
# Ascendant
asc_tropical = swe.houses_ex(jd, lat, lon, b'E')[0][0] % 360
asc_lon = (asc_tropical - ayanamsa) % 360
asc_sign_idx = int(asc_lon / 30) % 12
asc_sign = SIGNS[asc_sign_idx]
# Houses
houses = {}
for h in range(1, 13):
s = (asc_sign_idx + h - 1) % 12
houses[h] = {'sign': SIGNS[s], 'sign_idx': s}
# Planet houses
for pn, pd in planets_data.items():
pd['house'] = ((pd['sign_idx'] - asc_sign_idx) % 12) + 1
# Dasha (simplified Vimshottari)
moon_lon = planets_data['Moon']['lon']
nak_size = 360/27
nak_idx = int(moon_lon / nak_size)
dasha_lords = ['Ketu','Venus','Sun','Moon','Mars','Rahu','Jupiter','Saturn','Mercury']
dasha_years = [7,20,6,10,7,18,16,19,17]
nak_lord_idx = nak_idx % 9
md_lord = dasha_lords[nak_lord_idx]
total_years = dasha_years[nak_lord_idx]
elapsed = (moon_lon % nak_size) / nak_size * total_years
remaining = total_years - elapsed
house = canonical_chart.get('houses', {}).get(f'house_{h}', {})
sign = house.get('cusp_sign', SIGNS[(asc_sign_idx + h - 1) % 12])
houses[h] = {
'sign': sign,
'sign_idx': SIGNS.index(sign),
'cusp_degree': house.get('cusp_degree'),
}
moon_lon = float(planets_data['Moon']['lon'])
birth_dt = datetime(year, month, day, int(hour), int(minute), int(second))
elapsed_days = elapsed * 365.25636
dasha_start = birth_dt - timedelta(days=elapsed_days) if elapsed_days < 365*120 else birth_dt
canonical_dasha = calculation_service.compute_vimshottari_timeline(
birth_dt=birth_dt,
moon_lon=moon_lon,
current_date=birth_dt,
)
dasha_balance = canonical_dasha['birth_balance']
md_lord = dasha_balance['lord']
remaining = dasha_balance['remaining_years']
total_years = canonical_dasha['periods'][0]['years']
dasha_start = datetime.strptime(canonical_dasha['periods'][0]['start'], '%Y-%m-%d')
# Yoga detection
yogas = self._detect_yogas(planets_data, asc_sign_idx)
# Sade Sati
from sade_sati import calc_sade_sati_complete
# Transit Saturn (approximate)
saturn_year_progress = (year - 2026) * 12 / 30 # ~12 signs in 30 years
transit_saturn_sign = (planets_data['Saturn']['sign_idx'] + int(saturn_year_progress)) % 12
transit_saturn_lon = transit_saturn_sign * 30 + 15
sade_sati = calc_sade_sati_complete(moon_lon, asc_lon, transit_saturn_lon)
reference_date = (
body.get('transit_date')
or body.get('today')
or body.get('current_date')
or datetime.now().strftime('%Y-%m-%d')
)
sade_sati = calculation_service.compute_sade_sati(
moon_degree=moon_lon,
asc_degree=asc_lon,
reference_date=reference_date,
tz=tz,
ayanamsa=ayanamsa_name,
)
# Dasha清单
extended_dashas = _load_local_module('extended_dashas')
@@ -4120,7 +4209,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'ayanamsa': round(ayanamsa, 4),
'ayanamsa_name': ayanamsa_name,
'ayanamsa_display': ayanamsa_display,
'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')),
'node_mode': canonical_chart['calculation_contract']['effective']['node_mode'],
},
'ascendant': {
'sign': asc_sign,
@@ -4135,6 +4224,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'remaining_years': round(remaining, 2),
'total_years': total_years,
'start_date': dasha_start.isoformat() if hasattr(dasha_start, 'isoformat') else str(dasha_start),
'periods': canonical_dasha['periods'],
'birth_balance': canonical_dasha['birth_balance'],
'calculation_contract': canonical_dasha['calculation_contract'],
'result_hash': canonical_dasha['result_hash'],
},
'yogas': yogas,
'sade_sati': sade_sati,
@@ -4143,6 +4236,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'special_lagnas': special_lagnas,
'available_dashas': dasha_list,
'dasha_count': len(dasha_list),
'calculation_contract': canonical_chart['calculation_contract'],
'result_hash': canonical_chart['result_hash'],
}
result['modules'] = {
'chart': {
@@ -4150,6 +4245,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'ascendant': result['ascendant'],
'houses': result['houses'],
'birth_info': result['birth'],
'calculation_contract': result['calculation_contract'],
'result_hash': result['result_hash'],
},
'dasha': result['dasha'],
'shadbala': {'planets': sb.get('planets', {})} if 'sb' in locals() and isinstance(sb, dict) else {},
@@ -4740,9 +4837,20 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
tithi_num = self._get_int(body, 'tithi_num', 1, 1, 30)
vimshottari_analysis = None
canonical_dasha = None
if dasha_key == 'vimshottari':
periods = self._calc_vimshottari_periods(birth_dt, moon_lon)
precision = 'calculator'
calculation_service = _load_local_module('domain_calculation_service')
canonical_dasha = calculation_service.compute_vimshottari_timeline(
birth_dt=birth_dt,
moon_lon=moon_lon,
current_date=(
self._parse_optional_date(body.get('today') or body.get('current_date'))
if body.get('today') or body.get('current_date')
else None
),
)
periods = canonical_dasha['periods']
precision = 'canonical_birth_balance'
vimshottari_analysis = self._compute_vimshottari_analysis_layer(
birth_dt,
moon_lon,
@@ -4778,6 +4886,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
if vimshottari_analysis:
result['vimshottari_analysis'] = vimshottari_analysis
result['fragment_sources'] = ['dasha_analyzer.py', 'dasha_calculator_enhanced.py']
if canonical_dasha:
result['birth_balance'] = canonical_dasha['birth_balance']
result['calculation_contract'] = canonical_dasha['calculation_contract']
result['result_hash'] = canonical_dasha['result_hash']
return result
def _compute_vimshottari_analysis_layer(self, birth_dt, moon_lon, current_date=None):
@@ -4853,11 +4965,19 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
}
def _compute_sade_sati(self, body):
from sade_sati import calc_sade_sati_complete
return calc_sade_sati_complete(
self._normalize_degree(body, 'moon_degree', 0),
self._normalize_degree(body, 'asc_degree', 0),
self._normalize_degree(body, 'saturn_degree', 0),
calculation_service = _load_local_module('domain_calculation_service')
reference_date = (
body.get('reference_date')
or body.get('transit_date')
or body.get('current_date')
or datetime.now().strftime('%Y-%m-%d')
)
return calculation_service.compute_sade_sati(
moon_degree=self._normalize_degree(body, 'moon_degree', 0),
asc_degree=self._normalize_degree(body, 'asc_degree', 0),
reference_date=reference_date,
tz=self._get_float(body, 'tz', 0, -14, 14),
ayanamsa=body.get('ayanamsa', 'lahiri'),
)
def _compute_pmc(self, body):
@@ -7448,7 +7568,8 @@ def _parse_allowed_origins(value):
def start_server(port=5200, host='127.0.0.1', allowed_origins=None):
server = HTTPServer((host, port), JyotishAPIHandler)
server = ThreadingHTTPServer((host, port), JyotishAPIHandler)
server.daemon_threads = True
server.allowed_origins = allowed_origins or DEFAULT_ALLOWED_ORIGINS
print(f'Jyotish API v6.9.14 running on http://{host}:{port}')
print(f' CORS origins: {", ".join(sorted(server.allowed_origins))}')
+18 -6
View File
@@ -735,12 +735,24 @@ def _birth_datetime_from_args(args):
def _compute_chart_from_args(args):
return compute_chart_data(
args.year, args.month, args.day, args.hour, args.minute,
args.lat, args.lon, args.tz, getattr(args, 'node_mode', 'mean'),
second=_arg_second(args),
ayanamsa_name=_current_ayanamsa_name(args),
)
from domain_calculation_service import compute_chart
result = compute_chart({
'year': args.year,
'month': args.month,
'day': args.day,
'hour': args.hour,
'minute': args.minute,
'second': _arg_second(args),
'lat': args.lat,
'lon': args.lon,
'tz': args.tz,
'node_mode': getattr(args, 'node_mode', 'mean'),
'ayanamsa': _current_ayanamsa_name(args),
})
asc_idx = SIGNS.index(result['ascendant']['sign'])
birth = result['birth_info']
return result, asc_idx, birth['julian_day'], birth['ayanamsa']
def _current_ayanamsa_name(args=None):
+21 -2
View File
@@ -29,6 +29,7 @@ import sys
import re
import glob
import argparse
from urllib.parse import urlparse
try:
import markdown
@@ -352,6 +353,13 @@ def build_section(num, title, md_text):
</div>"""
def is_allowed_report_resource_url(url, *, report_url):
if url == report_url:
return True
parsed = urlparse(url)
return parsed.scheme in {'data', 'about', 'blob'}
def _html_to_pdf(html_path, pdf_path):
"""Convert HTML to PDF using Playwright headless Chromium."""
try:
@@ -364,14 +372,25 @@ def _html_to_pdf(html_path, pdf_path):
print(" Launching headless Chromium...")
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(f"file://{os.path.abspath(html_path)}", wait_until="networkidle")
context = browser.new_context(java_script_enabled=False)
page = context.new_page()
report_url = f"file://{os.path.abspath(html_path)}"
page.route(
"**/*",
lambda route: (
route.continue_()
if is_allowed_report_resource_url(route.request.url, report_url=report_url)
else route.abort()
),
)
page.goto(report_url, wait_until="networkidle")
page.pdf(
path=pdf_path,
format="A4",
print_background=True,
margin={"top": "22mm", "bottom": "24mm", "left": "20mm", "right": "20mm"},
)
context.close()
browser.close()
size_kb = os.path.getsize(pdf_path) / 1024
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Capture a public same-chart parity packet without overstating oracle closure."""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
from domain_calculation_service import compute_chart
ROOT = Path(__file__).resolve().parents[1]
PYJHORA_ARTIFACT = ROOT / "references/oracle/artifacts/pyjhora_steve_jobs_dasha_stdout_20260627.txt"
JYOTISHGANIT_ROOT = ROOT / "references/open_source_sources/jyotishganit"
PUBLIC_CASE = {
"case_id": "steve_jobs_public_1955_lahiri",
"year": 1955,
"month": 2,
"day": 24,
"hour": 19,
"minute": 15,
"second": 0,
"lat": 37.7749,
"lon": -122.4194,
"tz": -8.0,
"ayanamsa": "lahiri",
"node_mode": "mean",
}
def _write_json(path: Path, value: dict[str, Any]) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
return path
def _capture_jyotishganit_raw(output_dir: Path) -> tuple[dict[str, Any], str]:
sys.path.insert(0, str(JYOTISHGANIT_ROOT))
try:
from jyotishganit import calculate_birth_chart, get_birth_chart_json
chart = calculate_birth_chart(
datetime(
PUBLIC_CASE["year"],
PUBLIC_CASE["month"],
PUBLIC_CASE["day"],
PUBLIC_CASE["hour"],
PUBLIC_CASE["minute"],
PUBLIC_CASE["second"],
),
PUBLIC_CASE["lat"],
PUBLIC_CASE["lon"],
PUBLIC_CASE["tz"],
location_name="San Francisco, CA",
name="Steve Jobs (public benchmark)",
)
raw = get_birth_chart_json(chart)
path = _write_json(output_dir / "jyotishganit_raw.json", raw)
return raw, str(path)
except Exception as exc:
return {"error": f"{exc.__class__.__name__}: {exc}"}, ""
finally:
try:
sys.path.remove(str(JYOTISHGANIT_ROOT))
except ValueError:
pass
def _vedastro_state(*, allow_network: bool) -> dict[str, Any]:
if not allow_network:
return {
"status": "blocked",
"official_raw_response_path": "",
"reason": "network_disabled_for_public_replay",
}
return {
"status": "blocked",
"official_raw_response_path": "",
"reason": "official_runner_requires_explicit_raw_capture_workflow",
}
def build_public_case_replay(*, output_dir: Path, allow_vedastro_network: bool = False) -> dict[str, Any]:
output_dir.mkdir(parents=True, exist_ok=True)
local = compute_chart(PUBLIC_CASE)
jyotishganit_raw, jyotishganit_path = _capture_jyotishganit_raw(output_dir)
pyjhora_available = PYJHORA_ARTIFACT.is_file()
vedastro = _vedastro_state(allow_network=allow_vedastro_network)
rows = [
{
"section": "D1",
"field": "Sun.longitude",
"local_value": local["planets"]["Sun"]["lon"],
"oracle_values": {
"VedAstro": None,
"PyJHora_JHora": None,
"jyotishganit": None,
},
"status": "blocked",
"reason": "raw_values_not_normalized_across_all_three_engines",
},
{
"section": "Panchanga",
"field": "raw_capture",
"local_value": None,
"oracle_values": {
"VedAstro": None,
"PyJHora_JHora": "dasha_only_artifact",
"jyotishganit": "captured" if jyotishganit_path else None,
},
"status": "not_comparable",
"reason": "three_engine_scope_does_not_share_this_normalized_field",
},
]
report = {
"case_id": PUBLIC_CASE["case_id"],
"birth_data_policy": "public_case_only",
"status": "partial" if pyjhora_available and jyotishganit_path else "blocked",
"tested": False,
"blocked_reason": "official_vedastro_raw_missing_or_unverified",
"engines": {
"VedAstro": vedastro,
"PyJHora_JHora": {
"status": "raw_imported" if pyjhora_available else "blocked",
"raw_output_path": str(PYJHORA_ARTIFACT) if pyjhora_available else "",
"settings": {"ayanamsa": "LAHIRI", "node_mode": "PyJHora default"},
},
"jyotishganit": {
"status": "raw_captured" if jyotishganit_path else "blocked",
"raw_output_path": jyotishganit_path,
"error": jyotishganit_raw.get("error") if isinstance(jyotishganit_raw, dict) else None,
},
},
"local": {
"result_hash": local["result_hash"],
"calculation_contract": local["calculation_contract"],
},
"comparison_rows": rows,
"runtime_boundary": (
"This packet has real public raw artifacts but remains unverified until a VedAstro "
"official raw response and normalized three-engine field comparison are imported."
),
}
_write_json(output_dir / "three_engine_parity_replay.json", report)
return report
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--output-dir", default="scratch/local/three_engine_parity")
parser.add_argument("--allow-vedastro-network", action="store_true")
args = parser.parse_args()
report = build_public_case_replay(
output_dir=ROOT / args.output_dir,
allow_vedastro_network=args.allow_vedastro_network,
)
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+3 -15
View File
@@ -1,17 +1,5 @@
from datetime import datetime
import logging
def infer_timezone(lat: float, lon: float, dt: datetime) -> float:
from domain_calculation_service import infer_timezone_offset
def infer_timezone(lat: float, lon: float, dt: datetime, default: float = 8.0) -> float:
try:
from timezonefinder import TimezoneFinder
import pytz
tf = TimezoneFinder()
tz_name = tf.timezone_at(lng=lon, lat=lat)
if tz_name:
offset_seconds = pytz.timezone(tz_name).localize(dt).utcoffset().total_seconds()
offset = float(offset_seconds / 3600.0)
logging.info(f"[Timezone Auth] Detected {tz_name} offset {offset} for {dt}")
return offset
except Exception as e:
logging.warning(f"Timezone inference failed: {e}")
return float(default)
return infer_timezone_offset(lat=lat, lon=lon, local_datetime=dt)