diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index c6f1b977..5c35c066 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -36503,3 +36503,18 @@ - 相关记录:BUG-1268。 - 复发自:无。 - 修复版本:分支 `codex/upstream-sync8-20261017`。上游提交 `8643bc9a`。 + +## BUG-1381 | 月亮正好在星宿起点时大运余额变成 0 + +- 状态:resolved +- 首次发现 / 最近更新:2026-10-17 / 2026-10-18 +- 来源:issue #80,任务书 T2。上游 `3c2dad95`、`8643bc9a`、`9e04f0db`(只取星宿定位)。检索「lon_to_nakshatra」「int(moon_lon」,没有更早的同类记录。 +- 现象:月亮经度恰好是某星宿起点时,第一大运余额变成 0,整段大运被跳过。27 个起点里有 16 个会这样。 +- 触发条件:星宿序号用 `int(经度/跨度)`,已用比例又用 `经度 % 跨度`,两个浮点结果在起点上对不上。 +- 根因:序号和进度不是从同一个量算出来的。起点被算成「上一宿几乎走完」。 +- 修复:起点用同一把分数网格:这一点属于刚开始的那一宿,已用比例为 0。`cmd_dasha`、`lon_to_nakshatra`、`find_nakshatra` 和星盘星宿字段都走这个函数。不是起点的经度仍用原来的取模,结果不变。 +- 验证:27 个起点的第一大运年数等于完整年数,第一段小运主星等于大运主星。起点左右相邻浮点数分别落在前一宿和这一宿。5000 个非起点随机经度与旧算法一致。 +- 防复发:`tests/test_nakshatra_native_dasha_clock.py`。 +- 相关记录:BUG-1268、BUG-1380。 +- 复发自:无。 +- 修复版本:分支 `codex/upstream-sync8-20261017`。上游提交 `3c2dad95`、`8643bc9a`、`9e04f0db`。 diff --git a/scripts/dasha_analyzer.py b/scripts/dasha_analyzer.py index 479e4f8f..cfe92a90 100644 --- a/scripts/dasha_analyzer.py +++ b/scripts/dasha_analyzer.py @@ -68,12 +68,18 @@ def fuzzy_match_nakshatra(name): return None def lon_to_nakshatra(moon_lon): - """月亮黄经(0-360)→ Nakshatra + Pada""" - nak_span = 360 / 27 - pada_span = nak_span / 4 - idx = int(moon_lon / nak_span) - progress = (moon_lon % nak_span) / nak_span - pada = int((moon_lon % nak_span) / pada_span) + 1 + """月亮黄经(0-360)→ Nakshatra + Pada。序号与进度来自同一把尺子。""" + try: + from nakshatra_advanced import nakshatra_index_and_progress + except ImportError: + from scripts.nakshatra_advanced import nakshatra_index_and_progress + idx, progress = nakshatra_index_and_progress(moon_lon) + if progress == 0.0: + pada = 1 + else: + nak_span = 360 / 27 + pada_span = nak_span / 4 + pada = int((float(moon_lon) % nak_span) / pada_span) + 1 return NAKSHATRA_LIST[idx], progress, pada def build_dasha_timeline(birth_date_str, nak_info, nak_progress): diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index f66aa24a..d5c8daf1 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -12023,10 +12023,16 @@ def _load_strict_evidence_collector(): # ============================================================================ def _nakshatra_fields(sidereal_lon): """Nakshatra, pada and lord of a sidereal longitude — planets and the ascendant alike.""" - nak_span = 360.0 / 27 + from nakshatra_advanced import nakshatra_index_and_progress lon = sidereal_lon % 360 - pada = int((lon % nak_span) / (nak_span / 4)) + 1 - name, lord, _ = NAKSHATRA_LIST[int(lon / nak_span) % 27] + idx, progress = nakshatra_index_and_progress(lon) + if progress == 0.0: + pada = 1 + else: + nak_span = 360.0 / 27 + pada = int((lon % nak_span) / (nak_span / 4)) + 1 + idx = int(lon / nak_span) % 27 + name, lord, _ = NAKSHATRA_LIST[idx] return {"nakshatra": name, "nakshatra_pada": pada, "nakshatra_lord": lord} @@ -12165,8 +12171,9 @@ def _build_pratyantar_timeline(md_lord, ad_lord, ad_start, ad_end, reference_dt= def cmd_dasha(args): nak_info = None; progress = 0.5 if args.moon_lon is not None: - ns = 360.0 / 27; idx = int(args.moon_lon / ns); progress = (args.moon_lon % ns) / ns - nak_info = NAKSHATRA_LIST[idx % 27] + from nakshatra_advanced import nakshatra_index_and_progress + idx, progress = nakshatra_index_and_progress(args.moon_lon) + nak_info = NAKSHATRA_LIST[idx] elif args.nakshatra: nl = args.nakshatra.lower().replace(" ", "").replace("-", "") for n in NAKSHATRA_LIST: @@ -12183,8 +12190,9 @@ def cmd_dasha(args): moon_lon = moon.get("degree_raw") if moon_lon is None: return {"error": "无法计算Moon位置"} - ns = 360.0 / 27; idx = int(moon_lon / ns); progress = (moon_lon % ns) / ns - nak_info = NAKSHATRA_LIST[idx % 27] + from nakshatra_advanced import nakshatra_index_and_progress + idx, progress = nakshatra_index_and_progress(moon_lon) + nak_info = NAKSHATRA_LIST[idx] nak_name, start_lord, start_years = nak_info birthdate = args.birthdate or f"{args.year}-{args.month:02d}-{args.day:02d}" @@ -15425,10 +15433,15 @@ def cmd_full_reading(args): try: moon_data = planets.get('Moon', {}) moon_lon = moon_data.get('degree_raw', moon_data.get('degree', 0)) - nak_idx = int(moon_lon / (360 / 27)) % 27 + from nakshatra_advanced import nakshatra_index_and_progress + nak_idx, nak_progress = nakshatra_index_and_progress(moon_lon) + if nak_progress != 0.0: + nak_idx = int(moon_lon / (360 / 27)) % 27 + pada = int((moon_lon % (360/27)) / (360/108)) + 1 + else: + pada = 1 nak_name = NAKSHATRA_LIST[nak_idx][0] nak_lord = NAKSHATRA_LIST[nak_idx][1] - pada = int((moon_lon % (360/27)) / (360/108)) + 1 birthdate = f"{args.year}-{args.month:02d}-{args.day:02d}" today_str = getattr(args, 'transit_date', None) or getattr(args, 'today', None) or datetime.now().strftime('%Y-%m-%d') @@ -16459,8 +16472,13 @@ def cmd_full_reading(args): jaimini_data = report['modules'].get('jaimini', {}) chara_dasha_data = jaimini_data.get('chara_dasha', {}) if isinstance(jaimini_data, dict) else {} moon_lon = planet_lons.get('Moon', 0) - moon_nakshatra_index = int(moon_lon / (360 / 27)) % 27 - moon_pada = int((moon_lon % (360 / 27)) / (360 / 108)) + 1 + from nakshatra_advanced import nakshatra_index_and_progress + moon_nakshatra_index, moon_nak_progress = nakshatra_index_and_progress(moon_lon) + if moon_nak_progress != 0.0: + moon_nakshatra_index = int(moon_lon / (360 / 27)) % 27 + moon_pada = int((moon_lon % (360 / 27)) / (360 / 108)) + 1 + else: + moon_pada = 1 tithi_number = int(((moon_lon - planet_lons.get('Sun', 0)) % 360) / 12) + 1 birth_dt = _birth_datetime_from_args(args) ll_name = SIGN_LORDS[SIGNS[asc_idx]] diff --git a/scripts/nakshatra_advanced.py b/scripts/nakshatra_advanced.py index f33574c4..fe33e3fd 100644 --- a/scripts/nakshatra_advanced.py +++ b/scripts/nakshatra_advanced.py @@ -16,6 +16,14 @@ Nakshatra深度分析系统 from typing import Dict, List, Tuple, Optional import math from datetime import datetime, timedelta +from bisect import bisect_right +from fractions import Fraction + +# Exact star openings. float(Fraction) is the grid cmd_dasha must share with +# lon_to_nakshatra; 360/27 and modulo disagree at several of these points. +_NAK_SPAN = Fraction(40, 3) +_NAK_STARTS = tuple(float(_NAK_SPAN * index) for index in range(27)) +_NAK_SPAN_FLOAT = 360.0 / 27.0 NAK_LIST = [ ("Ashwini","Ketu",7),("Bharani","Venus",20),("Krittika","Sun",6), @@ -53,15 +61,42 @@ NAK_ELEMENT = { } +def nakshatra_index_and_progress(longitude: float) -> Tuple[int, float]: + """星宿序号和已用比例,由同一把尺子量出。 + + 经度恰好等于某星宿起点时,这一点属于刚开始的那一宿,已用比例为 0。 + 其余经度保持原来的取模结果,避免改动非边界点。 + """ + lon = float(longitude) % 360.0 + idx = bisect_right(_NAK_STARTS, lon) - 1 + if idx < 0: + idx = 0 + elif idx > 26: + idx = 26 + if lon == _NAK_STARTS[idx]: + return idx, 0.0 + legacy_idx = int(lon / _NAK_SPAN_FLOAT) % 27 + legacy_progress = (lon % _NAK_SPAN_FLOAT) / _NAK_SPAN_FLOAT + return legacy_idx, legacy_progress + + def find_nakshatra(longitude: float) -> Dict: """从恒星黄道经度精确计算Nakshatra""" - nak_span = 360.0 / 27 # 每个星宿13.333...° - nak_idx = int(longitude / nak_span) % 27 - nak_start = nak_idx * nak_span - deg_in_nak = longitude - nak_start - pada_span = nak_span / 4 # 每个Pada 3.333...° - pada = int(deg_in_nak / pada_span) + 1 - deg_in_pada = deg_in_nak - (pada - 1) * pada_span + idx, _progress = nakshatra_index_and_progress(longitude) + lon = float(longitude) % 360.0 + if lon == _NAK_STARTS[idx]: + nak_idx = idx + deg_in_nak = 0.0 + pada = 1 + deg_in_pada = 0.0 + else: + nak_span = 360.0 / 27 # 每个星宿13.333...° + nak_idx = int(longitude / nak_span) % 27 + nak_start = nak_idx * nak_span + deg_in_nak = longitude - nak_start + pada_span = nak_span / 4 # 每个Pada 3.333...° + pada = int(deg_in_nak / pada_span) + 1 + deg_in_pada = deg_in_nak - (pada - 1) * pada_span return { 'nakshatra': NAK_NAMES[nak_idx], @@ -126,11 +161,13 @@ def calc_vimshottari_start(moon_longitude: float) -> Dict: nak_lord = NAK_LORDS[nak_idx] total_years = NAK_YEARS[nak_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 + # 已用比例与星宿序号来自同一把尺子。起点处必须是 0,不能是「几乎走完」。 + _, used_ratio = nakshatra_index_and_progress(moon_longitude) + if float(moon_longitude) % 360.0 != _NAK_STARTS[nak_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 remaining_ratio = 1 - used_ratio # 第一个Mahadasha的剩余年数 diff --git a/tests/test_nakshatra_native_dasha_clock.py b/tests/test_nakshatra_native_dasha_clock.py index df07f0a4..2b61f3bc 100644 --- a/tests/test_nakshatra_native_dasha_clock.py +++ b/tests/test_nakshatra_native_dasha_clock.py @@ -1,4 +1,13 @@ """The interpretation helper must consume the real, unclipped MD/AD clock.""" +import math +from fractions import Fraction +from types import SimpleNamespace + +import pytest + +from scripts.dasha_analyzer import lon_to_nakshatra +from scripts.jyotish_engine import cmd_dasha +from scripts.nakshatra_advanced import nakshatra_index_and_progress from scripts.nakshatra_dasha import calc_nakshatra_dasha_breakdown @@ -15,3 +24,27 @@ def test_first_remaining_md_is_not_repartitioned_into_new_ads(): 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'} + + +@pytest.mark.parametrize('index', range(27)) +def test_native_vimshottari_exact_star_birth_balance(index): + packet = cmd_dasha(SimpleNamespace( + moon_lon=index * 40 / 3, nakshatra=None, pada=None, + birthdate='2000-01-01', year=2000, month=1, day=1, hour=12, minute=0, second=0, + lat=None, lon=None, tz=0, today='2000-01-01', dasha_year_days=365.25)) + first = packet['timeline'][0] + assert first['years'] == first['full_years'] + assert first['antardasha_timeline'][0]['lord'] == first['lord'] + + +def test_exact_star_neighbors_keep_adjacent_indexes(): + start = float(Fraction(40, 3)) + before = math.nextafter(start, 0.0) + after = math.nextafter(start, 360.0) + assert nakshatra_index_and_progress(before)[0] == 0 + assert nakshatra_index_and_progress(start)[0] == 1 + assert nakshatra_index_and_progress(after)[0] == 1 + assert lon_to_nakshatra(before)[0][0] == 'Ashwini' + assert lon_to_nakshatra(start)[0][0] == 'Bharani' + assert lon_to_nakshatra(after)[0][0] == 'Bharani' + assert lon_to_nakshatra(start)[1] == 0.0