diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d920af6..fa1049f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ - 标准报告没有这一章。 - Skill 版本不变(BUG-1427、BUG-1428)。 +## 2026-10-23 — 研究口径的推运会标出和 PyJHora 已知的差别 + +- 完整数据版研究章里,和 PyJHora 对过、顺序或年数不同的方法,会在该方法标题下写明差在哪一类。 +- 对过 10 张虚构盘。本命 Tithi 顺行两边一致,回归时刻差都在 1 分钟以内。本命 Tithi 逆行在 PyJHora 的公开函数里没有对应项,所以不标成不一致。 +- Skill 版本不变。 + +## 2026-10-23 — 完整数据版补上研究口径的推运、次小运和细分盘 + +- 完整数据版末尾新增三块:补充推运、辅助大运的次小运、研究型分盘。每块开头都写明这是研究口径,还没跟外部软件对过。 +- 四种星座大运补了次小运,原来的主运和子运不变。Kalachakra 只加第三层,并标明是简化模型。Niryaana Shoola 的子运这次不列,因为没有单独的书面出处。 +- D81、D108、D144 从普通分盘表挪到研究型分盘,不再两边都出现。 +- 这些内容不进对话卡,也不进生时校正。一张虚构盘从排盘到中英两份出齐,本机大约 1 到 2 分钟,没有超过 3 分钟。 +- Skill 版本不变。PyJHora 这一轮没有做成逐层对照,所以报告里先不写不一致。 + ## 2026-10-23 — 阅读页的 Yogini 子运缺字段时不再整段消失 - 补子运时只在原来的大运行上加子运,不改、不删原来的字段。缺完整年数时仍显示主运,不再整段报错。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index ca5c891d..a4c02934 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -19383,11 +19383,27 @@ - 复发自:无 - 修复版本:分支 `codex/full-data-parity-3-20261022`。 -## BUG-1429 | 独立年度方法参考不进结果指纹,重算对不上 +## 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`。 + +## BUG-1430 | 独立年度方法参考不进结果指纹,重算对不上 + +- 状态:resolved +- 首次发现:2026-10-23 +- 最近更新:2026-10-24 - 影响面:完整数据版资料包的结果指纹。阅读页正文不直接显示这串指纹。 - 用户现象:报告里已经有「独立年度方法参考」,按同一份资料包重算指纹却对不上。防篡改核对会把这份报告看成被改过。 - 触发条件:完整数据版组装资料包时挂上这一章,绑定指纹之前先把这一章摘掉,绑定后再放回去。 @@ -19395,7 +19411,23 @@ - 修复:删掉摘除再放回的做法,恢复直接绑定。这一章算进指纹。标准版在已经知道版本之后才决定要不要算这一章,避免标准版白起一次计算;完整数据版仍在绑定之前挂上,指纹才算得到它。 - 验证:`tests/test_pyjhora_annual_method_reference.py` 中两个不同标记的指纹不再相同。`tests/test_calculation_profile_contract.py` 仍要求绑定写在净化之后。`tests/test_report_reference_perf.py` 重算指纹与包上的指纹一致。 - 防复发:上面三条。再把本章摘出指纹,标记不同却指纹相同的测试会失败,重算也对不上。 -- 相关记录:BUG-693、BUG-694。issue #93 第 1 次打回。 +- 相关记录:BUG-693、BUG-694。issue #93 第 1 次打回。本分支曾写成 BUG-1429;该号已由 staging 的研究章日出记录使用,合并时改为 1430。 +- 复发自:无 +- 修复版本:分支 `codex/full-data-parity-3-20261022`。 + +## BUG-1431 | 完整数据版新选项没接到两处测试替身 + +- 状态:resolved +- 首次发现:2026-10-24 +- 最近更新:2026-10-24 +- 影响面:只影响测试。报告计算和阅读页没有改。 +- 用户现象:无。验收时两条测试报 `TypeError: unexpected keyword argument 'include_annual_method_reference'`。 +- 触发条件:组装报告时把这个选项传给资料包函数,测试却用只收位置参数的替身换掉了原函数。 +- 根因:上一轮有 4 个替身加了 `**_kwargs`,漏了阅读附录语言测试和报告密度测试里的两个。 +- 修复:这两处替身同样加上 `**_kwargs`。不改报告计算,不改原有断言。 +- 验证:`tests/test_reader_appendix_language.py::test_public_boundary_only_leaves_internal_renderer_and_json_unchanged` 与 `tests/test_report_density_facts.py::test_full_golden_shared_markdown_is_byte_identical_and_reader_copy_is_clean`。 +- 防复发:`tests/` 里替换这个函数的替身都要能接收额外关键字。再漏一处,同样的类型错误会让测试失败。 +- 相关记录:issue #93 第 2 次打回。BUG-1430。 - 复发自:无 - 修复版本:分支 `codex/full-data-parity-3-20261022`。 diff --git a/docs/tasks/PROGRESS-full-data-parity-2-20261021.md b/docs/tasks/PROGRESS-full-data-parity-2-20261021.md new file mode 100644 index 00000000..4220f668 --- /dev/null +++ b/docs/tasks/PROGRESS-full-data-parity-2-20261021.md @@ -0,0 +1,121 @@ +# PROGRESS — 完整数据版补齐(二)(#92) + +- 分支:`codex/full-data-parity-2-20261021` +- 开工基线:`origin/staging` = `85e8adddbc9c14e31c687c91ad8666afe3290e64`(#91 已合入)。本地先合并的就是这个 staging,没有 rebase。 +- 工作树:代理循环指定的 `G:\Ferti\Jyotisha\.worktrees\agent-loop`,没有另建 `.worktrees/full-data-parity-2-20261021`。 +- 最大 BUG 号仍是 BUG-1426。本单没有新开缺陷编号。 + +## 做了什么 + +完整数据版在原有六章之后加三块,渲染时现算,不写进资料包,不进对话卡,不进校正打分。 + +1. **补充推运计算数据**:Tithi Ashtottari 本命顺逆、年度三年里每一对 300 到 400 天的回归(角距和时间余额,顺逆,只到子运)、Yoga Vimshottari 本命、目标年内的 Yoga / Nakshatra 回归以及每个回归之后 300 到 400 天里的后继和 360 个太阴月年、七分盘星座主、Buddhi Gati、Kaala、Sudasa 出生周期、Sudasa 已确认起点(只写原因)、Varnada、Karana 三种(目标年里的每一次 Tithi 回归)。 +2. **辅助大运次小运数据**:Sthira、Drig、Shoola、Navamsha 的次小运。主运和子运的生产者没有改,次小运加在副本上。Niryaana Shoola 不列子运和次小运。Kalachakra 只加第三层,沿用简化模型,不改已有主运和子运。 +3. **研究型分盘**:D81、D108、D144,有 D150 时一并列出。普通分盘数值表里不再印这三盘。 + +每一节都重复研究口径那一句,因为阅读页按二级标题翻页,章首那句可能会被翻走。 + +位置用我们自己的星历和真岁差。没有搬视位置、Moshier、真位置开关,也没有搬 PyJHora 运行时对照模块。 + +## T0 盘点 + +| 方法 | 上游 | 输入 | 出处或 blocked | 我方改动 | +| --- | --- | --- | --- | --- | +| Tithi Ashtottari 本命顺/逆 | `tithi_ashtottari.py` | 出生日月角距,108 年 | 教程没有写第三层 | 经度改我方函数;次小运不列 | +| Tithi 年度 | `customer_timing_supplement.py` 的三年配对 | 目标年起三年,每一对间隔 300–400 天 | 同上,深度只到 2 | 同上;不拿「下一次短回归」冒充年 | +| Yoga Vimshottari 本命 | `yoga_annual_dasha.py` | 日月之和,120 年,三层 | 固定年长 | 经度改我方函数 | +| Yoga / Nakshatra 回归 | `annual_pravesha.py`、`pravesha_return_year.py` | 只要目标年里的回归;后继取之后 300–400 天内的全部,另加 360 太阴月年;三层 | 研究页 | 求根和时钟都用我方经度 | +| 七分盘星座主 | `aayu_sripati_periods.py` | 本命经度 | 关系表,不是大运 | 用我方经度 | +| Buddhi Gati | `buddhi_gati.py` | 上升与行星 | Atri 原文没有规定子运 | 不列子运和次小运 | +| Kaala | `kaala_dasha.py` | 日出日落 | 笔记没有规定子运 | 日出日落用星历文件,不用 Moshier | +| Sudasa 出生 | `sudasa.py` | 上升与行星 | 土星和计都同星座时该主运不列子运 | 我方经度 | +| Sudasa 已确认起点 | 同上 | 需要另有确认过的起点 | blocked:出生资料里没有这个起点 | 只写原因,不上空表 | +| Varnada | `varnada_dasha.py` | 两个备选起点分开 | JSS | 我方经度 | +| Karana 三种 | `karana_annual_dasha.py` | 目标年每次 Tithi 回归 | 材料没有子运 | 只列主运 | +| 四种星座大运次小运 | `rashi_dasha_explicit_contract.py` 的顺行等分 | 子运星座起顺行,十二等分 | 接既有星座大运出处表 | 只加在副本上 | +| Niryaana Shoola 子运 | `niryaana_shoola_dasha.py` | 主运 | blocked:上游写明子运规则没有单独出处;冻结引擎的行不能当书页 | 不列 | +| Kalachakra 第三层 | #91 已有的简化模型 | 现有主运 | 产品定:只加第三层,不改主运和子运 | 按比例从现有主运分出 | +| D81 / D108 / D144 / D150 | 我方分盘函数 | 本命经度 | 研究口径 | 只在研究章渲染 | + +## 体量(Windows,虚构盘 0 / 1 / 9) + +中英两份一起算,含排盘。都低于前端 180 秒。 + +| 盘 | 排盘(秒) | 英文(秒) | 中文(秒) | 中英合计(秒) | 连排盘(秒) | 中文字符 | 英文字符 | 中文字节 | 英文字节 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 0 | 42.4 | 43.6 | 17.3 | 60.9 | 103.3 | 1,917,563 | 2,491,902 | 2,695,716 | 2,493,514 | +| 1 | 20.7 | 12.6 | 20.5 | 33.0 | 53.7 | 1,919,551 | 2,490,711 | 2,691,687 | 2,492,316 | +| 9 | 18.7 | 15.7 | 16.7 | 32.3 | 51.0 | 2,060,310 | 2,639,451 | 2,858,271 | 2,641,071 | + +0 号盘只算研究章、不算原来的长文:中文 26.4 秒、1,015,374 字符;英文接着算 0.1 秒(同一份计算复用)、1,055,738 字符。目标年起三年 Tithi 回归 3 次,目标年 Yoga 1 次,Nakshatra 1 次。 + +0 号盘四种星座大运的次小运都是 1728 行(主运 12、子运 144,每个子运再等分成 12)。生产者里的主运和子运没有 `pratyantar` 字段。 + +没有删行,没有抽样,也没有把 1e-6 度的回归容差放宽。时钟在一天的格子里先按斜率走几步,残差仍要求不超过 1e-6 度;和原先 45 次二分相比,时刻差不超过百万分之一天。这一条有测试。 + +## 章节怎么拆 + +阅读页按 `##` 翻页。三章各自是一节,每个方法、每一对回归、每一种 Karana、每一种星座大运、Niryaana、Kalachakra、研究分盘也各是一节。每节都重复研究口径那一句。 + +参考时刻取报告月历第一行的当月 1 日正午。资料包没有另存「今天」。天文用儒略日,显示的时刻用历书换算,两者差一个闰秒量级,表上仍写到秒。 + +岁差按每个时刻重算,不拿一个岁差值套一整年。 + +## 让步和偏离 + +- **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 的中文是:「次小运按简化模型,从现有主运按比例分出。主运和子运与原来的表相同。」 + +## 自测 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 研究章、口径、隔离、校正 golden、时钟 | `pytest tests/test_research_timing.py -q` | 8 通过 | +| 星座大运生产者仍无次小运字段 | `pytest tests/test_rashi_dashas_native.py -q` | 通过 | +| 对话域读取 | `pytest tests/test_full_data_domain_reader.py -q` | 通过 | +| 卫生预算 | `pytest tests/test_python_hygiene_budget.py -q` | 通过 | +| 真恒星框架 | `pytest tests/test_sidereal_nutation_frame.py -q` | 通过 | +| 运行时不扫进 jhora | `pytest tests/test_runtime_jhora_import_scan.py -q` | 通过 | +| API 增长合同 | `pytest tests/test_api_server_growth_contract.py -q` | 通过 | +| 六章与研究章标题 | `pytest tests/test_full_data_parity_chapters.py -q` | 先有 1 个标题顺序失败;断言改为「六章之后才是研究章」后,该测试通过。同一次里其余测试已通过 | +| 英文版 | `pytest tests/test_report_english_edition.py -q` | 通过 | +| 前端 tsc / lint | 未跑 | 没有改前端 | +| test:db | 未跑 | 没有改表 | +| Python 全量、快速门 | 未跑 | 按分工留给验收 | + +标题顺序那条断言:原值是「拿掉六章后只少这六章」;新值是「基准同时拿掉六章和研究章,完整报告在六章之后还有研究章」;原因是研究章也是后加的二级标题。 + +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/PROGRESS-full-data-parity-3-20261022.md b/docs/tasks/PROGRESS-full-data-parity-3-20261022.md index 577f688a..b735ec8d 100644 --- a/docs/tasks/PROGRESS-full-data-parity-3-20261022.md +++ b/docs/tasks/PROGRESS-full-data-parity-3-20261022.md @@ -109,9 +109,9 @@ | 测试 | 原值 | 新值 | 原因 | | --- | --- | --- | --- | -| 结果指纹是否包含这一章 | 两个标记的指纹相同 | 两个标记的指纹不同,本章仍留在资料包里 | 交付包里有这一章时,重算必须对得上(BUG-1429) | +| 结果指纹是否包含这一章 | 两个标记的指纹相同 | 两个标记的指纹不同,本章仍留在资料包里 | 交付包里有这一章时,重算必须对得上(BUG-1430;1429 已由研究章日出占用) | -编号:BUG-1429。 +编号:BUG-1430。本分支原先写成 BUG-1429,合并 staging 时改号。 ## 这一轮跑过的检查 @@ -124,3 +124,36 @@ | 前端 | 本轮没改页面 | 没跑 | 依赖的 staging 提交是合并进来的 `4d0e398ab`。 + +## 第 2 轮(2026-10-24) + +产品选 B。合并了当时的 `origin/staging`(`0f66d92d4`)。没有重做上一轮已经通过的隔离和三年表,也没有改报告计算。 + +两处测试替身补上 `**_kwargs`,和已经改过的 4 处一样: + +- `tests/test_reader_appendix_language.py` +- `tests/test_report_density_facts.py` + +`tests/` 里其余替换这个函数的替身,本来就能接收额外参数,没有再改。原有断言没有改。 + +合并之后,研究章仍在六章后面、口径替换前面。独立年度方法参考仍在口径替换后面,避免来源那一行被删掉。六章测试同时认这两段后加的标题。 + +### 改过的断言 + +| 测试 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| 六章后面的二级标题 | 本分支只认六章再加最后的年度方法参考 | 六章之后先是研究章,最后仍是年度方法参考 | 合并 #92 后研究章插在中间 | + +编号:BUG-1431。指纹那条改为 BUG-1430,因为 BUG-1429 已是研究章在 1970 年以前的日出记录。 + +## 第 2 轮跑过的检查 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 两条打回的测试 | `pytest` 点名这两条 | 2 通过,退出码 0 | +| 六章、年度方法参考、计算合同、阅读页泄漏、结果性能 | 同上五个测试文件,阅读页只跑泄漏相关两条 | 72 通过,3 跳过,退出码 0。3 条跳过是计算合同里原有的「产品树未附带」,不是本轮新跳过 | +| 200 张英文扫描 | `pytest tests/test_report_english_sweep.py::test_two_hundred_fictional_charts_have_clean_english` | 1 通过,退出码 0。扫描不真起 PyJHora | +| 全量 pytest 和快速门 | 未跑 | 留给验收 | +| 前端 | 本轮没改页面 | 没跑 | + +依赖的 staging 提交是 `0f66d92d4d6545972202f209c63c87959d44b87e`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index f32fddf0..cd47a912 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -523,12 +523,12 @@ | `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` | — | #92 完整数据版补齐(二):研究型推运附录(Tithi Ashtottari、Yoga / Nakshatra Pravesha、Karana 等,口径换成我方真恒星)、四种星座大运第三层、Kalachakra 第三层(简化模型)、Niryaana Shoola 子运只在有书面出处时做、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 只留在研究口径章节 | 已合入 staging | `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 张虚构盘做完离线对照,不一致的体系在标题下写明差在哪一类 | 已合入 staging | `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` | `PROGRESS-full-data-parity-3-20261022.md` | #93 完整数据版补齐(三):「独立年度方法参考」正式环境调 PyJHora(子进程隔离;装上后其余输出逐字节不变,7 个换路径开关钉死);输入默认用本地时间与正确年份(D6 默认,未收到「照上游原样」) | 第 1 次打回,见修复单 | `codex/full-data-parity-3-20261022` | -| `TASK-full-data-parity-3-fix-20261023.md` | `PROGRESS-full-data-parity-3-20261022.md` | **#93 第 1 次打回**:结果指纹算进独立年度方法参考;标准版不再白算这一章;部署说明不点名年主开关(BUG-1429) | 待验收 | `codex/full-data-parity-3-20261022` | +| `TASK-full-data-parity-3-fix-20261023.md` | `PROGRESS-full-data-parity-3-20261022.md` | **#93 第 2 次打回**:两处测试替身补上额外参数(BUG-1431)。指纹记录在合并后改称 BUG-1430 | 待验收 | `codex/full-data-parity-3-20261022` | | `TASK-synastry-staging-fix-fix-20261022.md` | `PROGRESS-synastry-staging-fix-fix-20261022.md` | **#85 第 1 次打回**(T7~T11 主体通过):新地址没登记进路由清单致快速门红;CHANGELOG 与 T11 矛盾;侧栏点亮测试是空断言 | 已合入 staging | `codex/synastry-staging-fix-20261021` | | `TASK-report-english-han-leak-fix-20261022.md` | `PROGRESS-report-english-han-leak-fix-20261022.md` | **#89 第 1 次打回**:改了冻结的校正打分文件 `jaimini.py`;测试的 PyJHora 屏蔽没传到子进程(测的不是生产那份报告,也是慢的原因);缺英文时留空藏住漏洞 | 已合入 staging | `codex/report-english-han-leak-20261021` | | `TASK-full-data-parity-1-fix-20261022.md` | `PROGRESS-full-data-parity-1-fix-20261022.md` | **#91 A 段第 1 次打回**:新六章 69 节里 16 节在三张虚构盘上整节「未判定」,其中 12 节数据在资料包里、是读错了字段;测试抓不到整节未判定;新六章绕过了报告口径替换;中文附录悄悄丢列和瑜伽条目要量出来 | 已合入 staging | `codex/full-data-parity-1-20261021` | diff --git a/docs/tasks/TASK-full-data-parity-3-fix-20261023.md b/docs/tasks/TASK-full-data-parity-3-fix-20261023.md index 36287354..1b690cab 100644 --- a/docs/tasks/TASK-full-data-parity-3-fix-20261023.md +++ b/docs/tasks/TASK-full-data-parity-3-fix-20261023.md @@ -42,3 +42,12 @@ - `tests/test_pyjhora_annual_method_reference.py`、`tests/test_calculation_profile_contract.py`、`tests/test_report_reference_perf.py` 与原任务书第 7 节列出的文件。 - 200 张英文扫描(屏蔽 PyJHora)。 - 全量 pytest、快速门、前端由 Claude 复验。 + +## 第 2 轮(2026-10-24,产品选 B:交回 Grok) + +被验提交 `c899e3650`:P1~P3 与「标准版不启动 worker」都已改对,快速门通过。还差两条测试:给 `build_professional_report_reference_packet` 加了 `include_annual_method_reference` 参数后,有 4 个测试的替身函数补了 `**_kwargs`,漏了下面 2 个,单独跑都报 `TypeError: ... got an unexpected keyword argument 'include_annual_method_reference'`: + +- `tests/test_reader_appendix_language.py::test_public_boundary_only_leaves_internal_renderer_and_json_unchanged` +- `tests/test_report_density_facts.py::test_full_golden_shared_markdown_is_byte_identical_and_reader_copy_is_clean` + +修法与那 4 处相同:替身(lambda)加上 `**_kwargs`,不改业务代码、不改断言。交回前 `grep -rn "build_professional_report_reference_packet" tests/` 把其余替身也过一遍,并跑这两条加上上面列出的文件。 diff --git a/docs/tasks/research-timing-pyjhora-20261023.json b/docs/tasks/research-timing-pyjhora-20261023.json new file mode 100644 index 00000000..7a97074f --- /dev/null +++ b/docs/tasks/research-timing-pyjhora-20261023.json @@ -0,0 +1,206 @@ +{ + "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/docs/testing/full-data-parity-2-20261021.md b/docs/testing/full-data-parity-2-20261021.md new file mode 100644 index 00000000..87451ced --- /dev/null +++ b/docs/testing/full-data-parity-2-20261021.md @@ -0,0 +1,28 @@ +# 完整数据版补齐(二)真机清单 + +对照任务书 `docs/tasks/TASK-full-data-parity-2-20261021.md`。用虚构盘或公开名人,不要把真实用户的出生资料写进记录。 + +## 生成 + +1. 打开一个人的星盘,生成完整数据版。 +2. 等它结束。本机三张虚构盘从排盘到中英两份出齐大约 51 到 103 秒。超过 3 分钟还在转,就停下来记下时间,不要反复点把队列打满。 +3. 报告能打开。翻到末尾,能看到三块新内容:补充推运计算数据、辅助大运次小运数据、研究型分盘。 + +## 研究口径 + +1. 这三块每一节开头都有「研究口径:依赖所选方法,未经外部软件核对。」英文是 “Research basis: method-dependent; not checked against external software.” +2. 切到英文。这三块里没有整段中文。 +3. 切回中文。星座名仍是中文,不夹英文星座名。 + +## 翻页 + +1. 补充推运按方法分成多节,可以一节一节翻,不是一整章粘在一页上。 +2. 四种星座大运能看到次小运。Niryaana Shoola 那一节写明子运和次小运这次不列。 +3. Kalachakra 写明次小运按简化模型,从现有主运按比例分出,并写主运和子运与原来的表相同。 +4. 普通分盘表里不再出现 D81、D108、D144。这三盘只在「研究型分盘」里。 + +## 不该变的地方 + +1. 对话里问事业或婚恋,回答用的资料卡和以前一样,不出现这三块研究表。 +2. 已经在做的生时校正还能打开,分数不因这次报告变长而改。 +3. 导出中文 `.md` 后,用文本编辑器能找到上面三块标题。打印预览里这三块也都在,不要求单章被截断。 diff --git a/scripts/pl9_full_data_export.py b/scripts/pl9_full_data_export.py index 48f1092f..4b3b81ba 100644 --- a/scripts/pl9_full_data_export.py +++ b/scripts/pl9_full_data_export.py @@ -1866,6 +1866,9 @@ def _render_pl9_ai_density_raw_data_markdown_en(packet: dict, language: str = "e if isinstance(varga, dict): lines.extend(["## Divisional Charts Numeric Tables", ""]) for chart_name, chart_payload in varga.items(): + # D81 / D108 / D144 stay in the research chapter only. + if any(token in str(chart_name).upper() for token in ("D81", "D108", "D144")): + continue positions = chart_payload.get("positions") if isinstance(chart_payload, dict) else None if not isinstance(positions, dict): positions = chart_payload if isinstance(chart_payload, dict) else None @@ -2377,6 +2380,17 @@ def _append_parity_chapters(markdown: str, packet: dict, language: str) -> str: return markdown.rstrip() + "\n\n" + extra + "\n" +def _append_research_chapters(markdown: str, packet: dict, language: str) -> str: + try: + from scripts.research_timing.render import render_research_chapters + except ModuleNotFoundError: # pragma: no cover + from research_timing.render import render_research_chapters + extra = render_research_chapters(packet, language).strip() + if not extra: + return markdown + return markdown.rstrip() + "\n\n" + extra + "\n" + + def render_full_data_markdown(packet: dict) -> str: """Upstream `pl9_ai_density` branch at 23be1807, on this site's parity body.""" if _pl9_report_language(packet if isinstance(packet, dict) else {}) != "en": @@ -2417,6 +2431,7 @@ def render_full_data_markdown(packet: dict) -> str: _pl9_english_report_title(packet), ) rendered = _append_parity_chapters(rendered, packet, "en") + rendered = _append_research_chapters(rendered, packet, "en") rendered = _apply_local_report_conventions(rendered, language="en") return _append_annual_method_reference(rendered, packet, "en") zh_structured_appendix = re.sub( @@ -2440,6 +2455,7 @@ def render_full_data_markdown(packet: dict) -> str: ) rendered = _sanitize_pl9_ai_density_markdown(zh_markdown, _pl9_public_report_title(packet)) rendered = _append_parity_chapters(rendered, packet, "zh") + rendered = _append_research_chapters(rendered, packet, "zh") rendered = _apply_local_report_conventions(rendered, language="zh") return _append_annual_method_reference(rendered, packet, "zh") diff --git a/scripts/pl9_reader_export.py b/scripts/pl9_reader_export.py index 63b90293..69cc2cb1 100644 --- a/scripts/pl9_reader_export.py +++ b/scripts/pl9_reader_export.py @@ -1313,9 +1313,6 @@ def render_pl9_parity_markdown(packet: dict) -> str: ('D40_Khavedamsa', 'D40 Khavedamsa'), ('D45_Akshavedamsa', 'D45 Akshavedamsa'), ('D60_Shashtyamsa', 'D60 Shashtyamsa'), - ('D81', 'D81'), - ('D108', 'D108'), - ('D144', 'D144'), ] signs = globals().get('SIGNS', []) for chart_key, chart_label in chart_numeric_specs: diff --git a/scripts/research/research_timing_pyjhora_compare.py b/scripts/research/research_timing_pyjhora_compare.py new file mode 100644 index 00000000..c23ca513 --- /dev/null +++ b/scripts/research/research_timing_pyjhora_compare.py @@ -0,0 +1,770 @@ +"""Offline PyJHora comparison for the research timing appendix. + +Pytest does not import this file. The runtime report does not import it either. +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: + _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": "ImportError", + "systems": {}, + }) + print("not_run") + return 0 + 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 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/research_timing/__init__.py b/scripts/research_timing/__init__.py new file mode 100644 index 00000000..7e3b8f04 --- /dev/null +++ b/scripts/research_timing/__init__.py @@ -0,0 +1 @@ +"""Research-only timing and high vargas. Not for chat cards or rectification.""" diff --git a/scripts/research_timing/bundle.py b/scripts/research_timing/bundle.py new file mode 100644 index 00000000..279d26ab --- /dev/null +++ b/scripts/research_timing/bundle.py @@ -0,0 +1,474 @@ +"""Research timing for one chart. Nothing here is written onto the packet. + +The in-process cache is keyed by birth inputs so the two report languages +share one ephemeris pass. A failed family keeps its own reason and does not +drop the chapter. +""" + +from __future__ import annotations + +import logging +import time +from datetime import datetime, timedelta, timezone + +import swisseph as swe + +try: + from research_timing.cycles import ( + TITHI_FORWARD, + TITHI_REVERSE, + karana_year, + nakshatra_tribhagi, + remap_angular, + tithi_ashtottari, + yoga_vimshottari, + ) + from research_timing.families import ( + buddhi_gati, + hora_sign_from_events, + kaala_from_events, + karana_seed, + research_vargas, + saptavarga_lords, + sudasa_birth, + sudasa_confirmed_anchor, + varnada_jss, + ) + from research_timing.frame import SiderealFrame, birth_jd + from research_timing.search import ( + ElongationClock, + containing_tithi, + find_returns, + hindu_solar_events, + jd_to_utc, + measure_elongation, + ) +except ImportError: # pragma: no cover - package import + from scripts.research_timing.cycles import ( + TITHI_FORWARD, + TITHI_REVERSE, + karana_year, + nakshatra_tribhagi, + remap_angular, + tithi_ashtottari, + yoga_vimshottari, + ) + from scripts.research_timing.families import ( + buddhi_gati, + hora_sign_from_events, + kaala_from_events, + karana_seed, + research_vargas, + saptavarga_lords, + sudasa_birth, + sudasa_confirmed_anchor, + varnada_jss, + ) + from scripts.research_timing.frame import SiderealFrame, birth_jd + from scripts.research_timing.search import ( + ElongationClock, + containing_tithi, + find_returns, + hindu_solar_events, + jd_to_utc, + measure_elongation, + ) + +logger = logging.getLogger(__name__) +_CACHE: dict[tuple, dict] = {} +_PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu") + + +def research_bundle(chart: dict) -> dict: + """Compute every research family for one birth. ``chart`` is not retained.""" + key = _fingerprint(chart) + cached = _CACHE.get(key) + if cached is not None: + return cached + started = time.perf_counter() + built = _build(chart) + built["elapsed_seconds"] = time.perf_counter() - started + _CACHE[key] = built + return built + + +def _build(chart: dict) -> dict: + frame = SiderealFrame(chart["ayanamsa"], chart["node_mode"]) + natal_jd = birth_jd(chart["year"], chart["month"], chart["day"], chart["hour_decimal"], chart["tz"]) + sun, moon = frame.sun_moon(natal_jd) + longitudes = dict(chart["longitudes"]) + ascendant = float(chart["ascendant"]) + epoch = chart["epoch"] + year_days = float(chart["year_days"]) + query = chart["query"] + families: dict[str, dict] = {} + + def keep(name: str, producer) -> None: + try: + families[name] = producer() + except (ValueError, RuntimeError, OverflowError, KeyError, TypeError) as exc: + logger.warning("research timing %s failed: %s: %s", name, type(exc).__name__, exc) + families[name] = { + "status": "omitted", + "periods": [], + "reason_en": "This branch did not finish. The runtime log has the cause.", + "reason_zh": "这一支没有算成,原因记在运行日志里。", + } + + keep("tithi-natal-forward", lambda: _mark(tithi_ashtottari( + epoch, _tithi_number(sun, moon), _arc_remaining(sun, moon), 108 * year_days, TITHI_FORWARD, natal=True, + ), sun, moon)) + keep("tithi-natal-reverse", lambda: _mark(tithi_ashtottari( + epoch, _tithi_number(sun, moon), _arc_remaining(sun, moon), 108 * year_days, TITHI_REVERSE, natal=True, + ), sun, moon)) + keep("yoga-natal", lambda: _mark(yoga_vimshottari( + epoch, (sun + moon) % 360.0, 120 * year_days, natal=True, depth=3, + ), sun, moon)) + + annual = _annual_returns(frame, natal_jd, chart["target_year"]) + families["annual-returns"] = annual + _attach_tithi_pairs(keep, frame, natal_jd, annual["returns"]["tithi"], sun, moon) + _attach_pravesha(keep, frame, natal_jd, "yoga", annual["returns"]["yoga"], sun, moon) + _attach_pravesha(keep, frame, natal_jd, "nakshatra", annual["returns"]["nakshatra"], sun, moon) + keep("karana", lambda: _mark(_karana(frame, annual, chart, year_days), sun, moon)) + + keep("saptavarga", lambda: {"status": "calculated", "rows": saptavarga_lords(longitudes, ascendant), "birth_sun": sun, "birth_moon": moon}) + keep("buddhi-gati", lambda: _mark(buddhi_gati(int(ascendant // 30), longitudes, epoch, year_days), sun, moon)) + events = hindu_solar_events(epoch, chart["lat"], chart["lon"]) + keep("kaala", lambda: _mark(kaala_from_events(epoch, events, year_days), sun, moon)) + keep("sudasa-confirmed", lambda: _mark(sudasa_confirmed_anchor(), sun, moon)) + keep("sudasa-birth", lambda: _mark(sudasa_birth(ascendant, longitudes, epoch, year_days), sun, moon)) + hora = hora_sign_from_events(epoch, int(ascendant // 30), events) + if hora.get("status") != "calculated": + families["varnada"] = _mark(hora, sun, moon) + else: + built = varnada_jss(int(ascendant // 30), int(hora["sign_index"]), longitudes, epoch, year_days) + families["varnada"] = {"status": "calculated", "variants": [_mark(item, sun, moon) for item in built], "birth_sun": sun, "birth_moon": moon} + keep("research-vargas", lambda: {"status": "calculated", "rows": research_vargas(longitudes, ascendant), "birth_sun": sun, "birth_moon": moon}) + return { + "birth_sun": sun, + "birth_moon": moon, + "birth_jd": natal_jd, + "query": query.isoformat(), + "target_year": chart["target_year"], + "families": families, + "reference_note_en": ( + "The reference instant is noon on the first day of the first listed report month. " + "The packet does not keep a separate today field. " + "Yoga and Nakshatra returns use the report target year. " + "Tithi pairs use that year and the next two years." + ), + "reference_note_zh": ( + "参考时刻取报告月历第一行的月份,当月 1 日正午。资料包没有另存阅读当天的时刻。" + " Yoga 与 Nakshatra 的回归用这个目标年。 Tithi 配对用该年及后两年。" + ), + } + + +def _attach_tithi_pairs(keep, frame, natal_jd: float, rows: list[dict], sun: float, moon: float) -> None: + """Every 300–400 day pair. Three target years. Depth stays at two.""" + for index, row in enumerate(rows, start=1): + year = int(row["year"]) + label = f"tithi-{year}-{index}" + try: + partners = _later_returns(frame, natal_jd, "tithi_pravesha", row["jd_ut"], 0.0, 400.0) + partners = [item for item in partners if 300 <= item["jd_ut"] - row["jd_ut"] <= 400] + except (ValueError, RuntimeError, OverflowError) as exc: + logger.warning("research timing tithi pair failed: %s: %s", type(exc).__name__, exc) + keep(label, lambda: _failed()) + continue + if not partners: + keep(label, lambda: { + "status": "omitted", + "periods": [], + "reason_en": "No return falls between 300 and 400 days later, so this year is not listed.", + "reason_zh": "之后 300 到 400 天里没有下一次回归,这一年不列表。", + }) + continue + stamp = jd_to_utc(row["jd_ut"]) + arc = _arc_remaining(row["sun_longitude"], row["moon_longitude"]) + number = _tithi_number(row["sun_longitude"], row["moon_longitude"]) + for pair_index, partner in enumerate(partners, start=1): + span = float(partner["jd_ut"] - row["jd_ut"]) + for direction, name in ((TITHI_FORWARD, "forward"), (TITHI_REVERSE, "reverse")): + keep( + f"{label}-pair-{pair_index}-arc-{name}", + lambda direction=direction, span=span, stamp=stamp, arc=arc, number=number: _mark( + tithi_ashtottari(stamp, number, arc, span, direction, natal=False), sun, moon, + ), + ) + keep( + f"{label}-pair-{pair_index}-time-{name}", + lambda direction=direction, span=span, stamp=stamp, number=number, jd=row["jd_ut"]: _mark( + _tithi_elapsed(frame, jd, stamp, number, span, direction), sun, moon, + ), + ) + + +def _attach_pravesha(keep, frame, natal_jd: float, technique: str, rows: list[dict], sun: float, moon: float) -> None: + """Reference-year returns. Each successor in the next 300–400 days stays separate.""" + finder = "yoga_pravesha" if technique == "yoga" else "nakshatra_pravesha" + for index, row in enumerate(rows, start=1): + stamp = jd_to_utc(row["jd_ut"]) + try: + successors = _later_returns(frame, natal_jd, finder, row["jd_ut"], 300.0, 400.0) + except (ValueError, RuntimeError, OverflowError) as exc: + logger.warning("research timing %s successor failed: %s: %s", technique, type(exc).__name__, exc) + keep(f"{technique}-{index}-successor", lambda: _failed()) + keep(f"{technique}-{index}-tithi-year", lambda stamp=stamp, jd=row["jd_ut"]: _mark( + _remap_year(frame, technique, stamp, jd, None, 4320.0, depth=3), sun, moon, + )) + continue + if not successors: + keep(f"{technique}-{index}-successor", lambda: { + "status": "omitted", + "periods": [], + "reason_en": "No later return falls between 300 and 400 days, so the successor year is not listed.", + "reason_zh": "之后 300 到 400 天里没有下一次回归,后继年这里不列表。", + }) + for pair_index, partner in enumerate(successors, start=1): + keep( + f"{technique}-{index}-successor-{pair_index}", + lambda stamp=stamp, jd=row["jd_ut"], successor=partner["jd_ut"]: _mark( + _remap_year(frame, technique, stamp, jd, successor, measure_elongation(frame, jd, successor), depth=3), + sun, moon, + ), + ) + keep( + f"{technique}-{index}-tithi-year", + lambda stamp=stamp, jd=row["jd_ut"]: _mark( + _remap_year(frame, technique, stamp, jd, None, 4320.0, depth=3), sun, moon, + ), + ) + + +def _tithi_elapsed(frame, jd: float, stamp: datetime, number: int, span: float, direction: str) -> dict: + held = containing_tithi(frame, jd) + return tithi_ashtottari(stamp, number, held["remaining_time_fraction"], span, direction, natal=False) + + +def _remap_year(frame, technique: str, stamp: datetime, return_jd: float, successor: float | None, angle: float, depth: int) -> dict: + if technique == "yoga": + sun, moon = frame.sun_moon(return_jd) + template = yoga_vimshottari(stamp, (sun + moon) % 360.0, 360.0, natal=False, depth=depth) + else: + _sun, moon = frame.sun_moon(return_jd) + template = nakshatra_tribhagi(stamp, moon % 360.0, 360.0, depth=depth) + clock = ElongationClock(frame, return_jd, angle, successor) + template["periods"] = remap_angular(template["periods"], clock, return_jd) + return template + + +def _annual_returns(frame: SiderealFrame, natal_jd: float, target_year: int) -> dict: + """Tithi roots for three years. Yoga and nakshatra roots for the target year only.""" + tithi = [] + for year in range(int(target_year), int(target_year) + 3): + begin = float(swe.julday(year, 1, 1, 0.0)) + end = float(swe.julday(year + 1, 1, 1, 0.0)) + for row in find_returns(frame, "tithi_pravesha", natal_jd, begin, end)["returns"]: + copied = dict(row) + copied["year"] = year + tithi.append(copied) + begin = float(swe.julday(int(target_year), 1, 1, 0.0)) + end = float(swe.julday(int(target_year) + 1, 1, 1, 0.0)) + return { + "status": "calculated", + "returns": { + "tithi": tithi, + "yoga": list(find_returns(frame, "yoga_pravesha", natal_jd, begin, end)["returns"]), + "nakshatra": list(find_returns(frame, "nakshatra_pravesha", natal_jd, begin, end)["returns"]), + }, + } + + +def _later_returns(frame, natal_jd: float, technique: str, jd: float, start_offset: float, end_offset: float) -> list[dict]: + begin = float(jd) + float(start_offset) + end = float(jd) + float(end_offset) + if start_offset <= 0: + begin = float(jd) - 1e-4 + end = begin + 400.0 + found = find_returns(frame, technique, natal_jd, begin, end)["returns"] + return [item for item in found if item["jd_ut"] > float(jd) + 1e-6] + + +def _failed() -> dict: + return { + "status": "omitted", + "periods": [], + "reason_en": "This branch did not finish. The runtime log has the cause.", + "reason_zh": "这一支没有算成,原因记在运行日志里。", + } + + +def _tithi_number(sun: float, moon: float) -> int: + return int(((moon - sun) % 360.0) // 12) + 1 + + +def _arc_remaining(sun: float, moon: float) -> float: + return 1.0 - (((moon - sun) % 360.0) % 12.0) / 12.0 + + +def _mark(result: dict, sun: float, moon: float) -> dict: + result = dict(result) + result["birth_sun"] = sun + result["birth_moon"] = moon + return result + + +def _fingerprint(chart: dict) -> tuple: + return ( + chart["year"], chart["month"], chart["day"], round(chart["hour_decimal"], 6), + round(chart["lat"], 6), round(chart["lon"], 6), round(chart["tz"], 4), + chart["ayanamsa"], chart["node_mode"], chart["target_year"], + chart["query"].isoformat(), round(chart["year_days"], 6), + round(chart["ascendant"], 8), + tuple(round(float(chart["longitudes"][name]), 8) for name in _PLANETS), + ) + + +def _karana(frame: SiderealFrame, annual: dict, chart: dict, year_days: float) -> dict: + """Each target-year tithi return, with Julene, PVR, and the 360-tithi PVR year.""" + rows = [ + row for row in ((annual.get("returns") or {}).get("tithi") or []) + if int(row.get("year", -1)) == int(chart["target_year"]) + ] + if not rows: + return { + "status": "omitted", + "periods": [], + "reason_en": "No tithi return falls in the target year, so the karana year is not listed.", + "reason_zh": "目标年没有 Tithi 回归,Karana 年这里不列表。", + } + groups = [] + for index, row in enumerate(rows, start=1): + groups.append({ + "index": index, + "variants": _karana_variants(frame, chart, year_days, row), + }) + return {"status": "calculated", "returns": groups} + + +def _karana_variants(frame: SiderealFrame, chart: dict, year_days: float, row: dict) -> list[dict]: + stamp = jd_to_utc(row["jd_ut"]) + sun, moon = row["sun_longitude"], row["moon_longitude"] + ascendant = _return_ascendant(chart, row["jd_ut"]) + return_jd = row["jd_ut"] + variants = [] + for profile in ("julene", "pvr"): + seed = karana_seed(sun, moon, ascendant if profile == "pvr" else None, profile) + if seed.get("status") != "calculated": + variants.append({"profile": profile, **seed, "periods": []}) + continue + built = karana_year(stamp, seed["lord"], seed["remaining_fraction"], float(year_days)) + built["profile"] = profile + built["karana"] = seed["karana"] + built["reason_en"] = "The research passage does not supply child periods." + built["reason_zh"] = "这份研究材料没有给出子运,所以这里只列主运。" + variants.append(built) + pvr = next((item for item in variants if item.get("profile") == "pvr" and item.get("status") == "calculated"), None) + if pvr is None: + variants.append({ + "profile": "pvr-tithi-year", + "status": "omitted", + "periods": [], + "reason_en": "The quadrant lord is not available, so the tithi-year cycle is not listed.", + "reason_zh": "象限主星没有得到,360 个太阴月的年这里不列表。", + }) + else: + try: + template = karana_year(stamp, pvr["seed_lord"], pvr["remaining_fraction"], 360.0) + clock = ElongationClock(frame, return_jd, 4320.0, None) + template["periods"] = remap_angular(template["periods"], clock, return_jd) + template["profile"] = "pvr-tithi-year" + template["karana"] = pvr.get("karana") + template["reason_en"] = "The research passage does not supply child periods." + template["reason_zh"] = "这份研究材料没有给出子运,所以这里只列主运。" + except (ValueError, RuntimeError) as exc: + logger.warning("research timing karana tithi year failed: %s: %s", type(exc).__name__, exc) + template = { + "profile": "pvr-tithi-year", + "status": "omitted", + "periods": [], + "reason_en": "The tithi-year cycle did not finish. The runtime log has the cause.", + "reason_zh": "360 个太阴月的年没有算成,原因记在运行日志里。", + } + variants.append(template) + return variants + + +def chart_birth_jd(chart: dict) -> float: + return birth_jd(chart["year"], chart["month"], chart["day"], chart["hour_decimal"], chart["tz"]) + + +def _return_ascendant(chart: dict, jd: float) -> float: + try: + from jyotish_engine import compute_chart_data + except ImportError: # pragma: no cover - package import + from scripts.jyotish_engine import compute_chart_data + local = jd_to_utc(jd).astimezone(timezone(timedelta(hours=chart["tz"]))).replace(microsecond=0) + found, _index, chart_jd, ayanamsa_value = compute_chart_data( + local.year, local.month, local.day, local.hour, local.minute, + chart["lat"], chart["lon"], chart["tz"], + node_mode=chart["node_mode"], second=local.second, ayanamsa_name=chart["ayanamsa"], + ) + if not isinstance(found, dict): + raise RuntimeError("return ascendant is unavailable") + houses, _ascmc = swe.houses(float(chart_jd), chart["lat"], chart["lon"], b"A") + return (float(houses[0]) - float(ayanamsa_value)) % 360.0 + + +def chart_from_parts( + *, + year: int, + month: int, + day: int, + hour: int, + minute: int, + second: int, + lat: float, + lon: float, + tz: float, + ayanamsa: str, + node_mode: str, + target_year: int, + query: datetime, + year_days: float = 365.25, +) -> dict: + """Build the calculator input from a chart function result. Positions stay on our frame.""" + try: + from jyotish_engine import compute_chart_data + except ImportError: # pragma: no cover - package import + from scripts.jyotish_engine import compute_chart_data + chart, _asc_index, jd, ayanamsa_value = compute_chart_data( + int(year), int(month), int(day), int(hour), int(minute), + float(lat), float(lon), float(tz), + node_mode=node_mode, second=int(second), ayanamsa_name=ayanamsa, + ) + if not isinstance(chart, dict): + raise RuntimeError("chart function returned no positions") + houses, _ascmc = swe.houses(float(jd), float(lat), float(lon), b"A") + ascendant = (float(houses[0]) - float(ayanamsa_value)) % 360.0 + longitudes = {name: float(chart["planets"][name]["degree_raw"]) for name in _PLANETS} + epoch = datetime(int(year), int(month), int(day), int(hour), int(minute), int(second), + tzinfo=timezone(timedelta(hours=float(tz)))) + if query.utcoffset() is None: + query = query.replace(tzinfo=timezone(timedelta(hours=float(tz)))) + return { + "year": int(year), + "month": int(month), + "day": int(day), + "hour_decimal": int(hour) + int(minute) / 60.0 + int(second) / 3600.0, + "lat": float(lat), + "lon": float(lon), + "tz": float(tz), + "ayanamsa": str(ayanamsa or "lahiri"), + "node_mode": node_mode if node_mode in ("mean", "true") else "mean", + "target_year": int(target_year), + "query": query, + "year_days": float(year_days), + "epoch": epoch, + "ascendant": ascendant, + "longitudes": longitudes, + "chart_sun": longitudes["Sun"], + "chart_moon": longitudes["Moon"], + } diff --git a/scripts/research_timing/cycles.py b/scripts/research_timing/cycles.py new file mode 100644 index 00000000..738a2dad --- /dev/null +++ b/scripts/research_timing/cycles.py @@ -0,0 +1,315 @@ +"""Period arithmetic copied from the named research profiles. + +No ephemeris here. Depth stops where the source says there is no child rule. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +try: + from jyotish_engine import DASHA_ORDER, DASHA_YEARS +except ImportError: # pragma: no cover - package import + from scripts.jyotish_engine import DASHA_ORDER, DASHA_YEARS + +TITHI_LORDS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu") +TITHI_SEQUENCE = ("Sun", "Moon", "Mars", "Mercury", "Saturn", "Jupiter", "Rahu", "Venus") +TITHI_YEARS = (6, 15, 8, 17, 10, 19, 12, 21) +TITHI_FORWARD = "forward" +TITHI_REVERSE = "reverse" +KARANA_LORDS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn") +KAALA_LORDS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu") +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", +} + + +def _aware(epoch: datetime) -> datetime: + if epoch.utcoffset() is None: + raise ValueError("epoch requires a UTC offset") + return epoch.astimezone(timezone.utc) + + +def tithi_seed(tithi_num: int) -> str: + if not 1 <= int(tithi_num) <= 30: + raise ValueError("tithi number must be 1..30") + if int(tithi_num) == 30: + return "Rahu" + return TITHI_LORDS[((int(tithi_num) - 1) % 15) % 8] + + +def tithi_ashtottari( + epoch: datetime, + tithi_num: int, + remaining_fraction: float, + span_days: float, + direction: str, + *, + natal: bool, +) -> dict: + """108-part cycle. Annual span is 300..400 days. Natal span is one 108-year cycle. No third level.""" + if direction not in (TITHI_FORWARD, TITHI_REVERSE): + raise ValueError("tithi direction must be forward or reverse") + if not 0 <= remaining_fraction <= 1: + raise ValueError("remaining fraction must be 0..1") + low, high = (108 * 350, 108 * 370) if natal else (300, 400) + if not low <= span_days <= high: + raise ValueError("span is outside the selected calendar") + stamp = _aware(epoch) + seed = tithi_seed(tithi_num) + seed_index = TITHI_SEQUENCE.index(seed) + unit = span_days / 108.0 + offset = -TITHI_YEARS[seed_index] * (1 - remaining_fraction) * unit + window = span_days if not natal else span_days + offset + step = 1 if direction == TITHI_FORWARD else -1 + periods = [] + count = 8 if natal else 9 + for number in range(count): + index = (seed_index + number) % 8 + end = offset + TITHI_YEARS[index] * unit + visible_left, visible_right = max(offset, 0.0), min(end, window) + if visible_right - visible_left > 1e-10: + row = _span_row(stamp, TITHI_SEQUENCE[index], f"md:{number + 1}", offset, end, visible_left, visible_right) + children = [] + child_start = offset + for child_number in range(8): + child_index = (index + step * (child_number + 1)) % 8 + child_end = end if child_number == 7 else child_start + (end - offset) * TITHI_YEARS[child_index] / 108.0 + child_left, child_right = max(child_start, visible_left), min(child_end, visible_right) + if child_right - child_left > 1e-10: + children.append(_span_row( + stamp, TITHI_SEQUENCE[child_index], f"md:{number + 1}:ad:{child_number + 1}", + child_start, child_end, child_left, child_right, + )) + child_start = child_end + row["antardashas"] = children + periods.append(row) + offset = end + return { + "status": "calculated", + "seed_lord": seed, + "tithi_num": int(tithi_num), + "remaining_fraction": remaining_fraction, + "span_days": span_days, + "direction": direction, + "periods": periods, + "pd_status": "omitted", + "reason_en": "No explicit third-level rule in the admitted tutorial.", + "reason_zh": "收录的教程没有写明第三层。", + } + + +def yoga_vimshottari(epoch: datetime, sun_moon_sum: float, span_days: float, *, natal: bool, depth: int = 3) -> dict: + if depth not in (1, 2, 3): + raise ValueError("yoga depth must be 1, 2 or 3") + if not 0 <= sun_moon_sum < 360: + raise ValueError("yoga angle must be in [0, 360)") + low, high = (120 * 350, 120 * 370) if natal else (300, 400) + if not low <= span_days <= high: + raise ValueError("yoga span is outside the selected calendar") + stamp = _aware(epoch) + coordinate = sun_moon_sum / (360.0 / 27.0) + yoga_index = int(coordinate) + elapsed_fraction = coordinate - yoga_index + seed_index = (yoga_index + DASHA_ORDER.index("Saturn")) % 9 + seed = DASHA_ORDER[seed_index] + unit = span_days / 120.0 + full_start_day = -elapsed_fraction * DASHA_YEARS[seed] * unit + # Natal shows one clipped 120-year cycle. Annual adds the returning seed fragment. + count = 9 if natal else 10 + periods = [] + cursor = full_start_day + window_end = span_days if not natal else span_days + full_start_day + for number in range(count): + lord = DASHA_ORDER[(seed_index + number) % 9] + full_end = cursor + DASHA_YEARS[lord] * unit + left, right = max(cursor, 0.0), min(full_end, window_end if natal else span_days) + if right - left > 1e-8: + row = _day_row(stamp, lord, f"md:{number + 1}", cursor, full_end, left, right) + if depth > 1: + row["antardashas"] = _vimshottari_children(stamp, row, depth, "ad") + periods.append(row) + cursor = full_end + return { + "status": "calculated", + "seed_lord": seed, + "yoga_index": yoga_index + 1, + "remaining_fraction": 1 - elapsed_fraction, + "span_days": span_days, + "depth": depth, + "periods": periods, + } + + +def nakshatra_tribhagi(epoch: datetime, moon_longitude: float, span_days: float, depth: int = 3) -> dict: + if depth not in (1, 2, 3): + raise ValueError("nakshatra depth must be 1, 2 or 3") + if not 0 <= moon_longitude < 360 or not 300 <= span_days <= 400: + raise ValueError("nakshatra longitude or span is out of range") + stamp = _aware(epoch) + coordinate = moon_longitude / (360.0 / 27.0) + seed_star = int(coordinate) + fraction = coordinate - seed_star + seed_index = seed_star % 9 + seed = DASHA_ORDER[seed_index] + unit = span_days / 120.0 + cursor = -fraction * (DASHA_YEARS[seed] / 3.0) * unit + periods = [] + for number in range(28): + lord = DASHA_ORDER[(seed_index + number) % 9] + full_end = cursor + (DASHA_YEARS[lord] / 3.0) * unit + left, right = max(cursor, 0.0), min(full_end, span_days) + if right - left > 1e-8: + row = _day_row(stamp, lord, f"tribhagi:md:{number + 1}", cursor, full_end, left, right) + row["cycle_index"] = number // 9 + 1 + if depth > 1: + row["antardashas"] = _tribhagi_children(stamp, row, depth, "ad") + periods.append(row) + cursor = full_end + return {"status": "calculated", "seed_lord": seed, "span_days": span_days, "depth": depth, "periods": periods} + + +def _vimshottari_children(stamp: datetime, parent: dict, depth: int, level: str) -> list[dict]: + start = parent["full_start_days"] + span = parent["full_end_days"] - start + first = DASHA_ORDER.index(parent["lord"]) + rows = [] + for index in range(9): + lord = DASHA_ORDER[(first + index) % 9] + end = parent["full_end_days"] if index == 8 else start + span * DASHA_YEARS[lord] / 120.0 + left, right = max(start, parent["start_days"]), min(end, parent["end_days"]) + if right - left > 1e-8: + row = _day_row(stamp, lord, f"{parent['key']}:{level}:{index + 1}", start, end, left, right) + if level == "ad" and depth == 3: + row["pratyantardashas"] = _vimshottari_children(stamp, row, depth, "pd") + rows.append(row) + start = end + return rows + + +def _tribhagi_children(stamp: datetime, parent: dict, depth: int, level: str) -> list[dict]: + start = parent["full_start_days"] + span = parent["full_end_days"] - start + first = DASHA_ORDER.index(parent["lord"]) + rows = [] + for index in range(9): + lord = DASHA_ORDER[(first + index) % 9] + end = parent["full_end_days"] if index == 8 else start + span * (DASHA_YEARS[lord] / 3.0) / 40.0 + left, right = max(start, parent["start_days"]), min(end, parent["end_days"]) + if right - left > 1e-8: + row = _day_row(stamp, lord, f"{parent['key']}:{level}:{index + 1}", start, end, left, right) + if level == "ad" and depth == 3: + row["pratyantardashas"] = _tribhagi_children(stamp, row, depth, "pd") + rows.append(row) + start = end + return rows + + +def karana_year(epoch: datetime, lord: str, remaining_fraction: float, span_days: float) -> dict: + if lord not in KARANA_LORDS: + raise ValueError("karana seed must be Sun through Saturn") + if not 0 <= remaining_fraction <= 1 or not 300 <= span_days <= 400: + raise ValueError("karana fraction or span is out of range") + stamp = _aware(epoch) + seed = KARANA_LORDS.index(lord) + whole = span_days / 7.0 + offset = -(1 - remaining_fraction) * whole + periods = [] + for index in range(8): + start = offset + index * whole + end = offset + (index + 1) * whole + left, right = max(0.0, start), min(span_days, end) + if right > left: + periods.append(_day_row(stamp, KARANA_LORDS[(seed + index) % 7], f"karana:md:{index + 1}", start, end, left, right)) + return { + "status": "calculated", + "seed_lord": lord, + "remaining_fraction": remaining_fraction, + "span_days": span_days, + "periods": periods, + "pd_status": "blocked", + "pd_reason": "The research passage does not supply child periods", + } + + +def remap_angular(periods: list[dict], clock, return_jd: float) -> list[dict]: + """Replace coordinate-day offsets with elongation-clock instants. Tree shape stays.""" + + def convert(row: dict) -> dict: + out = dict(row) + for key in ("start_days", "end_days"): + solved = clock.solve(row[key]) + out[key.replace("_days", "_jd")] = solved + out["start"] = _iso(out["start_jd"]) + out["end"] = _iso(out["end_jd"]) + for child_key in ("antardashas", "pratyantardashas"): + if child_key in row: + out[child_key] = [convert(child) for child in row[child_key]] + return out + + del return_jd + return [convert(row) for row in periods] + + +def kaala_from_segment(epoch: datetime, segment_start: datetime, segment_end: datetime, year_days: float) -> dict: + start = _aware(epoch) + if not segment_start <= start < segment_end: + raise ValueError("birth is outside the selected solar segment") + fraction = (start - segment_start).total_seconds() / (segment_end - segment_start).total_seconds() + cycles = (120.0 * fraction, 120.0 * (1.0 - fraction)) + periods = [] + elapsed = 0.0 + for cycle, years in enumerate(cycles, start=1): + cycle_start = elapsed + for ordinal, lord in enumerate(KAALA_LORDS, start=1): + left = cycle_start + years * (ordinal - 1) * ordinal / 90.0 + right = cycle_start + years * ordinal * (ordinal + 1) / 90.0 + periods.append({ + "lord": lord, + "cycle": cycle, + "years": years * ordinal / 45.0, + "start": (start + timedelta(days=left * year_days)).isoformat(), + "end": (start + timedelta(days=right * year_days)).isoformat(), + "level": "MD", + }) + elapsed = cycle_start + years + return { + "status": "calculated", + "elapsed_fraction": fraction, + "periods": periods, + "pd_status": "blocked", + "pd_reason": "The Kaala note does not prescribe child periods", + } + + +def _span_row(stamp, lord, key, full_start, full_end, left, right) -> dict: + row = _day_row(stamp, lord, key, full_start, full_end, left, right) + return row + + +def _day_row(stamp: datetime, lord: str, key: str, full_start: float, full_end: float, left: float, right: float) -> dict: + return { + "key": key, + "lord": lord, + "level": "PD" if ":pd:" in key else "AD" if ":ad:" in key else "MD", + "full_start_days": full_start, + "full_end_days": full_end, + "start_days": left, + "end_days": right, + "start": (stamp + timedelta(days=left)).isoformat(), + "end": (stamp + timedelta(days=right)).isoformat(), + } + + +def _iso(jd: float) -> str: + try: + from research_timing.search import jd_to_utc + except ImportError: # pragma: no cover - package import + from scripts.research_timing.search import jd_to_utc + return jd_to_utc(jd).isoformat() diff --git a/scripts/research_timing/families.py b/scripts/research_timing/families.py new file mode 100644 index 00000000..5adba7b8 --- /dev/null +++ b/scripts/research_timing/families.py @@ -0,0 +1,427 @@ +"""Research dasha arithmetic that does not touch the ephemeris. + +Longitude values are supplied by the caller from this repository's chart +function. Child levels stop where the written source stops. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +try: + from chaturshitisama_dasha import DASHA_SEQUENCE + from muhurta import calc_karana + from narayana_dasha import _NARAYANA_DIGNITY, calc_narayana_mahadasha + from varga import SIGN_LORDS, SIGNS, calc_varga +except ImportError: # pragma: no cover - package import + from scripts.chaturshitisama_dasha import DASHA_SEQUENCE + from scripts.muhurta import calc_karana + from scripts.narayana_dasha import _NARAYANA_DIGNITY, calc_narayana_mahadasha + from scripts.varga import SIGN_LORDS, SIGNS, calc_varga + +JULENE_LORDS = { + "Bava": "Sun", "Balava": "Moon", "Kaulava": "Mars", "Taitila": "Mercury", + "Garija": "Jupiter", "Vanija": "Venus", "Vishti": "Saturn", + "Shakuni": "Rahu", "Chatushpada": "Ketu", "Naga": "Rahu", "Kimstughna": "Ketu", +} +PVR_MOVABLE = { + "Bava": "Sun", "Balava": "Moon", "Kaulava": "Mars", "Taitila": "Mercury", + "Garija": "Jupiter", "Vanija": "Venus", "Vishti": "Saturn", +} +PVR_FIXED_HOUSES = {"Shakuni": 1, "Chatushpada": 4, "Naga": 7, "Kimstughna": 10} +SAPTAVARGA = (1, 2, 3, 7, 9, 12, 30) +SAPTAVARGA_BODIES = ("Lagna", "Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn") +RESEARCH_DIVISIONS = (81, 108, 144) +CLASSICAL = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu") + + +def _aware(epoch: datetime) -> datetime: + if epoch.utcoffset() is None: + raise ValueError("epoch requires a UTC offset") + return epoch.astimezone(timezone.utc) + + +def _at(epoch: datetime, years: float, year_days: float) -> str: + return (_aware(epoch) + timedelta(days=float(years) * float(year_days))).isoformat() + + +def karana_seed(sun: float, moon: float, ascendant: float | None, profile: str) -> dict: + """Julene table, or PVR movable lords plus quadrant lords for the four fixed names.""" + if profile not in ("julene", "pvr"): + raise ValueError("karana lord profile must be julene or pvr") + found = calc_karana(float(sun), float(moon)) + name = found["karana"] + phase = (float(moon) - float(sun)) % 360.0 + if profile == "julene": + lord = JULENE_LORDS.get(name) + else: + lord = PVR_MOVABLE.get(name) + if lord is None and name in PVR_FIXED_HOUSES: + if ascendant is None: + return {"status": "omitted", "karana": name, "reason_en": "The quadrant lord needs the ascendant at the return.", "reason_zh": "象限主星需要返照时刻的上升点,这次没有算到。"} + house = PVR_FIXED_HOUSES[name] + sign = SIGNS[(int(float(ascendant) // 30) + house - 1) % 12] + lord = SIGN_LORDS[sign] + if lord in ("Rahu", "Ketu") or lord is None: + return { + "status": "omitted", + "karana": name, + "lord": lord, + "reason_en": "This karana lord is a node, so the seven-planet cycle is not listed.", + "reason_zh": "这个迦那的主星是交点,七曜周期这里不列表。", + } + return { + "status": "calculated", + "karana": name, + "lord": lord, + "remaining_fraction": 1.0 - (phase % 6.0) / 6.0, + } + + +def saptavarga_lords(longitudes: dict[str, float], ascendant: float) -> list[dict]: + points = {"Lagna": float(ascendant), **{name: float(longitudes[name]) for name in SAPTAVARGA_BODIES if name != "Lagna"}} + rows = [] + for division in SAPTAVARGA: + for body, longitude in points.items(): + if division == 1: + sign = SIGNS[int(longitude // 30) % 12] + degree = longitude % 30.0 + lord = SIGN_LORDS[sign] + else: + found = calc_varga(longitude, division) + sign = found["sign"] + degree = found["degree_in_sign"] + lord = found["lord"] + rows.append({ + "division": division, + "body": body, + "sign": sign, + "degree": degree, + "lord": lord, + }) + return rows + + +def research_vargas(longitudes: dict[str, float], ascendant: float) -> list[dict]: + points = {"Lagna": float(ascendant), **{name: float(longitudes[name]) for name in CLASSICAL}} + rows = [] + for division in RESEARCH_DIVISIONS: + for body, longitude in points.items(): + found = calc_varga(longitude, division) + rows.append({ + "division": division, + "body": body, + "sign": found["sign"], + "degree": found["degree_in_sign"], + "lord": found["lord"], + }) + try: + from divisional_charts_extended import DivisionalChartsCalculator + except ImportError: # pragma: no cover - package import + from scripts.divisional_charts_extended import DivisionalChartsCalculator + calculator = DivisionalChartsCalculator() + for body, longitude in points.items(): + found = calculator.calc_custom_varga(float(longitude), 150) + rows.append({ + "division": 150, + "body": body, + "sign": found.get("sign"), + "degree": found.get("degree"), + "lord": SIGN_LORDS.get(found.get("sign"), ""), + }) + return rows + + +def buddhi_gati(lagna_sign: int, longitudes: dict[str, float], epoch: datetime, year_days: float) -> dict: + """One nine-planet pass. The Atri passage does not name child periods.""" + planets = set(_NARAYANA_DIGNITY) | {"Rahu", "Ketu"} + if set(longitudes) < planets: + raise ValueError("Buddhi Gati needs all nine longitudes") + start = _aware(epoch) + fourth = (int(lagna_sign) + 3) % 12 + order = sorted(planets, key=lambda name: ((int(longitudes[name] // 30) - fourth) % 12, longitudes[name] % 30)) + periods = [] + elapsed = 0.0 + for ordinal, planet in enumerate(order): + tied = sorted(name for name in planets if longitudes[name] == longitudes[planet]) + if len(tied) > 1: + return { + "status": "omitted", + "periods": [], + "reason_en": "Two planets share a longitude, and the passage does not say which one comes first.", + "reason_zh": "有两颗星黄经相同,材料没有规定先后,所以这里不列表。", + } + sign = int(longitudes[planet] // 30) + target = (int(lagna_sign) + ordinal) % 12 + base = (target - sign) % 12 + dignity = _NARAYANA_DIGNITY.get(planet) + correction = 0 if dignity is None else int(sign in dignity["exalted"]) - int(sign in dignity["debilitated"]) + years = base + correction + if years <= 0: + return { + "status": "omitted", + "periods": [], + "reason_en": "A duration is not positive, and the passage does not say how to continue.", + "reason_zh": "有一段年数不是正数,材料没有规定怎么接下去,所以这里不列表。", + } + periods.append({ + "lord": planet, + "level": "MD", + "start": _at(start, elapsed, year_days), + "end": _at(start, elapsed + years, year_days), + }) + elapsed += years + return { + "status": "calculated", + "periods": periods, + "reason_en": "The Atri passage does not specify child periods.", + "reason_zh": "Atri 原文没有规定子运和次小运,所以这里只列主运。", + "pd_status": "omitted", + } + + +def kaala_from_events(epoch: datetime, events: list[dict], year_days: float) -> dict: + """Two weighted cycles inside the solar sixth that contains birth. No children.""" + start = _aware(epoch) + parsed = [] + for event in events: + if event.get("event") not in ("sunrise", "sunset"): + continue + parsed.append((event["event"], datetime.fromisoformat(event["datetime_utc"]))) + selected = None + for index in range(len(parsed) - 3): + group = parsed[index:index + 4] + if [item[0] for item in group] != ["sunset", "sunrise", "sunset", "sunrise"]: + continue + previous_set, rise, setting, next_rise = [item[1] for item in group] + if any((right - left).total_seconds() >= 86400 or right <= left for left, right in zip( + (previous_set, rise, setting), (rise, setting, next_rise), + )): + continue + day = setting - rise + before_night = rise - previous_set + after_night = next_rise - setting + boundaries = [ + rise - before_night / 6, + rise + day / 6, + setting - day / 6, + setting + after_night / 6, + next_rise - after_night / 6, + ] + for name, left, right in zip(("dawn", "daytime", "dusk", "nighttime"), boundaries, boundaries[1:]): + if left <= start < right: + selected = (name, left, right) + break + if selected is not None: + break + if selected is None: + return { + "status": "omitted", + "periods": [], + "reason_en": "Sunrise and sunset around birth do not bracket a solar sixth.", + "reason_zh": "出生前后的日出日落没有框住一个太阳六分时段,所以这里不列表。", + } + try: + from research_timing.cycles import kaala_from_segment + except ImportError: # pragma: no cover - package import + from scripts.research_timing.cycles import kaala_from_segment + name, left, right = selected + built = kaala_from_segment(start, left, right, year_days) + built["segment"] = name + built["reason_en"] = "The Kaala note does not prescribe child periods." + built["reason_zh"] = "Kaala 的材料没有规定子运和次小运,所以这里只列主运。" + built["pd_status"] = "omitted" + return built + + +def _sudasa_direction(sign: int, saturn_sign: int, ketu_sign: int) -> tuple[int, str]: + if saturn_sign == ketu_sign == sign: + raise ValueError("Saturn and Ketu exceptions meet on the same sign") + direction = 1 if sign % 2 == 0 else -1 + if saturn_sign == sign: + return 1, "Saturn keeps the forward order" + if ketu_sign == sign: + return -direction, "Ketu reverses the order" + return direction, "" + + +def _sudasa_children(sign: int, full_years: float, saturn_sign: int, ketu_sign: int, remaining: float) -> list[dict]: + direction, _note = _sudasa_direction(sign, saturn_sign, ketu_sign) + offsets = [group + quadrant * 3 for group in range(3) for quadrant in range(4)] + full = [] + for index, offset in enumerate(offsets): + full.append({ + "sign_index": (sign + direction * offset) % 12, + "start_offset": full_years * index / 12.0, + "end_offset": full_years * (index + 1) / 12.0, + }) + if remaining >= 1: + return full + elapsed = full_years * (1.0 - remaining) + clipped = [] + for row in full: + if row["end_offset"] <= elapsed: + continue + clipped.append({ + "sign_index": row["sign_index"], + "start_offset": max(row["start_offset"], elapsed) - elapsed, + "end_offset": row["end_offset"] - elapsed, + }) + return clipped + + +def sudasa_birth(ascendant: float, longitudes: dict[str, float], epoch: datetime, year_days: float) -> dict: + """Sree lagna from PVR 5.7, SJVC15 order, birth children clipped inside the full major.""" + moon = float(longitudes["Moon"]) + sree = (float(ascendant) + (moon * 27.0) % 360.0) % 360.0 + durations = calc_narayana_mahadasha( + 0, + {name: float(longitudes[name]) for name in CLASSICAL}, + duration_profile="jaimini_odd_footed_dignity_v2", + dual_lord_profile="legacy_primary_lord_v0", + ) + by_sign = {int(row["sign_idx"]): row for row in durations} + sign = int(sree // 30) + saturn = int(longitudes["Saturn"] // 30) + ketu = int(longitudes["Ketu"] // 30) + direction, note = _sudasa_direction(sign, saturn, ketu) + offsets = [group + quadrant * 3 for group in range(3) for quadrant in range(4)] + fraction = (30.0 - (sree % 30.0)) / 30.0 + periods = [] + elapsed = 0.0 + start = _aware(epoch) + for index, offset in enumerate(offsets): + current = (sign + direction * offset) % 12 + full_years = float(by_sign[current]["years"]) + years = full_years * fraction if index == 0 else full_years + row = { + "lord": by_sign[current].get("lord"), + "sign": SIGNS[current], + "level": "MD", + "start": _at(start, elapsed, year_days), + "end": _at(start, elapsed + years, year_days), + "antardashas": [], + } + try: + children = _sudasa_children(current, full_years, saturn, ketu, fraction if index == 0 else 1.0) + except ValueError: + row["note_en"] = "Saturn and Ketu both occupy this sign, so its child periods are not listed." + row["note_zh"] = "土星和计都同时落在这个星座,子运这里不列表。" + children = [] + for child in children: + child_sign = int(child["sign_index"]) + row["antardashas"].append({ + "sign": SIGNS[child_sign], + "lord": SIGN_LORDS[SIGNS[child_sign]], + "level": "AD", + "start": _at(start, elapsed + child["start_offset"], year_days), + "end": _at(start, elapsed + child["end_offset"], year_days), + }) + periods.append(row) + elapsed += years + return { + "status": "calculated", + "periods": periods, + "sree_longitude": sree, + "note_en": note, + "reason_en": "No third level is provided for this Sudasa reading.", + "reason_zh": "这一路 Sudasa 没有次小运。", + "pd_status": "omitted", + } + + +def sudasa_confirmed_anchor() -> dict: + """No confirmed full major start is stored, so this branch has no table.""" + return { + "status": "omitted", + "periods": [], + "reason_en": "The confirmed-start branch needs a written full major-period start. This report does not have that source, so no table is printed.", + "reason_zh": "已确认起点这一支需要书面确认的完整主运起点。这次没有这份依据,所以不列表。", + } + + +def _varnada_one(lagna: int, hora: int, seed: int, longitudes: dict[str, float], epoch: datetime, year_days: float) -> dict: + """Guru Rajesh inclusive count. Child thirds are not established.""" + count_direction = -1 if lagna % 2 else 1 + origin = 11 if count_direction == -1 else 0 + lagna_count = ((lagna - origin) * count_direction) % 12 + 1 + hora_count = ((hora - origin) * count_direction) % 12 + 1 + same = lagna_count % 2 == hora_count % 2 + number = lagna_count + hora_count if same else abs(lagna_count - hora_count) + varnada = (origin + count_direction * (number - 1)) % 12 + shift = ((varnada - lagna) * (1 if lagna % 2 == 0 else -1)) % 12 + 1 + signs = {name: int(float(longitudes[name]) // 30) for name in CLASSICAL} + direction = 1 if seed % 2 == 0 else -1 + if any(signs[name] == varnada for name in ("Rahu", "Ketu")): + direction *= -1 + start = _aware(epoch) + age = 0.0 + periods = [] + for index in range(12): + sign = (seed + direction * index) % 12 + step = 1 if sign % 2 == 0 else -1 + target = (sign + step * (shift - 1)) % 12 + lord = SIGN_LORDS[SIGNS[target]] + duration_direction = 1 if target % 2 == 0 else -1 + distance = ((signs[lord] - target) * duration_direction) % 12 + years = 12 if distance == 0 else 10 if distance == 6 else distance + 1 + children = [] + for child in range(12): + child_sign = (sign + step * child) % 12 + child_start = age + years * child / 12.0 + child_end = age + years * (child + 1) / 12.0 + children.append({ + "sign": SIGNS[child_sign], + "lord": SIGN_LORDS[SIGNS[child_sign]], + "level": "AD", + "start": _at(start, child_start, year_days), + "end": _at(start, child_end, year_days), + }) + periods.append({ + "sign": SIGNS[sign], + "lord": SIGN_LORDS[SIGNS[sign]], + "level": "MD", + "start": _at(start, age, year_days), + "end": _at(start, age + years, year_days), + "antardashas": children, + }) + age += years + return {"status": "calculated", "periods": periods, "seed_sign": SIGNS[seed]} + + +def varnada_jss(lagna: int, hora: int, longitudes: dict[str, float], epoch: datetime, year_days: float) -> list[dict]: + seeds = [lagna] if lagna == hora else [lagna, hora] + rows = [] + for seed in seeds: + built = _varnada_one(lagna, hora, seed, longitudes, epoch, year_days) + built["reason_en"] = "The translation does not establish a third level." + built["reason_zh"] = "这份译文没有建立次小运,所以这里只到子运。" + built["pd_status"] = "omitted" + rows.append(built) + return rows + + +def hora_sign_from_events(epoch: datetime, lagna_sign: int, events: list[dict]) -> dict: + """Day and night each have twelve horas. The sign origin is the lagna sign, not the exact degree.""" + start = _aware(epoch) + parsed = [] + for event in events: + if event.get("event") not in ("sunrise", "sunset"): + continue + parsed.append((event["event"], datetime.fromisoformat(event["datetime_utc"]))) + for (name, left), (_, right) in zip(parsed, parsed[1:]): + if left >= right or not left <= start < right or (right - left).total_seconds() >= 86400: + continue + duration = (right - left) // timedelta(microseconds=1) + elapsed = (start - left) // timedelta(microseconds=1) + if duration <= 0: + continue + quotient, remainder = divmod(elapsed * 12, duration) + direction = 1 if lagna_sign % 2 == 0 else -1 + sign = (lagna_sign + direction * int(quotient)) % 12 + return {"status": "calculated", "sign_index": sign, "segment": "day" if name == "sunrise" else "night"} + return { + "status": "omitted", + "reason_en": "Sunrise and sunset do not bracket birth, so the hora sign is not listed.", + "reason_zh": "日出日落没有框住出生时刻,时段上升星座这里不列表。", + } diff --git a/scripts/research_timing/frame.py b/scripts/research_timing/frame.py new file mode 100644 index 00000000..fafb5e58 --- /dev/null +++ b/scripts/research_timing/frame.py @@ -0,0 +1,46 @@ +"""Sidereal Sun and Moon on the chart function's frame. + +One process import. Ayanamsa is recomputed per instant so a year-long +search does not freeze the precession. This is not the apparent path. +""" + +from __future__ import annotations + +import swisseph as swe + +try: + from jyotish_engine import _apply_ayanamsa, _sidereal_calculation_profile +except ImportError: # pragma: no cover - package import + from scripts.jyotish_engine import _apply_ayanamsa, _sidereal_calculation_profile + +_SPEED = getattr(swe, "FLG_SPEED", 256) + + +class SiderealFrame: + def __init__(self, ayanamsa_name: str, node_mode: str = "mean") -> None: + self.ayanamsa_name = str(ayanamsa_name or "lahiri") + self.node_mode = node_mode if node_mode in ("mean", "true") else "mean" + _apply_ayanamsa(self.ayanamsa_name) + + def sun_moon(self, jd: float) -> tuple[float, float]: + _mode, calc_flags, ayanamsa = _sidereal_calculation_profile(float(jd), "legacy") + flags = calc_flags | _SPEED + sun = _body(float(jd), swe.SUN, flags, ayanamsa) + moon = _body(float(jd), swe.MOON, flags, ayanamsa) + return sun, moon + + +def _body(jd: float, body: int, flags: int, ayanamsa: float) -> float: + pos, ret = swe.calc_ut(jd, body, flags) + if ret < 0: + raise RuntimeError(f"swisseph calc_ut failed for body {body} at jd {jd}: code {ret}") + return (float(pos[0]) - float(ayanamsa)) % 360.0 + + +def birth_jd(year: int, month: int, day: int, hour: float, tz_hours: float) -> float: + """Same Julian day the chart function uses: local clock minus the offset.""" + return float(swe.julday(int(year), int(month), int(day), float(hour) - float(tz_hours))) + + +def year_start_jd(year: int) -> float: + return float(swe.julday(int(year), 1, 1, 0.0)) diff --git a/scripts/research_timing/rashi_pd.py b/scripts/research_timing/rashi_pd.py new file mode 100644 index 00000000..daca7162 --- /dev/null +++ b/scripts/research_timing/rashi_pd.py @@ -0,0 +1,79 @@ +"""Third-level rows for four rashi dashas, on a copy only. + +The producer stays free of this key. Printed pages name the sub-period +and not the third level. The split used here is the direct equal-time +rule in the upstream calendar helper: the first child keeps the +sub-period sign, the rest run forward, and the last child ends on the +sub-period end. An empty sub-period list, including a Drig strength tie, +gets no third level. +""" + +from __future__ import annotations + +from copy import deepcopy +from datetime import datetime + +try: + from rashi_dashas_native import SIGNS +except ImportError: # pragma: no cover - package import + from scripts.rashi_dashas_native import SIGNS + +FAMILIES = ("sthira", "drig", "shoola", "navamsha") + + +def attach_rashi_pratyantar(calculated: dict) -> dict: + """Return a deep copy with third-level rows. The input is not changed.""" + copied = deepcopy(calculated) + for name in FAMILIES: + section = copied.get(name) + if not isinstance(section, dict): + continue + for major in section.get("periods") or []: + if not isinstance(major, dict): + continue + children = major.get("antardasha") + if not isinstance(children, list) or not children: + continue + for child in children: + if not isinstance(child, dict): + continue + third = _equal_forward(child) + if third: + child["pratyantar"] = third + return copied + + +def _equal_forward(child: dict) -> list[dict]: + sign = child.get("sign_index") + if not isinstance(sign, int): + label = child.get("sign") + if label not in SIGNS: + return [] + sign = SIGNS.index(label) + try: + start = datetime.fromisoformat(str(child["start"])) + end = datetime.fromisoformat(str(child["end"])) + except (KeyError, TypeError, ValueError): + return [] + total = (end - start).total_seconds() + if total <= 0: + return [] + rows = [] + for offset in range(12): + moment = end if offset == 11 else start + _seconds(total * (offset + 1) / 12.0) + opened = start if offset == 0 else start + _seconds(total * offset / 12.0) + index = (int(sign) + offset) % 12 + rows.append({ + "sign": SIGNS[index], + "sign_index": index, + "start": opened.date().isoformat(), + "end": moment.date().isoformat(), + "level": "PD", + }) + rows[-1]["end"] = end.date().isoformat() + return rows + + +def _seconds(value: float): + from datetime import timedelta + return timedelta(seconds=value) diff --git a/scripts/research_timing/render.py b/scripts/research_timing/render.py new file mode 100644 index 00000000..9acc0729 --- /dev/null +++ b/scripts/research_timing/render.py @@ -0,0 +1,551 @@ +"""Snake-free bilingual chapters for the research appendix. + +The chapters are computed while the report is rendered. They are not stored +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__) + +try: + from report_render.parity_chapters import _first, _major_list + from report_render.text import PLANET_LABEL_CN, SIGNS_CN + from research_timing.bundle import chart_from_parts, research_bundle + from research_timing.rashi_pd import attach_rashi_pratyantar + from rashi_dashas_native import calculate_rashi_dashas +except ImportError: # pragma: no cover - package import + from scripts.report_render.parity_chapters import _first, _major_list + from scripts.report_render.text import PLANET_LABEL_CN, SIGNS_CN + from scripts.research_timing.bundle import chart_from_parts, research_bundle + from scripts.research_timing.rashi_pd import attach_rashi_pratyantar + from scripts.rashi_dashas_native import calculate_rashi_dashas + +try: + from annual_native.dasha_children import attach_kalachakra_simplified_children +except ImportError: # pragma: no cover - package import + from scripts.annual_native.dasha_children import attach_kalachakra_simplified_children + +BANNER = { + "zh": "研究口径:依赖所选方法,未经外部软件核对。", + "en": "Research basis: method-dependent; not checked against external software.", +} +_CHILD_KEYS = ("antardashas", "pratyantardashas", "antardasha", "pratyantar", "pratyantardasha") +_LEVELS = {"MD": "MD", "AD": "AD", "PD": "PD", "antardasha": "AD", "pratyantardasha": "PD", "pratyantar": "PD"} +_FIXED = { + "tithi-natal-forward": ("Natal Tithi Ashtottari forward", "本命 Tithi Ashtottari 顺行"), + "tithi-natal-reverse": ("Natal Tithi Ashtottari reverse", "本命 Tithi Ashtottari 逆行"), + "yoga-natal": ("Natal Yoga Vimshottari", "本命 Yoga Vimshottari"), + "saptavarga": ("Saptavarga lords", "七分盘星座主"), + "buddhi-gati": ("Buddhi Gati", "Buddhi Gati"), + "kaala": ("Kaala", "Kaala"), + "sudasa-confirmed": ("Sudasa confirmed start", "Sudasa 已确认起点"), + "sudasa-birth": ("Sudasa birth cycle", "Sudasa 出生周期"), + "varnada": ("Varnada", "Varnada"), + "karana": ("Karana year", "Karana 年"), +} +_TECH = {"tithi": "Tithi Ashtottari", "yoga": "Yoga Pravesha", "nakshatra": "Nakshatra Pravesha"} +_KIND = { + "en": { + "arc": "arc balance", "time": "elapsed balance", "successor": "successor arc", + "tithi-year": "360 tithi year", "forward": "forward", "reverse": "reverse", + }, + "zh": { + "arc": "角距", "time": "时间余额", "successor": "后继角距", + "tithi-year": "360 太阴月年", "forward": "顺行", "reverse": "逆行", + }, +} +_KARANA = { + "julene": ("Julene lords", "Julene 主星"), + "pvr": ("PVR quadrant lords", "PVR 象限主星"), + "pvr-tithi-year": ("PVR 360 tithi year", "PVR 360 太阴月年"), +} +_RASHI = ( + ("sthira", "Sthira", "Sthira"), + ("drig", "Drig", "Drig"), + ("shoola", "Shoola", "Shoola"), + ("navamsha", "Navamsha", "Navamsha"), +) +_DIVISION = {1: "D1", 2: "D2", 3: "D3", 7: "D7", 9: "D9", 12: "D12", 30: "D30", 81: "D81", 108: "D108", 144: "D144", 150: "D150"} + + +def render_research_chapters(packet: dict, language: str) -> str: + """Three research chapters. One failed family does not remove the others.""" + if language not in ("zh", "en"): + raise ValueError("language must be zh or en") + try: + chart = context_from_packet(packet if isinstance(packet, dict) else {}) + bundle = research_bundle(chart) + except (ValueError, RuntimeError, KeyError, TypeError) as exc: + logger.warning("research timing chapter unavailable: %s: %s", type(exc).__name__, exc) + text = _chapter( + "Additional timing calculations" if language == "en" else "补充推运计算数据", + language, + _reason(language, "This chapter did not finish. The runtime log has the cause.", "这一章没有算成,原因记在运行日志里。"), + ) + return text + timing = _timing_chapter(bundle, language) + auxiliary = _auxiliary_chapter(packet, chart, language) + vargas = _varga_chapter(bundle, language) + return "\n".join(part for part in (timing, auxiliary, vargas) if part) + + +def context_from_packet(packet: dict) -> dict: + birth = packet.get("birth_info") if isinstance(packet.get("birth_info"), dict) else {} + profile = packet.get("calculation_profile") if isinstance(packet.get("calculation_profile"), dict) else {} + settings = profile.get("effective_settings") if isinstance(profile.get("effective_settings"), dict) else {} + year, month, day = _birth_date(birth) + hour = int(birth.get("hour") if birth.get("hour") is not None else 0) + minute = int(birth.get("minute") if birth.get("minute") is not None else 0) + second = int(birth.get("second") if birth.get("second") is not None else 0) + if birth.get("lat") is None and birth.get("latitude") is None: + raise ValueError("birth place is missing") + if birth.get("lon") is None and birth.get("longitude") is None: + raise ValueError("birth place is missing") + lat = float(birth.get("lat") if birth.get("lat") is not None else birth.get("latitude")) + lon = float(birth.get("lon") if birth.get("lon") is not None else birth.get("longitude")) + tz = _tz(settings.get("timezone_offset") if settings.get("timezone_offset") not in (None, "") else birth.get("tz", birth.get("timezone"))) + ayanamsa = str(birth.get("ayanamsa_name") or birth.get("ayanamsa") or settings.get("ayanamsa") or "lahiri") + node_mode = str(birth.get("node_mode") or settings.get("node_mode") or "mean") + year_days = float(settings.get("dasha_year_days") or 365.25) + target_year, query = _query(packet, year, tz) + return chart_from_parts( + year=year, month=month, day=day, hour=hour, minute=minute, second=second, + lat=lat, lon=lon, tz=tz, ayanamsa=ayanamsa, node_mode=node_mode, + target_year=target_year, query=query, year_days=year_days, + ) + + +def _timing_chapter(bundle: dict, language: str) -> str: + parts = [_chapter("Additional timing calculations" if language == "en" else "补充推运计算数据", language, "")] + note = bundle["reference_note_en"] if language == "en" else bundle["reference_note_zh"] + counts = _return_counts(bundle) + count_line = ( + f"Tithi returns across the target year and the next two years: {counts['tithi']}. " + f"Yoga returns in the target year: {counts['yoga']}. " + f"Nakshatra returns in the target year: {counts['nakshatra']}." + if language == "en" + else ( + f"目标年及后两年的 Tithi 回归次数:{counts['tithi']}。" + f"目标年的 Yoga 回归次数:{counts['yoga']}。" + f"目标年的 Nakshatra 回归次数:{counts['nakshatra']}。" + ) + ) + parts.append(note + "\n\n" + count_line) + families = bundle.get("families") or {} + for key, family in families.items(): + if key in ("annual-returns", "research-vargas"): + continue + if key == "karana": + groups = family.get("returns") if isinstance(family, dict) else None + if isinstance(groups, list): + for group in groups: + number = group.get("index") or "" + suffix = f"return {number}" if language == "en" else f"第 {number} 次" + parts.extend(_variant_sections(group, language, "Karana", suffix)) + else: + parts.extend(_variant_sections(family, language, "Karana", "")) + continue + if key == "varnada" and isinstance(family.get("variants"), list): + parts.extend(_variant_sections(family, language, "Varnada")) + continue + title = _heading(key, language) + if not title: + continue + parts.append(_family_section(title, family, language, bundle.get("query"))) + return "\n\n".join(parts) + + +def _auxiliary_chapter(packet: dict, chart: dict, language: str) -> str: + parts = [_chapter("Auxiliary dasha subperiods" if language == "en" else "辅助大运次小运数据", language, "")] + birth = datetime(chart["year"], chart["month"], chart["day"]).date() + longitudes = chart["longitudes"] + original = calculate_rashi_dashas(birth, chart["ascendant"], longitudes) + copied = attach_rashi_pratyantar(original) + rule = ( + "The third level starts at the sub-period sign and runs forward, splitting that sub-period into twelve equal parts. Major and sub-period rows stay as they were." + if language == "en" + else "次小运从子运星座起顺行,把该子运的时间等分成十二段。主运和子运与原来的表相同。" + ) + for key, en, zh in _RASHI: + title = en if language == "en" else zh + section = copied.get(key) if isinstance(copied.get(key), dict) else {} + periods = _third_only(section.get("periods") or []) + body = rule + "\n\n" + (_table(periods, language) if periods else _reason( + language, + "This system has no sub-period rows, so the third level is not listed.", + "这个体系没有子运,所以不列次小运。", + )) + parts.append(_family_block(title, language, body)) + niryaana = ( + "Niryaana Shoola child periods and the third level are omitted. The upstream program states that the child-period rule is not independently specified. Rows from an unpublished frozen engine are not a written source." + if language == "en" + else "Niryaana Shoola 的子运和次小运这次不列。上游主程序写明子运规则没有单独的出处,冻结引擎里的几行不能当作书页依据。" + ) + parts.append(_family_block("Niryaana Shoola", language, niryaana)) + parts.append(_family_block("Kalachakra", language, _kalachakra(packet, language))) + return "\n\n".join(parts) + + +def _varga_chapter(bundle: dict, language: str) -> str: + family = (bundle.get("families") or {}).get("research-vargas") or {} + rows = family.get("rows") or [] + intro = ( + "D81, D108 and D144 are printed only in this chapter." + if language == "en" + else "D81、D108、D144 只出现在这一章。" + ) + body = intro if family.get("status") == "calculated" else _text(family, language) + if rows: + body += "\n\n" + _varga_table(rows, language) + return _chapter("Research divisional charts" if language == "en" else "研究型分盘", language, body) + + +def _variant_sections(family: dict, language: str, fallback: str, suffix: str = "") -> list[str]: + variants = family.get("variants") + if not isinstance(variants, list) or not variants: + return [_family_block(fallback, language, _text(family, language))] + sections = [] + for index, variant in enumerate(variants, start=1): + profile = variant.get("profile") + if profile in _KARANA: + title = _KARANA[profile][0 if language == "en" else 1] + else: + seed = variant.get("seed_sign") + title = f"{fallback} {_name(seed, language)}".strip() if seed else f"{fallback} {index}" + if suffix: + title = f"{title} {suffix}" + sections.append(_family_section(title, variant, language, None)) + return sections + + +def _family_section(title: str, family: dict, language: str, query: str | None) -> str: + body = [_text(family, language)] + periods = family.get("periods") or [] + if periods and family.get("status") != "omitted": + if query: + current = _current(periods, query) + if current: + label = "Reference row" if language == "en" else "参考时刻所在" + body.append(label + "\n\n" + _table(current, language)) + body.append(_table(periods, language)) + rows = family.get("rows") + if rows: + body.append(_varga_table(rows, language)) + return _family_block(title, language, "\n\n".join(part for part in body if part)) + + +def _third_only(periods: list[dict]) -> list[dict]: + rows = [] + for major in periods: + if not isinstance(major, dict): + continue + for child in major.get("antardasha") or []: + third = child.get("pratyantar") if isinstance(child, dict) else None + if isinstance(third, list) and third: + rows.extend(third) + return rows + + +def _kalachakra(packet: dict, language: str) -> str: + note = ( + "The third level uses the simplified model, split in proportion from the majors already on the chart. Major and sub-period rows stay as they were." + if language == "en" + else "次小运按简化模型,从现有主运按比例分出。主运和子运与原来的表相同。" + ) + module = _first(packet, ("timing_and_predictive_systems", "kalachakra_dasha")) + rows = [item for item in _major_list(module) if isinstance(item, dict) and "antardasha" not in item and "subperiods" not in item] + if not rows: + rows = [item for item in _major_list(module) if isinstance(item, dict)] + if not rows: + missing = "The chart has no Kalachakra majors, so the third level is not listed." if language == "en" else "这张盘没有 Kalachakra 主运,所以不列次小运。" + return note + "\n\n" + missing + try: + _enriched, _children, grands = attach_kalachakra_simplified_children(rows) + except (KeyError, TypeError, ValueError) as exc: + logger.warning("research kalachakra third level failed: %s: %s", type(exc).__name__, exc) + failed = "The third level did not finish. The runtime log has the cause." if language == "en" else "次小运没有算成,原因记在运行日志里。" + return note + "\n\n" + failed + shaped = [{ + "level": "PD", + "sign": item.get("rashi") or item.get("sign"), + "lord": item.get("lord"), + "start": item.get("start_date") or item.get("start"), + "end": item.get("end_date") or item.get("end"), + } for item in grands if isinstance(item, dict)] + if not shaped: + empty = "No third-level rows were produced." if language == "en" else "没有产生次小运行。" + return note + "\n\n" + empty + return note + "\n\n" + _table(shaped, language) + + +def _chapter(title: str, language: str, body: str) -> str: + text = f"## {title}\n\n{BANNER[language]}" + if body: + text += "\n\n" + body.strip() + return text + + +def _family_block(title: str, language: str, body: str) -> str: + 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: + if key in _FIXED: + en, zh = _FIXED[key] + return en if language == "en" else zh + matched = re.fullmatch(r"tithi-(\d{4})-(\d+)-pair-(\d+)-(arc|time)-(forward|reverse)", key) + if matched: + year, number, pair, kind, direction = matched.groups() + if language == "zh": + return ( + f"年度 Tithi Ashtottari {_KIND['zh'][kind]} {_KIND['zh'][direction]} " + f"{year} 年第 {number} 次,配对 {pair}" + ) + return ( + f"Annual Tithi Ashtottari {_KIND['en'][kind]} {_KIND['en'][direction]} " + f"{year} return {number} pair {pair}" + ) + matched = re.fullmatch(r"tithi-(\d{4})-(\d+)", key) + if matched: + year, number = matched.groups() + if language == "zh": + return f"年度 Tithi Ashtottari {year} 年第 {number} 次" + return f"Annual Tithi Ashtottari {year} return {number}" + matched = re.fullmatch(r"(yoga|nakshatra)-(\d+)-successor-(\d+)", key) + if matched: + tech, number, pair = matched.groups() + if language == "zh": + return f"年度 {_TECH[tech]} 后继角距 第 {number} 次,配对 {pair}" + return f"Annual {_TECH[tech]} successor arc return {number} pair {pair}" + matched = re.fullmatch(r"(yoga|nakshatra)-(\d+)-tithi-year", key) + if matched: + tech, number = matched.groups() + if language == "zh": + return f"年度 {_TECH[tech]} 360 太阴月年 第 {number} 次" + return f"Annual {_TECH[tech]} 360 tithi year return {number}" + matched = re.fullmatch(r"(yoga|nakshatra)-(\d+)-successor", key) + if matched: + tech, number = matched.groups() + if language == "zh": + return f"年度 {_TECH[tech]} 后继角距 第 {number} 次" + return f"Annual {_TECH[tech]} successor arc return {number}" + return "" + + +def _text(family: dict, language: str) -> str: + if not isinstance(family, dict): + return "" + custom = family.get("reason_en") if language == "en" else family.get("reason_zh") + if custom: + return str(custom) + if family.get("pd_status") in ("omitted", "blocked"): + return _reason( + language, + "No third level is listed for this method.", + "这个方法这里不列次小运。", + ) + if family.get("status") == "omitted": + return _reason(language, "This branch is not listed.", "这一支不列表。") + return "" + + +def _reason(language: str, en: str, zh: str) -> str: + return en if language == "en" else zh + + +def _table(periods: list[dict], language: str) -> str: + headers = ("Level", "Planet or name", "Start", "End") if language == "en" else ("层级", "主星/名称", "起始", "结束") + lines = ["| " + " | ".join(headers) + " |", "| --- | --- | --- | --- |"] + + def walk(rows: list, fallback: str) -> None: + for row in rows: + if not isinstance(row, dict): + continue + level = _LEVELS.get(str(row.get("level") or fallback), fallback) + label = "MD" if language == "en" else "主运" + if level == "AD": + label = "AD" if language == "en" else "子运" + elif level == "PD": + label = "PD" if language == "en" else "次小运" + name = row.get("lord") or row.get("planet") or row.get("sign") or "" + lines.append("| " + " | ".join(( + label, + _name(name, language), + _plain(row.get("start")), + _plain(row.get("end")), + )) + " |") + for key in _CHILD_KEYS: + children = row.get(key) + if isinstance(children, list) and children: + walk(children, "AD" if level == "MD" else "PD") + + walk(periods, "MD") + if len(lines) <= 2: + return "" + return "\n".join(lines) + + +def _current(periods: list[dict], query: str) -> list[dict]: + moment = datetime.fromisoformat(query) + found: list[dict] = [] + + def walk(rows: list) -> bool: + for row in rows: + if not isinstance(row, dict) or "start" not in row or "end" not in row: + continue + try: + start = _align(datetime.fromisoformat(str(row["start"])), moment) + end = _align(datetime.fromisoformat(str(row["end"])), moment) + except (TypeError, ValueError): + continue + if start <= moment < end: + found.append({key: row.get(key) for key in ("level", "lord", "planet", "sign", "start", "end")}) + for key in _CHILD_KEYS: + children = row.get(key) + if isinstance(children, list) and walk(children): + break + return True + return False + + walk(periods) + return found + + +def _align(value: datetime, sample: datetime) -> datetime: + if value.utcoffset() is None and sample.utcoffset() is not None: + return value.replace(tzinfo=sample.tzinfo) + if value.utcoffset() is not None and sample.utcoffset() is None: + return value.replace(tzinfo=None) + return value + + +def _varga_table(rows: list[dict], language: str) -> str: + headers = ("Division", "Body", "Sign", "Degree", "Sign lord") if language == "en" else ("分盘", "星体", "星座", "度数", "星座主") + lines = ["| " + " | ".join(headers) + " |", "| --- | --- | --- | --- | --- |"] + for row in rows: + division = _DIVISION.get(int(row.get("division") or 0), str(row.get("division") or "")) + degree = row.get("degree") + degree_text = f"{float(degree):.4f}" if isinstance(degree, (int, float)) else "" + lines.append("| " + " | ".join(( + division, + _name(row.get("body"), language), + _name(row.get("sign"), language), + degree_text, + _name(row.get("lord"), language), + )) + " |") + return "\n".join(lines) + + +def _name(value, language: str) -> str: + text = "" if value is None else str(value) + if language == "zh": + text = PLANET_LABEL_CN.get(text, SIGNS_CN.get(text, text)) + if text == "Lagna": + text = "上升" + return _plain(text) + + +def _plain(value) -> str: + return str(value or "").replace("|", "/").replace("\n", " ") + + +def _return_counts(bundle: dict) -> dict[str, int]: + found = ((bundle.get("families") or {}).get("annual-returns") or {}).get("returns") or {} + return {key: len(found.get(key) or []) for key in ("tithi", "yoga", "nakshatra")} + + +def _birth_date(birth: dict) -> tuple[int, int, int]: + if all(birth.get(key) is not None for key in ("year", "month", "day")): + return int(birth["year"]), int(birth["month"]), int(birth["day"]) + text = str(birth.get("date") or "") + year, month, day = text.split("-")[:3] + return int(year), int(month), int(day) + + +def _tz(value) -> float: + if isinstance(value, (int, float)) and not isinstance(value, bool): + return float(value) + text = str(value or "").strip().upper().replace("UTC", "") + matched = re.fullmatch(r"([+-])(\d{2}):(\d{2})", text) + if matched: + sign = 1 if matched.group(1) == "+" else -1 + return sign * (int(matched.group(2)) + int(matched.group(3)) / 60.0) + if not text: + raise ValueError("timezone is missing") + return float(text) + + +def _query(packet: dict, birth_year: int, tz: float) -> tuple[int, datetime]: + pack = _first(packet, ("timing_and_predictive_systems", "annual_tajika_pack")) + profile = pack.get("profile") if isinstance(pack, dict) else {} + target = profile.get("target_year") if isinstance(profile, dict) else None + if target in (None, ""): + target = packet.get("birth_info", {}).get("target_year") if isinstance(packet.get("birth_info"), dict) else None + ephemeris = _first(packet, ("timing_and_predictive_systems", "report_month_ephemeris")) + rows = ephemeris.get("report_rows") if isinstance(ephemeris, dict) else None + month_text = rows[0].get("month") if isinstance(rows, list) and rows and isinstance(rows[0], dict) else None + zone = timezone(timedelta(hours=tz)) + if isinstance(month_text, str) and len(month_text) >= 7 and month_text[4] == "-": + year = int(month_text[0:4]) + month = int(month_text[5:7]) + if target in (None, ""): + target = year + return int(target), datetime(year, month, 1, 12, 0, tzinfo=zone) + year = int(target if target not in (None, "") else birth_year) + return year, datetime(year, 1, 1, 12, 0, tzinfo=zone) diff --git a/scripts/research_timing/search.py b/scripts/research_timing/search.py new file mode 100644 index 00000000..0b92d94a --- /dev/null +++ b/scripts/research_timing/search.py @@ -0,0 +1,269 @@ +"""Return roots and elongation clocks on SiderealFrame. + +The step and bisection follow the research return search. The longitude +call is ours. Windows stay at most 400 days. +""" + +from __future__ import annotations + +import bisect +from datetime import datetime, timedelta, timezone + +import swisseph as swe + +try: + from research_timing.frame import SiderealFrame +except ImportError: # pragma: no cover - package import + from scripts.research_timing.frame import SiderealFrame + +try: + from muhurta import _find_next_boundary +except ImportError: # pragma: no cover - package import + from scripts.muhurta import _find_next_boundary + +_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( + frame: SiderealFrame, + technique: str, + birth_jd: float, + start_jd: float, + end_jd: float, +) -> dict: + if technique not in ("yoga_pravesha", "nakshatra_pravesha", "tithi_pravesha"): + raise ValueError(f"unsupported return technique: {technique}") + if not start_jd < end_jd or end_jd - start_jd > 400: + raise ValueError("search window must be positive and no longer than 400 days") + sun, moon = frame.sun_moon(birth_jd) + solar_sign = int(sun // 30) + target = _coordinate(technique, sun, moon) + + def residual(jd: float) -> float: + left, right = frame.sun_moon(jd) + value = _coordinate(technique, left, right) + return (value - target + 180.0) % 360.0 - 180.0 + + rows: list[dict] = [] + left = float(start_jd) + left_value = residual(left) + while left < end_jd: + right = min(left + _STEP, float(end_jd)) + right_value = residual(right) + if left_value * right_value <= 0 and abs(right_value - left_value) < 180: + root = left if abs(left_value) < 1e-8 else _bisect(left, right, left_value, residual) + body_sun, body_moon = frame.sun_moon(root) + error = residual(root) + if ( + start_jd <= root < end_jd + and abs(error) < _TOLERANCE + and int(body_sun // 30) == solar_sign + and all(abs(root - row["jd_ut"]) > _TOLERANCE for row in rows) + ): + rows.append({ + "jd_ut": root, + "sun_longitude": body_sun, + "moon_longitude": body_moon, + "residual_degrees": error, + }) + left, left_value = right, right_value + return { + "technique": technique, + "target_degrees": target, + "natal_solar_sign_index": solar_sign, + "natal_sun": sun, + "natal_moon": moon, + "returns": rows, + "frame": "swiss_ephemeris_true_precession", + } + + +def _coordinate(technique: str, sun: float, moon: float) -> float: + if technique == "tithi_pravesha": + return (moon - sun) % 360.0 + if technique == "yoga_pravesha": + return (sun + moon) % 360.0 + return moon % 360.0 + + +def _bisect(left: float, right: float, left_value: float, residual) -> float: + low, high, low_value = left, right, left_value + for _ in range(_BISECTIONS): + mid = (low + high) / 2.0 + value = residual(mid) + if low_value * value <= 0: + high = mid + else: + low, low_value = mid, value + return (low + high) / 2.0 + + +def jd_to_utc(jd: float) -> datetime: + year, month, day, hour = swe.revjul(float(jd), swe.GREG_CAL) + return datetime(int(year), int(month), int(day), tzinfo=timezone.utc) + timedelta(hours=float(hour)) + + +def containing_tithi(frame: SiderealFrame, jd: float) -> dict: + """Time fraction left in the 12-degree tithi that contains this instant.""" + + def phase(at: float) -> float: + sun, moon = frame.sun_moon(at) + return (moon - sun) % 360.0 + + angle = phase(jd) + cursor = jd - 1.6 + previous = None + following = None + for _ in range(4): + boundary = _find_next_boundary(cursor, phase, 12.0, 1.6) + if boundary is None or boundary <= cursor: + raise RuntimeError("containing tithi boundaries unavailable") + if boundary > jd: + following = boundary + break + previous, cursor = boundary, boundary + if previous is None or following is None or not previous <= jd < following: + raise RuntimeError("containing tithi boundaries unavailable") + return { + "tithi_num": int(angle // 12) + 1, + "elongation": angle, + "remaining_time_fraction": (following - jd) / (following - previous), + "remaining_angular_fraction": 1.0 - (angle % 12.0) / 12.0, + "start_jd_ut": previous, + "end_jd_ut": following, + } + + +def measure_elongation(frame: SiderealFrame, start_jd: float, end_jd: float) -> float: + """Unwrapped sidereal Moon-minus-Sun over at most 400 days.""" + if not 0 < end_jd - start_jd <= 400: + raise ValueError("motion interval must span more than 0 and at most 400 days") + cursor = float(start_jd) + sun, moon = frame.sun_moon(cursor) + previous = (moon - sun) % 360.0 + total = 0.0 + while cursor < end_jd: + cursor = min(cursor + 1.0, float(end_jd)) + sun, moon = frame.sun_moon(cursor) + current = (moon - sun) % 360.0 + total += (current - previous + 180.0) % 360.0 - 180.0 + previous = current + return total + + +class ElongationClock: + """Map a 360-unit year onto accumulated sidereal elongation.""" + + def __init__(self, frame: SiderealFrame, return_jd: float, total_angle: float, known_end_jd: float | None = None) -> None: + if not 0 < total_angle <= 6000: + raise ValueError("positive total angle within the annual grid is required") + self.frame = frame + self.return_jd = float(return_jd) + self.total_angle = float(total_angle) + self._days = [self.return_jd + index for index in range(-100, 501)] + self._phases = [self._phase(day) for day in self._days] + self._angles = [0.0] + for left, right in zip(self._phases, self._phases[1:]): + step = (right - left + 180.0) % 360.0 - 180.0 + if not 0 < step < 180: + raise ValueError("elongation clock requires forward daily motion") + self._angles.append(self._angles[-1] + step) + self._origin = self._angles[100] + self._cache = {0.0: self.return_jd} + if known_end_jd is not None: + if not self.return_jd < known_end_jd <= self.return_jd + 500: + raise ValueError("known endpoint must lie in the forward annual grid") + self._cache[360.0] = float(known_end_jd) + + def _phase(self, jd: float) -> float: + sun, moon = self.frame.sun_moon(jd) + return (moon - sun) % 360.0 + + def solve(self, offset: float) -> float: + if abs(offset) < 1e-8: + offset = 0.0 + if abs(offset - 360.0) < 1e-8: + offset = 360.0 + cached = self._cache.get(offset) + if cached is not None: + return cached + target = self._origin + offset / 360.0 * self.total_angle + index = bisect.bisect_right(self._angles, target) - 1 + if not 0 <= index < len(self._days) - 1: + raise ValueError("angular boundary outside the bounded search grid") + found = self._solve_day(index, target) + self._cache[offset] = found + return found + + def _solve_day(self, index: int, target: float) -> float: + """Day bracket, then a few slope steps. Residual stays within 1e-6 degree.""" + left, right = self._days[index], self._days[index + 1] + left_angle = self._angles[index] + span = self._angles[index + 1] - left_angle + if span <= 0 or right <= left: + raise ValueError("elongation clock day is not forward") + origin_phase = self._phases[index] + + def motion(jd: float) -> float: + return left_angle + (self._phase(jd) - origin_phase + 180.0) % 360.0 - 180.0 + + slope = span / (right - left) + guess = left + (target - left_angle) / span * (right - left) + for _ in range(6): + value = motion(guess) + error = value - target + if abs(error) <= 1e-6: + return guess + guess = min(right, max(left, guess - error / slope)) + low, high = left, right + for _ in range(40): + mid = (low + high) / 2.0 + error = motion(mid) - target + if abs(error) <= 1e-6 or high - low < 1e-9: + return mid + if error < 0: + low = mid + else: + high = mid + return (low + high) / 2.0 + + +def hindu_solar_events(moment: datetime, latitude: float, longitude: float) -> list[dict]: + """Sunrise and sunset from the Swiss ephemeris file. No Moshier and no true-position flag.""" + if moment.utcoffset() is None: + raise ValueError("solar events need a timezone") + midnight = moment.astimezone(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) + flags = swe.FLG_SWIEPH + events = [] + for offset in range(-3, 4): + jd = (midnight + timedelta(days=offset)).timestamp() / 86400.0 + 2440587.5 + for name, mode in (("sunrise", swe.CALC_RISE), ("sunset", swe.CALC_SET)): + status, times = swe.rise_trans( + jd, + swe.SUN, + mode | swe.BIT_HINDU_RISING, + (float(longitude), float(latitude), 0.0), + flags=flags, + ) + if status == 0 and jd <= times[0] < jd + 1: + 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_full_data_parity_chapters.py b/tests/test_full_data_parity_chapters.py index 4c12a581..e9282f3b 100644 --- a/tests/test_full_data_parity_chapters.py +++ b/tests/test_full_data_parity_chapters.py @@ -37,6 +37,24 @@ def _chapter_text(markdown: str, language: str) -> str: return markdown.split(marker, 1)[1] +def _parity_chapter_bodies(tail: str, language: str) -> dict[str, str]: + """Body of each of the six parity chapters, stopping before later research headings.""" + titles = [chapter_zh if language == "zh" else chapter_en for chapter_en, chapter_zh in CHAPTERS] + bodies: dict[str, str] = {} + remaining = tail + for index, title in enumerate(titles): + next_title = titles[index + 1] if index + 1 < len(titles) else None + if next_title is not None: + marker = f"\n## {next_title}\n" + head, _sep, rest = remaining.partition(marker) + bodies[title] = head + remaining = rest + continue + stop = remaining.find("\n## ") + bodies[title] = remaining if stop < 0 else remaining[:stop] + return bodies + + def _section_body(markdown: str, heading: str) -> str: marker = f"### {heading}\n" start = markdown.index(marker) + len(marker) @@ -183,9 +201,22 @@ def test_three_fictional_charts_render_the_same_chapters(fictional_reports: dict for chapter_en, chapter_zh in CHAPTERS: assert _heading_lines(chinese, 2).count(chapter_zh) == 1 assert _heading_lines(english, 2).count(chapter_en) == 1 - for title in FORBIDDEN_TITLES: - assert title not in zh - assert title not in en + # 原值:大运附表之后的全文不得出现 D81、D108、D144、独立年度方法参考 + # 新值:六章附表各自的正文仍不得出现这些标题;全文里这三个分盘只出现在研究口径章节 + # 原因:#92 把研究型分盘从普通分盘表挪到附表之后的研究口径章节,只允许出现在那一章 + zh_before, _, zh_research = chinese.partition("## 研究型分盘") + en_before, _, en_research = english.partition("## Research divisional charts") + for token in ("D81", "D108", "D144"): + assert token not in zh_before and token not in en_before + assert token in zh_research and token in en_research + bodies_zh = _parity_chapter_bodies(zh, "zh") + bodies_en = _parity_chapter_bodies(en, "en") + for chapter_en, chapter_zh in CHAPTERS: + for title in FORBIDDEN_TITLES: + assert title not in bodies_zh[chapter_zh] + assert title not in bodies_en[chapter_en] + assert "研究口径:依赖所选方法,未经外部软件核对。" in chinese + assert "Research basis: method-dependent; not checked against external software." in english assert _SNAKE.search(zh) is None assert _SNAKE.search(en) is None assert "印刷页 10" in zh and "printed pages 10-17" in en @@ -334,17 +365,20 @@ def test_six_chapters_are_reader_chapters_and_sections_sit_below_them(monkeypatc packet = _load_packet(0) full_zh = render_full_data_markdown(copy.deepcopy(packet)) full_en = _english_edition(copy.deepcopy(packet), True).get("markdown_en") or "" + # 原值:拿掉六章和独立年度方法参考后,二级标题只比完整报告少这六章再加最后一章 + # 新值:基准同时拿掉六章、研究口径章节和独立年度方法参考;六章之后先是研究章,最后一章仍是年度方法参考 + # 原因:合并 #92 后,研究章插在六章和年度方法参考之间,两边都要算进后加的二级标题 monkeypatch.setattr(export, "_append_parity_chapters", lambda markdown, _packet, _language: markdown) - # 原值:拿掉六章后,二级标题与完整报告只差这六章 - # 新值:基准同时拿掉六章和独立年度方法参考;完整报告在六章之后还有这一章 - # 原因:#93 的年度方法参考也是后加的二级标题 + monkeypatch.setattr(export, "_append_research_chapters", lambda markdown, _packet, _language: markdown) monkeypatch.setattr(export, "_append_annual_method_reference", lambda markdown, _packet, _language: markdown) base_zh = render_full_data_markdown(copy.deepcopy(packet)) base_en = _english_edition(copy.deepcopy(packet), True).get("markdown_en") or "" assert base_en - for full, base, chapters, extra in ( - (full_zh, base_zh, zh_chapters, "独立年度方法参考"), - (full_en, base_en, en_chapters, "Independent Annual Method Reference"), + research_zh = "补充推运计算数据" + research_en = "Additional timing calculations" + for full, base, chapters, research_title, annual_title in ( + (full_zh, base_zh, zh_chapters, research_zh, "独立年度方法参考"), + (full_en, base_en, en_chapters, research_en, "Independent Annual Method Reference"), ): h1 = _heading_lines(full, 1) assert len(h1) == 1, h1 @@ -352,12 +386,13 @@ def test_six_chapters_are_reader_chapters_and_sections_sit_below_them(monkeypatc assert _heading_lines(base, 1) == h1 full_h2 = _heading_lines(full, 2) base_h2 = _heading_lines(base, 2) - assert len(full_h2) == len(base_h2) + len(chapters) + 1 assert full_h2[: len(base_h2)] == base_h2 assert full_h2[len(base_h2): len(base_h2) + len(chapters)] == chapters - assert full_h2[-1] == extra + after = full_h2[len(base_h2) + len(chapters):] + assert after[0] == research_title + assert after[-1] == annual_title tail = full.split("\n## " + chapters[0] + "\n", 1)[1] - tail = tail.split("\n## " + extra + "\n", 1)[0] + tail = tail.split("\n## " + annual_title + "\n", 1)[0] assert _heading_lines(tail, 3)[: len(_chapter_sections()[0])] == [ (dict(SECTIONS)[title] if chapters is zh_chapters else title) for title in _chapter_sections()[0] ] diff --git a/tests/test_pyjhora_annual_method_reference.py b/tests/test_pyjhora_annual_method_reference.py index 303eb13d..f7ac584b 100644 --- a/tests/test_pyjhora_annual_method_reference.py +++ b/tests/test_pyjhora_annual_method_reference.py @@ -36,7 +36,7 @@ def setup_function() -> None: def test_result_hash_includes_the_annual_method_chapter() -> None: # 原值: 两个标记的 result_hash 相同(本章被摘出指纹)。 # 新值: 两个标记的 result_hash 不同,且本章仍留在资料包里。 - # 原因: 交付包里有这一章时,重算指纹必须对得上(BUG-1429)。 + # 原因: 交付包里有这一章时,重算指纹必须对得上(BUG-1430;合并 staging 后 1429 已是研究章日出记录)。 from scripts.calculation_profile_contract import bind_result_to_profile profile = {"input_hash": "ab" * 32} diff --git a/tests/test_reader_appendix_language.py b/tests/test_reader_appendix_language.py index 773631f1..b5e19a77 100644 --- a/tests/test_reader_appendix_language.py +++ b/tests/test_reader_appendix_language.py @@ -57,7 +57,7 @@ def test_public_boundary_only_leaves_internal_renderer_and_json_unchanged(): packet = {"status": "parameter_sensitive"} engine = SimpleNamespace( - build_professional_report_reference_packet=lambda *args: packet, + build_professional_report_reference_packet=lambda *args, **_kwargs: packet, render_pl9_markdown=lambda value: "| parameter_sensitive | cmd_full_reading |", ) handler = SimpleNamespace( diff --git a/tests/test_report_density_facts.py b/tests/test_report_density_facts.py index 48e2729b..a357f176 100644 --- a/tests/test_report_density_facts.py +++ b/tests/test_report_density_facts.py @@ -64,7 +64,7 @@ def test_full_golden_shared_markdown_is_byte_identical_and_reader_copy_is_clean( packet = json.loads((ROOT / "frontend/tests/fixtures/report-density-fictional-reader.json").read_text(encoding="utf8")) raw = packet["markdown"] - engine = SimpleNamespace(build_professional_report_reference_packet=lambda *args: packet, render_pl9_markdown=lambda value: raw) + engine = SimpleNamespace(build_professional_report_reference_packet=lambda *args, **_kwargs: packet, render_pl9_markdown=lambda value: raw) handler = SimpleNamespace(_high_rigor_birth_payload=lambda body: {"year": 2000, "hour": 12, "minute": 0, "today": "2026-09-22"}, _compute_full_reading_for_thematic=lambda birth: {}) for include in (False, True): result = build_professional_report_reference(handler, {"format": "markdown", "include_fact_tables": include}, engine=engine) diff --git a/tests/test_report_reader_main.py b/tests/test_report_reader_main.py index bdf6a474..3a7da96c 100644 --- a/tests/test_report_reader_main.py +++ b/tests/test_report_reader_main.py @@ -89,7 +89,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") @@ -170,6 +177,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 new file mode 100644 index 00000000..339e2727 --- /dev/null +++ b/tests/test_research_timing.py @@ -0,0 +1,295 @@ +"""Research timing appendix for issue #92. Fictional charts only.""" + +from __future__ import annotations + +import ast +import bisect +import hashlib +import json +import re +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from scripts.pl9_full_data_export import read_full_data_domain +from scripts.pl9_reader_english import HAN +from scripts.report_render.appendix_language import chinese_prose_gates +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, _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 + +ROOT = Path(__file__).resolve().parents[1] +_SNAKE = __import__("re").compile(r"[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+") +_GOLDEN = "tests/golden/rectification_engine_memoization_v5.json" + + +def _chart(index: int = 0): + 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 test_birth_sun_and_moon_match_the_chart_function() -> None: + _case, chart = _chart(0) + 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) + assert abs(sun - chart["chart_sun"]) <= 1e-6 + assert abs(moon - chart["chart_moon"]) <= 1e-6 + + +def test_one_tithi_return_matches_the_target_angle() -> None: + _case, chart = _chart(0) + 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) + assert found["returns"] + row = found["returns"][0] + sun, moon = frame.sun_moon(row["jd_ut"]) + target = found["target_degrees"] + error = ((moon - sun) - target + 180.0) % 360.0 - 180.0 + assert abs(error) <= 1e-6 + assert abs(row["residual_degrees"]) <= 1e-6 + + +def test_rashi_third_level_does_not_change_the_producer() -> None: + _case, chart = _chart(1) + birth = datetime(chart["year"], chart["month"], chart["day"]).date() + original = calculate_rashi_dashas(birth, chart["ascendant"], chart["longitudes"]) + before = json.dumps(original, ensure_ascii=False, sort_keys=True, default=str) + copied = attach_rashi_pratyantar(original) + assert json.dumps(original, ensure_ascii=False, sort_keys=True, default=str) == before + assert "pratyantar" not in before + produced = 0 + for name in ("sthira", "drig", "shoola", "navamsha"): + for major in copied[name]["periods"]: + for child in major.get("antardasha") or []: + third = child.get("pratyantar") or [] + if not third: + continue + produced += len(third) + assert len(third) == 12 + assert third[0]["sign"] == child["sign"] + assert third[-1]["end"] == child["end"] + assert "pratyantar" not in major + assert produced > 0 + + +def test_clock_solve_stays_within_a_microday_of_bisection() -> None: + _case, chart = _chart(0) + frame = SiderealFrame(chart["ayanamsa"], chart["node_mode"]) + jd = birth_jd(chart["year"], chart["month"], chart["day"], chart["hour_decimal"], chart["tz"]) + clock = ElongationClock(frame, jd, 360.0, None) + + def slow(offset: float) -> float: + target = clock._origin + offset / 360.0 * clock.total_angle + index = bisect.bisect_right(clock._angles, target) - 1 + low, high = clock._days[index], clock._days[index + 1] + left_angle = clock._angles[index] + origin_phase = clock._phases[index] + for _ in range(45): + mid = (low + high) / 2.0 + motion = left_angle + (clock._phase(mid) - origin_phase + 180.0) % 360.0 - 180.0 + if motion < target: + low = mid + else: + high = mid + return (low + high) / 2.0 + + for offset in (0.0, 30.0, 180.0, 360.0): + assert abs(clock.solve(offset) - slow(offset)) <= 1e-6 + + +def test_rendered_chapters_keep_the_research_banner_and_drop_snake_case() -> None: + case, chart = _chart(0) + packet = {"birth_info": case, "worksheets": {}, "calculation_profile": {}} + chinese = render_research_chapters(packet, "zh") + english = render_research_chapters(packet, "en") + assert "研究口径:依赖所选方法,未经外部软件核对。" in chinese + assert "Research basis: method-dependent; not checked against external software." in english + assert "Niryaana Shoola" in chinese and "不列" in chinese + assert "D81" in chinese and "D108" in chinese and "D144" in chinese + assert "独立年度方法参考" not in chinese and "独立年度方法参考" not in english + assert chinese_prose_gates(chinese) == {"english_signs": 0, "han_function_glue": 0} + assert HAN.findall(english) == [] + assert _SNAKE.search(chinese) is None + assert _SNAKE.search(english) is None + assert "blocked" not in english.lower() + # 原值: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 + bundle = research_bundle(chart) + skipped = {"annual-returns", "research-vargas", "karana", "varnada"} + missing = [key for key in bundle["families"] if key not in skipped and not _heading(key, "en")] + assert missing == [] + + +def test_domain_and_chat_card_ignore_the_research_chapter() -> None: + from scripts.consult_card_domain_facts import build_consult_card_facts + + for index in (0, 1, 9): + case, _chart_data = _chart(index) + packet = { + "report_version": "pl9_personal_long_report.v4", + "birth_info": case, + "core_chart": {"planets": {}}, + "calculation_profile": {}, + "worksheets": {"timing_and_predictive_systems": {}}, + } + before_domain = json.dumps(read_full_data_domain(packet, "career"), ensure_ascii=False, sort_keys=True, default=str) + before_card = json.dumps( + build_consult_card_facts(packet, ["career"], minute_confirmed=True), + ensure_ascii=False, sort_keys=True, default=str, + ) + render_research_chapters(packet, "zh") + after_domain = json.dumps(read_full_data_domain(packet, "career"), ensure_ascii=False, sort_keys=True, default=str) + after_card = json.dumps( + build_consult_card_facts(packet, ["career"], minute_confirmed=True), + ensure_ascii=False, sort_keys=True, default=str, + ) + assert before_domain == after_domain + assert before_card == after_card + for token in ("research_timing", "tithi_ashtottari", "pratyantar", "D81"): + assert token not in before_domain + + +def test_rectification_modules_do_not_import_the_research_package() -> None: + paths = list((ROOT / "scripts").glob("rectification*.py")) + paths.extend((ROOT / "scripts").glob("birth_time*.py")) + for name in ( + "active_rectification_scoring.py", + "dynamic_rectification.py", + "birth_time_rectifier.py", + "active_rectification_events.py", + ): + paths.append(ROOT / "scripts" / name) + seen = set() + for path in paths: + if not path.exists() or path in seen: + continue + seen.add(path) + tree = ast.parse(path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + modules = [alias.name for alias in node.names] + elif isinstance(node, ast.ImportFrom): + modules = [node.module or ""] + else: + continue + 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"