#!/usr/bin/env python3 """Audit Vimshottari Dasha boundary drift against an external reference date. This script is diagnostic. It does not tune or override the production Dasha engine; it quantifies how much of a boundary difference can be explained by birth-clock precision and year-length constants. """ from __future__ import annotations import argparse import json import math import os import sys from datetime import datetime, timedelta from typing import Any SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) if SCRIPT_DIR not in sys.path: sys.path.insert(0, SCRIPT_DIR) import jyotish_engine as engine # noqa: E402 YEAR_LENGTHS = [365.0, 365.2422, 365.25, 365.25636, 360.0] NAKSHATRA_SPAN = 360.0 / 27.0 def _birth_time_string(hour: int, minute: int, second: int = 0) -> str: if second: return f"{hour:02d}:{minute:02d}:{second:02d}" return f"{hour:02d}:{minute:02d}:00" def _birth_datetime(args: argparse.Namespace, *, second: int | None = None) -> datetime: sec = args.second if second is None else second return datetime(args.year, args.month, args.day, args.hour, args.minute, sec) def _engine_args(args: argparse.Namespace, *, second: int | None = None) -> argparse.Namespace: return argparse.Namespace( moon_lon=None, nakshatra=None, pada=None, birthdate=None, today=None, year=args.year, month=args.month, day=args.day, hour=args.hour, minute=args.minute, second=args.second if second is None else second, lat=args.lat, lon=args.lon, tz=args.tz, node_mode=args.node_mode, ) def _chart(args: argparse.Namespace, *, second: int | None = None) -> dict[str, Any]: chart, _asc_idx, _jd, _ayanamsa = engine._compute_chart_from_args(_engine_args(args, second=second)) if chart is None: raise RuntimeError("Swiss Ephemeris is required for dasha reference audit") return chart def _moon_context(args: argparse.Namespace, *, second: int | None = None) -> dict[str, Any]: chart = _chart(args, second=second) moon = chart.get("planets", {}).get("Moon", {}) moon_lon = float(moon["degree_raw"]) nak_index = int(moon_lon / NAKSHATRA_SPAN) progress = (moon_lon % NAKSHATRA_SPAN) / NAKSHATRA_SPAN nak_name, start_lord, start_years = engine.NAKSHATRA_LIST[nak_index % 27] return { "chart": chart, "moon_lon": moon_lon, "nakshatra_index": nak_index, "nakshatra": nak_name, "start_lord": start_lord, "start_years": start_years, "progress": progress, "elapsed_years": progress * start_years, "balance_years": start_years - progress * start_years, } def _boundary_from_context( birth_dt: datetime, context: dict[str, Any], year_length: float, ) -> datetime: elapsed_years = float(context["elapsed_years"]) return birth_dt - timedelta(days=elapsed_years * year_length) def _boundary_snapshot(args: argparse.Namespace, *, second: int, year_length: float = 365.25) -> dict[str, Any]: ctx = _moon_context(args, second=second) birth_dt = _birth_datetime(args, second=second) start_dt = _boundary_from_context(birth_dt, ctx, year_length) return { "birth_time": _birth_time_string(args.hour, args.minute, second), "year_length": year_length, "moon_lon": round(ctx["moon_lon"], 8), "nakshatra": ctx["nakshatra"], "start_lord": ctx["start_lord"], "progress": round(ctx["progress"], 10), "elapsed_years": round(ctx["elapsed_years"], 8), "balance_years": round(ctx["balance_years"], 8), "start_datetime": start_dt.isoformat(timespec="seconds"), } def _required_moon_delta_for_days( days_delta: float, start_years: float, year_length: float, ) -> float: """Return approximate Moon longitude delta in degrees for a boundary shift.""" elapsed_year_delta = abs(days_delta) / year_length progress_delta = elapsed_year_delta / start_years if start_years else 0.0 return progress_delta * NAKSHATRA_SPAN def build_report(args: argparse.Namespace) -> dict[str, Any]: production = engine.cmd_dasha(_engine_args(args)) if "error" in production: raise RuntimeError(production["error"]) first = production["timeline"][0] context = _moon_context(args) birth_dt = _birth_datetime(args) start_dt = datetime.fromisoformat(first["start_datetime"]) target_dt = datetime.strptime(args.target_start_date, "%Y-%m-%d") date_delta_days = (start_dt.date() - target_dt.date()).days exact_delta_days = (start_dt - target_dt).total_seconds() / 86400.0 required_moon_delta_deg = _required_moon_delta_for_days( exact_delta_days, float(context["start_years"]), 365.25, ) with_seconds = _boundary_snapshot(args, second=args.second) minute_only = _boundary_snapshot(args, second=0) with_seconds_dt = datetime.fromisoformat(with_seconds["start_datetime"]) minute_only_dt = datetime.fromisoformat(minute_only["start_datetime"]) clock_translation_seconds = args.second moon_recalculation_seconds = int((with_seconds_dt - minute_only_dt).total_seconds()) - clock_translation_seconds year_sensitivity = [] for year_length in YEAR_LENGTHS: start = _boundary_from_context(birth_dt, context, year_length) year_sensitivity.append({ "year_length": year_length, "start_datetime": start.isoformat(timespec="seconds"), "delta_days_vs_target_date": (start.date() - target_dt.date()).days, }) min_delta = min(abs(row["delta_days_vs_target_date"]) for row in year_sensitivity) if min_delta > 0: finding = ( "当前参考差异无法由秒级输入或年长常数单独解释;" "下一步应对比外部 oracle 的 Moon sidereal longitude、ayanamsa 与 Vimshottari 起算口径。" ) else: finding = "当前参考差异可能由年长常数解释;需增加外部 oracle 样本确认。" return { "scope": "vimshottari_dasha_reference_boundary_audit", "case": { "date": f"{args.year:04d}-{args.month:02d}-{args.day:02d}", "birth_time": _birth_time_string(args.hour, args.minute, args.second), "lat": args.lat, "lon": args.lon, "tz": args.tz, "node_mode": args.node_mode, }, "engine": { "moon_lon": round(context["moon_lon"], 8), "nakshatra": context["nakshatra"], "start_lord": context["start_lord"], "progress": round(context["progress"], 10), "elapsed_years": round(context["elapsed_years"], 8), "balance_years": round(context["balance_years"], 8), "start_datetime": first["start_datetime"], "end_datetime": first["end_datetime"], "start_date": first["start"], "end_date": first["end"], }, "target_reference": { "source": args.target_source, "start_date": args.target_start_date, "date_delta_days": date_delta_days, "exact_delta_days": round(exact_delta_days, 6), "required_moon_delta_degrees": round(required_moon_delta_deg, 8), "required_moon_delta_arcmin_range": { "min": round(required_moon_delta_deg * 60 * 0.95, 6), "max": round(required_moon_delta_deg * 60 * 1.05, 6), }, }, "clock_precision_sensitivity": { "with_seconds": with_seconds, "minute_only": minute_only, "seconds_effect": { "start_delta_seconds": int((with_seconds_dt - minute_only_dt).total_seconds()), "clock_translation_seconds": clock_translation_seconds, "moon_recalculation_seconds": moon_recalculation_seconds, "moon_delta_degrees": round(with_seconds["moon_lon"] - minute_only["moon_lon"], 8), "note": ( "Changing birth seconds both translates the birth clock and recomputes Moon longitude; " "near long Dasha lords this can shift the historical start boundary by more than seconds." ), }, }, "year_length_sensitivity": year_sensitivity, "finding": finding, "boundary": ( "This is a calculation-boundary audit, not an event-prediction accuracy claim. " "Do not tune production Dasha constants to one PDF without a larger oracle set." ), } def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Audit Vimshottari Dasha reference drift") parser.add_argument("--year", type=int, required=True) parser.add_argument("--month", type=int, required=True) parser.add_argument("--day", type=int, required=True) parser.add_argument("--hour", type=int, required=True) parser.add_argument("--minute", type=int, required=True) parser.add_argument("--second", type=int, default=0) parser.add_argument("--lat", type=float, required=True) parser.add_argument("--lon", type=float, required=True) parser.add_argument("--tz", type=float, required=True) parser.add_argument("--node-mode", choices=["mean", "true"], default="mean") parser.add_argument("--target-start-date", required=True) parser.add_argument("--target-source", default="external_reference") return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: args = parse_args(argv) if args.second < 0 or args.second > 59: raise SystemExit("--second must be between 0 and 59") report = build_report(args) print(json.dumps(report, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())