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
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-10-28 — 星盘页的大运日期和报告对齐
- 星盘页、对话旁星盘、今日星语、KP 月度报告里的大运和小运起止日期,改为和长报告一样从真实出生时刻起算。出生时间不在 0 点的盘,有些边界日期会挪一天(最多一天);大运顺序和年数不变。
- 生时校正内部的计算暂时没动。Skill 版本不变(BUG-1492,issue #137)。
## 2026-10-28 — 生时校正每一轮少等一次模型
- 生时校正每一轮不再先让模型花一次请求去读案例,由服务器直接读好交给模型,模型一开始就回应你。估计每轮快 3~8 秒;问答内容和流程不变。
+16
View File
@@ -20385,3 +20385,19 @@
- 关联:BUG-282 / BUG-937(思考模式与工具选择)、BUG-368(重复工具调用)。
- 复发自:无
- 修复版本:分支 `codex/rectification-preread-case-20261028`。
## BUG-1492 | 星盘页和报告的大运起止日期最多差一天
- 状态:resolved(代码 + 回归测试;待部署);校正打分那一半另议(见「未做」)
- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #118 代码体检第 19 条 → #137,产品 10-28 同意用户可见日期最多变一天)
- 影响面:星盘页与对话旁星盘(`/api/chart` 的 `canonical_dasha`、`/api/dasha` 的 Vimshottari、分析层的当前大运 / 小运)、今日星语(`scripts/daily_guidance_service.py`)、KP 月度报告(`scripts/kp_monthly_vimshottari.py`);新模块 `scripts/chart_dasha/birth_moment.py`;`scripts/jyotish_api_server.py` 只改调用点。
- 现象:同一张盘,星盘页和长报告 / 完整解读里的大运、小运起止日期,有时差一天。
- 触发条件:出生时间不在 0 点的盘(60 张标准测试盘里 51 张)。
- 根因:Vimshottari 有两套起算:报告用的 `jyotish_engine.cmd_dasha` 从真实出生时刻往回推第一大运的余额;星盘页走的 `domain_calculation_service.compute_vimshottari_timeline` 只把出生**日期**交给 `dasha_analyzer.build_dasha_timeline`,从出生当天 0 点起算。小运的排法也不同(报告按完整父段起点、浮点天数排,再裁到出生之后;星盘页按整数天)。
- 修复:新模块按 `cmd_dasha` 的同一套算法(余额从出生时刻往回推、一年 365.25 天、年数表读 `dasha_common.VIMSHOTTARI_YEARS`、小运从完整父段排并在第一大运裁到出生)给出与旧函数同形状的结果,星盘页、今日星语、KP 月度改用它。`domain_calculation_service.py`、`dasha_analyzer.py` 都在校正打分的冻结清单里(ERR-110),本单一个字节都没改,只读调用。
- 验证:60 张标准测试盘:新算法与 `cmd_dasha` 的大运起止 60/60 一致、小运起止 60/60 一致;旧的星盘页算法只有 9/60 一致;51 张盘的日期会变,每个边界最多一天,主星顺序、年数、出生余额不变。新测试 `tests/test_chart_dasha_birth_moment_20261028.py`(每 6 张抽 1 张比大运和小运逐项相等;全部 60 张只动日期且最多一天;`/api/dasha` 与 `cmd_dasha` 起点一致)。Python 快速门全部 Python 步骤通过(pytest 413.9 s,含校正验证完整性门禁:冻结文件一个字节没改);前端测试只有本机缺数据库 / Docker 的那些老失败。
- **未做(需拍板)**:生时校正的打分探针(`rectification/event_probes.py`、`active_rectification_event_engine.py`)也用 0 点起算。它们和 `dasha_analyzer.py`、`domain_calculation_service.py` 都在冻结清单里,而且改了会改变校正的打分结果(不是输出不变的那种),按研究协议要重跑封存 20 例与偏移 900 例、由产品确认新结果后另存标签。这一半没动,校正与星盘页之间最多一天的差还在。
- 防复发:上述测试。同一个量在产品里只留一套算法;用户可见的大运日期统一走 `cmd_dasha` 的口径。
- 关联:ERR-110(冻结文件)、BUG-1185。
- 复发自:无
- 修复版本:分支 `codex/chart-dasha-birth-moment-20261028`。
+5
View File
@@ -52,3 +52,8 @@
25. 说一件人生大事(带年月):照常回「记下了」并出下一题;进度、选项都正常。
26. 先说「先这样」暂停,再回来说「继续」:能接着校正,不会卡在「已暂停」。
## #137 星盘页的大运日期和报告对齐(BUG-1492)
27. 挑一张出生时间不在 0 点的盘:星盘页「大运」里当前大运和小运的起止日期,与同一张盘生成的完整报告里的日期逐个一样。
28. 今日星语里提到的当前大运 / 小运,和星盘页一致。
+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(
@@ -0,0 +1,96 @@
"""Issue #137: chart surfaces start Vimshottari from the birth moment, as reports do."""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
import pytest
from scripts.chart_dasha import birth_moment
from scripts.jyotish_engine import cmd_dasha
import domain_calculation_service as dcs
ROOT = Path(__file__).resolve().parents[1]
CHARTS = json.loads((ROOT / "references" / "standard_test_charts.json").read_text(encoding="utf-8"))["charts"]
def _tz(value) -> float:
if isinstance(value, (int, float)):
return float(value)
text = str(value)
sign = -1 if text.startswith("-") else 1
hours, _, minutes = text.lstrip("+-").partition(":")
return sign * (int(hours) + int(minutes or 0) / 60)
def _case(chart):
y, m, d = map(int, chart["date"].split("-"))
hh, mm = map(int, chart["time"].split(":")[:2])
return y, m, d, hh, mm, float(chart["lat"]), float(chart["lon"]), _tz(chart["tz"])
def _engine_timeline(y, m, d, hh, mm, lat, lon, tz):
args = SimpleNamespace(year=y, month=m, day=d, hour=hh, minute=mm, second=0, lat=lat, lon=lon, tz=tz,
ayanamsa="lahiri", node_mode="mean", today="2026-10-28", moon_lon=None,
nakshatra=None, pada=None, birthdate=None)
out = cmd_dasha(args)
return (out.get("result") or out)["timeline"]
def _moon(y, m, d, hh, mm, lat, lon, tz):
chart = dcs.compute_chart({"year": y, "month": m, "day": d, "hour": hh, "minute": mm, "second": 0,
"lat": lat, "lon": lon, "tz": tz, "ayanamsa": "lahiri", "node_mode": "mean"})
return chart["planets"]["Moon"]["lon"]
@pytest.mark.parametrize("chart", CHARTS[::6], ids=lambda chart: chart["name"])
def test_chart_md_and_ad_boundaries_equal_the_report(chart):
case = _case(chart)
birth = datetime(*case[:5])
engine = _engine_timeline(*case)
ours, *_ = birth_moment.mahadashas(birth, _moon(*case))
assert [(p["lord"], p["start"], p["end"]) for p in engine] == [
(p["lord"], p["start"].strftime("%Y-%m-%d"), p["end"].strftime("%Y-%m-%d")) for p in ours
]
engine_ad = [(a["lord"], a["start"], a["end"]) for p in engine for a in p["antardasha_timeline"]]
ours_ad = [
(a["lord"], a["start"].strftime("%Y-%m-%d"), a["end"].strftime("%Y-%m-%d"))
for index, p in enumerate(ours)
for a in birth_moment.antardashas(p, visible_from=birth if index == 0 else None)
]
assert engine_ad == ours_ad
def test_the_drop_in_keeps_the_shape_and_only_moves_dates_by_at_most_a_day():
shifted = 0
for chart in CHARTS:
case = _case(chart)
birth, moon = datetime(*case[:5]), _moon(*case)
old = dcs.compute_vimshottari_timeline(birth_dt=birth, moon_lon=moon)
new = birth_moment.compute_vimshottari_timeline(birth_dt=birth, moon_lon=moon)
assert set(new) == set(old)
assert new["birth_balance"] == old["birth_balance"]
assert new["calculation_contract"]["effective"]["start_basis"] == "birth_moment"
for before, after in zip(old["periods"], new["periods"]):
assert before["lord"] == after["lord"] and before["years"] == after["years"]
days = abs((datetime.strptime(after["start"], "%Y-%m-%d") - datetime.strptime(before["start"], "%Y-%m-%d")).days)
assert days <= 1
shifted += old["periods"] != new["periods"]
assert shifted > 0, "the golden set has charts whose dates move (51 of 60 when measured)"
def test_the_api_chart_dasha_is_the_reports():
import scripts.jyotish_api_server as server
chart = CHARTS[0]
y, m, d, hh, mm, lat, lon, tz = _case(chart)
body = {"year": y, "month": m, "day": d, "hour": hh, "minute": mm, "second": 0, "lat": lat, "lon": lon, "tz": tz,
"ayanamsa": "lahiri", "node_mode": "mean", "dasha": "vimshottari", "today": "2026-10-28",
"moon_lon": _moon(y, m, d, hh, mm, lat, lon, tz)}
result = server.JyotishComputeService()._compute_dasha_system(body)
periods = result.get("periods") or result.get("timeline") or []
engine = _engine_timeline(y, m, d, hh, mm, lat, lon, tz)
assert [(p["lord"], p["start"]) for p in periods][:9] == [(p["lord"], p["start"]) for p in engine]