# 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 os import sys from datetime import datetime from pathlib import Path ROOT = Path(__file__).resolve().parents[1] DATA = ROOT / 'data/benchmark_samples.json' OUT = ROOT / 'outputs' LOCAL_CANON = OUT / 'canonical' PYJHORA_OUT = OUT / 'pyjhora' # Keep personal data out of benchmark: samples are fictional/public smoke cases only. PLANETS = ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'] PYJHORA_PLANET_ID = { 0: 'Sun', 1: 'Moon', 2: 'Mars', 3: 'Mercury', 4: 'Jupiter', 5: 'Venus', 6: 'Saturn', 7: 'Rahu', 8: 'Ketu', } DASHA_LORDS = {8: 'Ketu', 5: 'Venus', 0: 'Sun', 1: 'Moon', 2: 'Mars', 7: 'Rahu', 4: 'Jupiter', 6: 'Saturn', 3: 'Mercury'} SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces'] 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' ] def patch_swisseph(): import swisseph as swe for name in ['SIDM_KRISHNAMURTI_VP291', 'SIDM_TRUE_MULA', 'SIDM_TRUE_CITRA', 'SIDM_TRUE_REVATI']: if not hasattr(swe, name): setattr(swe, name, getattr(swe, 'SIDM_KRISHNAMURTI', 1)) orig_calc_ut = swe.calc_ut def calc_ut(jd, body, flags=0, *args, **kwargs): if 'flags' in kwargs: flags = kwargs.pop('flags') return orig_calc_ut(jd, body, flags) swe.calc_ut = calc_ut orig_houses_ex = swe.houses_ex def houses_ex(tjdut, lat, lon, hsys=b'P', flags=0, *args, **kwargs): if 'flags' in kwargs: flags = kwargs.pop('flags') if 'hsys' in kwargs: hsys = kwargs.pop('hsys') return orig_houses_ex(tjdut, lat, lon, hsys, flags) swe.houses_ex = houses_ex return swe def sign_name(sign_idx): return SIGNS[int(sign_idx) % 12] def nakshatra_from_abs(abs_lon): x = abs_lon % 360.0 unit = 360.0 / 27.0 idx = int(x // unit) pada = int((x % unit) // (unit / 4.0)) + 1 return NAKSHATRAS[idx], pada def parse_chart_positions(rows): result = {} for key, value in rows: sign_idx, deg = value if key == 'L': body = 'Ascendant' else: body = PYJHORA_PLANET_ID.get(key) if not body: continue result[body] = { 'sign': sign_name(sign_idx), 'sign_idx': int(sign_idx), 'degree_in_sign': round(float(deg), 4), } return result def tuple_to_date(t): if not t: return None y, m, d, _fh = t return f'{int(y):04d}-{int(m):02d}-{int(d):02d}' def build_pyjhora_sample(sample): swe = patch_swisseph() from jhora import utils, const from jhora.panchanga import drik from jhora.horoscope.chart import charts from jhora.horoscope.dhasa.graha import vimsottari # Align benchmark口径: Lahiri + mean sidereal year. PyJHora default is TRUE_PUSHYA. const._DEFAULT_AYANAMSA_MODE = 'LAHIRI' drik.set_ayanamsa_mode('LAHIRI') try: const.dhasa_year_duration_default = const.DHASA_YEAR_DURATION.MEAN_SIDEREAL_YEAR except Exception: pass b = sample['birth'] jd = utils.julian_day_number((b['year'], b['month'], b['day']), (b['hour'], b['minute'], 0)) place = drik.Place(sample['label'], b['lat'], b['lon'], b['tz']) today = sample.get('today', '2026-06-03') ty, tm, td = [int(x) for x in today.split('-')] current_jd = utils.julian_day_number((ty, tm, td), (0, 0, 0)) rasi = parse_chart_positions(charts.rasi_chart(jd, place)) d9 = parse_chart_positions(charts.divisional_chart(jd, place, divisional_chart_factor=9, chart_method=1)) d10 = parse_chart_positions(charts.divisional_chart(jd, place, divisional_chart_factor=10, chart_method=1)) asc = rasi.get('Ascendant') or {} planets = {} for p in PLANETS: pd = rasi.get(p) or {} abs_lon = pd.get('sign_idx', 0) * 30.0 + float(pd.get('degree_in_sign', 0.0)) nak, pada = nakshatra_from_abs(abs_lon) planets[p] = { 'sign': pd.get('sign'), 'degree_in_sign': pd.get('degree_in_sign'), 'nakshatra': nak, 'nakshatra_pada': pada, } dasha = { 'mahadasha_lord': None, 'mahadasha_start': None, 'mahadasha_end': None, 'antardasha_lord': None, 'antardasha_start': None, 'antardasha_end': None, } try: ladder = vimsottari.get_running_dhasa_for_given_date(current_jd, jd, place, dhasa_level_index=2) if ladder: md = ladder[0] dasha['mahadasha_lord'] = DASHA_LORDS.get(md[0][0], str(md[0][0])) dasha['mahadasha_start'] = tuple_to_date(md[1]) dasha['mahadasha_end'] = tuple_to_date(md[2]) if len(ladder) > 1: ad = ladder[1] dasha['antardasha_lord'] = DASHA_LORDS.get(ad[0][-1], str(ad[0][-1])) dasha['antardasha_start'] = tuple_to_date(ad[1]) dasha['antardasha_end'] = tuple_to_date(ad[2]) except Exception as exc: dasha['error'] = f'{type(exc).__name__}: {exc}' return { 'sample_id': sample['id'], 'engine': 'PyJHora_4_8_6_lahiri_patched', 'parameters': { 'zodiac': 'sidereal', 'ayanamsa': 'LAHIRI', 'd9_method': 'PyJHora divisional_chart chart_method=1', 'd10_method': 'PyJHora divisional_chart chart_method=1', 'dasha_year': 'mean sidereal year', 'compat': 'monkeypatch swisseph keyword API + missing constants; dummy timezonefinder only for import', 'license_note': 'PyJHora is AGPL-3.0; used only as external benchmark, not vendored into skill.' }, 'ascendant': { 'sign': asc.get('sign'), 'degree_in_sign': asc.get('degree_in_sign'), }, 'planets': planets, 'varga': {'D9': d9, 'D10': d10}, 'dasha': dasha, } def compare_scalar(rows, sample_id, section, body, field, local_value, pyjhora_value, tolerance=None, date_tolerance_days=None, boundary_sensitive=False, status_override=None): status = status_override or 'match' delta = '' if not status_override: if date_tolerance_days is not None: try: ld = datetime.strptime(str(local_value), '%Y-%m-%d') pd = datetime.strptime(str(pyjhora_value), '%Y-%m-%d') delta_val = abs((ld - pd).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(pyjhora_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 == pyjhora_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, 'pyjhora': pyjhora_value, 'delta': delta, 'status': status, }) def compare_one(sample_id, local, pyjhora): rows = [] compare_scalar(rows, sample_id, 'ascendant', 'Ascendant', 'sign', local['ascendant'].get('sign'), pyjhora['ascendant'].get('sign')) compare_scalar(rows, sample_id, 'ascendant', 'Ascendant', 'degree_in_sign', local['ascendant'].get('degree_in_sign'), pyjhora['ascendant'].get('degree_in_sign'), tolerance=0.15) for p in PLANETS: l = local['planets'].get(p, {}) y = pyjhora['planets'].get(p, {}) for field in ['sign', 'nakshatra', 'nakshatra_pada']: compare_scalar(rows, sample_id, 'planet', p, field, l.get(field), y.get(field)) compare_scalar(rows, sample_id, 'planet', p, 'degree_in_sign', l.get('degree_in_sign'), y.get('degree_in_sign'), tolerance=0.15) for varga_name in ['D9', 'D10']: for body in ['Ascendant'] + PLANETS: l = (local['varga'].get(varga_name) or {}).get(body) or {} y = (pyjhora['varga'].get(varga_name) or {}).get(body) or {} boundary_sensitive = False try: boundary_sensitive = abs(float(l.get('degree_in_sign', 99)) - float(y.get('degree_in_sign', -99))) > 20 and l.get('sign') != y.get('sign') except Exception: pass compare_scalar(rows, sample_id, varga_name, body, 'sign', l.get('sign'), y.get('sign'), boundary_sensitive=boundary_sensitive) compare_scalar(rows, sample_id, varga_name, body, 'degree_in_sign', l.get('degree_in_sign'), y.get('degree_in_sign'), tolerance=0.2, boundary_sensitive=boundary_sensitive) # PyJHora dasha is useful as external signal, but currently has different default starting convention/seed in some cases. # Keep fields in matrix, with generous date tolerance; differences are classified below in report. for field in ['mahadasha_lord', 'antardasha_lord']: compare_scalar(rows, sample_id, 'dasha', 'Vimshottari_current', field, local['dasha'].get(field), pyjhora['dasha'].get(field)) for field in ['mahadasha_start', 'mahadasha_end', 'antardasha_start', 'antardasha_end']: compare_scalar(rows, sample_id, 'dasha', 'Vimshottari_current', field, local['dasha'].get(field), pyjhora['dasha'].get(field), date_tolerance_days=7) return rows def write_report(samples, rows): total = len(rows) counts = {} by_section = {} for r in rows: counts[r['status']] = counts.get(r['status'], 0) + 1 stat = by_section.setdefault(r['section'], {'total': 0}) stat['total'] += 1 stat[r['status']] = stat.get(r['status'], 0) + 1 matches = counts.get('match', 0) mismatches = [r for r in rows if r['status'] == 'mismatch'] boundary = [r for r in rows if r['status'] == 'boundary_sensitive'] non_dasha_rows = [r for r in rows if r['section'] != 'dasha'] non_dasha_match = sum(1 for r in non_dasha_rows if r['status'] == 'match') non_dasha_ok = sum(1 for r in non_dasha_rows if r['status'] in ('match', 'boundary_sensitive')) lines = [] lines.append('# Jyotish benchmark 第三轮:PyJHora 对比报告') lines.append('') lines.append('生成时间:2026-06-03') lines.append('') lines.append('## 1. 本轮范围') lines.append('') lines.append('- 外部引擎:PyJHora 4.8.6。') lines.append('- 用途:第二个独立 Jyotish 开源项目对标,重点验证 D1、D9、D10,并初探 Vimshottari。') lines.append('- 样本:10个公开/虚构 smoke case,不包含用户个人资料。') lines.append('- 口径:强制 Lahiri;PyJHora 默认 TRUE_PUSHYA,因此本轮显式切换到 LAHIRI。') lines.append('- 兼容处理:PyJHora 4.8.6 与本机 pyswisseph API 存在关键字参数/常量兼容问题,本脚本只在 benchmark 进程内 monkeypatch,不改 PyJHora 源码,不把 AGPL 代码并入 skill。') 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'- 总严格匹配率:{matches / total:.2%}' if total else '- 总严格匹配率:N/A') lines.append(f'- 非 Dasha 字段严格匹配率:{non_dasha_match / len(non_dasha_rows):.2%}' if non_dasha_rows else '- 非 Dasha 字段严格匹配率:N/A') lines.append(f'- 非 Dasha 字段边界归因后可接受率:{non_dasha_ok / len(non_dasha_rows):.2%}' if non_dasha_rows else '- 非 Dasha 字段边界归因后可接受率: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.get('total',0)} | {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 | PyJHora | Delta |') lines.append('|---|---|---|---|---|---|---:|') for r in mismatches[:160]: lines.append(f"| {r['sample_id']} | {r['section']} | {r['body']} | {r['field']} | {r['local_skill']} | {r['pyjhora']} | {r['delta']} |") lines.append('') if boundary: lines.append('## 4b. 边界敏感字段') lines.append('') lines.append('| Sample | Section | Body | Field | Local skill | PyJHora | Delta |') lines.append('|---|---|---|---|---|---|---:|') for r in boundary[:80]: lines.append(f"| {r['sample_id']} | {r['section']} | {r['body']} | {r['field']} | {r['local_skill']} | {r['pyjhora']} | {r['delta']} |") lines.append('') lines.append('## 5. 判断') lines.append('') lines.append('- PyJHora 作为第二开源引擎已经接入成功。') lines.append('- D1/D9/D10若高匹配,说明当前 skill 的分盘算法不仅与 Swiss direct 自算一致,也能通过独立 Jyotish 项目的实测。') lines.append('- Dasha 部分若存在系统性差异,优先视为 PyJHora seed_star / dasha year / 起运规则口径差异,不能马上判定本 skill 错;需要 JHora 或 Drik Panchang 再仲裁。') lines.append('- PyJHora 是 AGPL-3.0,适合做外部 benchmark,不适合把其源码或派生实现并入当前 skill。') return '\n'.join(lines) def main(): PYJHORA_OUT.mkdir(parents=True, exist_ok=True) samples = json.loads(DATA.read_text()) all_rows = [] for sample in samples: pyjhora = build_pyjhora_sample(sample) (PYJHORA_OUT / f"{sample['id']}.pyjhora.json").write_text(json.dumps(pyjhora, ensure_ascii=False, indent=2)) local = json.loads((LOCAL_CANON / f"{sample['id']}.canonical.json").read_text()) all_rows.extend(compare_one(sample['id'], local, pyjhora)) matrix = OUT / 'pyjhora_comparison_matrix.csv' with matrix.open('w', newline='') as f: writer = csv.DictWriter(f, fieldnames=['sample_id', 'section', 'body', 'field', 'local_skill', 'pyjhora', 'delta', 'status']) writer.writeheader() writer.writerows(all_rows) report = OUT / 'jyotish_benchmark_round3_pyjhora_compare.md' report.write_text(write_report(samples, all_rows)) print(json.dumps({'report': str(report), 'matrix': str(matrix), 'samples': len(samples), 'fields': len(all_rows)}, ensure_ascii=False, indent=2)) if __name__ == '__main__': main()