diff --git a/CHANGELOG.md b/CHANGELOG.md index c8e9d29f..5eacff60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-10-23 — 研究口径的推运会标出和 PyJHora 已知的差别 + +- 完整数据版研究章里,和 PyJHora 对过、顺序或年数不同的方法,会在该方法标题下写明差在哪一类。 +- 对过 10 张虚构盘。本命 Tithi 顺行两边一致,回归时刻差都在 1 分钟以内。本命 Tithi 逆行在 PyJHora 的公开函数里没有对应项,所以不标成不一致。 +- Skill 版本不变。 + ## 2026-10-23 — 完整数据版补上研究口径的推运、次小运和细分盘 - 完整数据版末尾新增三块:补充推运、辅助大运的次小运、研究型分盘。每块开头都写明这是研究口径,还没跟外部软件对过。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 1f327c3e..5d933d64 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -19350,3 +19350,19 @@ - 复发自:无 - 修复版本:分支 `codex/full-data-parity-1-20261021`。 +## BUG-1429 | 1970 年以前的研究章日出在 Windows 上把整份报告打崩 + +- 状态:resolved +- 首次发现:2026-10-23 +- 最近更新:2026-10-23 +- 影响面:完整数据版研究章里要用日出日落的 Kaala、Varnada。Windows 上整份报告渲染会中断。 +- 用户现象:200 张虚构盘的英文扫描里,研究章没算完,报告生成直接报 `OSError: [Errno 22] Invalid argument`。 +- 触发条件:在 Windows 上渲染完整数据版,盘的年份早于 1970。Linux 上同一调用可以完成。 +- 根因:日出时刻用 `datetime.fromtimestamp` 换算。Windows 不接受 1970 年以前的时间戳。这一步在各方法的失败保护之外,所以整章一起停。 +- 修复:儒略日改用 1970-01-01 加减时间差换算,不再调用 `fromtimestamp`。1970 年之后的时刻与原来的换算一致。 +- 验证:`tests/test_research_timing.py::test_solar_events_before_1970_stay_available_on_windows` 通过。200 张虚构盘英文扫描修复后重跑通过,约 1956 秒,退出码 0。 +- 防复发:上面这条测试覆盖 1950 年的直接调用,以及种子里每一个早于 1970 年的虚构盘。 +- 相关记录:issue #92。编号 1427、1428 留给另一条尚未合并的分支。 +- 复发自:无 +- 修复版本:分支 `codex/full-data-parity-2-20261021`。 + diff --git a/docs/tasks/PROGRESS-full-data-parity-2-20261021.md b/docs/tasks/PROGRESS-full-data-parity-2-20261021.md index a32330aa..4220f668 100644 --- a/docs/tasks/PROGRESS-full-data-parity-2-20261021.md +++ b/docs/tasks/PROGRESS-full-data-parity-2-20261021.md @@ -63,9 +63,26 @@ ## 让步和偏离 -- **T5**:这台机器能导入 PyJHora。对照文件是 `docs/tasks/research-timing-pyjhora-20261023.json`,状态 `not_compared`,没有逐张盘逐层比对。报告因此不写「与 PyJHora 不一致」。这低于任务书「至少 5 张」的让步线,没有拿猜测去填标注。 -- 没有让出 Karana 的 360 太阴月年,也没有让出 Tithi 的时间余额。 -- 英文不写 PyJHora 这个名字,避免整份英文被卫生门丢掉。中文只有在对照 JSON 真有差异时才写那个名字;这一轮 JSON 没有差异列表。 +- **T5(修复单补上)**:这一轮先把 `origin/staging` 的 `bc6795e2a353f6a10fa65ab79766aeb578714b0b` 合并进来,没有 rebase。对照文件 `docs/tasks/research-timing-pyjhora-20261023.json`,状态 `compared`,10 张固定种子虚构盘,PyJHora 4.8.7。15 个体系里:一致 1,无此方法 1,不一致 13。回归时刻差在 1 分钟内写进 `within_tolerance`,不算不一致;这 10 张本命 Tithi 顺行的回归差都在 0.003 分钟以内。两边岁差和星历文件不同,所以留这一分钟。 +- 没有让出盘数(仍是 10,不是下限 5),没有让出 Karana 的 360 太阴月年,也没有让出 Tithi 的时间余额。 +- 已知差别写在该方法标题下。中文是「与 PyJHora 不一致:差在 {类别}」,英文是「Differs from PyJHora: {category}」。句子加在按行清理之后,清理器本身没改,所以英文版能留下这句。整份英文不会因为这一句被丢掉。 +- 顺序整段相反才标「方向」。这一轮没有「方向」,也没有超出 1 分钟的「回归时刻」。开头主星相同、只是同一圈又多走了几步的,改去比年数,不再写成起点不同。 +- Karana 三种都对 PyJHora 里那一个公开函数,比的是出生当下那一圈,不是目标年里的回归。Varnada 的两个起点合成一条,标题按前缀对上。Sudasa 已确认起点没有书面锚点,不参与对照,也不标不一致。Niryaana Shoola 按 10 月 23 日的决定仍不列子运。年度 Tithi、Yoga、Nakshatra 不在这次对照名单里。 +- 本命 Tithi 逆行:PyJHora 的公开函数没有大运逆行这一项,标成「无此方法」,不算不一致。 + +| 体系 | 结果 | 差在 | +| --- | --- | --- | +| 本命 Tithi Ashtottari 顺行 | 一致 | 回归都在 1 分钟内 | +| 本命 Tithi Ashtottari 逆行 | 无此方法 | 公开函数没有这一项 | +| 本命 Yoga Vimshottari | 不一致 | 子运划分 | +| Buddhi Gati | 不一致 | 起点 | +| Kaala | 不一致 | 年数 | +| Sudasa 出生周期 | 不一致 | 年数 | +| Varnada | 不一致 | 起点 | +| Julene 主星 / PVR 象限主星 / PVR 360 太阴月年 | 不一致 | 年数 | +| Sthira / Drig / Shoola / Navamsha | 不一致 | 起点 | +| Kalachakra | 不一致 | 起点 | + - 表头用「层级 | 主星/名称 | 起始 | 结束」,与任务书用词一致。 - Kalachakra 的中文是:「次小运按简化模型,从现有主运按比例分出。主运和子运与原来的表相同。」 @@ -89,3 +106,16 @@ 标题顺序那条断言:原值是「拿掉六章后只少这六章」;新值是「基准同时拿掉六章和研究章,完整报告在六章之后还有研究章」;原因是研究章也是后加的二级标题。 D81 出现位置那条是新断言,不是改旧数字:研究章之前的正文不得再出现 D81、D108、D144。 + +## 修复单自测(Windows) + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 研究章、对照标注、校正 golden | `pytest tests/test_research_timing.py tests/test_sidereal_nutation_frame.py tests/test_rashi_dashas_native.py tests/test_full_data_domain_reader.py tests/test_report_english_edition.py tests/test_python_hygiene_budget.py tests/test_api_server_growth_contract.py tests/test_runtime_jhora_import_scan.py tests/test_report_reader_main.py tests/test_repo_privacy_markers.py -q` | 163 通过,失败 0,约 1013 秒 | +| 1970 年以前的日出 | `pytest tests/test_research_timing.py::test_solar_events_before_1970_stay_available_on_windows -q` | 通过。Windows 上不再用 `fromtimestamp`,见 BUG-1429 | +| 200 张虚构盘英文 | `pytest tests/test_report_english_sweep.py::test_two_hundred_fictional_charts_have_clean_english -q` | 通过,约 1956 秒。修复前,早于 1970 年的盘会在 Windows 上中断整份报告(BUG-1429) | +| 前端 tsc / lint | 未跑 | 没有改前端 | +| test:db | 未跑 | 没有改表 | +| Python 全量、快速门 | 未跑 | 留给验收 | + +阅读页泄漏断言:原值是整篇出现 PyJHora 都算泄漏;新值是以「与 PyJHora 不一致:」或「Differs from PyJHora:」开头的行先拿掉,其余照旧;原因是修复单要求这两句留在标题下。英文研究章同理:原值是英文不得出现 PyJHora;新值是只允许出现在「Differs from PyJHora:」开头的行。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 6c87704a..8401b8e5 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -523,8 +523,8 @@ | `TASK-report-english-han-leak-20261021.md` | `PROGRESS-report-english-han-leak-20261021.md` | #89 个人报告英文版约 4 成整份丢弃:漏译汉字源头补英文孪生字段、修吃空格;200 张固定种子回归 + 快速门 smoke(复发自 BUG-1272);中文逐字节不变 | 已合入 staging | `codex/report-english-han-leak-20261021` | | `TASK-chart-dasha-yogini-20261021.md` | — | #88 星盘页大运右栏切换 Chara / Yogini,Yogini 补小运(按比例,内部核对,页面注明未经外部软件核对);Ashtottari 另立单(起运表与 PyJHora 8/12 宿不一致) | 已合入 staging | `codex/chart-dasha-yogini-20261021` | | `TASK-full-data-parity-1-20261021.md` | `PROGRESS-full-data-parity-1-b-20261023.md` | #91 完整数据版补齐(一):K1–K3 已做;K4 把年运新字段放到原键之后,Yogini 缺完整年数时不再整段失败(BUG-1425、BUG-1426) | 已合入 staging | `codex/full-data-parity-1-20261021` | -| `TASK-full-data-parity-2-20261021.md` | `PROGRESS-full-data-parity-2-20261021.md` | #92 完整数据版补齐(二):研究型推运附录、四种星座大运次小运、Kalachakra 第三层、D81 / D108 / D144 只留在研究口径章节 | 执行中 | `codex/full-data-parity-2-20261021` | -| `TASK-full-data-parity-2-fix-20261023.md` | `PROGRESS-full-data-parity-2-20261021.md` | **#92 第 1 次打回**:其余全过(Linux 生成约 18 秒、英文汉字 0);T5 PyJHora 离线对照一张没做(下限 5 张),报告无法标出不一致的体系 | 执行中 | `codex/full-data-parity-2-20261021` | +| `TASK-full-data-parity-2-20261021.md` | `PROGRESS-full-data-parity-2-20261021.md` | #92 完整数据版补齐(二):研究型推运附录、四种星座大运次小运、Kalachakra 第三层、D81 / D108 / D144 只留在研究口径章节 | 待验收 | `codex/full-data-parity-2-20261021` | +| `TASK-full-data-parity-2-fix-20261023.md` | `PROGRESS-full-data-parity-2-20261021.md` | **#92 第 1 次打回**:其余全过(Linux 生成约 18 秒、英文汉字 0);T5 已用 10 张虚构盘做完离线对照,不一致的体系在标题下写明差在哪一类 | 待验收 | `codex/full-data-parity-2-20261021` | | `TASK-goel-jaimini-strength-20261022.md` | `PROGRESS-goel-jaimini-strength-20261022.md` | #94 搬上游公开的 Goel Jaimini 强度与 Brahma / Maheshwara / Rudra(4 个上游原样文件,按 #81 决策 A 先例根目录预算 448→452;来源串改书目引用);只做计算、不改报告;冻结身份不变 | 已合入 staging | `codex/goel-jaimini-strength-20261022` | | `TASK-chart-subject-table-fix-20261022.md` | `PROGRESS-chart-subject-table-fix-20261022.md` | **#86 第 1 次打回**:报告所有表被强制单行(打印跑出纸面);保存他人复制了档案页代码且已分叉;点编辑框内边距会丢修改 | 已合入 staging | `codex/chart-subject-table-20261021` | | `TASK-full-data-parity-3-20261022.md` | — | #93 完整数据版补齐(三):「独立年度方法参考」正式环境调 PyJHora(子进程隔离;装上后其余输出逐字节不变,7 个换路径开关钉死);输入默认用本地时间与正确年份(D6 待产品确认) | 待领取(等 #91 合入) | `codex/full-data-parity-3-20261022` | diff --git a/docs/tasks/research-timing-pyjhora-20261023.json b/docs/tasks/research-timing-pyjhora-20261023.json index 9bd9c7af..7a97074f 100644 --- a/docs/tasks/research-timing-pyjhora-20261023.json +++ b/docs/tasks/research-timing-pyjhora-20261023.json @@ -1,6 +1,206 @@ { - "status": "not_compared", - "charts": 0, - "reason": "PyJHora imported, but this pass does not guess a mismatch without a completed comparison", - "mismatches": [] + "status": "compared", + "charts": 10, + "chart_indexes": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9 + ], + "pyjhora_version": "4.8.7", + "return_rule": "A return moment within 1 minute is listed in within_tolerance and is not a mismatch. The two sides use different ayanamsa implementations and ephemeris files.", + "omitted_from_labels": [ + "Sudasa confirmed start has no written full major start on our side, so it is not compared.", + "Niryaana Shoola child periods stay omitted by the 2026-10-23 decision.", + "Annual Tithi, Yoga, and Nakshatra headings are outside this pass." + ], + "within_tolerance": [ + { + "system": "tithi-natal-forward", + "chart": 0, + "minutes": 0.002 + }, + { + "system": "tithi-natal-forward", + "chart": 1, + "minutes": 0.001 + }, + { + "system": "tithi-natal-forward", + "chart": 2, + "minutes": 0.001 + }, + { + "system": "tithi-natal-forward", + "chart": 3, + "minutes": 0.003 + }, + { + "system": "tithi-natal-forward", + "chart": 4, + "minutes": 0.0 + }, + { + "system": "tithi-natal-forward", + "chart": 5, + "minutes": 0.002 + }, + { + "system": "tithi-natal-forward", + "chart": 6, + "minutes": 0.001 + }, + { + "system": "tithi-natal-forward", + "chart": 7, + "minutes": 0.002 + }, + { + "system": "tithi-natal-forward", + "chart": 8, + "minutes": 0.002 + }, + { + "system": "tithi-natal-forward", + "chart": 9, + "minutes": 0.003 + } + ], + "systems": { + "tithi-natal-forward": { + "status": "一致", + "heading_en": "Natal Tithi Ashtottari forward", + "heading_zh": "本命 Tithi Ashtottari 顺行", + "match": "exact" + }, + "tithi-natal-reverse": { + "status": "无此方法", + "heading_en": "Natal Tithi Ashtottari reverse", + "heading_zh": "本命 Tithi Ashtottari 逆行", + "match": "exact" + }, + "yoga-natal": { + "status": "不一致", + "heading_en": "Natal Yoga Vimshottari", + "heading_zh": "本命 Yoga Vimshottari", + "match": "exact", + "category": "子运划分", + "category_en": "sub-period division", + "example": "chart 2 third level under Mercury does not line up" + }, + "buddhi-gati": { + "status": "不一致", + "heading_en": "Buddhi Gati", + "heading_zh": "Buddhi Gati", + "match": "exact", + "category": "起点", + "category_en": "start", + "example": "chart 0 period 1 native Sun vs PyJHora Mercury" + }, + "kaala": { + "status": "不一致", + "heading_en": "Kaala", + "heading_zh": "Kaala", + "match": "exact", + "category": "年数", + "category_en": "year count", + "example": "chart 0 year count differs at the first compared period of Sun" + }, + "sudasa-birth": { + "status": "不一致", + "heading_en": "Sudasa birth cycle", + "heading_zh": "Sudasa 出生周期", + "match": "exact", + "category": "年数", + "category_en": "year count", + "example": "chart 0 year count differs at the first compared period of Scorpio" + }, + "varnada": { + "status": "不一致", + "heading_en": "Varnada", + "heading_zh": "Varnada", + "match": "prefix", + "category": "起点", + "category_en": "start", + "example": "chart 0 period 1 native Aries vs PyJHora Capricorn" + }, + "karana-julene": { + "status": "不一致", + "heading_en": "Julene lords", + "heading_zh": "Julene 主星", + "match": "prefix", + "category": "年数", + "category_en": "year count", + "example": "chart 0 year count differs at the first compared period of Mars" + }, + "karana-pvr": { + "status": "不一致", + "heading_en": "PVR quadrant lords", + "heading_zh": "PVR 象限主星", + "match": "prefix", + "category": "年数", + "category_en": "year count", + "example": "chart 0 year count differs at the first compared period of Mars" + }, + "karana-pvr-tithi-year": { + "status": "不一致", + "heading_en": "PVR 360 tithi year", + "heading_zh": "PVR 360 太阴月年", + "match": "prefix", + "category": "年数", + "category_en": "year count", + "example": "chart 0 year count differs at the first compared period of Mars" + }, + "sthira": { + "status": "不一致", + "heading_en": "Sthira", + "heading_zh": "Sthira", + "match": "exact", + "category": "起点", + "category_en": "start", + "example": "chart 1 period 1 native Capricorn vs PyJHora Cancer" + }, + "drig": { + "status": "不一致", + "heading_en": "Drig", + "heading_zh": "Drig", + "match": "exact", + "category": "起点", + "category_en": "start", + "example": "chart 1 period 10 native Leo vs PyJHora Aquarius" + }, + "shoola": { + "status": "不一致", + "heading_en": "Shoola", + "heading_zh": "Shoola", + "match": "exact", + "category": "起点", + "category_en": "start", + "example": "chart 1 period 1 native Sagittarius vs PyJHora Gemini" + }, + "navamsha": { + "status": "不一致", + "heading_en": "Navamsha", + "heading_zh": "Navamsha", + "match": "exact", + "category": "起点", + "category_en": "start", + "example": "chart 0 period 1 native Capricorn vs PyJHora Aquarius" + }, + "kalachakra": { + "status": "不一致", + "heading_en": "Kalachakra", + "heading_zh": "Kalachakra", + "match": "exact", + "category": "起点", + "category_en": "start", + "example": "chart 0 period 1 native Taurus vs PyJHora Gemini" + } + } } diff --git a/scripts/research/research_timing_pyjhora_compare.py b/scripts/research/research_timing_pyjhora_compare.py index 78eeb526..c23ca513 100644 --- a/scripts/research/research_timing_pyjhora_compare.py +++ b/scripts/research/research_timing_pyjhora_compare.py @@ -1,41 +1,768 @@ """Offline PyJHora comparison for the research timing appendix. Pytest does not import this file. The runtime report does not import it either. -If PyJHora is absent, the result is ``not_run`` and the report grows no mismatch label. +PyJHora is imported only inside ``pyjhora_fingerprints``, and only when this +file is run as a worker process. A missing public function is recorded as +「无此方法」 and is not a mismatch. """ from __future__ import annotations +import importlib.util import json +import subprocess +import sys +import tempfile +import warnings +from datetime import datetime, timedelta, timezone from pathlib import Path +ROOT = Path(__file__).resolve().parents[2] +OUT = ROOT / "docs" / "tasks" / "research-timing-pyjhora-20261023.json" +CHARTS = tuple(range(10)) +YEAR_BASIS = 365.25 +RETURN_MINUTE = 1.0 +CATEGORIES = ( + ("起点", "start"), + ("方向", "direction"), + ("年数", "year count"), + ("子运划分", "sub-period division"), + ("回归时刻", "return moment"), +) +HEADINGS = { + "tithi-natal-forward": ("Natal Tithi Ashtottari forward", "本命 Tithi Ashtottari 顺行", "exact"), + "tithi-natal-reverse": ("Natal Tithi Ashtottari reverse", "本命 Tithi Ashtottari 逆行", "exact"), + "yoga-natal": ("Natal Yoga Vimshottari", "本命 Yoga Vimshottari", "exact"), + "buddhi-gati": ("Buddhi Gati", "Buddhi Gati", "exact"), + "kaala": ("Kaala", "Kaala", "exact"), + "sudasa-birth": ("Sudasa birth cycle", "Sudasa 出生周期", "exact"), + "varnada": ("Varnada", "Varnada", "prefix"), + "karana-julene": ("Julene lords", "Julene 主星", "prefix"), + "karana-pvr": ("PVR quadrant lords", "PVR 象限主星", "prefix"), + "karana-pvr-tithi-year": ("PVR 360 tithi year", "PVR 360 太阴月年", "prefix"), + "sthira": ("Sthira", "Sthira", "exact"), + "drig": ("Drig", "Drig", "exact"), + "shoola": ("Shoola", "Shoola", "exact"), + "navamsha": ("Navamsha", "Navamsha", "exact"), + "kalachakra": ("Kalachakra", "Kalachakra", "exact"), +} + + +def _ensure_root() -> None: + """Match the test path: ``scripts/`` first so engine modules import, repo root second.""" + for entry in (str(ROOT / "scripts"), str(ROOT)): + if entry in sys.path: + sys.path.remove(entry) + sys.path.insert(0, entry) + + +def _span_years(start, end) -> float | None: + if not start or not end: + return None + try: + left = datetime.fromisoformat(str(start)) + right = datetime.fromisoformat(str(end)) + except ValueError: + return None + days = (right - left).total_seconds() / 86400.0 + if days <= 0: + return None + return round(days / YEAR_BASIS, 2) + + +def _label(row: dict, kind: str) -> str: + if kind == "sign": + value = row.get("sign") or row.get("rashi") or row.get("lord") + else: + value = row.get("lord") or row.get("planet") or row.get("sign") + return "" if value is None else str(value) + + +def fingerprint_periods(periods: list, *, kind: str) -> dict: + """Lords or signs, nominal years, first period's children, and its first grandchildren.""" + maha: list[str] = [] + years: list[float | None] = [] + children: list[str] = [] + grand: list[str] = [] + for index, row in enumerate(periods or []): + if not isinstance(row, dict): + continue + maha.append(_label(row, kind)) + if "full_end_days" in row and "full_start_days" in row: + span = (float(row["full_end_days"]) - float(row["full_start_days"])) / YEAR_BASIS + years.append(round(span, 2)) + elif isinstance(row.get("years"), (int, float)) and not isinstance(row.get("years"), bool): + years.append(round(float(row["years"]), 2)) + else: + years.append(_span_years(row.get("start") or row.get("start_date"), row.get("end") or row.get("end_date"))) + if index != 0: + continue + kids = row.get("antardashas") or row.get("antardasha") or [] + for child in kids: + if not isinstance(child, dict): + continue + children.append(_label(child, kind)) + if grand: + continue + thirds = child.get("pratyantardashas") or child.get("pratyantar") or child.get("pratyantardasha") or [] + for item in thirds: + if isinstance(item, dict): + grand.append(_label(item, kind)) + if not maha: + return {"skipped": True} + return {"maha": maha, "years": years, "children": children, "grand": grand} + + +def _chart_input(index: int) -> tuple[dict, dict]: + _ensure_root() + from tests.report_english_fictional import fictional_case + from scripts.research_timing.bundle import chart_from_parts + + case = fictional_case(index) + zone = timezone(timedelta(hours=float(case["tz"]))) + query = datetime(int(case["target_year"]), 1, 1, 12, 0, tzinfo=zone) + chart = chart_from_parts( + year=case["year"], month=case["month"], day=case["day"], + hour=case["hour"], minute=case["minute"], second=case["second"], + lat=case["lat"], lon=case["lon"], tz=case["tz"], + ayanamsa=case["ayanamsa"], node_mode=case["node_mode"], + target_year=case["target_year"], query=query, + ) + return case, chart + + +def _tithi_return_days(chart: dict) -> float | None: + from scripts.research_timing.frame import SiderealFrame, birth_jd + from scripts.research_timing.search import find_returns + + frame = SiderealFrame(chart["ayanamsa"], chart["node_mode"]) + jd = birth_jd(chart["year"], chart["month"], chart["day"], chart["hour_decimal"], chart["tz"]) + found = find_returns(frame, "tithi_pravesha", jd, jd, jd + 400.0) + for row in found.get("returns") or []: + elapsed = float(row["jd_ut"]) - jd + if elapsed > 1e-3: + return elapsed + return None + + +def collect_native(indexes: tuple[int, ...] = CHARTS) -> dict: + """Our research periods for the fixed fictional charts. This process does not import jhora.""" + _ensure_root() + from scripts.annual_native.dasha_children import attach_kalachakra_simplified_children + from scripts.kalachakra_dasha import calculate_kalachakra_dasha + from scripts.research_timing.bundle import _arc_remaining, _tithi_number + from scripts.research_timing.cycles import ( + TITHI_FORWARD, + TITHI_REVERSE, + karana_year, + tithi_ashtottari, + yoga_vimshottari, + ) + from scripts.research_timing.families import ( + buddhi_gati, + hora_sign_from_events, + kaala_from_events, + karana_seed, + sudasa_birth, + varnada_jss, + ) + from scripts.research_timing.frame import SiderealFrame, birth_jd + from scripts.research_timing.rashi_pd import attach_rashi_pratyantar + from scripts.research_timing.search import hindu_solar_events + from scripts.rashi_dashas_native import calculate_rashi_dashas + + payload = {} + for index in indexes: + try: + _case, chart = _chart_input(index) + except OSError: + print(f"native chart {index} skipped", file=sys.stderr) + continue + year_days = float(chart["year_days"]) + frame = SiderealFrame(chart["ayanamsa"], chart["node_mode"]) + jd = birth_jd(chart["year"], chart["month"], chart["day"], chart["hour_decimal"], chart["tz"]) + sun, moon = frame.sun_moon(jd) + epoch = chart["epoch"] + systems: dict[str, dict] = {} + + def keep(name: str, producer) -> None: + try: + systems[name] = producer() + except (ValueError, RuntimeError, OverflowError, KeyError, TypeError, ZeroDivisionError, OSError) as exc: + systems[name] = {"error": type(exc).__name__} + + number = _tithi_number(sun, moon) + fraction = _arc_remaining(sun, moon) + keep("tithi-natal-forward", lambda: fingerprint_periods(tithi_ashtottari( + epoch, number, fraction, 108 * year_days, TITHI_FORWARD, natal=True, + )["periods"], kind="planet")) + keep("tithi-natal-reverse", lambda: fingerprint_periods(tithi_ashtottari( + epoch, number, fraction, 108 * year_days, TITHI_REVERSE, natal=True, + )["periods"], kind="planet")) + keep("yoga-natal", lambda: fingerprint_periods(yoga_vimshottari( + epoch, (sun + moon) % 360.0, 120 * year_days, natal=True, depth=3, + )["periods"], kind="planet")) + keep("buddhi-gati", lambda: fingerprint_periods( + buddhi_gati(int(chart["ascendant"] // 30), chart["longitudes"], epoch, year_days).get("periods") or [], + kind="planet", + )) + try: + events = hindu_solar_events(epoch, chart["lat"], chart["lon"]) + except (ValueError, RuntimeError, OverflowError, KeyError, TypeError, OSError) as exc: + events = None + systems["kaala"] = {"error": type(exc).__name__} + systems["varnada"] = {"error": type(exc).__name__} + if events is not None: + keep("kaala", lambda: fingerprint_periods( + kaala_from_events(epoch, events, year_days).get("periods") or [], kind="planet", + )) + try: + hora = hora_sign_from_events(epoch, int(chart["ascendant"] // 30), events) + if hora.get("status") != "calculated": + systems["varnada"] = {"skipped": True} + else: + variants = varnada_jss( + int(chart["ascendant"] // 30), int(hora["sign_index"]), + chart["longitudes"], epoch, year_days, + ) + parts = [fingerprint_periods(item.get("periods") or [], kind="sign") for item in variants] + systems["varnada"] = {"variants": parts} + except (ValueError, RuntimeError, OverflowError, KeyError, TypeError, OSError) as exc: + systems["varnada"] = {"error": type(exc).__name__} + for profile, span, key in ( + ("julene", year_days, "karana-julene"), + ("pvr", year_days, "karana-pvr"), + ("pvr", 360.0, "karana-pvr-tithi-year"), + ): + def karana_fp(profile=profile, span=span): + seed = karana_seed(sun, moon, chart["ascendant"], "pvr" if profile == "pvr" else "julene") + if seed.get("status") != "calculated": + return {"skipped": True} + built = karana_year(epoch, seed["lord"], float(seed["remaining_fraction"]), float(span)) + return fingerprint_periods(built.get("periods") or [], kind="planet") + keep(key, karana_fp) + birth = datetime(chart["year"], chart["month"], chart["day"]).date() + try: + copied = attach_rashi_pratyantar(calculate_rashi_dashas(birth, chart["ascendant"], chart["longitudes"])) + for name in ("sthira", "drig", "shoola", "navamsha"): + section = copied.get(name) if isinstance(copied.get(name), dict) else {} + systems[name] = fingerprint_periods(section.get("periods") or [], kind="sign") + except (ValueError, RuntimeError, OverflowError, KeyError, TypeError, OSError) as exc: + for name in ("sthira", "drig", "shoola", "navamsha"): + systems[name] = {"error": type(exc).__name__} + try: + moon_lon = float(chart["longitudes"]["Moon"]) + nak = int(moon_lon // (360.0 / 27.0)) % 27 + pada = int((moon_lon % (360.0 / 27.0)) // (360.0 / 108.0)) + 1 + # This calculator subtracts datetime.now(), which is naive. Keep the + # clock time and drop the zone so the call can finish. + birth_dt = epoch.replace(tzinfo=None) if isinstance(epoch, datetime) and epoch.tzinfo is not None else epoch + calculated = calculate_kalachakra_dasha({ + "moon_nakshatra_index": nak, + "moon_pada": pada, + "birth_datetime": birth_dt, + }) + enriched, _children, _grands = attach_kalachakra_simplified_children(calculated.get("major") or []) + systems["kalachakra"] = fingerprint_periods(enriched, kind="sign") + except (ValueError, RuntimeError, OverflowError, KeyError, TypeError, OSError) as exc: + systems["kalachakra"] = {"error": type(exc).__name__} + try: + systems["sudasa-birth"] = fingerprint_periods( + sudasa_birth(chart["ascendant"], chart["longitudes"], epoch, year_days).get("periods") or [], + kind="sign", + ) + except (ValueError, RuntimeError, OverflowError, KeyError, TypeError, OSError) as exc: + systems["sudasa-birth"] = {"error": type(exc).__name__} + try: + return_days = _tithi_return_days(chart) + except (ValueError, RuntimeError, OverflowError, KeyError, TypeError, OSError): + return_days = None + payload[str(index)] = {"systems": systems, "tithi_return_days": return_days} + print(f"native chart {index}", file=sys.stderr) + return payload + + +def _as_name(value, kind: str) -> str: + planets = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu") + signs = ( + "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", + ) + if isinstance(value, str): + return value + if isinstance(value, bool) or not isinstance(value, (int, float)): + return str(value) + index = int(value) + table = planets if kind == "planet" else signs + if 0 <= index < len(table): + return table[index] + return str(value) + + +def _start_days(value): + if isinstance(value, (list, tuple)) and len(value) >= 3: + year, month, day = int(value[0]), int(value[1]), int(value[2]) + hours = float(value[3]) if len(value) > 3 else 0.0 + try: + stamp = datetime(year, month, day) + timedelta(hours=hours) + except ValueError: + return None + return stamp.toordinal() + hours / 24.0 + return None + + +def _unpack_row(row): + if not isinstance(row, (list, tuple)) or not row or not isinstance(row[0], (list, tuple)): + return None + lords = tuple(row[0]) + start = None + duration = None + for item in row[1:]: + if isinstance(item, (list, tuple)) and start is None: + start = _start_days(item) + elif isinstance(item, (int, float)) and not isinstance(item, bool): + duration = float(item) + return lords, start, duration + + +def fingerprint_rows(rows: list, *, kind: str) -> dict: + parsed = [] + for row in rows or []: + unpacked = _unpack_row(row) + if unpacked is None: + continue + lords, start, duration = unpacked + if not lords: + continue + parsed.append((tuple(_as_name(item, kind) for item in lords), start, duration)) + if not parsed: + return {"skipped": True} + groups = [] + for lords, start, duration in parsed: + if not groups or groups[-1]["lord"] != lords[0]: + groups.append({"lord": lords[0], "start": start, "durs": [], "children": [], "grands": {}}) + group = groups[-1] + if duration is not None: + group["durs"].append(duration) + if len(lords) < 2: + continue + child = lords[1] + if child not in group["grands"]: + group["children"].append(child) + group["grands"][child] = [] + if len(lords) >= 3: + group["grands"][child].append(lords[2]) + maha = [group["lord"] for group in groups] + years: list[float | None] = [] + for index, group in enumerate(groups): + if group["durs"]: + years.append(round(sum(group["durs"]), 2)) + continue + nxt = groups[index + 1]["start"] if index + 1 < len(groups) else None + if group["start"] is not None and nxt is not None: + years.append(round((nxt - group["start"]) / YEAR_BASIS, 2)) + else: + years.append(None) + first = groups[0] + children = first["children"] + grand = first["grands"].get(children[0], []) if children else [] + return {"maha": maha, "years": years, "children": children, "grand": grand} + + +def _rows_of(result): + if isinstance(result, list): + return result + if isinstance(result, tuple): + for item in result: + if isinstance(item, list): + return item + return [] + + +def pyjhora_fingerprints(indexes: tuple[int, ...] = CHARTS) -> dict: + """PyJHora periods. Importing jhora here is intentional and stays in this process.""" + warnings.filterwarnings("ignore") + _ensure_root() + import jhora + from jhora import const, utils + from jhora.panchanga import drik + from jhora.horoscope.dhasa.graha import buddhi_gathi, kaala, karana_chathuraaseethi_sama, tithi_ashtottari, yoga_vimsottari + from jhora.horoscope.dhasa.raasi import drig, kalachakra, navamsa, shoola, sthira, sudasa, varnada + + version = getattr(jhora, "__version__", None) + if not version: + try: + from importlib.metadata import version as pkg_version + version = pkg_version("PyJHora") + except (ImportError, OSError, ValueError): + version = "installed" + charts = {} + for index in indexes: + try: + case, _chart = _chart_input(index) + mode = {"lahiri": "LAHIRI", "raman": "RAMAN", "kp": "KP"}.get(str(case["ayanamsa"]).lower(), "LAHIRI") + drik.set_planet_list(set_rahu_ketu_as_true_nodes=str(case["node_mode"]).lower() == "true", include_western_planets=False) + drik.set_ayanamsa_mode(mode) + place = drik.Place("fictional", case["lat"], case["lon"], case["tz"], elevation=0.0) + dob = drik.Date(int(case["year"]), int(case["month"]), int(case["day"])) + tob = (int(case["hour"]), int(case["minute"]), int(case["second"])) + jd = utils.julian_day_number(dob, tob) + except OSError: + print(f"pyjhora chart {index} skipped", file=sys.stderr) + continue + systems: dict[str, dict] = {} + + def run(name: str, kind: str, producer) -> None: + try: + produced = producer() + except AttributeError: + # The public names are imported above. An AttributeError from the + # call itself is a broken invocation, not 「无此方法」. + systems[name] = {"error": "AttributeError"} + return + except (TypeError, ValueError, RuntimeError, OverflowError, KeyError, ZeroDivisionError, OSError) as exc: + systems[name] = {"error": type(exc).__name__} + return + try: + systems[name] = fingerprint_rows(_rows_of(produced), kind=kind) + except (TypeError, ValueError, RuntimeError, OverflowError, KeyError, ZeroDivisionError, OSError, AttributeError) as exc: + systems[name] = {"error": type(exc).__name__} + + run("tithi-natal-forward", "planet", lambda: tithi_ashtottari.get_dhasa_bhukthi(jd, place, dhasa_level_index=2)) + systems["tithi-natal-reverse"] = {"missing": True} + run("yoga-natal", "planet", lambda: yoga_vimsottari.get_dhasa_bhukthi(jd, place, dhasa_level_index=3)) + run("buddhi-gati", "planet", lambda: buddhi_gathi.get_dhasa_bhukthi(dob, tob, place, dhasa_level_index=2)) + run("kaala", "planet", lambda: kaala.get_dhasa_antardhasa(dob, tob, place, dhasa_level_index=2)) + run("sudasa-birth", "sign", lambda: sudasa.get_dhasa_bhukthi(dob, tob, place, dhasa_level_index=2)) + run("varnada", "sign", lambda: varnada.get_dhasa_antardhasa(dob, tob, place, dhasa_level_index=2)) + run("karana-julene", "planet", lambda: karana_chathuraaseethi_sama.get_dhasa_bhukthi(dob, tob, place, dhasa_level_index=2)) + systems["karana-pvr"] = systems["karana-julene"] + systems["karana-pvr-tithi-year"] = systems["karana-julene"] + run("sthira", "sign", lambda: sthira.get_dhasa_antardhasa(dob, tob, place, dhasa_level_index=3)) + run("drig", "sign", lambda: drig.get_dhasa_antardhasa(jd, place, dhasa_level_index=3)) + run("shoola", "sign", lambda: shoola.get_dhasa_bhukthi(dob, tob, place, dhasa_level_index=3)) + run("navamsha", "sign", lambda: navamsa.get_dhasa_antardhasa(dob, tob, place, dhasa_level_index=3)) + def kala_rows(): + jd_utc = jd - float(case["tz"]) / 24.0 + moon = drik.sidereal_longitude(jd_utc, const._MOON) + return kalachakra.kalachakra_dhasa(moon, jd, dhasa_level_index=3, place=place) + run("kalachakra", "sign", kala_rows) + try: + systems["tithi_return_days"] = _py_tithi_return_days(jd, float(case["tz"])) + except (TypeError, ValueError, RuntimeError, OverflowError, KeyError, ZeroDivisionError, OSError): + systems["tithi_return_days"] = None + charts[str(index)] = systems + print(f"pyjhora chart {index}", file=sys.stderr) + return {"version": str(version), "charts": charts} + + +def _py_tithi_return_days(jd_local: float, tz: float) -> float | None: + from jhora import const + from jhora.panchanga import drik + + jd_utc = jd_local - tz / 24.0 + phase0 = drik.lunar_phase(jd_utc) + sign0 = int(drik.sidereal_longitude(jd_utc, const._SUN) // 30) + + def residual(offset: float) -> float: + phase = drik.lunar_phase(jd_utc + offset) + return (phase - phase0 + 180.0) % 360.0 - 180.0 + + left = 0.05 + left_value = residual(left) + while left < 400.0: + right = min(left + 0.25, 400.0) + right_value = residual(right) + if left_value * right_value <= 0 and abs(right_value - left_value) < 180.0: + lo, hi, lo_value = left, right, left_value + for _ in range(40): + mid = (lo + hi) / 2.0 + mid_value = residual(mid) + if lo_value * mid_value <= 0: + hi = mid + else: + lo, lo_value = mid, mid_value + root = (lo + hi) / 2.0 + if abs(residual(root)) < 1e-3 and int(drik.sidereal_longitude(jd_utc + root, const._SUN) // 30) == sign0: + return root + left, left_value = right, right_value + return None + + +def _base_cycle(seq: list[str]) -> list[str]: + """Shortest prefix that tiles ``seq``, so one extra lord of the same cycle still matches.""" + for size in range(1, len(seq) + 1): + cycle = seq[:size] + if all(seq[index] == cycle[index % size] for index in range(len(seq))): + return cycle + return seq + + +def _same_lords(left: list[str], right: list[str]) -> bool: + """Same order, including when one side continues a partial repeat of the other's cycle.""" + if left == right: + return True + if not left or not right: + return False + short, long = (left, right) if len(left) <= len(right) else (right, left) + cycle = _base_cycle(short) + if not cycle: + return False + return all(name == cycle[index % len(cycle)] for index, name in enumerate(long)) + + +def _is_reverse(left: list[str], right: list[str]) -> bool: + if not left or not right: + return False + return _same_lords(list(reversed(left)), right) + + +def _order_example(left: list[str], right: list[str]) -> str: + """Name the first period that differs. Never say X vs X when the opening lords match.""" + count = min(len(left), len(right)) + for index in range(count): + if left[index] != right[index]: + return f"period {index + 1} native {left[index]} vs PyJHora {right[index]}" + return f"period count native {len(left)} vs PyJHora {len(right)}" + + +def _years_match(left: list, right: list, lords_left: list[str], lords_right: list[str]) -> bool: + count = min(len(left), len(right), len(lords_left), len(lords_right)) + pairs = [] + for index in range(count): + a, b = left[index], right[index] + if isinstance(a, (int, float)) and isinstance(b, (int, float)): + pairs.append((float(a), float(b))) + if not pairs: + return True + return all(abs(a - b) <= 0.35 for a, b in pairs) + + +def _slice_match(native: list[str], other: list[str]) -> bool: + if native == other: + return True + if not native or not other: + return False + if len(native) > len(other): + native, other = other, native + return any(other[index:index + len(native)] == native for index in range(len(other) - len(native) + 1)) + + +def _usable(fp: dict | None) -> bool: + return isinstance(fp, dict) and not fp.get("skipped") and not fp.get("error") and not fp.get("missing") and bool(fp.get("maha")) + + +def classify_pair(native: dict, other: dict, *, chart: int, return_days: tuple[float | None, float | None] | None = None) -> dict: + if isinstance(other, dict) and other.get("missing"): + return {"status": "无此方法", "chart": chart} + if (isinstance(native, dict) and native.get("error")) or (isinstance(other, dict) and other.get("error")): + return {"status": "error", "chart": chart} + if not isinstance(native, dict) or not isinstance(other, dict): + return {"status": "skipped", "chart": chart} + native_rows = native.get("variants") if isinstance(native.get("variants"), list) else [native] + other_rows = other.get("variants") if isinstance(other.get("variants"), list) else [other] + usable_native = [row for row in native_rows if _usable(row)] + usable_other = [row for row in other_rows if _usable(row)] + if not usable_native or not usable_other: + if not usable_native and not usable_other: + return {"status": "skipped", "chart": chart} + return _mismatch("起点", chart, "one side listed no periods") + verdicts = [] + for native_fp in usable_native: + for other_fp in usable_other: + verdicts.append(_classify_rows(native_fp, other_fp, chart, return_days if len(verdicts) == 0 else None)) + mismatches = [item for item in verdicts if item["status"] == "不一致"] + if not mismatches and any(item["status"] == "一致" for item in verdicts): + agreed = next(item for item in verdicts if item["status"] == "一致") + return agreed + if mismatches: + return mismatches[0] + return {"status": "skipped", "chart": chart} + + +def _classify_rows(native: dict, other: dict, chart: int, return_days) -> dict: + left = [str(item) for item in native.get("maha") or []] + right = [str(item) for item in other.get("maha") or []] + if not _same_lords(left, right): + if _is_reverse(left, right): + return _mismatch("方向", chart, f"period order runs the other way, {_order_example(left, right)}") + return _mismatch("起点", chart, _order_example(left, right)) + if not _years_match(native.get("years") or [], other.get("years") or [], left, right): + return _mismatch("年数", chart, f"year count differs at the first compared period of {left[0]}") + if not _slice_match(list(native.get("children") or []), list(other.get("children") or [])): + return _mismatch("子运划分", chart, f"child periods under {left[0]} do not line up") + if not _slice_match(list(native.get("grand") or []), list(other.get("grand") or [])): + return _mismatch("子运划分", chart, f"third level under {left[0]} does not line up") + note = _return_note(return_days) + if note and note["status"] == "不一致": + return note + agreed = {"status": "一致", "chart": chart} + if note and note.get("within_tolerance") is not None: + agreed["within_tolerance"] = note["within_tolerance"] + return agreed + + +def _mismatch(category: str, chart: int, example: str) -> dict: + english = dict(CATEGORIES)[category] + return { + "status": "不一致", + "category": category, + "category_en": english, + "example": f"chart {chart} {example}", + "chart": chart, + } + + +def _return_note(pair) -> dict | None: + if pair is None: + return None + native, other = pair + if native is None and other is None: + return None + if native is None or other is None: + return { + "status": "不一致", + "category": "回归时刻", + "category_en": "return moment", + "example": "tithi return is present on only one side", + } + minutes = abs(float(native) - float(other)) * 1440.0 + if minutes <= RETURN_MINUTE: + return {"status": "一致", "within_tolerance": round(minutes, 3)} + return { + "status": "不一致", + "category": "回归时刻", + "category_en": "return moment", + "example": f"tithi return differs by {minutes:.1f} minutes", + } + + +def _merge_status(rows: list[dict]) -> dict: + errors = [row for row in rows if row.get("status") == "error"] + comparable = [row for row in rows if row.get("status") not in {"skipped", "error"}] + if not comparable: + return {"status": "error"} if errors else {"status": "无此方法"} + if all(row.get("status") == "无此方法" for row in comparable): + return {"status": "无此方法"} + mismatches = [row for row in comparable if row.get("status") == "不一致"] + if mismatches: + chosen = sorted(mismatches, key=lambda row: (CATEGORIES.index((row["category"], row["category_en"])), row.get("chart", 0)))[0] + return { + "status": "不一致", + "category": chosen["category"], + "category_en": chosen["category_en"], + "example": chosen.get("example") or "", + } + if any(row.get("status") == "一致" for row in comparable): + return {"status": "一致"} + return {"status": "无此方法"} + + +def _system_record(key: str, merged: dict) -> dict: + en, zh, match = HEADINGS[key] + record = { + "status": merged["status"], + "heading_en": en, + "heading_zh": zh, + "match": match, + } + if merged["status"] == "不一致": + record["category"] = merged["category"] + record["category_en"] = merged["category_en"] + record["example"] = merged.get("example") or "" + return record + + +def build_report(native: dict, pyjhora: dict) -> dict: + indexes = sorted({int(key) for key in native} & {int(key) for key in pyjhora.get("charts") or {}}) + within = [] + systems = {} + for key in HEADINGS: + rows = [] + for index in indexes: + ours = (native[str(index)].get("systems") or {}).get(key) or {"skipped": True} + theirs = (pyjhora["charts"][str(index)] or {}).get(key) or {"skipped": True} + pair = None + if key == "tithi-natal-forward": + pair = ( + native[str(index)].get("tithi_return_days"), + (pyjhora["charts"][str(index)] or {}).get("tithi_return_days"), + ) + verdict = classify_pair(ours, theirs, chart=index, return_days=pair) + if verdict.get("status") == "不一致" and verdict.get("category") == "回归时刻": + verdict["example"] = f"chart {index} {verdict.get('example') or ''}".replace(f"chart {index} chart {index} ", f"chart {index} ") + if verdict.get("within_tolerance") is not None: + within.append({"system": key, "chart": index, "minutes": verdict["within_tolerance"]}) + rows.append(verdict) + systems[key] = _system_record(key, _merge_status(rows)) + return { + "status": "compared", + "charts": len(indexes), + "chart_indexes": indexes, + "pyjhora_version": pyjhora.get("version") or "", + "return_rule": ( + "A return moment within 1 minute is listed in within_tolerance and is not a mismatch. " + "The two sides use different ayanamsa implementations and ephemeris files." + ), + "omitted_from_labels": [ + "Sudasa confirmed start has no written full major start on our side, so it is not compared.", + "Niryaana Shoola child periods stay omitted by the 2026-10-23 decision.", + "Annual Tithi, Yoga, and Nakshatra headings are outside this pass.", + ], + "within_tolerance": within, + "systems": systems, + } + + +def _write(payload: dict) -> None: + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + def main() -> int: - root = Path(__file__).resolve().parents[2] - out = root / "docs" / "tasks" / "research-timing-pyjhora-20261023.json" - try: - import jhora # noqa: F401 - except ImportError as exc: - payload = { + _ensure_root() + if "--pyjhora-worker" in sys.argv: + slot = sys.argv.index("--pyjhora-worker") + target = Path(sys.argv[slot + 1]) + indexes = tuple(int(item) for item in sys.argv[slot + 2:]) or CHARTS + target.write_text(json.dumps(pyjhora_fingerprints(indexes), ensure_ascii=False), encoding="utf-8") + print(target) + return 0 + if importlib.util.find_spec("jhora") is None: + _write({ "status": "not_run", "charts": 0, "reason": "PyJHora is not installed in this interpreter", - "error_type": type(exc).__name__, - "mismatches": [], - } - out.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - print(out) + "error_type": "ImportError", + "systems": {}, + }) print("not_run") return 0 - payload = { - "status": "not_compared", - "charts": 0, - "reason": "PyJHora imported, but this pass does not guess a mismatch without a completed comparison", - "mismatches": [], - } - out.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - print(out) - print("imported") + native = collect_native(CHARTS) + worker_out = Path(tempfile.gettempdir()) / "jyotisha-92-pyjhora.json" + command = [ + sys.executable, "-m", "scripts.research.research_timing_pyjhora_compare", + "--pyjhora-worker", str(worker_out), *[str(index) for index in CHARTS], + ] + completed = subprocess.run(command, cwd=str(ROOT), check=False) + if completed.returncode != 0 or not worker_out.exists(): + print("pyjhora worker failed", file=sys.stderr) + return 1 + pyjhora = json.loads(worker_out.read_text(encoding="utf-8")) + worker_out.unlink(missing_ok=True) + report = build_report(native, pyjhora) + if report["charts"] < 5: + print(f"only {report['charts']} charts", file=sys.stderr) + return 1 + failed = [key for key, item in report["systems"].items() if item.get("status") == "error"] + if failed: + print("call failed on every chart: " + ",".join(failed), file=sys.stderr) + return 1 + _write(report) + counts = {} + for item in report["systems"].values(): + counts[item["status"]] = counts.get(item["status"], 0) + 1 + print(json.dumps({"charts": report["charts"], "counts": counts}, ensure_ascii=False)) return 0 diff --git a/scripts/research_timing/render.py b/scripts/research_timing/render.py index 2954d9a3..9acc0729 100644 --- a/scripts/research_timing/render.py +++ b/scripts/research_timing/render.py @@ -6,9 +6,11 @@ on the packet, and they are not sent to chat cards or rectification. from __future__ import annotations +import json import logging import re from datetime import datetime, timedelta, timezone +from pathlib import Path logger = logging.getLogger(__name__) @@ -291,7 +293,56 @@ def _chapter(title: str, language: str, body: str) -> str: def _family_block(title: str, language: str, body: str) -> str: - return f"## {title}\n\n{BANNER[language]}\n\n{body.strip()}" + note = _pyjhora_note(title, language) + text = f"## {title}\n\n{BANNER[language]}" + if note: + text += "\n\n" + note + if body.strip(): + text += "\n\n" + body.strip() + return text + + +_PYJHORA_NOTES: dict | None = None + + +def _pyjhora_notes() -> dict: + """Committed offline comparison. Missing or unread JSON adds no label.""" + global _PYJHORA_NOTES + if _PYJHORA_NOTES is None: + path = Path(__file__).resolve().parents[2] / "docs" / "tasks" / "research-timing-pyjhora-20261023.json" + notes: dict[tuple[str, str], str] = {} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + payload = {} + systems = payload.get("systems") if isinstance(payload, dict) else None + for item in (systems or {}).values(): + if not isinstance(item, dict) or item.get("status") != "不一致": + continue + zh = str(item.get("heading_zh") or "") + en = str(item.get("heading_en") or "") + category = str(item.get("category") or "") + category_en = str(item.get("category_en") or "") + match = item.get("match") or "exact" + if zh and category: + notes[("zh", zh, match)] = f"与 PyJHora 不一致:差在 {category}" + if en and category_en: + notes[("en", en, match)] = f"Differs from PyJHora: {category_en}" + _PYJHORA_NOTES = notes + return _PYJHORA_NOTES + + +def _pyjhora_note(title: str, language: str) -> str: + best = "" + found = "" + for (lang, heading, match), sentence in _pyjhora_notes().items(): + if lang != language or not heading: + continue + if title == heading or (match == "prefix" and title.startswith(heading + " ")): + if len(heading) >= len(best): + best = heading + found = sentence + return found def _heading(key: str, language: str) -> str: diff --git a/scripts/research_timing/search.py b/scripts/research_timing/search.py index 38da34b9..0b92d94a 100644 --- a/scripts/research_timing/search.py +++ b/scripts/research_timing/search.py @@ -24,6 +24,21 @@ except ImportError: # pragma: no cover - package import _STEP = 0.125 _BISECTIONS = 45 _TOLERANCE = 1e-6 +_UNIX_EPOCH_JD = 2440587.5 + + +def _utc_from_jd(jd: float) -> datetime: + """UTC from a Julian day. Windows ``fromtimestamp`` rejects instants before 1970.""" + unix = (float(jd) - _UNIX_EPOCH_JD) * 86400.0 + seconds = int(unix) + micros = int(round((unix - seconds) * 1_000_000)) + if micros >= 1_000_000: + seconds += 1 + micros -= 1_000_000 + elif micros < 0: + seconds -= 1 + micros += 1_000_000 + return datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta(seconds=seconds, microseconds=micros) def find_returns( @@ -248,7 +263,7 @@ def hindu_solar_events(moment: datetime, latitude: float, longitude: float) -> l flags=flags, ) if status == 0 and jd <= times[0] < jd + 1: - stamp = datetime.fromtimestamp((times[0] - 2440587.5) * 86400, timezone.utc) + stamp = _utc_from_jd(times[0]) events.append({"event": name, "datetime_utc": stamp.isoformat()}) events.sort(key=lambda row: row["datetime_utc"]) return events diff --git a/tests/test_report_reader_main.py b/tests/test_report_reader_main.py index bc45b872..b1d0b47c 100644 --- a/tests/test_report_reader_main.py +++ b/tests/test_report_reader_main.py @@ -88,7 +88,14 @@ def _class_args(**overrides): def _leak_hits(markdown: str) -> list[str]: - return [pattern.pattern for pattern in LEAK_PATTERNS if pattern.search(markdown)] + # 原值:整篇里出现 PyJHora 都算泄漏 + # 新值:以「与 PyJHora 不一致:」或「Differs from PyJHora:」开头的行先拿掉,其余照旧 + # 原因:#92 研究章要在标题下保留这两句对照;其它 PyJHora 字样仍然失败 + kept = "\n".join( + line for line in (markdown or "").splitlines() + if not line.startswith("与 PyJHora 不一致:") and not line.startswith("Differs from PyJHora:") + ) + return [pattern.pattern for pattern in LEAK_PATTERNS if pattern.search(kept)] @pytest.fixture(scope="module") @@ -169,6 +176,15 @@ def test_class_args_copy_keeps_year_for_kp_and_transit(full_reading) -> None: assert "error" not in module or not str(module.get("error", "")).startswith("KP producer failed") +def test_pyjhora_mismatch_lines_are_not_reader_leaks() -> None: + # 原值:任何含 PyJHora 的文本都命中泄漏 + # 新值:只有对照句以外的 PyJHora 命中泄漏 + # 原因:#92 允许研究章保留这两句,不能因此放行其它位置 + assert _leak_hits("Differs from PyJHora: start\n与 PyJHora 不一致:差在 起点\n") == [] + hits = _leak_hits("Calculated by PyJHora.\n") + assert any("PyJHora" in hit for hit in hits) + + def test_reader_main_hides_audit_vocabulary(full_reading) -> None: from scripts.jyotish_engine import build_professional_report_reference_packet diff --git a/tests/test_research_timing.py b/tests/test_research_timing.py index 01add370..339e2727 100644 --- a/tests/test_research_timing.py +++ b/tests/test_research_timing.py @@ -6,6 +6,7 @@ import ast import bisect import hashlib import json +import re from datetime import datetime, timedelta, timezone from pathlib import Path @@ -16,7 +17,7 @@ from scripts.research_timing.bundle import chart_from_parts, research_bundle from scripts.research_timing.frame import SiderealFrame, birth_jd from scripts.research_timing.rashi_pd import attach_rashi_pratyantar from scripts.research_timing.render import _heading, render_research_chapters -from scripts.research_timing.search import ElongationClock, find_returns +from scripts.research_timing.search import ElongationClock, _utc_from_jd, find_returns, hindu_solar_events from scripts.rashi_dashas_native import calculate_rashi_dashas from tests.report_english_fictional import fictional_case @@ -125,7 +126,11 @@ def test_rendered_chapters_keep_the_research_banner_and_drop_snake_case() -> Non assert _SNAKE.search(chinese) is None assert _SNAKE.search(english) is None assert "blocked" not in english.lower() - assert "PyJHora" not in english + # 原值:assert "PyJHora" not in english + # 新值:英文里的 PyJHora 只允许出现在「Differs from PyJHora:」开头的对照行 + # 原因:#92 修复单要求不一致的体系写这句;其它位置仍然不能出现 PyJHora + stray = [line for line in english.splitlines() if "PyJHora" in line and not line.startswith("Differs from PyJHora:")] + assert stray == [] assert "主星/名称" in chinese assert "次小运按简化模型,从现有主运按比例分出。主运和子运与原来的表相同。" in chinese assert "收录的教程没有写明第三层。" in chinese @@ -190,6 +195,101 @@ def test_rectification_modules_do_not_import_the_research_package() -> None: assert all("research_timing" not in module for module in modules), path.name +def _sections(markdown: str) -> list[tuple[str, str]]: + parts = re.split(r"(?m)^## ", markdown) + rows = [] + for part in parts[1:]: + title, _, body = part.partition("\n") + rows.append((title.strip(), body)) + return rows + + +def _under(rows: list[tuple[str, str]], heading: str, match: str) -> list[str]: + found = [] + for title, body in rows: + if title == heading or (match == "prefix" and title.startswith(heading + " ")): + found.append(body) + return found + + +def test_solar_events_before_1970_stay_available_on_windows() -> None: + """BUG-1429. Windows fromtimestamp rejects instants before 1970.""" + moment = datetime(1950, 6, 15, 12, 0, tzinfo=timezone.utc) + events = hindu_solar_events(moment, 0.0, 0.0) + assert events + assert all(item["event"] in {"sunrise", "sunset"} for item in events) + parsed = datetime.fromisoformat(events[0]["datetime_utc"]) + assert parsed.year == 1950 + assert parsed.tzinfo is not None + after = _utc_from_jd(2451545.0) + expect = datetime.fromtimestamp((2451545.0 - 2440587.5) * 86400.0, timezone.utc) + assert abs((after - expect).total_seconds()) < 1e-3 + seen = 0 + for index in range(200): + case = fictional_case(index) + if int(case["year"]) >= 1970: + continue + zone = timezone(timedelta(hours=float(case["tz"]))) + birth = datetime( + int(case["year"]), int(case["month"]), int(case["day"]), 12, 0, tzinfo=zone, + ) + rows = hindu_solar_events(birth, float(case["lat"]), float(case["lon"])) + assert rows + seen += 1 + assert seen > 0 + + +def test_pyjhora_mismatch_labels_follow_the_committed_json() -> None: + """Read the offline JSON. This test does not import jhora.""" + path = ROOT / "docs" / "tasks" / "research-timing-pyjhora-20261023.json" + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["charts"] >= 5 + assert payload["status"] == "compared" + systems = payload["systems"] + assert isinstance(systems, dict) and systems + case, _chart_data = _chart(0) + base = {"birth_info": case, "worksheets": {}, "calculation_profile": {}} + from scripts.pl9_full_data_export import render_full_data_markdown + + chinese = render_full_data_markdown(dict(base)) + english_packet = dict(base) + english_packet["report_language"] = "en" + english = render_full_data_markdown(english_packet) + if any(item.get("status") == "不一致" for item in systems.values()): + assert "与 PyJHora 不一致:" in chinese + assert "Differs from PyJHora:" in english + zh_rows = _sections(chinese) + en_rows = _sections(english) + for name, item in systems.items(): + status = item["status"] + assert status in {"一致", "不一致", "无此方法"}, name + same_lord = re.search(r"native (\w+) vs PyJHora (\w+)", str(item.get("example") or "")) + if same_lord: + assert same_lord.group(1) != same_lord.group(2), (name, item.get("example")) + for rows, heading, sentence in ( + (zh_rows, item["heading_zh"], f"与 PyJHora 不一致:差在 {item.get('category') or ''}"), + (en_rows, item["heading_en"], f"Differs from PyJHora: {item.get('category_en') or ''}"), + ): + bodies = _under(rows, heading, item.get("match") or "exact") + assert bodies, (name, heading) + if status == "不一致": + assert all(sentence in body for body in bodies), (name, heading) + else: + assert all("与 PyJHora 不一致:" not in body and "Differs from PyJHora:" not in body for body in bodies), name + tree = ast.parse((ROOT / "scripts" / "research" / "research_timing_pyjhora_compare.py").read_text(encoding="utf-8")) + allowed = {"pyjhora_fingerprints", "_py_tithi_return_days"} + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.name in allowed: + continue + for child in ast.walk(node): + module = "" + if isinstance(child, ast.Import): + module = " ".join(alias.name for alias in child.names) + elif isinstance(child, ast.ImportFrom): + module = child.module or "" + assert not module.startswith("jhora"), module + + def test_rectification_golden_file_is_unchanged() -> None: digest = hashlib.sha256((ROOT / _GOLDEN).read_bytes()).hexdigest() assert digest == "92efd8fdbae43f553b0006cfe28e929d5255fe02f3c36e616475fa149a356212"