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:
co-authored by
Claude Opus 5.5
parent
699e353887
commit
5fd79a5649
@@ -1,5 +1,10 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-28 — 星盘页的大运日期和报告对齐
|
||||
|
||||
- 星盘页、对话旁星盘、今日星语、KP 月度报告里的大运和小运起止日期,改为和长报告一样从真实出生时刻起算。出生时间不在 0 点的盘,有些边界日期会挪一天(最多一天);大运顺序和年数不变。
|
||||
- 生时校正内部的计算暂时没动。Skill 版本不变(BUG-1492,issue #137)。
|
||||
|
||||
## 2026-10-28 — 生时校正每一轮少等一次模型
|
||||
|
||||
- 生时校正每一轮不再先让模型花一次请求去读案例,由服务器直接读好交给模型,模型一开始就回应你。估计每轮快 3~8 秒;问答内容和流程不变。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -52,3 +52,8 @@
|
||||
25. 说一件人生大事(带年月):照常回「记下了」并出下一题;进度、选项都正常。
|
||||
26. 先说「先这样」暂停,再回来说「继续」:能接着校正,不会卡在「已暂停」。
|
||||
|
||||
## #137 星盘页的大运日期和报告对齐(BUG-1492)
|
||||
|
||||
27. 挑一张出生时间不在 0 点的盘:星盘页「大运」里当前大运和小运的起止日期,与同一张盘生成的完整报告里的日期逐个一样。
|
||||
28. 今日星语里提到的当前大运 / 小运,和星盘页一致。
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Chart-facing dasha arithmetic that is not part of the frozen scoring files."""
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user