fix(kp): place exact angular boundaries on the opening subdivision
Upstream 8eec9a54. Floating accumulation and a 120-degree remainder put star, pada, and sub-lord on different sides of the same boundary. Random longitudes away from those boundaries are unchanged. BUG-1383. Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Grok (agent loop)
Cursor
parent
5be864a8e4
commit
b5eb72343a
@@ -36533,3 +36533,18 @@
|
||||
- 相关记录:BUG-1268、BUG-1380。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/upstream-sync8-20261017`。上游提交 `3c2dad95`。
|
||||
|
||||
## BUG-1383 | KP 子分区在精确边界上落到不同半开区间
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现 / 最近更新:2026-10-17 / 2026-10-18
|
||||
- 来源:issue #80,任务书 T7。上游 `8eec9a54`。检索「get_kp_lords」「sub_sub」,没有更早的同类记录。
|
||||
- 现象:星宿、四分和子主星在精确边界上各算各的。按角秒扫一圈,约 129.6 万个点里有 499 处不一致,都在精确边界上。
|
||||
- 触发条件:`get_kp_lords` 用浮点累加子区间,再和 120° 取余混用。
|
||||
- 根因:区间边界被重复舍入,共享边界没有固定归给新开的那一段。
|
||||
- 修复:子区间起点按分数算好一次,再用二分查找。非法输入(布尔、空值、字符串、非有限数)抛出 `ValueError`。调用点已经先做 `float()`,或同时捕获 `TypeError` 和 `ValueError`,没有只依赖旧 `TypeError` 的分支。
|
||||
- 验证:`tests/test_kp_exact_angular_boundaries.py`。20 万个随机经度新旧结果 0 处不同。
|
||||
- 防复发:上述边界测试。
|
||||
- 相关记录:BUG-1381。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/upstream-sync8-20261017`。上游提交 `8eec9a54`。
|
||||
|
||||
+34
-33
@@ -11,7 +11,10 @@ KP (Krishnamurti Paddhati) 占星系统模块
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
from bisect import bisect_right
|
||||
from datetime import datetime, timedelta
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple, Optional
|
||||
|
||||
@@ -44,6 +47,29 @@ VIMSHOTTARI_YEARS = dict(zip(KP_LORDS, VIMSHOTTARI_DURATION))
|
||||
|
||||
NAKSHATRA_SPAN = 360.0 / 27.0 # 13.333... degrees
|
||||
|
||||
|
||||
def _angular_partition_starts():
|
||||
"""Exact proportional starts, rounded once at the float API boundary."""
|
||||
starts, owners = [], []
|
||||
for star in range(27):
|
||||
sub_start = Fraction(40 * star, 3)
|
||||
for offset in range(9):
|
||||
sub = (star + offset) % 9
|
||||
sub_span = Fraction(40, 3) * VIMSHOTTARI_DURATION[sub] / 120
|
||||
child_start = sub_start
|
||||
for child_offset in range(9):
|
||||
child = (sub + child_offset) % 9
|
||||
starts.append(float(child_start))
|
||||
owners.append((sub, child))
|
||||
child_start += sub_span * VIMSHOTTARI_DURATION[child] / 120
|
||||
sub_start += sub_span
|
||||
return starts, owners
|
||||
|
||||
|
||||
_KP_STARTS, _KP_OWNERS = _angular_partition_starts()
|
||||
_STAR_STARTS = [float(Fraction(40 * i, 3)) for i in range(27)]
|
||||
_PADA_STARTS = [float(Fraction(10 * i, 3)) for i in range(108)]
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@@ -228,50 +254,25 @@ def get_kp_lords(degree: float) -> Dict:
|
||||
Returns:
|
||||
KP lords字典
|
||||
"""
|
||||
if isinstance(degree, bool) or not isinstance(degree, (int, float)) or not math.isfinite(degree):
|
||||
raise ValueError('KP longitude must be a finite number')
|
||||
deg = degree % 360
|
||||
|
||||
# 1. Sign lord
|
||||
sign_index = int(deg // 30)
|
||||
|
||||
# 2. Nakshatra
|
||||
nakshatra_index = int(deg // NAKSHATRA_SPAN) % 27
|
||||
nakshatra_deg = deg % NAKSHATRA_SPAN
|
||||
pada = int(nakshatra_deg // (NAKSHATRA_SPAN / 4)) + 1
|
||||
|
||||
# 3. Sublord & SubSubLord(KP核心算法)
|
||||
# 将Vimshottari 120年周期按比例投影到度数上
|
||||
deg_remainder = deg - 120 * int(deg / 120)
|
||||
deg_cumulative = 0.0
|
||||
|
||||
for i in range(9):
|
||||
deg_nl = NAKSHATRA_SPAN # 13.333... degrees per nakshatra
|
||||
for j in range(i, i + 9):
|
||||
j_mod = j % 9
|
||||
deg_sl = deg_nl * VIMSHOTTARI_DURATION[j_mod] / 120.0
|
||||
for k in range(j_mod, j_mod + 9):
|
||||
k_mod = k % 9
|
||||
deg_ss = deg_sl * VIMSHOTTARI_DURATION[k_mod] / 120.0
|
||||
deg_cumulative += deg_ss
|
||||
if deg_cumulative >= deg_remainder:
|
||||
return {
|
||||
'rasi_lord': SIGN_LORDS.get(SIGNS[sign_index], ''),
|
||||
'sign': SIGNS[sign_index],
|
||||
'nakshatra': NAKSHATRAS[nakshatra_index],
|
||||
'nakshatra_lord': STAR_LORDS[nakshatra_index],
|
||||
'pada': pada,
|
||||
'sub_lord': KP_LORDS[j_mod],
|
||||
'sub_sub_lord': KP_LORDS[k_mod],
|
||||
}
|
||||
|
||||
# Fallback
|
||||
# 2. Nakshatra. A shared boundary belongs to the subdivision that opens there.
|
||||
nakshatra_index = bisect_right(_STAR_STARTS, deg) - 1
|
||||
pada = (bisect_right(_PADA_STARTS, deg) - 1) % 4 + 1
|
||||
sub, child = _KP_OWNERS[bisect_right(_KP_STARTS, deg) - 1]
|
||||
return {
|
||||
'rasi_lord': SIGN_LORDS.get(SIGNS[sign_index], ''),
|
||||
'sign': SIGNS[sign_index],
|
||||
'nakshatra': NAKSHATRAS[nakshatra_index],
|
||||
'nakshatra_lord': STAR_LORDS[nakshatra_index],
|
||||
'pada': pada,
|
||||
'sub_lord': 'Unknown',
|
||||
'sub_sub_lord': 'Unknown',
|
||||
'sub_lord': KP_LORDS[sub],
|
||||
'sub_sub_lord': KP_LORDS[child],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Synthetic angular fixtures; no birth records or predictive assertions."""
|
||||
from fractions import Fraction
|
||||
import math
|
||||
import pytest
|
||||
from scripts.kp_system import get_kp_lords
|
||||
|
||||
ORDER='Ketu Venus Sun Moon Mars Rahu Jupiter Saturn Mercury'.split()
|
||||
YEARS=(7,20,6,10,7,18,16,19,17)
|
||||
|
||||
|
||||
def test_every_subsub_boundary_is_left_closed_right_open():
|
||||
starts=[]
|
||||
for star in range(27):
|
||||
start=Fraction(star*40,3)
|
||||
for offset in range(9):
|
||||
sub=(star+offset)%9;span=Fraction(40*YEARS[sub],360)
|
||||
for child_offset in range(9):
|
||||
child=(sub+child_offset)%9
|
||||
starts.append((float(start),ORDER[sub],ORDER[child]))
|
||||
start+=span*YEARS[child]/120
|
||||
for i,(angle,sub,child) in enumerate(starts):
|
||||
for probe in (angle,math.nextafter(angle,math.inf)):
|
||||
actual=get_kp_lords(probe)
|
||||
assert (actual['sub_lord'],actual['sub_sub_lord'])==(sub,child)
|
||||
if i:
|
||||
before=get_kp_lords(math.nextafter(angle,-math.inf))
|
||||
assert (before['sub_lord'],before['sub_sub_lord'])==starts[i-1][1:]
|
||||
|
||||
|
||||
def test_quarter_boundaries_and_wrap():
|
||||
for i in range(108):
|
||||
degree=float(Fraction(10*i,3))
|
||||
assert get_kp_lords(degree)['pada']==i%4+1
|
||||
if i:assert get_kp_lords(math.nextafter(degree,-math.inf))['pada']==(i-1)%4+1
|
||||
assert get_kp_lords(70)['pada']==2
|
||||
assert get_kp_lords(360)==get_kp_lords(0)
|
||||
assert get_kp_lords(-1)==get_kp_lords(359)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('invalid',[True,None,'70',float('nan'),float('inf'),float('-inf')])
|
||||
def test_nonfinite_or_nonnumeric_longitude_rejected(invalid):
|
||||
with pytest.raises(ValueError):get_kp_lords(invalid)
|
||||
Reference in New Issue
Block a user