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 <cursoragent@cursor.com>
This commit is contained in:
jesse-ux
2026-10-18 16:08:31 +08:00
co-authored by Grok (agent loop) Cursor
parent e7bf60c1f2
commit 68762ccec5
3 changed files with 75 additions and 96 deletions
+15
View File
@@ -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`。
+43 -96
View File
@@ -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(过境星宿叠加大运)
# ============================================================================
@@ -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'}