706 lines
26 KiB
Python
706 lines
26 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Yoga 准确率验证框架 v1.0
|
||
|
||
功能:
|
||
1. 用同一组出生数据,分别调用 PyJhora 和 skill 的 Yoga 检测
|
||
2. 对比结果,生成准确率报告
|
||
3. 支持批量测试用例(JSON 文件)
|
||
|
||
用法:
|
||
python3 validate_yoga_accuracy.py --benchmark
|
||
python3 validate_yoga_accuracy.py --test-cases test_cases.json
|
||
python3 validate_yoga_accuracy.py --interactive
|
||
"""
|
||
|
||
import json
|
||
import re
|
||
import sys
|
||
import glob
|
||
import argparse
|
||
import traceback
|
||
from pathlib import Path
|
||
from typing import Dict, List, Optional, Tuple, Set
|
||
|
||
from ayanamsa_utils import sidereal_flags
|
||
|
||
# ============================================================
|
||
# 路径设置
|
||
# ============================================================
|
||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||
SKILL_ROOT = SCRIPT_DIR.parent
|
||
|
||
# 确保 skill 脚本在 sys.path 中
|
||
if str(SCRIPT_DIR) not in sys.path:
|
||
sys.path.insert(0, str(SCRIPT_DIR))
|
||
|
||
# ============================================================
|
||
# 常量(自包含,与 yoga_engine.py 一致)
|
||
# ============================================================
|
||
SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
|
||
'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces']
|
||
SIGN_LORDS = {
|
||
'Aries': 'Mars', 'Taurus': 'Venus', 'Gemini': 'Mercury', 'Cancer': 'Moon',
|
||
'Leo': 'Sun', 'Virgo': 'Mercury', 'Libra': 'Venus', 'Scorpio': 'Mars',
|
||
'Sagittarius': 'Jupiter', 'Capricorn': 'Saturn', 'Aquarius': 'Saturn', 'Pisces': 'Jupiter'
|
||
}
|
||
|
||
# ============================================================
|
||
# Skill 功能性 Yoga Category(PyJhora 中无对应规则,属于 skill 特色)
|
||
# ============================================================
|
||
FUNCTIONAL_CATEGORIES = {
|
||
'dhana', 'kalatra', 'putra', 'ayur', 'vidya', 'shakti',
|
||
'karma', 'asha', 'moksha', 'aryama', 'guru', 'ari', 'varga',
|
||
# raja 虽然是经典概念,但 PyJhora 没有单独函数,skill 按功能性实现
|
||
'raja',
|
||
}
|
||
|
||
# 加载 skill 的 yoga_rules.json,建立 name -> category 映射
|
||
def _load_skill_rule_categories() -> Dict[str, str]:
|
||
"""读取 yoga_rules.json,返回 {归一化名称: category}"""
|
||
rules_path = SKILL_ROOT / "references" / "yoga_rules.json"
|
||
if not rules_path.exists():
|
||
return {}
|
||
try:
|
||
with open(rules_path, 'r', encoding='utf-8') as f:
|
||
data = json.load(f)
|
||
mapping = {}
|
||
for r in data.get('rules', []):
|
||
if not r.get('enabled', True):
|
||
continue
|
||
key = normalize_name(r.get('name', ''))
|
||
if key:
|
||
mapping[key] = r.get('category', 'unknown')
|
||
return mapping
|
||
except Exception:
|
||
return {}
|
||
|
||
# 延迟加载,避免在模块导入时读取文件
|
||
_SKILL_RULE_CATEGORIES: Optional[Dict[str, str]] = None
|
||
|
||
def get_skill_rule_categories() -> Dict[str, str]:
|
||
global _SKILL_RULE_CATEGORIES
|
||
if _SKILL_RULE_CATEGORIES is None:
|
||
_SKILL_RULE_CATEGORIES = _load_skill_rule_categories()
|
||
return _SKILL_RULE_CATEGORIES
|
||
|
||
|
||
# ============================================================
|
||
# Swiss Ephemeris 星盘计算(独立实现,不依赖 jyotish_engine)
|
||
# ============================================================
|
||
def skill_compute_chart(year, month, day, hour, minute, lat, lon, tz,
|
||
node_mode='mean'):
|
||
"""
|
||
用 Swiss Ephemeris 计算星盘。
|
||
返回:(planets_dict, asc_idx, jd, ayanamsa)
|
||
"""
|
||
import swisseph as swe
|
||
|
||
# Julian Day(本地时间 → UTC)
|
||
hour_decimal = hour + minute / 60.0 - tz
|
||
jd = swe.julday(year, month, day, hour_decimal)
|
||
|
||
# Ayanamsa
|
||
flags = sidereal_flags(swe, 'lahiri')
|
||
ayanamsa = swe.get_ayanamsa_ut(jd)
|
||
|
||
# Ascendant
|
||
asc_info = swe.houses(jd, lat, lon, b'A') # 'A' = equal houses (Vedic)
|
||
asc_long = asc_info[0][0]
|
||
asc_deg = (asc_long - ayanamsa) % 360
|
||
asc_sign = int(asc_deg / 30)
|
||
asc_idx = asc_sign + 1 # 1-based
|
||
|
||
# 行星
|
||
planets = {}
|
||
planet_map = [
|
||
('Sun', swe.SUN), ('Moon', swe.MOON), ('Mars', swe.MARS),
|
||
('Mercury', swe.MERCURY), ('Jupiter', swe.JUPITER),
|
||
('Venus', swe.VENUS), ('Saturn', swe.SATURN),
|
||
]
|
||
|
||
for pname, pid in planet_map:
|
||
res = swe.calc_ut(jd, pid, flags)
|
||
long = res[0][0] % 360
|
||
sign = int(long / 30)
|
||
deg = long % 30.0
|
||
speed = res[0][3]
|
||
is_retro = speed < 0
|
||
house = ((sign - asc_sign) % 12) + 1
|
||
planets[pname] = {
|
||
'longitude': long, 'sign': SIGNS[sign],
|
||
'degree': deg, 'house': house,
|
||
'is_retro': is_retro, 'is_combust': False,
|
||
}
|
||
|
||
# Rahu / Ketu
|
||
if node_mode == 'true':
|
||
rahu_res = swe.calc_ut(jd, swe.TRUE_NODE, flags)
|
||
else:
|
||
rahu_res = swe.calc_ut(jd, swe.MEAN_NODE, flags)
|
||
rahu_long = rahu_res[0][0] % 360
|
||
ketu_long = (rahu_long + 180.0) % 360
|
||
|
||
for name, long in [('Rahu', rahu_long), ('Ketu', ketu_long)]:
|
||
sign = int(long / 30)
|
||
deg = long % 30.0
|
||
house = ((sign - asc_sign) % 12) + 1
|
||
planets[name] = {
|
||
'longitude': long, 'sign': SIGNS[sign],
|
||
'degree': deg, 'house': house,
|
||
'is_retro': False, 'is_combust': False,
|
||
}
|
||
|
||
# 燃烧判断(简化)
|
||
sun_long = planets['Sun']['longitude']
|
||
combust_limits = {
|
||
'Moon': 12.0, 'Mars': 17.0, 'Mercury': 14.0,
|
||
'Jupiter': 11.0, 'Venus': 10.0, 'Saturn': 15.0,
|
||
}
|
||
for pname, limit in combust_limits.items():
|
||
p_long = planets[pname]['longitude']
|
||
diff = abs((p_long - sun_long + 180) % 360 - 180)
|
||
planets[pname]['is_combust'] = diff < limit
|
||
|
||
return planets, asc_idx, jd, ayanamsa
|
||
|
||
|
||
def skill_detect_yogas(planets, asc):
|
||
"""调用 skill 的 Yoga 引擎检测 Yoga"""
|
||
from yoga_engine import detect_yogas
|
||
return detect_yogas(planets, asc)
|
||
|
||
|
||
# ============================================================
|
||
# PyJhora 接口封装
|
||
# ============================================================
|
||
def init_external_benchmark():
|
||
"""初始化 PyJhora,返回是否成功"""
|
||
try:
|
||
import jhora.horoscope.chart.yoga as py_yoga
|
||
import jhora.panchanga.drik as drik
|
||
import jhora.horoscope.chart.charts as charts
|
||
import jhora.const as const
|
||
return True
|
||
except ImportError as e:
|
||
print(f"⚠️ PyJhora 导入失败: {e}")
|
||
return False
|
||
|
||
|
||
def external_benchmark_jd(year, month, day, hour_frac):
|
||
"""计算 Julian Day(与 PyJhora 一致)"""
|
||
import swisseph as swe
|
||
return swe.julday(year, month, day, hour_frac)
|
||
|
||
|
||
def external_benchmark_get_yogas(jd, lat, lon, tz, divisional_chart_factor=1):
|
||
"""
|
||
调用 PyJhora 获取 D1 宫盘的 Yoga 检测结果。
|
||
返回:{yoga_function_name: {"name": ..., "desc": ..., "benefits": ...}}
|
||
"""
|
||
import jhora.horoscope.chart.yoga as py_yoga
|
||
import jhora.panchanga.drik as drik
|
||
|
||
place = drik.Place("TestPlace", lat, lon, tz)
|
||
yoga_results, _, _ = py_yoga.get_yoga_details(
|
||
jd, place, divisional_chart_factor=divisional_chart_factor, language='en'
|
||
)
|
||
# yoga_results: {yoga_function_name: [chart_id, yoga_name, yoga_description, yoga_benefits]}
|
||
result = {}
|
||
for fname, details in yoga_results.items():
|
||
result[fname] = {
|
||
"name": details[1] if len(details) > 1 else fname,
|
||
"desc": details[2] if len(details) > 2 else "",
|
||
"benefits": details[3] if len(details) > 3 else "",
|
||
"chart_id": details[0] if len(details) > 0 else "",
|
||
}
|
||
return result
|
||
|
||
|
||
# ============================================================
|
||
# 名称归一化(用于对比)
|
||
# ============================================================
|
||
def normalize_name(name: str) -> str:
|
||
"""归一化 Yoga 名称(与 benchmark_yoga_coverage.py 一致)"""
|
||
s = name.lower()
|
||
s = re.sub(r"\([^)]*\)", " ", s)
|
||
s = s.replace("&", " and ")
|
||
s = re.sub(r"[^a-z0-9]+", "_", s)
|
||
s = re.sub(r"_+", "_", s).strip("_")
|
||
for suffix in ("_yoga", "_graha", "_classic", "_calculation", "_calc"):
|
||
if s.endswith(suffix):
|
||
s = s[: -len(suffix)]
|
||
s = re.sub(r"_\d+$", "", s)
|
||
return s
|
||
|
||
|
||
# 单向名称映射:PyJhora 归一化名称 -> skill 标准归一化名称
|
||
# 键是 PyJhora 的归一化名称,值是 skill 的标准归一化名称
|
||
# 两边都会通过 _canonical_name() 映射到统一的标准名称空间
|
||
CROSS_NAME_MAP = {
|
||
# --- 经典 Yoga 别名(PyJhora -> skill) ---
|
||
"nipuna": "budhaditya",
|
||
"gaja_kesari": "gajakesari",
|
||
"maalavya": "malavya",
|
||
"shasha": "sasa",
|
||
"murdha": "moordha",
|
||
"kedara": "kedaara",
|
||
"kaahala": "kahala",
|
||
"daama": "dama",
|
||
"veenaa": "veena",
|
||
"paasa": "pasa",
|
||
"mridang": "mridanga",
|
||
"sankh": "sankha",
|
||
"chatusagar": "chatussagara",
|
||
"rajalakshan": "rajalakshana",
|
||
"bhaarathi": "bharati",
|
||
"saraswathi": "saraswati",
|
||
"kalpadrum": "kalpadruma",
|
||
"trikala_gnana": "thrikaala_gnana",
|
||
"yukti_samanwithavagmi": "yukthi_samanwithavagmi",
|
||
"mathibhraman": "mathibhramana",
|
||
"swaviryaddhana": "swaveeryaddhana",
|
||
"antya_vayasi_dhana": "anthya_vayasi_dhana",
|
||
"madhya_vayasi_dhana": "madhya_vayasi_dhana",
|
||
"matrumuladdhana": "matrumooladdhana",
|
||
"kalanirdesat_puthranaasa": "kaalanirdesat_puthranaasa",
|
||
"dvadasa_sahodara": "dwadasa_sahodara",
|
||
"vahan": "vahana",
|
||
"parakram": "parakrama",
|
||
"yuddha_pravin": "yuddha_praveena",
|
||
"sumukh": "sumukha",
|
||
"durmukh": "durmukha",
|
||
"kapat": "kapata",
|
||
"nishkapat": "nishkapata",
|
||
"asatyavad": "asatyavadi",
|
||
"bhratruvriddh": "bhratruvriddhi",
|
||
"bandhubhisthyakt": "bandhubhisthyaktha",
|
||
"bandhu_pujy": "bandhu_pujya",
|
||
"matrunas": "matrunasa",
|
||
"matru_sneh": "matru_sneha",
|
||
"bahu_puthr": "bahu_puthra",
|
||
"bahu_stri": "bahu_sthree",
|
||
"eka_puthr": "eka_puthra",
|
||
"satkalatr": "satkalatra",
|
||
"sareera_soukh": "sareera_soukhya",
|
||
"rogagrasth": "rogagrastha",
|
||
"dehasthoul": "dehasthoulya",
|
||
"dehapusht": "dehapushti",
|
||
"krisang": "krisanga",
|
||
"sada_sanchar": "sada_sanchara",
|
||
"annadan": "annadana",
|
||
"amal": "amala",
|
||
"asubh": "asubha",
|
||
"guhyarog": "guhyaroga",
|
||
"dharidhr": "dharidhra",
|
||
"vanchana_chora_bheeth": "vanchana_chora_bheethi",
|
||
"rajabhrasht": "rajabhrashta",
|
||
"vran": "vrana",
|
||
"buddhimatur": "buddhimaturya",
|
||
"ayatna_griha_prapt": "ayatna_griha_prapta",
|
||
}
|
||
|
||
|
||
def get_skill_yoga_keys(yoga_list: List[Dict]) -> Set[str]:
|
||
"""从 skill 检测结果中提取归一化名称集合"""
|
||
keys = set()
|
||
for y in yoga_list:
|
||
for field in ['name', 'name_cn']:
|
||
val = y.get(field, "")
|
||
if not val or not isinstance(val, str):
|
||
continue
|
||
# 排除纯数字、空字符串、太短的名字
|
||
stripped = val.strip()
|
||
if not stripped or stripped.replace('_', '').replace('-', '').isdigit():
|
||
continue
|
||
if len(stripped) <= 2:
|
||
continue
|
||
key = normalize_name(val)
|
||
if not key or key.replace('_', '').isdigit():
|
||
continue
|
||
key = _canonical_name(key)
|
||
keys.add(key)
|
||
return keys
|
||
|
||
|
||
def _canonical_name(key: str) -> str:
|
||
"""通过 CROSS_NAME_MAP 将别名映射到统一的标准名称"""
|
||
return CROSS_NAME_MAP.get(key, key)
|
||
|
||
|
||
def get_external_benchmark_yoga_keys(external_benchmark_results: Dict[str, dict]) -> Set[str]:
|
||
"""从 PyJhora 检测结果中提取归一化名称集合"""
|
||
keys = set()
|
||
for fname in external_benchmark_results.keys():
|
||
# 去掉 _from_jd_place 等后缀
|
||
base = re.sub(r"_(from_jd_place|from_planet_positions|calculation|calc)$",
|
||
"", fname)
|
||
base = base.replace("_yoga", "")
|
||
key = normalize_name(base)
|
||
# 映射到标准名称
|
||
key = _canonical_name(key)
|
||
keys.add(key)
|
||
return keys
|
||
|
||
|
||
# ============================================================
|
||
# 核心验证逻辑
|
||
# ============================================================
|
||
def validate_one_case(case: dict, run_external_benchmark: bool = True) -> dict:
|
||
"""
|
||
验证单个测试用例。
|
||
|
||
case = {
|
||
"name": "Einstein",
|
||
"year": 1879, "month": 3, "day": 14,
|
||
"hour": 10, "minute": 30,
|
||
"lat": 48.0, "lon": 11.0, "tz": 1.0
|
||
}
|
||
"""
|
||
result = {
|
||
"case_name": case.get("name", "unknown"),
|
||
"birth": f"{case['year']}-{case['month']:02d}-{case['day']:02d} "
|
||
f"{case.get('hour', 12):02d}:{case.get('minute', 0):02d}",
|
||
"skill_yogas": [],
|
||
"external_benchmark_yogas": [],
|
||
"matched": [],
|
||
"skill_only": [], # false positive
|
||
"external_benchmark_only": [], # false negative
|
||
"error": None,
|
||
}
|
||
|
||
year = case['year']
|
||
month = case['month']
|
||
day = case['day']
|
||
hour = case.get('hour', 12)
|
||
minute = case.get('minute', 0)
|
||
lat = case['lat']
|
||
lon = case['lon']
|
||
tz = case.get('tz', 0)
|
||
|
||
# --- Skill 检测 ---
|
||
try:
|
||
planets, asc, jd, ayanamsa = skill_compute_chart(
|
||
year, month, day, hour, minute, lat, lon, tz
|
||
)
|
||
skill_yogas = skill_detect_yogas(planets, asc)
|
||
result['skill_yogas'] = [y.get('name', y.get('id', '?')) for y in skill_yogas]
|
||
result['skill_count'] = len(skill_yogas)
|
||
result['skill_details'] = []
|
||
for y in skill_yogas:
|
||
result['skill_details'].append({
|
||
'name': y.get('name', ''),
|
||
'id': y.get('id', ''),
|
||
'strength': y.get('strength', ''),
|
||
})
|
||
except Exception as e:
|
||
result['error'] = f"Skill error: {e}\n{traceback.format_exc()}"
|
||
return result
|
||
|
||
# --- PyJhora 检测 ---
|
||
if run_external_benchmark:
|
||
try:
|
||
jd_py = external_benchmark_jd(year, month, day, hour + minute / 60.0)
|
||
external_benchmark_results = external_benchmark_get_yogas(jd_py, lat, lon, tz,
|
||
divisional_chart_factor=1)
|
||
result['external_benchmark_yogas'] = list(external_benchmark_results.keys())
|
||
result['external_benchmark_count'] = len(external_benchmark_results)
|
||
result['external_benchmark_details'] = []
|
||
for fname, details in external_benchmark_results.items():
|
||
result['external_benchmark_details'].append({
|
||
'function': fname,
|
||
'name': details.get('name', ''),
|
||
'desc': details.get('desc', ''),
|
||
})
|
||
except Exception as e:
|
||
result['error'] = f"PyJhora error: {e}\n{traceback.format_exc()}"
|
||
return result
|
||
|
||
# --- 对比 ---
|
||
skill_keys = get_skill_yoga_keys(skill_yogas)
|
||
external_benchmark_keys = get_external_benchmark_yoga_keys(external_benchmark_results)
|
||
|
||
result['matched'] = sorted(skill_keys & external_benchmark_keys)
|
||
result['skill_only'] = sorted(skill_keys - external_benchmark_keys)
|
||
result['external_benchmark_only'] = sorted(external_benchmark_keys - skill_keys)
|
||
|
||
# --- 分类:功能性 Yoga vs 经典 Yoga ---
|
||
cat_map = get_skill_rule_categories()
|
||
result['skill_only_functional'] = []
|
||
result['skill_only_classic'] = []
|
||
for key in result['skill_only']:
|
||
cat = cat_map.get(key, 'unknown')
|
||
if cat in FUNCTIONAL_CATEGORIES:
|
||
result['skill_only_functional'].append(key)
|
||
else:
|
||
result['skill_only_classic'].append(key)
|
||
|
||
return result
|
||
|
||
|
||
def run_validation(cases: List[dict], run_external_benchmark: bool = True) -> dict:
|
||
"""运行批量验证"""
|
||
report = {
|
||
"total_cases": len(cases),
|
||
"cases": [],
|
||
"summary": {
|
||
"total_skill_yogas": 0,
|
||
"total_external_benchmark_yogas": 0,
|
||
"total_matched": 0,
|
||
"total_skill_only": 0,
|
||
"total_external_benchmark_only": 0,
|
||
"total_skill_only_functional": 0,
|
||
"total_skill_only_classic": 0,
|
||
}
|
||
}
|
||
|
||
for case in cases:
|
||
name = case.get('name', 'unknown')
|
||
print(f"🔍 验证: {name} ({case['year']}-{case['month']:02d}-{case['day']:02d})")
|
||
r = validate_one_case(case, run_external_benchmark=run_external_benchmark)
|
||
report['cases'].append(r)
|
||
|
||
if r['error']:
|
||
print(f" ❌ 错误: {r['error'][:300]}")
|
||
continue
|
||
|
||
sc = r.get('skill_count', 0)
|
||
pc = r.get('external_benchmark_count', '?')
|
||
func_n = len(r.get('skill_only_functional', []))
|
||
classic_n = len(r.get('skill_only_classic', []))
|
||
print(f" Skill: {sc} | PyJhora: {pc}")
|
||
print(f" 匹配: {len(r['matched'])} | "
|
||
f"Skill独有: {len(r['skill_only'])} (功能性{func_n}, 经典{classic_n}) | "
|
||
f"PyJhora独有: {len(r['external_benchmark_only'])}")
|
||
|
||
report['summary']['total_skill_yogas'] += sc
|
||
if isinstance(pc, int):
|
||
report['summary']['total_external_benchmark_yogas'] += pc
|
||
report['summary']['total_matched'] += len(r['matched'])
|
||
report['summary']['total_skill_only'] += len(r['skill_only'])
|
||
if isinstance(pc, int):
|
||
report['summary']['total_external_benchmark_only'] += len(r['external_benchmark_only'])
|
||
report['summary']['total_skill_only_functional'] += func_n
|
||
report['summary']['total_skill_only_classic'] += classic_n
|
||
|
||
return report
|
||
|
||
|
||
# ============================================================
|
||
# 测试用例
|
||
# ============================================================
|
||
BENCHMARK_CASES = [
|
||
{
|
||
"name": "Einstein",
|
||
"year": 1879, "month": 3, "day": 14,
|
||
"hour": 10, "minute": 30,
|
||
"lat": 48.0, "lon": 11.0, "tz": 1.0,
|
||
},
|
||
{
|
||
"name": "Steve Jobs",
|
||
"year": 1955, "month": 2, "day": 24,
|
||
"hour": 19, "minute": 30,
|
||
"lat": 37.0, "lon": -122.0, "tz": -8.0,
|
||
},
|
||
{
|
||
"name": "NM Gandhi",
|
||
"year": 1869, "month": 10, "day": 2,
|
||
"hour": 8, "minute": 52,
|
||
"lat": 21.0, "lon": 73.0, "tz": 5.5,
|
||
},
|
||
{
|
||
"name": "APJ Abdul Kalam",
|
||
"year": 1931, "month": 10, "day": 15,
|
||
"hour": 6, "minute": 0,
|
||
"lat": 8.0, "lon": 78.0, "tz": 5.5,
|
||
},
|
||
{
|
||
"name": "Queen Elizabeth II",
|
||
"year": 1926, "month": 4, "day": 21,
|
||
"hour": 2, "minute": 40,
|
||
"lat": 51.0, "lon": 0.0, "tz": 0.0,
|
||
},
|
||
]
|
||
|
||
|
||
# ============================================================
|
||
# 报告输出
|
||
# ============================================================
|
||
def print_report(report: dict):
|
||
"""打印验证报告"""
|
||
print("\n" + "=" * 70)
|
||
print("🧘 Yoga 准确率验证报告")
|
||
print("=" * 70)
|
||
|
||
summary = report['summary']
|
||
total_skill = summary['total_skill_yogas']
|
||
total_external_benchmark = summary['total_external_benchmark_yogas']
|
||
matched = summary['total_matched']
|
||
skill_only = summary['total_skill_only']
|
||
external_benchmark_only = summary['total_external_benchmark_only']
|
||
func_only = summary.get('total_skill_only_functional', 0)
|
||
classic_only = summary.get('total_skill_only_classic', 0)
|
||
|
||
print(f"\n📊 汇总:")
|
||
print(f" 测试用例数: {report['total_cases']}")
|
||
print(f" Skill 检测总数: {total_skill}")
|
||
print(f" PyJhora 检测总数: {total_external_benchmark}")
|
||
print(f" 匹配总数: {matched}")
|
||
print(f" Skill 独有: {skill_only} (功能性{func_only}, 经典{classic_only})")
|
||
print(f" PyJhora 独有 (skill 缺失): {external_benchmark_only}")
|
||
|
||
if total_skill > 0:
|
||
precision = matched / total_skill * 100
|
||
print(f"\n 总体精确率 (Precision): {precision:.1f}%")
|
||
|
||
if total_external_benchmark > 0:
|
||
recall = matched / total_external_benchmark * 100
|
||
print(f" 总体召回率 (Recall): {recall:.1f}%")
|
||
|
||
# --- 核心经典 Yoga 准确率(排除 skill 特色功能性 Yoga)---
|
||
classic_skill_total = total_skill - func_only
|
||
if classic_skill_total > 0:
|
||
classic_precision = matched / classic_skill_total * 100
|
||
print(f"\n 🎯 核心经典 Yoga 精确率: {classic_precision:.1f}%")
|
||
print(f" (排除 {func_only} 条功能性 Yoga 后: {matched}/{classic_skill_total})")
|
||
if total_external_benchmark > 0:
|
||
classic_recall = matched / total_external_benchmark * 100
|
||
print(f" 🎯 核心经典 Yoga 召回率: {classic_recall:.1f}%")
|
||
|
||
# --- 全局不匹配统计 ---
|
||
print(f"\n🔍 全局不匹配分析:")
|
||
all_skill_classic = set()
|
||
all_skill_func = set()
|
||
all_external_benchmark_missing = set()
|
||
for r in report['cases']:
|
||
if r.get('error'):
|
||
continue
|
||
all_skill_classic.update(r.get('skill_only_classic', []))
|
||
all_skill_func.update(r.get('skill_only_functional', []))
|
||
all_external_benchmark_missing.update(r.get('external_benchmark_only', []))
|
||
|
||
print(f" Skill 经典 Yoga 不匹配(可能误判): {len(all_skill_classic)} 种")
|
||
if all_skill_classic:
|
||
print(f" {sorted(all_skill_classic)[:15]}")
|
||
print(f" Skill 功能性 Yoga(PyJhora 无对应,属 skill 特色): {len(all_skill_func)} 种")
|
||
if all_skill_func:
|
||
print(f" {sorted(all_skill_func)[:15]}")
|
||
print(f" PyJhora 有但 Skill 缺失的 Yoga: {len(all_external_benchmark_missing)} 种")
|
||
if all_external_benchmark_missing:
|
||
print(f" {sorted(all_external_benchmark_missing)[:15]}")
|
||
|
||
# --- 改进建议 ---
|
||
print(f"\n💡 改进建议:")
|
||
print(f" 1. 【规则校准】以下 {len(all_skill_classic)} 种经典 Yoga 两边实现不一致,")
|
||
print(f" 建议逐条核对 BPHS 原文,修正 skill 规则条件:")
|
||
for name in sorted(all_skill_classic)[:10]:
|
||
print(f" - {name}")
|
||
if len(all_skill_classic) > 10:
|
||
print(f" ... 等共 {len(all_skill_classic)} 种")
|
||
|
||
missing_top = sorted(all_external_benchmark_missing)[:20]
|
||
print(f"\n 2. 【规则补齐】以下 {len(all_external_benchmark_missing)} 种 Yoga PyJhora 已实现但 skill 缺失,")
|
||
print(f" 建议按优先级补充(推荐先补充高频出现的):")
|
||
for name in missing_top[:15]:
|
||
print(f" - {name}")
|
||
if len(all_external_benchmark_missing) > 15:
|
||
print(f" ... 等共 {len(all_external_benchmark_missing)} 种")
|
||
|
||
print(f"\n 3. 【名称映射】当前 CROSS_NAME_MAP 已覆盖常见别名,")
|
||
print(f" 如仍有新别名发现,请添加到映射表中。")
|
||
|
||
print(f"\n📋 逐案例详情:")
|
||
for r in report['cases']:
|
||
print(f"\n 【{r['case_name']}】 {r['birth']}")
|
||
if r.get('error'):
|
||
print(f" ❌ {r['error'][:300]}")
|
||
continue
|
||
sc = r.get('skill_count', 0)
|
||
pc = r.get('external_benchmark_count', '?')
|
||
print(f" Skill ({sc}): {r['skill_yogas'][:5]}{'...' if len(r['skill_yogas']) > 5 else ''}")
|
||
if 'external_benchmark_yogas' in r:
|
||
print(f" PyJhora ({pc}): {r['external_benchmark_yogas'][:5]}{'...' if len(r['external_benchmark_yogas']) > 5 else ''}")
|
||
func_n = len(r.get('skill_only_functional', []))
|
||
cls_n = len(r.get('skill_only_classic', []))
|
||
print(f" 匹配: {len(r['matched'])} | Skill独有: {len(r['skill_only'])}(功能{func_n},经典{cls_n}) | PyJhora独有: {len(r['external_benchmark_only'])}")
|
||
|
||
if r.get('skill_only_classic'):
|
||
print(f" Skill 经典不匹配: {r['skill_only_classic'][:10]}")
|
||
if r.get('skill_only_functional'):
|
||
print(f" Skill 功能性: {r['skill_only_functional'][:10]}")
|
||
if r.get('external_benchmark_only'):
|
||
print(f" PyJhora 独有: {r['external_benchmark_only'][:10]}")
|
||
|
||
|
||
def save_report(report: dict, output_file: str):
|
||
"""保存报告为 JSON"""
|
||
with open(output_file, 'w', encoding='utf-8') as f:
|
||
json.dump(report, f, ensure_ascii=False, indent=2)
|
||
print(f"\n💾 报告已保存: {output_file}")
|
||
|
||
|
||
# ============================================================
|
||
# 主程序
|
||
# ============================================================
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="Yoga 准确率验证框架")
|
||
parser.add_argument('--test-cases', help='测试用例 JSON 文件路径')
|
||
parser.add_argument('--interactive', action='store_true', help='手动输入出生数据')
|
||
parser.add_argument('--benchmark', action='store_true', help='运行内置基准测试')
|
||
parser.add_argument('--output', '-o', default='validation_report.json', help='输出报告文件')
|
||
parser.add_argument('--skip-pyjhora', action='store_true', help='跳过 PyJhora(只测试 skill)')
|
||
args = parser.parse_args()
|
||
|
||
# 检查 PyJhora
|
||
if not args.skip_external_benchmark:
|
||
if not init_external_benchmark():
|
||
print("❌ PyJhora 不可用,请先安装: pip install pyjhora swisseph")
|
||
print(" 提示:也可用 --skip-pyjhora 只测试 skill 侧")
|
||
return 1
|
||
|
||
# 加载测试用例
|
||
if args.test_cases:
|
||
with open(args.test_cases, 'r', encoding='utf-8') as f:
|
||
cases = json.load(f)
|
||
elif args.benchmark:
|
||
cases = BENCHMARK_CASES
|
||
elif args.interactive:
|
||
cases = [interactive_input()]
|
||
else:
|
||
print("使用 --benchmark 运行内置测试,或 --test-cases 指定测试用例文件")
|
||
return 1
|
||
|
||
# 运行验证
|
||
report = run_validation(cases, run_external_benchmark=not args.skip_external_benchmark)
|
||
|
||
# 输出报告
|
||
print_report(report)
|
||
save_report(report, args.output)
|
||
|
||
return 0
|
||
|
||
|
||
def interactive_input() -> dict:
|
||
"""手动输入出生数据"""
|
||
print("\n请输入出生数据:")
|
||
name = input(" 姓名 (可选): ").strip() or "Test"
|
||
year = int(input(" 年: "))
|
||
month = int(input(" 月: "))
|
||
day = int(input(" 日: "))
|
||
hour = int(input(" 时 (24小时制): "))
|
||
minute = int(input(" 分: "))
|
||
lat = float(input(" 纬度 (度, 北正南负): "))
|
||
lon = float(input(" 经度 (度, 东正西负): "))
|
||
tz = float(input(" 时区 (小时, 东正西负): "))
|
||
|
||
return {
|
||
"name": name, "year": year, "month": month, "day": day,
|
||
"hour": hour, "minute": minute,
|
||
"lat": lat, "lon": lon, "tz": tz,
|
||
}
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(main())
|