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>
This commit is contained in:
Jesse_Chen
2026-10-28 14:27:35 +08:00
co-authored by Claude Opus 5.5
parent 699e353887
commit 5fd79a5649
9 changed files with 244 additions and 13 deletions
+1
View File
@@ -0,0 +1 @@
"""Chart-facing dasha arithmetic that is not part of the frozen scoring files."""
+102
View File
@@ -0,0 +1,102 @@
"""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
+4 -2
View File
@@ -15,11 +15,13 @@ if str(SCRIPT_DIR) not in sys.path:
try:
import swisseph as swe
from ayanamsa_utils import apply_ayanamsa, normalize_ayanamsa_name, sidereal_ayanamsa_degrees
from domain_calculation_service import compute_chart, compute_vimshottari_timeline
from domain_calculation_service import compute_chart
from chart_dasha.birth_moment import compute_vimshottari_timeline # issue #137: same start as the report
except ModuleNotFoundError:
import swisseph as swe
from scripts.ayanamsa_utils import apply_ayanamsa, normalize_ayanamsa_name, sidereal_ayanamsa_degrees
from scripts.domain_calculation_service import compute_chart, compute_vimshottari_timeline
from scripts.domain_calculation_service import compute_chart
from scripts.chart_dasha.birth_moment import compute_vimshottari_timeline
SIGNS = [
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
+13 -9
View File
@@ -2530,6 +2530,12 @@ def execute_consultation_workflow(
return apply_commercial_skill_truth(result)
def _birth_moment_vimshottari(**kwargs):
"""Chart surfaces start Vimshottari from the birth moment, as reports do (issue #137)."""
from scripts.chart_dasha.birth_moment import compute_vimshottari_timeline
return compute_vimshottari_timeline(**kwargs)
def _load_local_module(module_name):
cached = _LOCAL_MODULE_CACHE.get(module_name)
if cached:
@@ -6026,7 +6032,7 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
moon_lon = float(planets_data['Moon']['lon'])
birth_dt = datetime(year, month, day, int(hour), int(minute), int(second))
canonical_dasha = calculation_service.compute_vimshottari_timeline(
canonical_dasha = _birth_moment_vimshottari(
birth_dt=birth_dt,
moon_lon=moon_lon,
current_date=birth_dt,
@@ -6128,7 +6134,7 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
'ayanamsa': _request_ayanamsa(body),
'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')),
})
canonical_dasha = calculation_service.compute_vimshottari_timeline(
canonical_dasha = _birth_moment_vimshottari(
birth_dt=birth_dt,
moon_lon=moon_lon,
current_date=datetime.utcnow(),
@@ -6803,7 +6809,7 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
computed = None
if dasha_key == 'vimshottari':
calculation_service = _load_local_module('domain_calculation_service')
canonical_dasha = calculation_service.compute_vimshottari_timeline(
canonical_dasha = _birth_moment_vimshottari(
birth_dt=birth_dt,
moon_lon=moon_lon,
current_date=(
@@ -6859,15 +6865,13 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
try:
analyzer = _load_local_module('dasha_analyzer')
enhanced = _load_local_module('dasha_calculator_enhanced')
from scripts.chart_dasha import birth_moment
nak_info, nak_progress, pada = analyzer.lon_to_nakshatra(moon_lon % 360)
timeline, elapsed, remaining, start_lord = analyzer.build_dasha_timeline(
birth_dt.strftime('%Y-%m-%d'),
nak_info,
nak_progress,
)
# issue #137: from the birth moment, as cmd_dasha (reports) does.
timeline, elapsed, remaining, start_lord = birth_moment.mahadashas(birth_dt, moon_lon)
today = self._parse_optional_date(current_date) if current_date else datetime.now()
current_idx, current_md = analyzer.find_current(timeline, today)
antardashas = analyzer.build_antardasha(current_md)
antardashas = birth_moment.antardashas(current_md, visible_from=birth_dt if current_idx == 0 else None)
current_ad = analyzer.find_current_sub(antardashas, today)
years_into_md = max(0.0, (today - current_md['start']).days / 365.25)
five_levels = enhanced.calculate_five_level_dasha(current_md['lord'], years_into_md)
+2 -2
View File
@@ -7,10 +7,10 @@ from datetime import datetime
try: # pragma: no cover - import path differs under CLI vs pytest
from dasha_calculator_enhanced import calculate_five_level_dasha
from domain_calculation_service import compute_vimshottari_timeline
from chart_dasha.birth_moment import compute_vimshottari_timeline # issue #137: same start as the report
except ImportError: # pragma: no cover
from scripts.dasha_calculator_enhanced import calculate_five_level_dasha
from scripts.domain_calculation_service import compute_vimshottari_timeline
from scripts.chart_dasha.birth_moment import compute_vimshottari_timeline
def build_monthly_vimshottari_snapshot(