fix(dasha): measure nakshatra index and progress on one grid

Upstream 3c2dad95, 8643bc9a, and the lon_to_nakshatra part of 9e04f0db. Exact star starts were split across int-division and modulo, so 16 of 27 openings reported a zero balance and skipped the mahadasha. Off-boundary longitudes keep the old modulo. BUG-1381.

Co-Authored-By: Grok (agent loop)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jesse-ux
2026-10-18 16:15:48 +08:00
co-authored by Grok (agent loop) Cursor
parent 68762ccec5
commit d11ee6f0dd
5 changed files with 138 additions and 29 deletions
+15
View File
@@ -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`。
+12 -6
View File
@@ -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):
+29 -11
View File
@@ -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]]
+49 -12
View File
@@ -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的剩余年数
@@ -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