Files
Jyotisha/benchmarks/jyotish/scripts/run_swiss_extended_compare.py
T
732642856 d2cd5a369f v6.1.9: Add public benchmarks and research roadmap
- Add sanitized Jyotish benchmark suite with fictional/public smoke samples
- Include benchmark scripts and markdown reports while excluding raw JSON/CSV outputs
- Add open-source Jyotish project comparison research
- Add complete technique coverage roadmap
- Add privacy-safe PDF chart validation methodology
- Update SKILL.md and CHANGELOG with v6.1.9 scope and privacy boundaries

Validation:
- py_compile benchmarks/jyotish/scripts/*.py passed
- quality gate passed with 35 pytest tests and golden case
2026-06-08 13:16:42 +08:00

304 lines
15 KiB
Python

# NOTE: This script was sanitized for the public repository in v6.1.9.
# It assumes it is run from the repository root unless JYOTISH_BENCHMARK_ROOT
# or JYOTISH_SKILL_SCRIPT is provided. Raw output directories are generated locally
# and are intentionally not committed.
#!/usr/bin/env python3
import csv
import json
import math
from datetime import datetime, timedelta
from pathlib import Path
import swisseph as swe
ROOT = Path(__file__).resolve().parents[1]
DATA = ROOT / 'data/benchmark_samples.json'
OUT = ROOT / 'outputs'
CANON = OUT / 'canonical'
SWISS_OUT = OUT / 'swiss_extended'
PLANETS = {
'Sun': swe.SUN,
'Moon': swe.MOON,
'Mars': swe.MARS,
'Mercury': swe.MERCURY,
'Jupiter': swe.JUPITER,
'Venus': swe.VENUS,
'Saturn': swe.SATURN,
'Rahu': swe.MEAN_NODE,
}
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'}
NAKSHATRAS = [
'Ashwini','Bharani','Krittika','Rohini','Mrigashira','Ardra','Punarvasu','Pushya','Ashlesha',
'Magha','Purva Phalguni','Uttara Phalguni','Hasta','Chitra','Swati','Vishakha','Anuradha','Jyeshtha',
'Mula','Purva Ashadha','Uttara Ashadha','Shravana','Dhanishta','Shatabhisha','Purva Bhadrapada','Uttara Bhadrapada','Revati'
]
DASHA_ORDER = ['Ketu', 'Venus', 'Sun', 'Moon', 'Mars', 'Rahu', 'Jupiter', 'Saturn', 'Mercury']
DASHA_YEARS = {'Ketu':7, 'Venus':20, 'Sun':6, 'Moon':10, 'Mars':7, 'Rahu':18, 'Jupiter':16, 'Saturn':19, 'Mercury':17}
def normalize(deg):
return deg % 360.0
def sign_idx(lon):
return int(normalize(lon) // 30) % 12
def sign_of(lon):
return SIGNS[sign_idx(lon)]
def degree_in_sign(lon):
return normalize(lon) % 30
def jd_utc(birth):
local = datetime(int(birth['year']), int(birth['month']), int(birth['day']), int(birth['hour']), int(birth['minute']))
utc_dt = local - timedelta(hours=float(birth['tz']))
hour = utc_dt.hour + utc_dt.minute / 60.0 + utc_dt.second / 3600.0
return swe.julday(utc_dt.year, utc_dt.month, utc_dt.day, hour, swe.GREG_CAL)
def calc_d9(lon):
nav_lon = normalize(lon * 9.0)
vsi = sign_idx(nav_lon)
return {'sign': SIGNS[vsi], 'sign_idx': vsi, 'degree_in_sign': round(degree_in_sign(nav_lon), 4), 'lord': SIGN_LORDS[SIGNS[vsi]]}
def varga_map(si, pi, div):
odd = si % 2 == 0
if div == 10:
return (si + pi) % 12 if odd else (si + 8 + pi) % 12
raise ValueError('unsupported')
def calc_d10(lon):
si = sign_idx(lon)
d = degree_in_sign(lon)
pi = int(d / 3.0)
dp = (d - pi * 3.0) * 10.0
vsi = varga_map(si, pi, 10)
return {'sign': SIGNS[vsi], 'sign_idx': vsi, 'degree_in_sign': round(dp, 4), 'lord': SIGN_LORDS[SIGNS[vsi]]}
def calc_vimshottari(moon_lon, birth, today_str):
nak_span = 360.0 / 27.0
idx = int(moon_lon / nak_span) % 27
progress = (moon_lon % nak_span) / nak_span
start_lord = DASHA_ORDER[idx % 9]
start_years = DASHA_YEARS[start_lord]
elapsed = progress * start_years
remaining = start_years - elapsed
birth_dt = datetime(int(birth['year']), int(birth['month']), int(birth['day']))
dt = birth_dt - timedelta(days=elapsed * 365.25)
si = DASHA_ORDER.index(start_lord)
timeline = []
today = datetime.strptime(today_str, '%Y-%m-%d')
current = None
for i in range(9):
lord = DASHA_ORDER[(si + i) % 9]
years = DASHA_YEARS[lord]
end_dt = dt + timedelta(days=years * 365.25)
md = {'lord': lord, 'start': dt.strftime('%Y-%m-%d'), 'end': end_dt.strftime('%Y-%m-%d')}
total_days = (end_dt - dt).days
li = DASHA_ORDER.index(lord)
sub = []
sdt = dt
for j in range(9):
sl = DASHA_ORDER[(li + j) % 9]
sd = total_days * DASHA_YEARS[sl] / 120.0
se = sdt + timedelta(days=sd)
ad = {'lord': sl, 'start': sdt.strftime('%Y-%m-%d'), 'end': se.strftime('%Y-%m-%d')}
sub.append(ad)
sdt = se
md['antardasha_timeline'] = sub
if dt <= today < end_dt:
current_ad = None
for ad in sub:
ads = datetime.strptime(ad['start'], '%Y-%m-%d')
ade = datetime.strptime(ad['end'], '%Y-%m-%d')
if ads <= today < ade:
current_ad = ad
break
current = {'mahadasha_lord': lord, 'mahadasha_start': md['start'], 'mahadasha_end': md['end'], 'antardasha_lord': current_ad['lord'] if current_ad else None, 'antardasha_start': current_ad['start'] if current_ad else None, 'antardasha_end': current_ad['end'] if current_ad else None}
timeline.append(md)
dt = end_dt
return current
def calc_swiss_extended(sample):
birth = sample['birth']
jd = jd_utc(birth)
swe.set_sid_mode(swe.SIDM_LAHIRI, 0, 0)
ayanamsa = swe.get_ayanamsa_ut(jd)
cusps, ascmc = swe.houses(jd, float(birth['lat']), float(birth['lon']), b'A')
asc_lon = normalize(cusps[0] - ayanamsa)
# houses_ex is an independent sidereal asc check. Keep both for diagnostics.
try:
cusps_ex, ascmc_ex = swe.houses_ex(jd, float(birth['lat']), float(birth['lon']), b'A', swe.FLG_SIDEREAL)
asc_lon_ex = normalize(cusps_ex[0])
except Exception:
asc_lon_ex = None
flags = swe.FLG_SWIEPH | swe.FLG_SIDEREAL | swe.FLG_SPEED
planets = {}
for name, pid in PLANETS.items():
res, ret = swe.calc_ut(jd, pid, flags)
lon = normalize(res[0])
planets[name] = {'longitude': round(lon, 6), 'sign': sign_of(lon), 'degree_in_sign': round(degree_in_sign(lon), 6), 'retrograde': bool(res[3] < 0)}
ketu_lon = normalize(planets['Rahu']['longitude'] + 180.0)
planets['Ketu'] = {'longitude': round(ketu_lon, 6), 'sign': sign_of(ketu_lon), 'degree_in_sign': round(degree_in_sign(ketu_lon), 6), 'retrograde': planets['Rahu']['retrograde']}
all_lons = {'Ascendant': asc_lon, **{k: v['longitude'] for k, v in planets.items()}}
return {
'sample_id': sample['id'],
'engine': 'swiss_direct_extended_lahiri_mean_node',
'julian_day_ut': jd,
'ayanamsa': ayanamsa,
'ascendant': {'longitude': round(asc_lon, 6), 'longitude_houses_ex': round(asc_lon_ex, 6) if asc_lon_ex is not None else None, 'sign': sign_of(asc_lon), 'degree_in_sign': round(degree_in_sign(asc_lon), 6), 'lord': SIGN_LORDS[sign_of(asc_lon)]},
'planets': planets,
'varga': {
'D9': {body: calc_d9(lon) for body, lon in all_lons.items()},
'D10': {body: calc_d10(lon) for body, lon in all_lons.items()},
},
'dasha': calc_vimshottari(planets['Moon']['longitude'], birth, sample.get('today', '2026-06-03')),
}
def compare_scalar(rows, sample_id, section, body, field, local_value, swiss_value, tolerance=None, date_tolerance_days=None, boundary_sensitive=False):
status = 'match'
delta = ''
if date_tolerance_days is not None:
try:
ld = datetime.strptime(str(local_value), '%Y-%m-%d')
sd = datetime.strptime(str(swiss_value), '%Y-%m-%d')
delta_val = abs((ld - sd).days)
delta = delta_val
status = 'match' if delta_val <= date_tolerance_days else 'mismatch'
except Exception:
status = 'not_comparable'
elif tolerance is not None:
try:
delta_val = abs(float(local_value) - float(swiss_value))
delta = round(delta_val, 6)
status = 'match' if delta_val <= tolerance else 'mismatch'
except Exception:
status = 'not_comparable'
else:
status = 'match' if local_value == swiss_value else 'mismatch'
if status == 'mismatch' and boundary_sensitive:
status = 'boundary_sensitive'
rows.append({'sample_id': sample_id, 'section': section, 'body': body, 'field': field, 'local_skill': local_value, 'swiss_extended': swiss_value, 'delta': delta, 'status': status})
def compare_sample(sample_id, swiss):
local = json.loads((CANON / f'{sample_id}.canonical.json').read_text())
rows = []
compare_scalar(rows, sample_id, 'ascendant', 'Ascendant', 'sign', local['ascendant'].get('sign'), swiss['ascendant'].get('sign'))
compare_scalar(rows, sample_id, 'ascendant', 'Ascendant', 'degree_in_sign', local['ascendant'].get('degree_in_sign'), swiss['ascendant'].get('degree_in_sign'), tolerance=0.1)
compare_scalar(rows, sample_id, 'ascendant', 'Ascendant', 'lord', local['ascendant'].get('lord'), swiss['ascendant'].get('lord'))
for varga_name in ['D9', 'D10']:
for body in ['Ascendant','Sun','Moon','Mars','Mercury','Jupiter','Venus','Saturn','Rahu','Ketu']:
l = local['varga'][varga_name].get(body) or {}
s = swiss['varga'][varga_name].get(body) or {}
boundary_sensitive = varga_name == 'D10' and body in ('Rahu', 'Ketu') and l.get('sign') != s.get('sign')
compare_scalar(rows, sample_id, varga_name, body, 'sign', l.get('sign'), s.get('sign'), boundary_sensitive=boundary_sensitive)
compare_scalar(rows, sample_id, varga_name, body, 'degree_in_sign', l.get('degree_in_sign'), s.get('degree_in_sign'), tolerance=0.1, boundary_sensitive=boundary_sensitive)
for field in ['mahadasha_lord','antardasha_lord']:
compare_scalar(rows, sample_id, 'dasha', 'Vimshottari_current', field, local['dasha'].get(field), swiss['dasha'].get(field) if swiss.get('dasha') else None)
for field in ['mahadasha_start','mahadasha_end','antardasha_start','antardasha_end']:
compare_scalar(rows, sample_id, 'dasha', 'Vimshottari_current', field, local['dasha'].get(field), swiss['dasha'].get(field) if swiss.get('dasha') else None, date_tolerance_days=3)
return rows
def write_report(rows):
total = len(rows)
matches = sum(1 for r in rows if r['status'] == 'match')
mismatches = [r for r in rows if r['status'] == 'mismatch']
not_comp = [r for r in rows if r['status'] == 'not_comparable']
boundary = [r for r in rows if r['status'] == 'boundary_sensitive']
by_section = {}
for r in rows:
stat = by_section.setdefault(r['section'], {'total':0, 'match':0, 'mismatch':0, 'not_comparable':0, 'boundary_sensitive':0})
stat['total'] += 1
stat[r['status']] = stat.get(r['status'], 0) + 1
lines = []
lines.append('# Jyotish benchmark 第二轮 Swiss extended 对比报告')
lines.append('')
lines.append('生成时间:2026-06-03')
lines.append('')
lines.append('## 1. 范围')
lines.append('')
lines.append('- 对比对象:当前 skill canonical baseline vs 直接调用 Swiss Ephemeris + 独立复写的 D9/D10/Vimshottari 公式。')
lines.append('- 样本:10 个公开/虚构 smoke case,不含用户个人资料。')
lines.append('- 本轮新增字段:Ascendant、D9、D10、当前 Vimshottari MD/AD。')
lines.append('- 注意:D9/D10/Vimshottari 的公式仍参考当前 skill 的公开公式重写,属于“独立脚本复算”,不是 PyJHora/JHora 级别的完全外部流派验证。')
lines.append('')
lines.append('## 2. 总体结果')
lines.append('')
lines.append(f'- 字段总数:{total}')
lines.append(f'- 匹配:{matches}')
lines.append(f'- 不匹配:{len(mismatches)}')
lines.append(f'- 边界敏感:{len(boundary)}')
lines.append(f'- 不可比:{len(not_comp)}')
lines.append(f'- 严格匹配率:{matches / total:.2%}' if total else '- 严格匹配率:N/A')
lines.append(f'- 容差/边界归因后可接受率:{(matches + len(boundary)) / total:.2%}' if total else '- 容差/边界归因后可接受率:N/A')
lines.append('')
lines.append('## 3. 分模块结果')
lines.append('')
lines.append('| Section | Total | Match | Mismatch | Boundary sensitive | Not comparable |')
lines.append('|---|---:|---:|---:|---:|---:|')
for section, stat in sorted(by_section.items()):
lines.append(f"| {section} | {stat['total']} | {stat.get('match',0)} | {stat.get('mismatch',0)} | {stat.get('boundary_sensitive',0)} | {stat.get('not_comparable',0)} |")
lines.append('')
if mismatches:
lines.append('## 4. 不匹配字段')
lines.append('')
lines.append('| Sample | Section | Body | Field | Local skill | Swiss extended | Delta |')
lines.append('|---|---|---|---|---|---|---:|')
for r in mismatches[:120]:
lines.append(f"| {r['sample_id']} | {r['section']} | {r['body']} | {r['field']} | {r['local_skill']} | {r['swiss_extended']} | {r['delta']} |")
lines.append('')
if boundary:
lines.append('## 4b. 边界敏感字段')
lines.append('')
lines.append('- 这些字段不是普通错配,而是度数处于分盘切分边界附近;四舍五入、Mean/True Node、JHora流派参数都可能导致落入相邻分盘。后续必须用 PyJHora/JHora 再仲裁。')
lines.append('')
lines.append('| Sample | Section | Body | Field | Local skill | Swiss extended | Delta |')
lines.append('|---|---|---|---|---|---|---:|')
for r in boundary[:80]:
lines.append(f"| {r['sample_id']} | {r['section']} | {r['body']} | {r['field']} | {r['local_skill']} | {r['swiss_extended']} | {r['delta']} |")
lines.append('')
lines.append('## 5. 判断')
lines.append('')
if mismatches:
lines.append('- 第二轮发现不匹配,需先定位算法差异,再接入第三方引擎。')
else:
lines.append('- 第二轮未发现不匹配,说明当前 skill 的 Ascendant、D9、D10、Vimshottari 当前 MD/AD 在本地独立复算下稳定。')
lines.append('- 这仍然不能替代 PyJHora / JHora / VedAstro 的外部多引擎验证;它只是把内部公式错误和 UTC/边界错误的风险进一步压低。')
report = OUT / 'jyotish_benchmark_round2_swiss_extended_compare.md'
report.write_text('\n'.join(lines))
return report
def main():
OUT.mkdir(parents=True, exist_ok=True)
SWISS_OUT.mkdir(parents=True, exist_ok=True)
samples = json.loads(DATA.read_text())
all_rows = []
for sample in samples:
swiss = calc_swiss_extended(sample)
(SWISS_OUT / f"{sample['id']}.swiss_extended.json").write_text(json.dumps(swiss, ensure_ascii=False, indent=2))
all_rows.extend(compare_sample(sample['id'], swiss))
csv_path = OUT / 'swiss_extended_comparison_matrix.csv'
with csv_path.open('w', newline='') as f:
writer = csv.DictWriter(f, fieldnames=['sample_id','section','body','field','local_skill','swiss_extended','delta','status'])
writer.writeheader()
writer.writerows(all_rows)
report = write_report(all_rows)
print(json.dumps({'matrix': str(csv_path), 'report': str(report), 'rows': len(all_rows)}, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()