Files
Jyotisha/scripts/chart_dasha/birth_moment.py
Jesse_ChenandClaude Opus 5.5 5fd79a5649 Chart surfaces start Vimshottari from the birth moment, as reports do (issue #137, BUG-1492)
/api/chart, /api/dasha and the analysis layer, daily guidance and the KP
monthly packet used compute_vimshottari_timeline, which hands
build_dasha_timeline only the birth date, so periods started at midnight;
cmd_dasha (reports) starts from the birth clock and lays antardashas from the
full parent with float days. scripts/chart_dasha/birth_moment.py reproduces
cmd_dasha; 60 standard charts: MD and AD 60/60 equal (old chart path 9/60),
51 charts move by at most one day. Frozen scoring files are untouched, so
rectification probes keep the day start (left for a product decision).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:27:35 +08:00

103 lines
4.7 KiB
Python

"""Vimshottari from the real birth moment, as the report computes it (issue #137).
`/api/chart` and the dasha endpoints went through
``domain_calculation_service.compute_vimshottari_timeline``, which hands
``build_dasha_timeline`` only the birth *date*, so every period started from
midnight of the birth day. ``jyotish_engine.cmd_dasha`` (reports, full
reading) starts from the birth clock. The two disagreed by up to a day.
This module reproduces ``cmd_dasha``'s arithmetic for the chart surfaces:
the balance is taken back from the birth moment, years are 365.25 days, and
antardashas run from the full parent start and are clipped to birth for the
first (balance) mahadasha. The frozen scoring files
(``domain_calculation_service``, ``dasha_analyzer``) are read, never changed,
so rectification keeps its own frozen results.
"""
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timedelta
from typing import Any
try:
from scripts.dasha_common import VIMSHOTTARI_ORDER, VIMSHOTTARI_YEARS
from scripts.dasha_analyzer import lon_to_nakshatra
except ModuleNotFoundError: # pragma: no cover - script execution path
from dasha_common import VIMSHOTTARI_ORDER, VIMSHOTTARI_YEARS
from dasha_analyzer import lon_to_nakshatra
YEAR_DAYS = 365.25
START_BASIS = "birth_moment"
def mahadashas(birth_dt: datetime, moon_lon: float) -> tuple[list[dict[str, Any]], float, float, str]:
"""Same tuple as ``dasha_analyzer.build_dasha_timeline``, from the birth moment."""
(nak_name, start_lord, start_years), progress, _pada = lon_to_nakshatra(float(moon_lon) % 360)
elapsed = progress * start_years
remaining = start_years - elapsed
start = birth_dt - timedelta(days=elapsed * YEAR_DAYS)
first = VIMSHOTTARI_ORDER.index(start_lord)
timeline = []
for offset in range(9):
lord = VIMSHOTTARI_ORDER[(first + offset) % 9]
years = VIMSHOTTARI_YEARS[lord]
end = start + timedelta(days=years * YEAR_DAYS)
timeline.append({"lord": lord, "start": start, "end": end, "years": years})
start = end
return timeline, round(elapsed, 2), round(remaining, 2), start_lord
def antardashas(mahadasha: dict[str, Any], *, visible_from: datetime | None = None) -> list[dict[str, Any]]:
"""Sub-periods as ``cmd_dasha`` lays them out; ``visible_from`` clips the balance period at birth."""
lord = mahadasha["lord"]
parent_start, parent_end = mahadasha["start"], mahadasha["end"]
total = timedelta(days=float(VIMSHOTTARI_YEARS[lord]) * YEAR_DAYS)
first = VIMSHOTTARI_ORDER.index(lord)
rows = []
start = parent_start
for offset in range(9):
sub_lord = VIMSHOTTARI_ORDER[(first + offset) % 9]
end = parent_end if offset == 8 else start + total * (VIMSHOTTARI_YEARS[sub_lord] / 120.0)
if visible_from is None or end > visible_from:
rows.append({
"lord": sub_lord,
"start": max(start, visible_from) if visible_from else start,
"end": end,
"years": VIMSHOTTARI_YEARS[sub_lord],
})
start = end
return rows
def _canonical_hash(payload: dict[str, Any]) -> str:
encoded = json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":"), default=str)
return hashlib.sha256(encoded.encode("ascii")).hexdigest()
def compute_vimshottari_timeline(*, birth_dt: datetime, moon_lon: float, current_date: datetime | None = None) -> dict[str, Any]:
"""Drop-in for ``domain_calculation_service.compute_vimshottari_timeline``, from the birth moment."""
try: # read-only: the frozen service's contract version
from scripts.domain_calculation_service import CONTRACT_VERSION
except ModuleNotFoundError: # pragma: no cover - script execution path
from domain_calculation_service import CONTRACT_VERSION
(nak_name, _lord, _years), _progress, pada = lon_to_nakshatra(float(moon_lon) % 360)
timeline, elapsed, remaining, start_lord = mahadashas(birth_dt, moon_lon)
result = {
"periods": [
{"lord": row["lord"], "years": row["years"], "start": row["start"].strftime("%Y-%m-%d"), "end": row["end"].strftime("%Y-%m-%d")}
for row in timeline
],
"birth_balance": {"lord": start_lord, "elapsed_years": elapsed, "remaining_years": remaining},
"calculation_contract": {
"contract_version": CONTRACT_VERSION,
"algorithm": "vimshottari_birth_balance",
"requested": {"moon_longitude": float(moon_lon) % 360},
"effective": {"year_basis_days": YEAR_DAYS, "nakshatra": nak_name, "pada": pada, "start_basis": START_BASIS},
},
}
result["result_hash"] = _canonical_hash(result)
return result