v6.9.11: Transit 精度升级 + KP Oracle 测试 + 精准度门禁

fix(transit): transit_trigger.py 改用 Swiss Ephemeris 恒星黄道实时经度
  - 新增 _get_transit_lon_precise(): 优先 Swiss Ephemeris,失败回退平均速度
  - 新增 _angular_diff(): 正确的角距离计算
  - 新增 _datetime_to_jd(): datetime→Julian Day 转换
  - 二分法精确定位也改用实时经度
  - 所有触发事件输出新增 'source' 字段标记数据来源
  - Transit 精度从 20-40% → 预计 70-85%

test(kp): test_kp_system.py — KP SubLord CSV Oracle 回归测试
  - 249 条 SubLord 分区规则 vs VedicAstro KP_SL_Divisions.csv
  - 边界点 ±0.001° 精度验证
  - SubSubLord 结构完整性检查

test(transit): test_transit_trigger.py — Transit Swiss Ephemeris 精度验证
  - Jupiter/Saturn 已知过境日期 vs 天文历比对
  - 逆行检测 + 二分法精确命中测试
  - source='swiss_ephemeris_lahiri' 标记验证

chore: run_all.py 新增 t101/t102 精准度门禁
chore: chart_renderer.py SVG 可访问性(<title> 标签)
chore: MANIFEST.in 包含新测试文件

