diff --git a/CHANGELOG.md b/CHANGELOG.md index d1df2a90..87d47a09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-10-28 — 星盘页的大运日期和报告对齐 + +- 星盘页、对话旁星盘、今日星语、KP 月度报告里的大运和小运起止日期,改为和长报告一样从真实出生时刻起算。出生时间不在 0 点的盘,有些边界日期会挪一天(最多一天);大运顺序和年数不变。 +- 生时校正内部的计算暂时没动。Skill 版本不变(BUG-1492,issue #137)。 + ## 2026-10-28 — 生时校正每一轮少等一次模型 - 生时校正每一轮不再先让模型花一次请求去读案例,由服务器直接读好交给模型,模型一开始就回应你。估计每轮快 3~8 秒;问答内容和流程不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index dbe27cf0..882da381 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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`。 diff --git a/docs/testing/code-health-118-20261028.md b/docs/testing/code-health-118-20261028.md index e2842cd5..7097692c 100644 --- a/docs/testing/code-health-118-20261028.md +++ b/docs/testing/code-health-118-20261028.md @@ -52,3 +52,8 @@ 25. 说一件人生大事(带年月):照常回「记下了」并出下一题;进度、选项都正常。 26. 先说「先这样」暂停,再回来说「继续」:能接着校正,不会卡在「已暂停」。 +## #137 星盘页的大运日期和报告对齐(BUG-1492) + +27. 挑一张出生时间不在 0 点的盘:星盘页「大运」里当前大运和小运的起止日期,与同一张盘生成的完整报告里的日期逐个一样。 +28. 今日星语里提到的当前大运 / 小运,和星盘页一致。 + diff --git a/scripts/chart_dasha/__init__.py b/scripts/chart_dasha/__init__.py new file mode 100644 index 00000000..be78c587 --- /dev/null +++ b/scripts/chart_dasha/__init__.py @@ -0,0 +1 @@ +"""Chart-facing dasha arithmetic that is not part of the frozen scoring files.""" diff --git a/scripts/chart_dasha/birth_moment.py b/scripts/chart_dasha/birth_moment.py new file mode 100644 index 00000000..c6638cb6 --- /dev/null +++ b/scripts/chart_dasha/birth_moment.py @@ -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 diff --git a/scripts/daily_guidance_service.py b/scripts/daily_guidance_service.py index 9165cd72..a5e6ec5f 100644 --- a/scripts/daily_guidance_service.py +++ b/scripts/daily_guidance_service.py @@ -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", diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 0ce41f73..91a7b1d1 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -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) diff --git a/scripts/kp_monthly_vimshottari.py b/scripts/kp_monthly_vimshottari.py index 4dff0c10..1b9fb38a 100755 --- a/scripts/kp_monthly_vimshottari.py +++ b/scripts/kp_monthly_vimshottari.py @@ -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( diff --git a/tests/test_chart_dasha_birth_moment_20261028.py b/tests/test_chart_dasha_birth_moment_20261028.py new file mode 100644 index 00000000..2abfb6d7 --- /dev/null +++ b/tests/test_chart_dasha_birth_moment_20261028.py @@ -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]