#!/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, timezone from typing import Any from zoneinfo import ZoneInfo import swisseph as swe from ayanamsa_utils import ( DEFAULT_AYANAMSA_NAME, UnsupportedAyanamsaError, 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() def swiss_ephemeris_lock() -> threading.RLock: """Shared Swiss Ephemeris lock. Sidereal mode is process-global.""" return _SWISSEPH_LOCK _PLANET_IDS = { "Jupiter": swe.JUPITER, "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: timezone_context = resolve_timezone_context( lat=lat, lon=lon, local_datetime=local_datetime, ) offset = timezone_context["timezone_offset"] if offset is None: raise TimezoneInferenceError( f"local time is {timezone_context['local_time_status']} in IANA zone" ) return float(offset) def resolve_timezone_context( *, lat: float, lon: float, local_datetime: datetime | None = None ) -> dict[str, Any]: """Resolve an IANA zone and, when safe, its historical local UTC offset. A missing local time still permits timezone identification. DST folds and gaps deliberately return no offset instead of silently choosing one. """ 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") if local_datetime is None: return { "timezone_id": tz_name, "timezone_offset": None, "local_time_status": "not_provided", } try: zone = ZoneInfo(tz_name) valid_offsets: set[float] = set() for fold in (0, 1): aware = local_datetime.replace(tzinfo=zone, fold=fold) round_trip = aware.astimezone(timezone.utc).astimezone(zone).replace(tzinfo=None) offset = aware.utcoffset() if round_trip == local_datetime and offset is not None: valid_offsets.add(offset.total_seconds() / 3600.0) except Exception as exc: raise TimezoneInferenceError("timezone inference failed for IANA zone") from exc if not valid_offsets: return { "timezone_id": tz_name, "timezone_offset": None, "local_time_status": "nonexistent", } if len(valid_offsets) > 1: return { "timezone_id": tz_name, "timezone_offset": None, "local_time_status": "ambiguous", } return { "timezone_id": tz_name, "timezone_offset": valid_offsets.pop(), "local_time_status": "resolved", } 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") try: ayanamsa = normalize_ayanamsa_name(payload.get("ayanamsa")) except UnsupportedAyanamsaError as exc: raise CalculationError(str(exc)) from exc 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_id = payload.get("timezone_id", payload.get("timezoneId")) timezone_source = "explicit_offset" if tz_requested in {None, ""}: timezone_context = resolve_timezone_context(lat=lat, lon=lon, local_datetime=local_dt) timezone_id = timezone_context["timezone_id"] tz = timezone_context["timezone_offset"] if tz is None: raise TimezoneInferenceError( f"local time is {timezone_context['local_time_status']} in IANA zone" ) 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_id": str(timezone_id).strip() if timezone_id else None, "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, } if request["timezone_id"]: effective["timezone_id"] = request["timezone_id"] requested = { "ayanamsa": payload.get("ayanamsa") or DEFAULT_AYANAMSA_NAME, "node_mode": payload.get("node_mode", payload.get("nodeMode", "mean")), "timezone_offset": payload.get("tz"), } requested_timezone_id = payload.get("timezone_id", payload.get("timezoneId")) if requested_timezone_id: requested["timezone_id"] = requested_timezone_id 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 = DEFAULT_AYANAMSA_NAME ) -> 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 = DEFAULT_AYANAMSA_NAME, ) -> 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 _CLOCK_PLANETS = ( "Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu", ) _MINUTES_PER_DAY = 24 * 60 def _require_hhmm(value: Any, *, field: str) -> str: clock = str(value or "").strip() if len(clock) != 5 or clock[2] != ":": raise CalculationError(f"{field} must be HH:MM") try: hour = int(clock[:2]) minute = int(clock[3:]) except ValueError as exc: raise CalculationError(f"{field} must be HH:MM") from exc if hour < 0 or hour > 23 or minute < 0 or minute > 59: raise CalculationError(f"{field} must be HH:MM") return f"{hour:02d}:{minute:02d}" def _clock_minutes(clock: str) -> int: return int(clock[:2]) * 60 + int(clock[3:]) def _minutes_to_clock(total: int) -> str: normalized = total % _MINUTES_PER_DAY if normalized < 0: normalized += _MINUTES_PER_DAY return f"{normalized // 60:02d}:{normalized % 60:02d}" def declared_window_probe_clocks(range_start: str, range_end: str) -> list[str]: start = _clock_minutes(_require_hhmm(range_start, field="range_start")) end = _clock_minutes(_require_hhmm(range_end, field="range_end")) span = end + _MINUTES_PER_DAY - start if end < start else end - start if span < 1: raise CalculationError("declared window must span at least one minute") clocks: list[str] = [] seen: set[str] = set() for numerator in (0, 1, 2, 3): offset = int((span * numerator) / 3 + 0.5) clock = _minutes_to_clock(start + offset) if clock not in seen: seen.add(clock) clocks.append(clock) if len(clocks) < 2: raise CalculationError("declared window must yield at least two distinct probes") return clocks def _probe_role(index: int, count: int) -> str: if index == 0: return "range_start" if index == count - 1: return "range_end" return "interior" def _window_layer_snapshot(chart: dict[str, Any]) -> dict[str, Any]: planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {} planet_signs: dict[str, str] = {} for name in _CLOCK_PLANETS: planet = planets.get(name) sign = planet.get("sign") if isinstance(planet, dict) else None if isinstance(sign, str) and sign: planet_signs[name] = sign moon = planets.get("Moon") if isinstance(planets.get("Moon"), dict) else {} moon_nakshatra = moon.get("nakshatra") if isinstance(moon.get("nakshatra"), str) else None ascendant = chart.get("ascendant") if isinstance(chart.get("ascendant"), dict) else {} ascendant_sign = ascendant.get("sign") if isinstance(ascendant.get("sign"), str) else None houses_raw = chart.get("houses") if isinstance(chart.get("houses"), dict) else {} house_cusp_signs: dict[str, str] = {} for index in range(1, 13): house = houses_raw.get(f"house_{index}") sign = house.get("cusp_sign") if isinstance(house, dict) else None if isinstance(sign, str) and sign: house_cusp_signs[str(index)] = sign return { "planet_signs": planet_signs, "moon_nakshatra": moon_nakshatra, "ascendant_sign": ascendant_sign, "house_cusp_signs": house_cusp_signs, } def _unique_in_order(values: list[Any]) -> list[Any]: ordered: list[Any] = [] for value in values: if value not in ordered: ordered.append(value) return ordered def compute_declared_window_chart(payload: dict[str, Any]) -> dict[str, Any]: if "hour" in payload or "minute" in payload or "second" in payload: raise CalculationError("declared window must not include a single birth minute") range_start = _require_hhmm(payload.get("range_start", payload.get("rangeStart")), field="range_start") range_end = _require_hhmm(payload.get("range_end", payload.get("rangeEnd")), field="range_end") clocks = declared_window_probe_clocks(range_start, range_end) snapshots: list[dict[str, Any]] = [] probes: list[dict[str, str]] = [] for index, clock in enumerate(clocks): hour = int(clock[:2]) minute = int(clock[3:]) chart = compute_chart({ "year": payload["year"], "month": payload["month"], "day": payload["day"], "hour": hour, "minute": minute, "second": 0, "lat": payload["lat"], "lon": payload["lon"], "tz": payload["tz"], "timezone_id": payload.get("timezone_id", payload.get("timezoneId")), "ayanamsa": payload.get("ayanamsa"), "node_mode": payload.get("node_mode", payload.get("nodeMode", "mean")), }) snapshots.append(_window_layer_snapshot(chart)) probes.append({"clock": clock, "role": _probe_role(index, len(clocks))}) stable_planet_signs: dict[str, str] = {} varying_planet_signs: dict[str, list[str]] = {} for name in _CLOCK_PLANETS: signs = _unique_in_order([ snapshot["planet_signs"][name] for snapshot in snapshots if name in snapshot["planet_signs"] ]) if len(signs) == 1: stable_planet_signs[name] = signs[0] elif len(signs) > 1: varying_planet_signs[name] = signs moon_nakshatras = _unique_in_order([ snapshot["moon_nakshatra"] for snapshot in snapshots if snapshot["moon_nakshatra"] ]) ascendant_signs = _unique_in_order([ snapshot["ascendant_sign"] for snapshot in snapshots if snapshot["ascendant_sign"] ]) house_variation: dict[str, list[str]] = {} stable_houses: dict[str, str] = {} for house in (str(index) for index in range(1, 13)): signs = _unique_in_order([ snapshot["house_cusp_signs"][house] for snapshot in snapshots if house in snapshot["house_cusp_signs"] ]) if len(signs) == 1: stable_houses[house] = signs[0] elif len(signs) > 1: house_variation[house] = signs stable_layers: dict[str, Any] = {"planet_signs": stable_planet_signs} if len(moon_nakshatras) == 1: stable_layers["moon_nakshatra"] = moon_nakshatras[0] if len(ascendant_signs) == 1: stable_layers["ascendant_sign"] = ascendant_signs[0] if stable_houses: stable_layers["house_cusp_signs"] = stable_houses varying_layers: dict[str, Any] = {} if varying_planet_signs: varying_layers["planet_signs"] = varying_planet_signs if len(moon_nakshatras) > 1: varying_layers["moon_nakshatra"] = moon_nakshatras if len(ascendant_signs) > 1: varying_layers["ascendant_signs"] = ascendant_signs if house_variation: varying_layers["house_cusp_signs"] = house_variation wraps_midnight = _clock_minutes(range_end) < _clock_minutes(range_start) packet = { "declared_range": { "start": range_start, "end": range_end, "wraps_midnight": wraps_midnight, }, "probe_count": len(probes), "probes": probes, "stable_layers": stable_layers, "varying_layers": varying_layers, "blocked_layers": [ "vimshottari_boundaries", "narayana_boundaries", "vargas", "personal_transits", *(["lagna", "houses"] if len(ascendant_signs) > 1 else []), ], "answer_policy": { "can_answer_direction": bool( stable_planet_signs or stable_layers.get("moon_nakshatra") or stable_layers.get("ascendant_sign") ), "can_answer_precise_timing": False, "birth_time_confidence": "declared_window", "candidate_is_confirmed": False, "should_lead_with_limitations": True, }, } packet["result_hash"] = _canonical_hash(packet) return packet