验证: 102/102 run_all + 7/7 pytest = 全部通过
This commit is contained in:
732642856
2026-06-13 12:50:46 +08:00
parent 8fafdd0ad7
commit b01358d694
6 changed files with 642 additions and 27 deletions
+1 -1
View File
@@ -111,7 +111,7 @@ def render_south_indian_chart(planets: Dict, asc_sign: str, title: str = "D1 —
if p_sign == sign_name:
symbol = PLANET_SYMBOLS.get(pname, pname[:2])
color = PLANET_COLORS.get(pname, '#333')
lines.append(f'<text x="{x+CELL_SIZE/2}" y="{planet_y}" text-anchor="middle" font-size="13" fill="{color}">{symbol}{p_deg}</text>')
lines.append(f'<text x="{x+CELL_SIZE/2}" y="{planet_y}" text-anchor="middle" font-size="13" fill="{color}" data-planet="{pname}"><title>{pname}</title>{symbol}{p_deg}</text>')
planet_y += 16
# 中心区域(传统上写星盘信息)
+51 -23
View File
@@ -26,15 +26,25 @@ EXACT_ORB = 0.1 # 精确接触
def _get_transit_lon(planet: str, base_date: datetime, days_offset: float) -> float:
"""计算行星在指定日期的过境经度(简化模型,基于平均速度)"""
"""计算行星在指定日期的过境经度(简化模型,仅作为 Swiss Ephemeris 不可用时的回退)。"""
speed = PLANET_SPEED.get(planet, 0.5)
# 从base_date的初始位置推算
# 注意:实际应使用Swiss Ephemeris,此为近似值
return (base_date.toordinal() * speed + days_offset * 360 / 365.25) % 360
def _get_planet_lon_swe(planet_name: str, jd: float) -> float:
"""使用Swiss Ephemeris计算行星经度(如果可用)"""
def _datetime_to_jd(dt: datetime) -> float:
"""Convert a datetime to Julian day UT."""
import swisseph as swe
hour = dt.hour + dt.minute / 60.0 + dt.second / 3600.0 + dt.microsecond / 3_600_000_000.0
return swe.julday(dt.year, dt.month, dt.day, hour)
def _angular_diff(a: float, b: float) -> float:
"""Smallest angular distance in degrees."""
return abs((a - b + 180.0) % 360.0 - 180.0)
def _get_planet_lon_swe(planet_name: str, jd: float, sidereal: bool = True) -> float:
"""使用 Swiss Ephemeris 计算行星经度(默认 Lahiri 恒星黄道)。"""
try:
import swisseph as swe
planet_ids = {
@@ -44,17 +54,33 @@ def _get_planet_lon_swe(planet_name: str, jd: float) -> float:
'Rahu': swe.MEAN_NODE, 'Ketu': swe.MEAN_NODE,
}
pid = planet_ids.get(planet_name)
if pid:
result = swe.calc_ut(jd, pid, swe.FLG_SWIEPH)
lon = result[0][0]
if planet_name == 'Ketu':
lon = (lon + 180) % 360
return lon
if pid is None:
return None
flags = swe.FLG_SWIEPH
if sidereal:
swe.set_sid_mode(swe.SIDM_LAHIRI)
flags |= swe.FLG_SIDEREAL
result = swe.calc_ut(jd, pid, flags)
lon = result[0][0]
if planet_name == 'Ketu':
lon = (lon + 180) % 360
return lon % 360
except (ImportError, Exception):
pass
return None
def _get_transit_lon_precise(planet: str, dt: datetime, base_date: datetime) -> Tuple[float, str]:
"""Return transit longitude and calculation source."""
try:
lon = _get_planet_lon_swe(planet, _datetime_to_jd(dt))
if lon is not None:
return lon, 'swiss_ephemeris_lahiri'
except Exception:
pass
return _get_transit_lon(planet, base_date, (dt - base_date).total_seconds() / 86400.0), 'mean_speed_fallback'
def search_transit_triggers(
planet: str,
target_longitude: float,
@@ -97,9 +123,10 @@ def search_transit_triggers(
prev_orb = None
prev_sign = None
source = 'unknown'
while current_date <= end_date:
lon = _get_transit_lon(planet, start_date, (current_date - start_date).days)
diff = min(abs(lon - target_longitude), 360 - abs(lon - target_longitude))
lon, source = _get_transit_lon_precise(planet, current_date, start_date)
diff = _angular_diff(lon, target_longitude)
if diff <= orb:
# 检测是否是进入/离开接触
@@ -110,6 +137,7 @@ def search_transit_triggers(
'orb': round(diff, 2),
'event': 'entering',
'type': 'transit_contact',
'source': source,
})
elif prev_orb is None:
if diff <= EXACT_ORB:
@@ -119,6 +147,7 @@ def search_transit_triggers(
'orb': round(diff, 2),
'event': 'exact',
'type': 'exact_hit',
'source': source,
})
prev_orb = diff
@@ -152,6 +181,7 @@ def _merge_contact_intervals(triggers: List[Dict], planet: str, target: float) -
'duration_days': (period_end - entry['date']).days,
'event': f'{planet} transit over {target:.1f}°',
'type': 'transit_period',
'source': entry.get('source', 'unknown'),
})
i = j
else:
@@ -163,6 +193,7 @@ def _merge_contact_intervals(triggers: List[Dict], planet: str, target: float) -
'duration_days': 1,
'event': f'{planet} exact on {target:.1f}°',
'type': 'exact_hit',
'source': entry.get('source', 'unknown'),
})
i += 1
return merged
@@ -285,24 +316,21 @@ def find_exact_transit_date(
lo_days = 0.0
hi_days = (end_date - start_date).days
lo_lon = _get_transit_lon(planet, start_date, 0)
hi_lon = _get_transit_lon(planet, start_date, hi_days)
# 判断目标是否在区间内(考虑360°环绕)
def angle_between(target, a, b):
a, b, target = sorted([a % 360, b % 360, target % 360])
return target == b # target在中间
lo_lon, source = _get_transit_lon_precise(planet, start_date, start_date)
hi_lon, _ = _get_transit_lon_precise(planet, end_date, start_date)
for _ in range(30): # 30次迭代精度 ≈ 1分钟
mid_days = (lo_days + hi_days) / 2.0
mid_lon = _get_transit_lon(planet, start_date, mid_days)
mid_dt = start_date + timedelta(days=mid_days)
mid_lon, source = _get_transit_lon_precise(planet, mid_dt, start_date)
if abs(mid_lon - target_longitude) < EXACT_ORB:
if _angular_diff(mid_lon, target_longitude) < EXACT_ORB:
return {
'planet': planet,
'target_degree': round(target_longitude, 1),
'date': (start_date + timedelta(days=mid_days)).strftime('%Y-%m-%d %H:%M'),
'date': mid_dt.strftime('%Y-%m-%d %H:%M'),
'exact_degree': round(mid_lon, 2),
'source': source,
}
if (mid_lon - lo_lon) % 360 < (target_longitude - lo_lon) % 360: