From 68762ccec555669159884f59ebd6eb0ab2fbcc2f Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Sun, 18 Oct 2026 16:08:31 +0800 Subject: [PATCH] fix(nakshatra): keep the first mahadasha's antardashas on the full clock Upstream 8643bc9a. The nakshatra breakdown was restarting the nine antardashas from the birth remainder, so mid-Ashwini reported Ketu instead of Rahu. Reuse cmd_dasha and clip to birth. BUG-1380. Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 15 +++ scripts/nakshatra_dasha.py | 139 +++++++-------------- tests/test_nakshatra_native_dasha_clock.py | 17 +++ 3 files changed, 75 insertions(+), 96 deletions(-) create mode 100644 tests/test_nakshatra_native_dasha_clock.py diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index c0e40027..c6f1b977 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -36488,3 +36488,18 @@ - 相关记录:BUG-1361、BUG-1362。 - 复发自:无。 - 修复版本:分支 `codex/beta-safety-net-20261014`。本机没有装到生产主机,也没有点生产按钮。 + +## BUG-1380 | 星宿拆解把第一大运剩余年数重新切成九段小运 + +- 状态:resolved +- 首次发现 / 最近更新:2026-10-17 / 2026-10-18 +- 来源:issue #80,任务书 `docs/tasks/TASK-upstream-sync8-20261017.md` T1。上游 `8643bc9a`。检索「first_remaining」「antardasha_full」,没有更早的同类记录。BUG-1268 修了 `cmd_dasha` 的同一类错误,这条拆解函数当时没跟上。 +- 现象:月亮在 Ashwini 中点时,出生当下的小运被报成 Ketu。按完整大运时钟应为 Rahu。 +- 触发条件:`calc_nakshatra_dasha_breakdown` 把第一大运的剩余年数交给按整段比例切分的小运函数。完整报告的星宿拆解走这条路径。 +- 根因:剩余段被当成一段新的完整大运,九段小运从大运主星重新起算。 +- 修复:小运改走 `cmd_dasha` 已有时间线:从完整大运真实起点排,再裁到出生之后。缺本命经度的行星不再用 0° 填星宿。 +- 验证:`tests/test_nakshatra_native_dasha_clock.py` 的 Ashwini 中点用例通过,当前小运为 Rahu,起点裁在出生日时。 +- 防复发:上述测试。不得再把第一大运余额交给「按余额重切九段」的函数。 +- 相关记录:BUG-1268。 +- 复发自:无。 +- 修复版本:分支 `codex/upstream-sync8-20261017`。上游提交 `8643bc9a`。 diff --git a/scripts/nakshatra_dasha.py b/scripts/nakshatra_dasha.py index 3d5eccfc..b7a4aa4f 100644 --- a/scripts/nakshatra_dasha.py +++ b/scripts/nakshatra_dasha.py @@ -304,13 +304,6 @@ def _calc_ashtottari_antardasha(md_lord: str, md_years: float) -> List[Dict]: # Vimshottari Nakshatra-level 拆解 # ============================================================================ -# Vimshottari Dasha 顺序(9星循环) -try: - from dasha_common import VIMSHOTTARI_ORDER, VIMSHOTTARI_YEARS -except ModuleNotFoundError: # pragma: no cover - package import - from scripts.dasha_common import VIMSHOTTARI_ORDER, VIMSHOTTARI_YEARS - - def calc_nakshatra_dasha_breakdown( moon_longitude: float, planet_lons: Dict[str, float], @@ -338,87 +331,58 @@ def calc_nakshatra_dasha_breakdown( moon_nak_data = find_nakshatra(moon_longitude) moon_nak_idx = moon_nak_data['nakshatra_idx'] - # 计算 Vimshottari 起始 - nak = find_nakshatra(moon_longitude) - nak_idx = nak['nakshatra_idx'] - nak_span = 360.0 / 27 - nak_start = nak_idx * nak_span - deg_in_nak = moon_longitude - nak_start - used_ratio = deg_in_nak / nak_span - - # 起始守护星和剩余年数 - start_lord = NAK_LORDS[nak_idx] - start_total = VIMSHOTTARI_YEARS.get(start_lord, 7) - first_remaining = start_total * (1 - used_ratio) - - # 构建大运序列 - start_idx = VIMSHOTTARI_ORDER.index(start_lord) - sequence = [] - cumulative = 0.0 - - # 首个(剩余)大运 - sequence.append({ - 'lord': start_lord, - 'years': round(first_remaining, 4), - 'cumulative_from': 0.0, - 'is_first': True, - }) - cumulative += first_remaining - - idx = (start_idx + 1) % 9 - for _ in range(15): # 足够覆盖到120岁 - lord = VIMSHOTTARI_ORDER[idx] - years = VIMSHOTTARI_YEARS[lord] - sequence.append({ - 'lord': lord, - 'years': round(years, 4), - 'cumulative_from': round(cumulative, 4), - 'is_first': False, - }) - cumulative += years - idx = (idx + 1) % 9 - if cumulative > 120: - break - - # 定位当前大运 - current_md = None - current_md_idx = -1 - for i, item in enumerate(sequence): - if item['cumulative_from'] <= age < (item['cumulative_from'] + item['years']): - current_md = item - current_md_idx = i - break - + # 小运沿用 cmd_dasha:从完整大运真实起点排,再裁到出生之后。 + # 把第一大运的剩余年数当成完整大运会把早期小运整段重切(BUG-1380)。 + from types import SimpleNamespace + try: + from jyotish_engine import cmd_dasha + except ImportError: + from scripts.jyotish_engine import cmd_dasha + if not math.isfinite(age) or age < 0: + raise ValueError('age must be finite and nonnegative') + birth = datetime.fromisoformat(birth_date_str) + reference = birth + timedelta(days=age * 365.25) + packet = cmd_dasha(SimpleNamespace( + moon_lon=moon_longitude, nakshatra=None, pada=None, + birthdate=birth.date().isoformat(), year=birth.year, month=birth.month, + day=birth.day, hour=birth.hour, minute=birth.minute, second=birth.second, + lat=None, lon=None, tz=0, today=reference.date().isoformat(), + dasha_year_days=365.25)) + current_md = next((row for row in packet['timeline'] + if datetime.fromisoformat(row['start_datetime']) <= reference + < datetime.fromisoformat(row['end_datetime'])), None) if current_md is None: return {'error': f'无法定位年龄 {age} 在 Vimshottari 序列中'} - md_lord = current_md['lord'] - md_years = current_md['years'] - md_start_age = current_md['cumulative_from'] - years_in_md = age - md_start_age + md_start = datetime.fromisoformat(current_md['start_datetime']) + md_end = datetime.fromisoformat(current_md['end_datetime']) + md_years = (md_end - md_start).total_seconds() / (86400 * 365.25) + md_start_age = (md_start - birth).total_seconds() / (86400 * 365.25) + years_in_md = (reference - md_start).total_seconds() / (86400 * 365.25) # 大运守护星的 Nakshatra md_lord_nak = None if md_lord in planet_lons: md_lord_nak = find_nakshatra(planet_lons[md_lord]) - # 计算当前 Antardasha - antardasas = _calc_vimshottari_antardasha_full(md_lord, md_years) - - # 定位当前 Antardasha - ad_cumulative = 0.0 + antardasas = [] current_ad = None - for ad in antardasas: - ad_end = ad_cumulative + ad['years'] - if ad_cumulative <= years_in_md < ad_end: + for ad in current_md['antardasha_timeline']: + ad_start = datetime.fromisoformat(ad['start_datetime']) + ad_end = datetime.fromisoformat(ad['end_datetime']) + years = (ad_end - ad_start).total_seconds() / (86400 * 365.25) + antardasas.append({'lord': ad['lord'], 'years': years}) + if ad_start <= reference < ad_end: + nominal_start = datetime.fromisoformat(ad['unclipped_start_datetime']) + span = (ad_end - nominal_start).total_seconds() current_ad = { 'lord': ad['lord'], - 'years': ad['years'], - 'progress': (years_in_md - ad_cumulative) / ad['years'] if ad['years'] > 0 else 0, - 'remaining': round(ad_end - years_in_md, 4), + 'years': round(years, 4), + 'progress': (reference - nominal_start).total_seconds() / span if span else 0, + 'remaining': round((ad_end - reference).total_seconds() / (86400 * 365.25), 4), + 'start_datetime': ad['start_datetime'], + 'end_datetime': ad['end_datetime'], } - break - ad_cumulative = ad_end # 当前 Antardasha 守护星的 Nakshatra ad_lord_nak = None @@ -451,8 +415,8 @@ def calc_nakshatra_dasha_breakdown( md_years_per_pada = md_years_per_nak / 4 for ad in antardasas: - if md_years_per_nak > 0: - ad_nak = find_nakshatra(planet_lons.get(ad['lord'], 0)) + if md_years_per_nak > 0 and ad['lord'] in planet_lons: + ad_nak = find_nakshatra(planet_lons[ad['lord']]) pada_windows.append({ 'period': f"AD {ad['lord']}", 'nakshatra': ad_nak['nakshatra'], @@ -470,6 +434,8 @@ def calc_nakshatra_dasha_breakdown( return { 'system': 'Vimshottari Nakshatra-level Breakdown', + 'period_source': 'jyotish_engine.cmd_dasha native timeline', + 'reference_datetime': reference.isoformat(timespec='seconds'), 'moon_nakshatra': moon_nak_data['nakshatra'], 'moon_nakshatra_idx': moon_nak_idx, 'moon_pada': moon_nak_data['pada'], @@ -490,25 +456,6 @@ def calc_nakshatra_dasha_breakdown( } -def _calc_vimshottari_antardasha_full(md_lord: str, md_years: float) -> List[Dict]: - """计算完整的 Vimshottari Antardasha(9个周期)""" - md_start_idx = VIMSHOTTARI_ORDER.index(md_lord) - total_years = sum(VIMSHOTTARI_YEARS.values()) # 120 - - result = [] - for i in range(9): - ad_lord = VIMSHOTTARI_ORDER[(md_start_idx + i) % 9] - ad_years_total = VIMSHOTTARI_YEARS[ad_lord] - ad_years = md_years * ad_years_total / total_years - result.append({ - 'lord': ad_lord, - 'years': round(ad_years, 4), - 'months': round(ad_years * 12, 1), - }) - - return result - - # ============================================================================ # Nakshatra Transit Overlay(过境星宿叠加大运) # ============================================================================ diff --git a/tests/test_nakshatra_native_dasha_clock.py b/tests/test_nakshatra_native_dasha_clock.py new file mode 100644 index 00000000..df07f0a4 --- /dev/null +++ b/tests/test_nakshatra_native_dasha_clock.py @@ -0,0 +1,17 @@ +"""The interpretation helper must consume the real, unclipped MD/AD clock.""" +from scripts.nakshatra_dasha import calc_nakshatra_dasha_breakdown + + +def test_first_remaining_md_is_not_repartitioned_into_new_ads(): + # Mid-Ashwini: half of Ketu's seven years elapsed. The active AD is Rahu, + # not Ketu; the helper must not restart the first parent's child clock. + result = calc_nakshatra_dasha_breakdown( + 20 / 3, {'Ketu': 1., 'Rahu': 181.}, + '2000-01-01T12:34:56', 0.) + assert result['mahadasha']['lord'] == 'Ketu' + assert result['antardasha']['lord'] == 'Rahu' + assert result['antardasha']['start_datetime'] == '2000-01-01T12:34:56' + assert result['reference_datetime'] == '2000-01-01T12:34:56' + assert result['antardasha']['progress'] > 0 + # Missing natal positions are never silently fabricated as 0 degrees. + assert {row['period'] for row in result['pada_windows']} <= {'AD Ketu', 'AD Rahu'}