diff --git a/BLOCKED.md b/BLOCKED.md index 17e1050e..cd54281f 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -1,5 +1,11 @@ # BLOCKED +## #263 年运行运交叉与其他参照 + +- Sudarshana 本命三参照已接入。上游与仓内引擎均缺逐年推进。采用何种年度推进口径需拍板。 +- Bhrigu Pada 的来源尚未闭环,引擎输出整数年龄样点。附录显示对应年龄段供观察。若要把这些样点升级为事件窗口,需拍板并补外部验证。 +- Windows Docker Linux 引擎不可用,预检碎片扫描和定向套件超时。见 [#263 进度](docs/tasks/PROGRESS-annual-transit-checks-20261114.md)。 + ## #257 年运 Muntha 验收环境 - Windows Docker Desktop Linux 引擎不可用。碎片扫描和预检定向套件超时,远端和适配器诊断通过。替代证据见 [#257 进度](docs/tasks/PROGRESS-annual-muntha-records-20261113.md)。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 02b39ff0..238c48e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-11-14 — 年运附录增加行运交叉与其他参照 + +- 计算依据分组列出木星与土星的主题宫位核对、Sudarshana 本命三参照、Bhrigu Pada 年龄段观察。沿用当前 Skill 版本(BUG-1883)。 + ## 2026-11-14 — 年运附录分列 Muntha 四项记录 - 计算依据新增年盘结构、Muntha 主状态、慢星换座与停滞、逐年推进。沿用当前 Skill 版本(BUG-1880)。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index edc847ad..cb3c64c1 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -25407,3 +25407,15 @@ - 验证及防复发:公开盘和虚构盘核对四项、逆行往返、停滞、空区间、岁差与交点、旧快照及写作证据。详见 [#257 进度](tasks/PROGRESS-annual-muntha-records-20261113.md)。 - 关联记录:#257、BUG-1212、BUG-1219、BUG-1817、BUG-1868。 - 修复版本:`codex/annual-muntha-records-20261113`,待发布。 + +## BUG-1883 | 年运未列主题宫位的行运交叉及本命参照 + +- 状态:fixed-pending-verify(待独立验收)。 +- 首次发现 / 最近更新:2026-11-12 / 2026-11-14。 +- 影响面:年运报告计算依据、技法执行表和 Markdown 导出。 +- 现象和触发:读取年运技法执行表,Double Transit、Sudarshana、Bhrigu Pada 均列为未完成。原生慢星换座与本命引擎数据没有接入对应附录。 +- 根因:没有按主题宫位投影原生换座和同座、相照结果,也没有在年包调用两种本命参照。 +- 修复:按原生换座分段,记录木星与土星共同作用的目标宫或宫主。接入本命三参照和九分盘年龄段观察,分别保留逐年推进缺口和参数敏感性。 +- 验证和防复发:公开盘、逆行回切、无窗口、缺输入、半开区间、写作证据哈希、页面与 Markdown 回归。见 [#263 进度](tasks/PROGRESS-annual-transit-checks-20261114.md)。 +- 关联记录:#263、BUG-1060、BUG-1061、BUG-1185、BUG-1653。 +- 修复版本:`codex/annual-transit-checks-20261114`。 diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index 03e43356..58d75fd8 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -2,6 +2,10 @@ Purpose: read this file before substantial project work. It exists to stop repeat mistakes caused by multiple Codex windows, WorkBuddy mirrors, local drafts, backup folders, and partial cloud-git visibility. +## 2026-11-14 · #263 年运行运交叉预检 + +- 本机 Python、Gitea 远端可见性与引擎适配器诊断通过。碎片扫描与预检定向套件分别在 90 秒、45 秒超时。证据和环境缺口见 [#263 进度](../tasks/PROGRESS-annual-transit-checks-20261114.md)。 + ## 2026-11-13 · #239 选盘续问预检 - 目录扫描和定向预检分别在 90 秒和 45 秒超时,Gitea 远端、Python 和适配器检查通过。并发运行 Next 构建时,Windows 快速门的来源清单扫描遇到被构建替换的 .next 文件,快速门改用独立 worktree。 diff --git a/docs/tasks/PROGRESS-annual-transit-checks-20261114.md b/docs/tasks/PROGRESS-annual-transit-checks-20261114.md new file mode 100644 index 00000000..39a7774c --- /dev/null +++ b/docs/tasks/PROGRESS-annual-transit-checks-20261114.md @@ -0,0 +1,33 @@ +# #263 年运行运交叉与其他参照 + +- [任务书](TASK-annual-transit-checks-20261114.md) +- 开工基线 `a09fbfd3e3e0cbe15342a00b31569329abfde209` +- 分支 `codex/annual-transit-checks-20261114` +- BUG-1883 按任务书预留。BUG-1881、BUG-1882 分属并行的 #260、#262。 + +## 实现 + +- 木星与土星按报告起点和原生换座时刻核对六个主题的目标宫及其宫主。沿用原生整宫同座与相照判定,只配对两星共同作用的同一目标。相邻有效时段合并成连续区间,区间内保留作用方式变化。各主题列明实际核对的宫位。 +- Sudarshana 保存上升、月亮、太阳三参照、汇聚及引擎原始判断,逐年推进在审计中列为缺口。 +- Bhrigu Pada 使用本命月亮与原生九分盘。原生整数年龄样点按 365.25 天的年龄段和报告区间求交,页面标明参数敏感并提示解释边界。整数年龄段的进一步事件口径需拍板。 +- 三组观察只进入附录和双轴审计。页面沿用原生折叠,Markdown 共用相同文案。 + +## 断言变更 + +| 原值 | 新值 | 原因 | +| --- | --- | --- | +| 公开盘 Double Transit 审计 `not_built` | `executed`、`observation_only` | 任务书要求运行全年主题宫位核对并记录实际用途 | + +## 验证 + +- 公开盘核对六个主题、宫位和原生换座边界。合成盘核对木星或土星逆行的断开与恢复、连续段内作用方式变化、无窗口、Bhrigu Pada 缺输入和年龄段裁剪。 +- 年运 Python 定向回归 53 项通过。源码卫生与中点文案合约 9 项通过。公开盘和合成盘各十个写作区段的 evidence 哈希与开工 staging 逐项一致,旧单轴审计哈希通过。 +- 前端真实引擎样本覆盖三组附录、折叠、Markdown、审计与关系文案。 +- 前端无 Docker 全量复跑 3734 项,3652 通过、45 失败、37 跳过、0 取消。开工 staging 为 3733 项,少一项新通过用例,失败名称逐项一致。首轮出现的咨询截断时钟断言在同机 staging 定向复现,分支全量复跑通过该用例。 +- 生产构建与 TypeScript 检查通过,lint 0 error、62 warnings。基线和分支首页均为静态,首页 gzip 均为 JS 516100 B、CSS 92069 B。 +- Python 快速门 1490 项通过、2 项跳过,另有 29 个子测试通过。三项 Kaala / PyJHora 失败与同机 staging 定向复跑一致,快速门退出 1。仓库隐私检查 62 项通过。 +- 远端可见性和外部适配器诊断通过。Windows 本机碎片扫描和预检定向套件超时,Docker Linux 引擎不可用。 + +## 交接 + +- Skill 版本沿用。独立验收请以 PR head 核对三组附录、主题边界及完整门禁。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index e72f6142..896f50d8 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -33,6 +33,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | +| [#263 任务书](TASK-annual-transit-checks-20261114.md) | [执行进度](PROGRESS-annual-transit-checks-20261114.md) | 年运行运交叉与其他参照(BUG-1883) | 待验收 | `codex/annual-transit-checks-20261114` | | [#239 修订任务书](FIX-chart-choice-rounds-page-5.md) | [执行进度](PROGRESS-chart-choice-rounds-page-20261113.md) | 按盘选盘与报告秒级区间(BUG-1873) | 待验收 | `codex/chart-choice-rounds-page-20261113` | | [#234 任务书](TASK-remove-middle-dot-20261113.md) | [执行进度](PROGRESS-remove-middle-dot-20261113.md) | 用户可见分隔符(BUG-1870) | 待验收 | `codex/remove-middle-dot-20261113` | | [#212 任务书](TASK-annual-page-option-b-20261113.md) | [执行进度](PROGRESS-annual-page-option-b-20261113.md) | 年运页面方案 B(BUG-1868) | 待验收 | `codex/annual-page-option-b-20261113` | diff --git a/docs/tasks/TASK-annual-transit-checks-20261114.md b/docs/tasks/TASK-annual-transit-checks-20261114.md new file mode 100644 index 00000000..e75134ac --- /dev/null +++ b/docs/tasks/TASK-annual-transit-checks-20261114.md @@ -0,0 +1,87 @@ +# TASK:年运 Double Transit 按主题宫位扫全年,加 Sudarshana 与 Bhrigu Pada(2026-11-14) + +- Gitea:#263 +- 分支:`codex/annual-transit-checks-20261114`,从 staging `a09fbfd3` 切出。 +- Bug 编号:BUG-1883。开工时核对 `docs/BUG_HISTORY.md` 末尾;已被占用就顺延,并在 PROGRESS 写明。 +- 负责验收:Claude(jyotisha-99)。 + +任务书没写到的取舍,不得写成"产品决定"或"裁定",一律标「需拍板」。不得修改或删除既有断言来让测试通过。确需修改的,在 PROGRESS 逐条写明原值、新值和原因。 + +同时在做的还有 #262、#264,它们都改同样几个文件。各单只改自己的函数;后合入的负责 rebase。 + +## 背景 + +占星师 2026-11-12 年运 Q3(原意): + +| 技法 | 要求 | 可以阻塞或不适用的真实条件 | +|---|---|---| +| Double Transit | 对相应的事件主题做窗口交叉检查 | 事件类别没有冻结,或缺行运数据;不得把心动、成对、成婚混用 | +| Bhrigu Pada | 相关主题执行 | 真的没有这个主题才算不适用;来源没闭环只限制解释强度 | +| Sudarshana | 相关的年度主题执行 | 写明缺什么输入或哪些范围不相关 | + +另外: +- 她 2026-11-06 的来源审查没有批准任何本命对行运的数值容许度。 +- R-05:Double Transit 只对 checked_houses 里的宫位有结论,不外推到别的宫或别的领域。 + +现状: +- 审计里这三项都是 `not_built`。 +- 引擎: + - Double Transit:`jyotish_engine._check_pac(planet, lon, target_lon, asc_idx)`,在冻结文件里,只能 import 调用。问答用的是 `consultation_native_layers.build_double_transit`(也冻结),按日期跑 1–12 宫加 DK 和 UL。个人报告的 `reading_facts.py:665` 有 checked_houses 的写法可以对照。 + - Sudarshana:`sudarshana_chakra.calc_sudarshana_chakra(planet_lons, asc_lon, house=None)`,只有本命轮,没有逐年推进。上游 `yinduzhanxing` 也没有逐年推进的实现。 + - Bhrigu Pada:`bhrigu_pada_dasha.bhrigu_pada_dasha_full_report(birth_moon_lon, birth_date_jd, d9_7lord_sign, d9_planets, *, target_date_jd)`。`jyotish_engine.py` 16316–16354 行的调用方式可以对照,包括 `parameter_sensitive` 和 claim_boundary。 +- 年运已有慢星扫描 `slow_transits`,含木星、土星的换座和停滞事件。主题宫位见 `report_facts.HOUSE_DOMAINS`。 + +## 要做的 + +### 1. Double Transit 全年窗口 + +- **事件类别冻结在本单**:用 `HOUSE_DOMAINS` 的六个主题和它们的宫位(事业 10、6;财富 2、11;关系 7、5;家宅 4;身心 1、6、12;学习 4、5、9)。 + - 关系主题标明「看 7 宫和 5 宫的关系」,不写成婚姻、成对或心动。 + - 这个冻结只在年运里用,常量写进 `report_facts.py`,并注释出处为本任务书。 +- **计算方法**:木星和土星对目标宫的作用只按星座判断(落入或星座相位),只在换座时会变。所以只在区间起点和木星、土星每次换座时取样,用 `_check_pac` 判断,得到「两星同时作用于某宫」的连续区间。不用容许度,不逐日扫。 + - 判断的目标包括该宫,以及 `_check_pac` 原本支持的宫主星。目标照问答链路的做法。 +- **输出**:每个主题、每个宫一行,包括窗口的起止时刻、两星各自是怎么作用的(落入,或几宫相位),以及 checked_houses。 +- 没有窗口时写「本期间木星和土星没有同时作用于这些宫」。 + +### 2. Sudarshana + +- 对每个主题的宫位,用 `calc_sudarshana_chakra(..., house=h)` 取三个参照点(上升、月亮、太阳)的汇聚情况和 judgment,原样记录。 +- 逐年推进:上游和本仓都没有实现,审计写 `partial`,note 写「已算本命三参照汇聚;逐年推进没有实现(上游也没有)」。不得自己实现逐年推进。 + +### 3. Bhrigu Pada + +- 它在引擎里看的是婚姻,属于关系主题。照 `jyotish_engine.py` 的调用方式取结果: + - 只列出与报告区间相交的 `marriage_windows` 和 `venus_encounter_windows`; + - 带上 `parameter_sensitive` 和 claim_boundary:「不能据此确定事件时间或婚姻结论」。 +- 缺月亮经度或 D9 输入时写 blocked。 + +### 4. 审计、附录和写作输入 + +- 三行审计照实写: + - Double Transit:`executed` + `observation_only`。 + - Sudarshana:`partial`。 + - Bhrigu Pada:`executed`,状态是参数敏感,用途 `observation_only`。 +- 页面附录新增小节「行运交叉与其他参照」,三组分开列。行数多时折叠。 +- 写作输入不变:十个写作区段的 evidence 哈希不变。 + +## 不要碰 + +- 冻结文件只能 import,不能改。 +- `references/**`、`consult-preset-prompts.ts`。 +- 不加容许度。不实现 Sudarshana 逐年推进。 + +## 要改的文件 + +- `scripts/annual_service/package.py`、`scripts/annual_service/report_facts.py` +- `frontend/src/lib/annual-report-generate.ts` +- 测试:`tests/test_annual_transit_checks_20261114.py`、`frontend/tests/annual-transit-checks-20261114.test.tsx` + - 合成数据覆盖:木星或土星逆行造成的窗口断开再接上;没有窗口;关系主题的文字里不出现「婚」;Bhrigu 缺输入时 blocked;写作输入哈希不变。 +- `docs/BUG_HISTORY.md`,只在末尾追加 +- `docs/tasks/PROGRESS-annual-transit-checks-20261114.md` + +## 验收标准 + +1. 公开名人盘上,每个主题都有 Double Transit 的窗口或「没有窗口」的说明。窗口的起止只落在区间起点或木星、土星的换座时刻。 +2. Sudarshana 和 Bhrigu Pada 照实列出,审计状态与第 4 节一致。 +3. 用户可见文字里没有「·」、字段名、下划线,也没有「婚姻」类结论。 +4. tsc、eslint(0 错)、`npm test`(基线失败除外)、quick gate 全部通过。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index c53b64b4..d46827b1 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1409,6 +1409,7 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting` - **年运附录(BUG-1855):** Narayana 标为条件参考。种子未决时在口径说明中列出候选数,每支单独一行。 - **年运附录(BUG-1878):** 「这份报告的口径」在年内展开说明下列出 Mudda 主结果和两种对照口径。跨年结果不同时按年盘分行。技法执行表保留西方占星各行,计算栏显示「不适用」,使用栏附理由。 - **年运 Muntha(#257,BUG-1880):** 计算依据按年盘列「Muntha 四项记录」。年盘结构、Muntha 主、慢星进出与停滞、逐年推进各自列出,导出沿用同一顺序。 +- **行运交叉与其他参照(#263,BUG-1883):** 计算依据分三组展示木星与土星的主题宫位核对、Sudarshana 三参照和 Bhrigu Pada 关系参照。逐段显示核对范围、时间和作用方式,多行沿用原生折叠。Markdown 导出沿用同一分组。 - **入口**:「我的报告」最上面两个横条并排(手机上下排,2026-11-03 方案 A,见 Personal report centre):「年运」横条是 `Sun` 图标方块、标题、一行「这一年每段怎么安排,几个要留意的时间点」,右边描边按钮「打开年运」(手机「打开」),直接打开 `/reports/annual`。没有单独的入口页。个人报告横条上不写份数。 - **地址**:`/reports/annual`(`?report=` 打开某一份),放在 `/reports` 下,侧栏仍亮「我的报告」。沿用二级页外壳,标题「年运」,标题旁是人物切换。正文一栏 `min(720px, 100%)` 居中。 - **遮罩**:报告的章节骨架在后面虚着(灰标题 + 两条微光条),上面一层 `--color-canvas` 72% 半透明 + 6px 模糊,正中一张卡片(`min(520px, 100%)`、圆角 20px、细边、`--shadow-elevated`,超出时卡内滚动):范围小字「YYYY-MM-DD 到 YYYY-MM-DD」,衬线标题「某人的这一年」;三个胶囊切换「生日年度 / 公历年 / 未来十二个月」(`ToggleGroup`,选中描边 `--color-action-strong`、文字 `--color-action`);三条注意事项(主色勾);出生时间没确认时一行浅底提示,本人和别人都带「先做生时校正」链接(#160 起可以给档案里的其他人做校正);「补充现在的情况(可以不填)」折叠,展开后是关系状态、工作 / 学习、最关心的(最多三项)、已有的计划(一行输入)、不想看的;居中实心按钮「开始写年运」(最小 220×52,手机占满宽),下面一行「本次消耗 N 点,写到一半断掉不扣」。价格读不到时这一行写「计费配置不可用」,按钮不可点。没有别的提示小字。 diff --git a/frontend/src/lib/annual-report-generate.ts b/frontend/src/lib/annual-report-generate.ts index 06a2f7dd..c58cb798 100644 --- a/frontend/src/lib/annual-report-generate.ts +++ b/frontend/src/lib/annual-report-generate.ts @@ -330,6 +330,101 @@ function tajikaYogaLine(row: Row): string { return `${subject}${subject ? "," : ""}${stateText}${gap}。`; } +const TRANSIT_THEMES: Readonly> = { + career: "事业", wealth: "财富", relationship: "关系(看 7 宫和 5 宫)", + home: "家宅", energy: "身心", learning: "学习", +}; + +function transitInterval(row: Row): string { + const time = (edge: unknown) => { + const instant = record(edge); + const local = str(instant.local); + return local ? `${local.slice(0, 19).replace("T", " ")}(UTC${str(instant.utc_offset)})` : ""; + }; + return `${time(row.start)} 至 ${time(row.end)}`; +} + +function doubleTransitLines(block: Row): string[] { + if (!block.status) return []; + const domains = record(block.domains); + if (!Object.keys(domains).length) return [str(block.reason)]; + return Object.entries(TRANSIT_THEMES).flatMap(([key, name]) => { + const domain = record(domains[key]); + const houses = rows(domain.rows); + const checked = Array.isArray(domain.checked_houses) ? domain.checked_houses.join("、") : ""; + const heading = `${name}。${checked ? `已核对第 ${checked} 宫。` : "这些宫位均缺少核对资料。"}`; + const lines = houses.flatMap((house) => { + const label = `第 ${house.house} 宫`; + if (house.status === "blocked") return [`${label}。${str(house.reason)}`]; + const windows = rows(house.windows); + if (!windows.length) return [`${label}没有同时作用的区间。`]; + return windows.map((window) => { + const segments = rows(window.segments); + const details = segments.map((segment) => { + const contacts = rows(segment.contacts).map((contact) => { + const target = contact.target === "house_lord" ? `宫主${planetZh(str(contact.planet))}` : "该宫"; + const effects = record(contact.effects); + const pair = ["Jupiter", "Saturn"].map((planet) => { + const actions = rows(effects[planet]).map((effect) => effect.type === "Position" + ? (contact.target === "house_lord" ? "与宫主同座" : "落入该宫") : `第 ${effect.offset} 宫相照`); + return `${planetZh(planet)}${actions.join("、")}`; + }); + return `${target}(${pair.join(",")})`; + }); + return segments.length > 1 ? `${transitInterval(segment)} ${contacts.join("、")}` : contacts.join("、"); + }); + return `${label}。${transitInterval(window)}。${details.join(";")}。`; + }); + }); + const empty = checked && houses.every((house) => house.status === "computed" && !rows(house.windows).length) + ? ["本期间木星和土星没有同时作用于这些宫。"] : []; + return [heading, ...empty, ...lines]; + }); +} + +function sudarshanaLines(block: Row): string[] { + if (!block.status) return []; + if (block.status !== "computed") return [str(block.reason)]; + const domains = record(block.domains); + const references = { asc_lagna: "从上升数", moon_lagna: "从月亮数", sun_lagna: "从太阳数" }; + return [str(block.note), ...Object.entries(TRANSIT_THEMES).flatMap(([key, title]) => rows(domains[key]).map((house) => { + const positions = Object.entries(references).map(([ref, label]) => { + const field = record(house[ref]); + const planets = Array.isArray(field.planets) ? field.planets.map((planet) => planetZh(str(planet))) : []; + return `${label}为${signZh(str(field.sign))}座,${planets.length ? planets.join("、") + "在内" : "无行星落入"}`; + }); + const convergence = rows(house.convergence).map((row) => `${planetZh(str(row.planet))}${row.level === "triple" ? "三" : "两"}处汇聚`); + const judgment = str(house.judgment); + return `${title}第 ${house.house} 宫。${positions.join(";")}。${convergence.length ? convergence.join("、") + "。" : "没有汇聚记录。"}${judgment ? `引擎本命判断:${judgment}。` : ""}`; + }))]; +} + +function bhriguPadaLines(block: Row): string[] { + if (!block.status) return []; + if (block.status !== "computed") return [str(block.reason)]; + const windows = [...rows(block.marriage_windows).map((row) => ({ row, label: "九分盘第七宫主所在星座" })), + ...rows(block.venus_encounter_windows).map((row) => ({ row, label: "金星所管星座" }))]; + const coverage = record(block.coverage); + return [ + "关系参照随出生参数变化。年龄段来自引擎的年度取样,仅供观察,不能据此确定事件时间或关系结果。", + ...(block.coverage_status === "partial" ? [`原生引擎只覆盖 ${transitInterval(coverage)}。`] : []), + ...(windows.length ? windows.map(({ row, label }) => + `${transitInterval(row)}。 ${label}为${signZh(str(row.sign))}座。`) : ["本期间没有符合条件的年龄段。"]), + ]; +} + +function transitCheckAppendices(root: Row): AnnualAppendixBlock[] { + const checks = record(record(root.page_appendix).transit_checks); + const groups = [ + ["double-transit", "木星与土星的交叉核对", doubleTransitLines(record(checks.double_transit))], + ["sudarshana", "Sudarshana 三参照", sudarshanaLines(record(checks.sudarshana))], + ["bhrigu-pada", "Bhrigu Pada 关系参照", bhriguPadaLines(record(checks.bhrigu_pada))], + ] as const; + return groups.flatMap(([id, title, lines]) => lines.some(Boolean) ? [{ + id: `annual-transit-check-${id}`, title: `行运交叉与其他参照 / ${title}`, lines, collapseAfterFirst: true, + }] : []); +} + function appendicesByYear(root: Row): AnnualAppendixBlock[] { const appendix = record(root.page_appendix); const overview = yearOverviews(root); @@ -407,7 +502,8 @@ function annualAudit(root: Row): AnnualAppendixBlock { .map((row) => { const axes = techniqueAuditAxes(row); const name = str(row.technique); - const reason = ["blocked", "not_applicable", "not_built"].includes(axes.calculation_status) ? str(row.note) : ""; + const transitCheck = str(row.raw_path).startsWith("package.independent_annual_views.transit_checks."); + const reason = transitCheck || ["blocked", "not_applicable", "not_built"].includes(axes.calculation_status) ? str(row.note) : ""; return { method: AUDIT_NAMES[name] ?? displayTechniqueAuditLabel(name), calculation: techniqueCalculationStatusLabel(axes.calculation_status), @@ -515,6 +611,7 @@ export function buildAppendix(facts: unknown, range: AnnualSnapshot["range"], bi }, ...muddaCrossingBlocks(root), ...appendicesByYear(root), + ...transitCheckAppendices(root), annualAudit(root), { id: "conflicts", diff --git a/frontend/tests/annual-transit-checks-20261114.test.tsx b/frontend/tests/annual-transit-checks-20261114.test.tsx new file mode 100644 index 00000000..dd0a3838 --- /dev/null +++ b/frontend/tests/annual-transit-checks-20261114.test.tsx @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { AnnualReportView } from "../src/components/annual/annual-report-page.tsx"; +import { annualAppendixMarkdown, buildAppendix, type AnnualSnapshot } from "../src/lib/annual-report-generate.ts"; + +const { checks, audit } = JSON.parse(readFileSync( + new URL("./fixtures/annual-transit-checks-20261114.json", import.meta.url), "utf8", +)); +const range = { kind: "birthday_year", key: "annual:birthday_year:2032", label: "生日年度", + span: "2032 到 2033", crossesBirthday: false, birthdays: [] } as const; +const birth = { known: false, beforeMinutes: null, afterMinutes: null, source: null, rectification: "none" } as const; + +test("annual transit checks stay in three folded appendix groups with bounded claims", () => { + const appendix = buildAppendix({ page_appendix: { transit_checks: checks }, technique_audit_axes: audit }, range, birth); + const blocks = appendix.filter((block) => block.id.startsWith("annual-transit-check-")); + assert.deepEqual(blocks.map((block) => block.title), [ + "行运交叉与其他参照 / 木星与土星的交叉核对", + "行运交叉与其他参照 / Sudarshana 三参照", + "行运交叉与其他参照 / Bhrigu Pada 关系参照", + ]); + assert.ok(blocks.every((block) => block.collapseAfterFirst)); + const markdown = annualAppendixMarkdown(blocks); + assert.match(markdown, /关系(看 7 宫和 5 宫)/); + assert.match(markdown, /本期间木星和土星没有同时作用于这些宫/); + assert.match(markdown, /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(UTC\+08:00) 至 /); + assert.match(markdown, /木星.*相照.*土星/); + assert.match(markdown, /从上升数.*从月亮数.*从太阳数/); + assert.match(markdown, /引擎本命判断:第\d+宫/); + assert.match(markdown, /逐年推进没有实现/); + assert.match(markdown, /九分盘第七宫主所在星座/); + assert.match(markdown, /不能据此确定事件时间或关系结果/); + assert.doesNotMatch(markdown, /[·_婚]|judgment/); + const execution = appendix.find((block) => block.id === "execution")?.audit ?? []; + assert.equal(execution.length, 3); + assert.ok(execution.some((row) => row.usage.includes("逐年推进没有实现"))); + assert.ok(execution.some((row) => row.usage.includes("受出生参数影响"))); + const snapshot: AnnualSnapshot = { + kind: "annual_report_snapshot", version: 1, status: "ready", range, appendix, + subject: { name: "合成盘", isSelf: false }, plan: [], sections: {}, windows: [], remaining: [], factsSha256: "", + }; + const html = renderToStaticMarkup(createElement(AnnualReportView, { snapshot, generating: false })); + assert.ok(blocks.every((block) => html.includes(block.title))); + assert.match(html, /展开其余/); + assert.ok(!buildAppendix({}, range, birth).some((block) => block.id.startsWith("annual-transit-check-"))); +}); diff --git a/frontend/tests/fixtures/annual-transit-checks-20261114.json b/frontend/tests/fixtures/annual-transit-checks-20261114.json new file mode 100644 index 00000000..de7b8158 --- /dev/null +++ b/frontend/tests/fixtures/annual-transit-checks-20261114.json @@ -0,0 +1,1154 @@ +{ + "checks": { + "double_transit": { + "status": "computed", + "producer": "jyotish_engine._check_pac", + "producer_status": "computed", + "checked_houses": [ + 1, + 2, + 4, + 5, + 6, + 7, + 9, + 10, + 11, + 12 + ], + "domains": { + "career": { + "checked_houses": [ + 10, + 6 + ], + "rows": [ + { + "house": 10, + "status": "computed", + "windows": [] + }, + { + "house": 6, + "status": "computed", + "windows": [] + } + ] + }, + "wealth": { + "checked_houses": [ + 2, + 11 + ], + "rows": [ + { + "house": 2, + "status": "computed", + "windows": [] + }, + { + "house": 11, + "status": "computed", + "windows": [ + { + "start": { + "utc": "2032-08-30T02:53:38Z", + "local": "2032-08-30T10:53:38+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463474.62058067 + }, + "end": { + "utc": "2032-10-06T13:02:57Z", + "local": "2032-10-06T21:02:57+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463512.04371834 + }, + "segments": [ + { + "start": { + "utc": "2032-08-30T02:53:38Z", + "local": "2032-08-30T10:53:38+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463474.62058067 + }, + "end": { + "utc": "2032-10-06T13:02:57Z", + "local": "2032-10-06T21:02:57+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463512.04371834 + }, + "contacts": [ + { + "target": "house", + "planet": null, + "effects": { + "Jupiter": [ + { + "type": "Aspect", + "offset": 7 + } + ], + "Saturn": [ + { + "type": "Position" + } + ] + } + } + ] + } + ] + }, + { + "start": { + "utc": "2033-03-11T08:20:53Z", + "local": "2033-03-11T16:20:53+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463667.84783268 + }, + "end": { + "utc": "2033-06-15T04:28:11Z", + "local": "2033-06-15T12:28:11+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463763.68623632 + }, + "segments": [ + { + "start": { + "utc": "2033-03-11T08:20:53Z", + "local": "2033-03-11T16:20:53+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463667.84783268 + }, + "end": { + "utc": "2033-06-15T04:28:11Z", + "local": "2033-06-15T12:28:11+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463763.68623632 + }, + "contacts": [ + { + "target": "house", + "planet": null, + "effects": { + "Jupiter": [ + { + "type": "Aspect", + "offset": 5 + } + ], + "Saturn": [ + { + "type": "Position" + } + ] + } + } + ] + } + ] + } + ] + } + ] + }, + "relationship": { + "checked_houses": [ + 7, + 5 + ], + "rows": [ + { + "house": 7, + "status": "computed", + "windows": [] + }, + { + "house": 5, + "status": "computed", + "windows": [ + { + "start": { + "utc": "2032-08-30T02:53:38Z", + "local": "2032-08-30T10:53:38+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463474.62058067 + }, + "end": { + "utc": "2032-10-06T13:02:57Z", + "local": "2032-10-06T21:02:57+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463512.04371834 + }, + "segments": [ + { + "start": { + "utc": "2032-08-30T02:53:38Z", + "local": "2032-08-30T10:53:38+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463474.62058067 + }, + "end": { + "utc": "2032-10-06T13:02:57Z", + "local": "2032-10-06T21:02:57+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463512.04371834 + }, + "contacts": [ + { + "target": "house", + "planet": null, + "effects": { + "Jupiter": [ + { + "type": "Position" + } + ], + "Saturn": [ + { + "type": "Aspect", + "offset": 7 + } + ] + } + }, + { + "target": "house_lord", + "planet": "Jupiter", + "effects": { + "Jupiter": [ + { + "type": "Aspect", + "offset": 7 + } + ], + "Saturn": [ + { + "type": "Position" + } + ] + } + } + ] + } + ] + }, + { + "start": { + "utc": "2033-03-11T08:20:53Z", + "local": "2033-03-11T16:20:53+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463667.84783268 + }, + "end": { + "utc": "2033-06-15T04:28:11Z", + "local": "2033-06-15T12:28:11+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463763.68623632 + }, + "segments": [ + { + "start": { + "utc": "2033-03-11T08:20:53Z", + "local": "2033-03-11T16:20:53+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463667.84783268 + }, + "end": { + "utc": "2033-06-15T04:28:11Z", + "local": "2033-06-15T12:28:11+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463763.68623632 + }, + "contacts": [ + { + "target": "house_lord", + "planet": "Jupiter", + "effects": { + "Jupiter": [ + { + "type": "Aspect", + "offset": 5 + } + ], + "Saturn": [ + { + "type": "Position" + } + ] + } + } + ] + } + ] + } + ] + } + ] + }, + "home": { + "checked_houses": [ + 4 + ], + "rows": [ + { + "house": 4, + "status": "computed", + "windows": [] + } + ] + }, + "energy": { + "checked_houses": [ + 1, + 6, + 12 + ], + "rows": [ + { + "house": 1, + "status": "computed", + "windows": [ + { + "start": { + "utc": "2032-08-30T02:53:38Z", + "local": "2032-08-30T10:53:38+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463474.62058067 + }, + "end": { + "utc": "2032-10-06T13:02:57Z", + "local": "2032-10-06T21:02:57+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463512.04371834 + }, + "segments": [ + { + "start": { + "utc": "2032-08-30T02:53:38Z", + "local": "2032-08-30T10:53:38+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463474.62058067 + }, + "end": { + "utc": "2032-10-06T13:02:57Z", + "local": "2032-10-06T21:02:57+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463512.04371834 + }, + "contacts": [ + { + "target": "house", + "planet": null, + "effects": { + "Jupiter": [ + { + "type": "Aspect", + "offset": 9 + } + ], + "Saturn": [ + { + "type": "Aspect", + "offset": 3 + } + ] + } + }, + { + "target": "house_lord", + "planet": "Sun", + "effects": { + "Jupiter": [ + { + "type": "Aspect", + "offset": 7 + } + ], + "Saturn": [ + { + "type": "Position" + } + ] + } + } + ] + } + ] + }, + { + "start": { + "utc": "2033-03-11T08:20:53Z", + "local": "2033-03-11T16:20:53+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463667.84783268 + }, + "end": { + "utc": "2033-06-15T04:28:11Z", + "local": "2033-06-15T12:28:11+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463763.68623632 + }, + "segments": [ + { + "start": { + "utc": "2033-03-11T08:20:53Z", + "local": "2033-03-11T16:20:53+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463667.84783268 + }, + "end": { + "utc": "2033-06-15T04:28:11Z", + "local": "2033-06-15T12:28:11+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463763.68623632 + }, + "contacts": [ + { + "target": "house", + "planet": null, + "effects": { + "Jupiter": [ + { + "type": "Aspect", + "offset": 7 + } + ], + "Saturn": [ + { + "type": "Aspect", + "offset": 3 + } + ] + } + }, + { + "target": "house_lord", + "planet": "Sun", + "effects": { + "Jupiter": [ + { + "type": "Aspect", + "offset": 5 + } + ], + "Saturn": [ + { + "type": "Position" + } + ] + } + } + ] + } + ] + } + ] + }, + { + "house": 6, + "status": "computed", + "windows": [] + }, + { + "house": 12, + "status": "computed", + "windows": [] + } + ] + }, + "learning": { + "checked_houses": [ + 4, + 5, + 9 + ], + "rows": [ + { + "house": 4, + "status": "computed", + "windows": [] + }, + { + "house": 5, + "status": "computed", + "windows": [ + { + "start": { + "utc": "2032-08-30T02:53:38Z", + "local": "2032-08-30T10:53:38+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463474.62058067 + }, + "end": { + "utc": "2032-10-06T13:02:57Z", + "local": "2032-10-06T21:02:57+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463512.04371834 + }, + "segments": [ + { + "start": { + "utc": "2032-08-30T02:53:38Z", + "local": "2032-08-30T10:53:38+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463474.62058067 + }, + "end": { + "utc": "2032-10-06T13:02:57Z", + "local": "2032-10-06T21:02:57+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463512.04371834 + }, + "contacts": [ + { + "target": "house", + "planet": null, + "effects": { + "Jupiter": [ + { + "type": "Position" + } + ], + "Saturn": [ + { + "type": "Aspect", + "offset": 7 + } + ] + } + }, + { + "target": "house_lord", + "planet": "Jupiter", + "effects": { + "Jupiter": [ + { + "type": "Aspect", + "offset": 7 + } + ], + "Saturn": [ + { + "type": "Position" + } + ] + } + } + ] + } + ] + }, + { + "start": { + "utc": "2033-03-11T08:20:53Z", + "local": "2033-03-11T16:20:53+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463667.84783268 + }, + "end": { + "utc": "2033-06-15T04:28:11Z", + "local": "2033-06-15T12:28:11+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463763.68623632 + }, + "segments": [ + { + "start": { + "utc": "2033-03-11T08:20:53Z", + "local": "2033-03-11T16:20:53+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463667.84783268 + }, + "end": { + "utc": "2033-06-15T04:28:11Z", + "local": "2033-06-15T12:28:11+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463763.68623632 + }, + "contacts": [ + { + "target": "house_lord", + "planet": "Jupiter", + "effects": { + "Jupiter": [ + { + "type": "Aspect", + "offset": 5 + } + ], + "Saturn": [ + { + "type": "Position" + } + ] + } + } + ] + } + ] + } + ] + }, + { + "house": 9, + "status": "computed", + "windows": [] + } + ] + } + }, + "profile": "annual_whole_sign_house_and_lord" + }, + "sudarshana": { + "status": "computed", + "producer": "sudarshana_chakra.calc_sudarshana_chakra", + "producer_status": "partial", + "profile": "natal_three_reference_points", + "note": "已算本命三参照汇聚;逐年推进没有实现(上游也没有)", + "domains": { + "career": [ + { + "house": 10, + "asc_lagna": { + "sign": "Taurus", + "sign_cn": "金牛座", + "lord": "Venus", + "lord_house": 9, + "planets": [ + "Mercury" + ] + }, + "moon_lagna": { + "sign": "Scorpio", + "sign_cn": "天蝎座", + "lord": "Mars", + "lord_house": 2, + "planets": [] + }, + "sun_lagna": { + "sign": "Pisces", + "sign_cn": "双鱼座", + "lord": "Jupiter", + "lord_house": 1, + "planets": [ + "Mars" + ] + }, + "convergence": [], + "composite_strength": 0.383, + "judgment": "第10宫整体偏弱,该领域需更多努力与补救" + }, + { + "house": 6, + "asc_lagna": { + "sign": "Capricorn", + "sign_cn": "摩羯座", + "lord": "Saturn", + "lord_house": 6, + "planets": [ + "Saturn", + "Rahu" + ] + }, + "moon_lagna": { + "sign": "Cancer", + "sign_cn": "巨蟹座", + "lord": "Moon", + "lord_house": 1, + "planets": [ + "Ketu" + ] + }, + "sun_lagna": { + "sign": "Scorpio", + "sign_cn": "天蝎座", + "lord": "Mars", + "lord_house": 10, + "planets": [] + }, + "convergence": [], + "composite_strength": 0.267, + "judgment": "第6宫整体偏弱,该领域需更多努力与补救" + } + ], + "wealth": [ + { + "house": 2, + "asc_lagna": { + "sign": "Virgo", + "sign_cn": "处女座", + "lord": "Mercury", + "lord_house": 10, + "planets": [] + }, + "moon_lagna": { + "sign": "Pisces", + "sign_cn": "双鱼座", + "lord": "Jupiter", + "lord_house": 5, + "planets": [ + "Mars" + ] + }, + "sun_lagna": { + "sign": "Cancer", + "sign_cn": "巨蟹座", + "lord": "Moon", + "lord_house": 9, + "planets": [ + "Ketu" + ] + }, + "convergence": [], + "composite_strength": 0.333, + "judgment": "第2宫整体偏弱,该领域需更多努力与补救" + }, + { + "house": 11, + "asc_lagna": { + "sign": "Gemini", + "sign_cn": "双子座", + "lord": "Mercury", + "lord_house": 10, + "planets": [ + "Sun", + "Jupiter" + ] + }, + "moon_lagna": { + "sign": "Sagittarius", + "sign_cn": "射手座", + "lord": "Jupiter", + "lord_house": 5, + "planets": [] + }, + "sun_lagna": { + "sign": "Aries", + "sign_cn": "白羊座", + "lord": "Mars", + "lord_house": 10, + "planets": [ + "Venus" + ] + }, + "convergence": [], + "composite_strength": 0.467, + "judgment": "第11宫中等强度,部分参考盘有利,部分偏弱" + } + ], + "relationship": [ + { + "house": 7, + "asc_lagna": { + "sign": "Aquarius", + "sign_cn": "水瓶座", + "lord": "Saturn", + "lord_house": 6, + "planets": [ + "Moon" + ] + }, + "moon_lagna": { + "sign": "Leo", + "sign_cn": "狮子座", + "lord": "Sun", + "lord_house": 5, + "planets": [] + }, + "sun_lagna": { + "sign": "Sagittarius", + "sign_cn": "射手座", + "lord": "Jupiter", + "lord_house": 1, + "planets": [] + }, + "convergence": [], + "composite_strength": 0.283, + "judgment": "第7宫整体偏弱,该领域需更多努力与补救" + }, + { + "house": 5, + "asc_lagna": { + "sign": "Sagittarius", + "sign_cn": "射手座", + "lord": "Jupiter", + "lord_house": 11, + "planets": [] + }, + "moon_lagna": { + "sign": "Gemini", + "sign_cn": "双子座", + "lord": "Mercury", + "lord_house": 4, + "planets": [ + "Sun", + "Jupiter" + ] + }, + "sun_lagna": { + "sign": "Libra", + "sign_cn": "天秤座", + "lord": "Venus", + "lord_house": 11, + "planets": [] + }, + "convergence": [], + "composite_strength": 0.4, + "judgment": "第5宫中等强度,部分参考盘有利,部分偏弱" + } + ], + "home": [ + { + "house": 4, + "asc_lagna": { + "sign": "Scorpio", + "sign_cn": "天蝎座", + "lord": "Mars", + "lord_house": 8, + "planets": [] + }, + "moon_lagna": { + "sign": "Taurus", + "sign_cn": "金牛座", + "lord": "Venus", + "lord_house": 3, + "planets": [ + "Mercury" + ] + }, + "sun_lagna": { + "sign": "Virgo", + "sign_cn": "处女座", + "lord": "Mercury", + "lord_house": 12, + "planets": [] + }, + "convergence": [], + "composite_strength": 0.2, + "judgment": "第4宫整体偏弱,该领域需更多努力与补救" + } + ], + "energy": [ + { + "house": 1, + "asc_lagna": { + "sign": "Leo", + "sign_cn": "狮子座", + "lord": "Sun", + "lord_house": 11, + "planets": [] + }, + "moon_lagna": { + "sign": "Aquarius", + "sign_cn": "水瓶座", + "lord": "Saturn", + "lord_house": 12, + "planets": [ + "Moon" + ] + }, + "sun_lagna": { + "sign": "Gemini", + "sign_cn": "双子座", + "lord": "Mercury", + "lord_house": 12, + "planets": [ + "Sun", + "Jupiter" + ] + }, + "convergence": [], + "composite_strength": 0.3, + "judgment": "第1宫整体偏弱,该领域需更多努力与补救" + }, + { + "house": 6, + "asc_lagna": { + "sign": "Capricorn", + "sign_cn": "摩羯座", + "lord": "Saturn", + "lord_house": 6, + "planets": [ + "Saturn", + "Rahu" + ] + }, + "moon_lagna": { + "sign": "Cancer", + "sign_cn": "巨蟹座", + "lord": "Moon", + "lord_house": 1, + "planets": [ + "Ketu" + ] + }, + "sun_lagna": { + "sign": "Scorpio", + "sign_cn": "天蝎座", + "lord": "Mars", + "lord_house": 10, + "planets": [] + }, + "convergence": [], + "composite_strength": 0.267, + "judgment": "第6宫整体偏弱,该领域需更多努力与补救" + }, + { + "house": 12, + "asc_lagna": { + "sign": "Cancer", + "sign_cn": "巨蟹座", + "lord": "Moon", + "lord_house": 7, + "planets": [ + "Ketu" + ] + }, + "moon_lagna": { + "sign": "Capricorn", + "sign_cn": "摩羯座", + "lord": "Saturn", + "lord_house": 12, + "planets": [ + "Saturn", + "Rahu" + ] + }, + "sun_lagna": { + "sign": "Taurus", + "sign_cn": "金牛座", + "lord": "Venus", + "lord_house": 11, + "planets": [ + "Mercury" + ] + }, + "convergence": [], + "composite_strength": 0.333, + "judgment": "第12宫整体偏弱,该领域需更多努力与补救" + } + ], + "learning": [ + { + "house": 4, + "asc_lagna": { + "sign": "Scorpio", + "sign_cn": "天蝎座", + "lord": "Mars", + "lord_house": 8, + "planets": [] + }, + "moon_lagna": { + "sign": "Taurus", + "sign_cn": "金牛座", + "lord": "Venus", + "lord_house": 3, + "planets": [ + "Mercury" + ] + }, + "sun_lagna": { + "sign": "Virgo", + "sign_cn": "处女座", + "lord": "Mercury", + "lord_house": 12, + "planets": [] + }, + "convergence": [], + "composite_strength": 0.2, + "judgment": "第4宫整体偏弱,该领域需更多努力与补救" + }, + { + "house": 5, + "asc_lagna": { + "sign": "Sagittarius", + "sign_cn": "射手座", + "lord": "Jupiter", + "lord_house": 11, + "planets": [] + }, + "moon_lagna": { + "sign": "Gemini", + "sign_cn": "双子座", + "lord": "Mercury", + "lord_house": 4, + "planets": [ + "Sun", + "Jupiter" + ] + }, + "sun_lagna": { + "sign": "Libra", + "sign_cn": "天秤座", + "lord": "Venus", + "lord_house": 11, + "planets": [] + }, + "convergence": [], + "composite_strength": 0.4, + "judgment": "第5宫中等强度,部分参考盘有利,部分偏弱" + }, + { + "house": 9, + "asc_lagna": { + "sign": "Aries", + "sign_cn": "白羊座", + "lord": "Mars", + "lord_house": 8, + "planets": [ + "Venus" + ] + }, + "moon_lagna": { + "sign": "Libra", + "sign_cn": "天秤座", + "lord": "Venus", + "lord_house": 3, + "planets": [] + }, + "sun_lagna": { + "sign": "Aquarius", + "sign_cn": "水瓶座", + "lord": "Saturn", + "lord_house": 8, + "planets": [ + "Moon" + ] + }, + "convergence": [], + "composite_strength": 0.267, + "judgment": "第9宫整体偏弱,该领域需更多努力与补救" + } + ] + } + }, + "bhrigu_pada": { + "status": "computed", + "producer": "bhrigu_pada_dasha.bhrigu_pada_dasha_full_report", + "producer_status": "parameter_sensitive", + "profile": "bhrigu_pada_approximate_progression", + "domain": "relationship", + "claim_boundary": "不能据此确定事件时间或婚姻结论", + "coverage": { + "start": { + "utc": "2032-06-14T22:16:22Z", + "local": "2032-06-15T06:16:22+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463398.42803197 + }, + "end": { + "utc": "2033-06-15T04:28:11Z", + "local": "2033-06-15T12:28:11+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463763.68623632 + } + }, + "coverage_status": "complete", + "year_basis_days": 365.25, + "reference": { + "target_jd": 2463398.428031975, + "scope": "explicit_target" + }, + "marriage_windows": [ + { + "age": 42, + "sign": "Aries", + "start": { + "utc": "2032-06-14T22:16:22Z", + "local": "2032-06-15T06:16:22+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463398.42803197 + }, + "end": { + "utc": "2033-06-14T22:00:00Z", + "local": "2033-06-15T06:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463763.41666667 + } + }, + { + "age": 43, + "sign": "Aries", + "start": { + "utc": "2033-06-14T22:00:00Z", + "local": "2033-06-15T06:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463763.41666667 + }, + "end": { + "utc": "2033-06-15T04:28:11Z", + "local": "2033-06-15T12:28:11+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2463763.68623632 + } + } + ], + "venus_encounter_windows": [] + } + }, + "audit": [ + { + "technique": "Double Transit PAC", + "calculation_status": "executed", + "interpretation_status": "observation_only", + "note": "按主题宫位核对木星与土星的同座和相照。", + "profile": "annual_whole_sign_house_and_lord", + "raw_path": "package.independent_annual_views.transit_checks.double_transit" + }, + { + "technique": "Sudarshana", + "calculation_status": "partial", + "interpretation_status": "observation_only", + "note": "已算本命三参照汇聚;逐年推进没有实现(上游也没有)", + "profile": "natal_three_reference_points", + "raw_path": "package.independent_annual_views.transit_checks.sudarshana" + }, + { + "technique": "Bhrigu Pada", + "calculation_status": "executed", + "interpretation_status": "observation_only", + "note": "受出生参数影响,只供观察。", + "profile": "bhrigu_pada_approximate_progression", + "raw_path": "package.independent_annual_views.transit_checks.bhrigu_pada" + } + ] +} diff --git a/scripts/annual_service/package.py b/scripts/annual_service/package.py index a2eacccd..8a3a86e0 100644 --- a/scripts/annual_service/package.py +++ b/scripts/annual_service/package.py @@ -518,6 +518,9 @@ def build_annual_package(request: dict[str, Any], *, detail: str = "full") -> di slow = block("blocked", "jyotish_engine._calc_sidereal_planets_for_jd", reason=reason) transit_context = block("blocked", "annual_service.package._transit_context", reason=reason) tithi = [] + from .report_facts import ANNUAL_TRANSIT_CHECK_HOUSES + from .transit_checks import build as build_transit_checks + package = { "schema": SCHEMA, "annual_package_version": ANNUAL_PACKAGE_VERSION, @@ -528,6 +531,7 @@ def build_annual_package(request: dict[str, Any], *, detail: str = "full") -> di "slow_transits": slow, "transit_context": transit_context, "independent_annual_views": { + "transit_checks": build_transit_checks(ctx, natal, slow, range_start, range_end, ANNUAL_TRANSIT_CHECK_HOUSES), "tithi_pravesha": block( "computed" if tithi else "blocked", "annual_native.tithi_pravesh.build_tithi_pravesh", diff --git a/scripts/annual_service/report_facts.py b/scripts/annual_service/report_facts.py index 79beec44..060ea1a2 100644 --- a/scripts/annual_service/report_facts.py +++ b/scripts/annual_service/report_facts.py @@ -111,6 +111,11 @@ HOUSE_DOMAINS: dict[str, tuple[int, ...]] = { "energy": (1, 6, 12), "learning": (4, 5, 9), } +# TASK-annual-transit-checks-20261114 freezes these six themes for the annual appendix. +ANNUAL_TRANSIT_CHECK_HOUSES = { + "career": (10, 6), "wealth": (2, 11), "relationship": (7, 5), + "home": (4,), "energy": (1, 6, 12), "learning": (4, 5, 9), +} DOMAIN_VARGAS: dict[str, tuple[int, ...]] = { "career": (10,), "wealth": (2, 11), @@ -1806,17 +1811,27 @@ def _technique_audit_axes(package: dict, natal: dict) -> list[dict[str, Any]]: )) add("Natal Yogas", natal.get("yogas"), "natal_base.yogas", "本命候选瑜伽" if natal_ready else natal["reason"], interpretation="supporting") + checks = annual_views.get("transit_checks") or {} + for name, key, fallback in ( + ("Double Transit PAC", "double_transit", "按主题宫位核对木星与土星的同座和相照。"), + ("Sudarshana", "sudarshana", "核对本命三参照。"), + ("Bhrigu Pada", "bhrigu_pada", "受出生参数影响,只供观察。"), + ): + field = checks.get(key) or {} + rows.append(_audit_axes_row( + name, field.get("producer_status", field.get("status", "blocked")), + field.get("reason") or field.get("note") or fallback, + raw_path=f"package.independent_annual_views.transit_checks.{key}" if field else None, + profile=field.get("profile"), interpretation="observation_only", + )) for name, note in ( ("Chara Dasha", "年包只计算 Vimshottari 和 Narayana 本命运限"), ("Yogini Dasha", "年包未运行 Yogini 运限"), ("Ashtottari Dasha", "年包未核对适用条件或运行 Ashtottari 运限"), ("Kalachakra Dasha", "年包未运行 Kalachakra 运限"), - ("Bhrigu Pada", "本命年运证据未接入 Bhrigu Pada 计算"), ("KP / Sub-lord", "年运未计算 KP 宫头与子主"), - ("Double Transit PAC", "现有慢星行运与相位未执行 PAC 条件核对"), ("Transit LL / 7L", "未单独追踪上升主与七宫主的行运窗口"), ("Bhava Chalit", "年运本命宫位采用整宫,未另算 Bhava Chalit"), - ("Sudarshana", "年运未运行 Sudarshana 多参考点"), ("Nakshatra Overlay", "本命月宿字段已保存,未运行叠加核对"), ("Natal Shadbala", "已计算年盘 Shadbala,未单独计算本命 Shadbala"), ("Argala", "年运未执行 Argala 支持与阻碍核对"), @@ -2146,8 +2161,9 @@ def _page_appendix(package: dict, natal: dict) -> dict: "tripataki": {key: tripataki[key] for key in ("status", "vedha_rows") if key in tripataki}, **_page_tajika(blocks.get("tajika_yogas") or {}), }) - tithi = ((package.get("independent_annual_views") or {}).get("tithi_pravesha") or {}).get("years") or [] - return {"years": years, "tithi_pravesha": tithi} + views = package.get("independent_annual_views") or {} + tithi = (views.get("tithi_pravesha") or {}).get("years") or [] + return {"years": years, "tithi_pravesha": tithi, "transit_checks": views.get("transit_checks") or {}} def build_annual_report_facts(request: dict[str, Any], *, birth_window: Any = None) -> dict[str, Any]: diff --git a/scripts/annual_service/transit_checks.py b/scripts/annual_service/transit_checks.py new file mode 100644 index 00000000..13ca50be --- /dev/null +++ b/scripts/annual_service/transit_checks.py @@ -0,0 +1,209 @@ +"""Appendix observations from the annual slow-transit scan and natal chart.""" +from __future__ import annotations + +from datetime import timedelta +from math import isfinite + +from . import package as ap + +SLOW_PAIR = ("Jupiter", "Saturn") +BHRIGU_YEAR_DAYS = 365.25 +DOUBLE_TRANSIT = "jyotish_engine._check_pac" +SUDARSHANA = "sudarshana_chakra.calc_sudarshana_chakra" +BHRIGU_PADA = "bhrigu_pada_dasha.bhrigu_pada_dasha_full_report" +SUDARSHANA_NOTE = "已算本命三参照汇聚;逐年推进没有实现(上游也没有)" +BHRIGU_BOUNDARY = "不能据此确定事件时间或婚姻结论" + + +def _finite(value): + return isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value) + + +def _sign_index(sign): + return ap.SIGNS.index(sign) if sign in ap.SIGNS else None + + +def _check_house(pac, asc, house, natal, sign_pairs): + sign = (asc + house - 1) % 12 + lord = ap.SIGN_LORDS[ap.SIGNS[sign]] + lord_longitude = natal.get(lord) + if not _finite(lord_longitude): + return None + targets = (("house", None, sign), ("house_lord", lord, ap._sign_idx(lord_longitude))) + hits = {} + for signs in sign_pairs: + contacts = [] + for target, planet, target_sign in targets: + effects = { + body: [{key: value for key, value in row.items() if key in ("type", "offset")} + for row in pac(body, position * 30 + 15, target_sign * 30 + 15, asc) + if row["type"] in ("Position", "Aspect")] + for body, position in zip(SLOW_PAIR, signs, strict=True) + } + if all(effects.values()): + contacts.append({"target": target, "planet": planet, "effects": effects}) + if contacts: + hits[signs] = contacts + return hits + + +def _transit_segments(slow, start, end): + first = slow.get("positions_at_range_start") or {} + signs = {body: _sign_index((first.get(body) or {}).get("sign")) for body in SLOW_PAIR} + if None in signs.values(): + return None + events = {} + for event in slow.get("events") or []: + body = event.get("body") + if body not in SLOW_PAIR or event.get("kind") != "ingress": + continue + instant = event["instant"] + if start["jd_ut"] <= instant["jd_ut"] < end["jd_ut"]: + events.setdefault(instant["jd_ut"], []).append(event) + segments = [] + left = start + for jd, rows in sorted(events.items()): + instant = rows[0]["instant"] + if left["jd_ut"] < jd: + segments.append((left, instant, tuple(signs.values()))) + for row in rows: + body = row["body"] + new_sign = _sign_index(row.get("to_sign")) + old_sign = _sign_index(row.get("from_sign")) + if new_sign is None or old_sign is None: + return None + if signs[body] != old_sign and not (jd == start["jd_ut"] and signs[body] == new_sign): + return None + signs[body] = new_sign + left = instant + segments.append((left, end, tuple(signs.values()))) + return segments + + +def _windows(segments, hits): + windows = [] + for start, end, signs in segments: + if not (contacts := hits.get(signs)): + continue + if not windows or windows[-1]["end"]["jd_ut"] != start["jd_ut"]: + windows.append({"start": start, "end": end, "segments": []}) + current = windows[-1] + current["end"] = end + parts = current["segments"] + if parts and parts[-1]["contacts"] == contacts: + parts[-1]["end"] = end + else: + parts.append({"start": start, "end": end, "contacts": contacts}) + return windows + + +def double_transit(natal, slow, start, end, domains): + if start is None or end is None or slow.get("status") != "computed": + return ap.block("blocked", DOUBLE_TRANSIT, reason="缺少报告区间或木星、土星的行运资料。") + asc = natal.get("lagna_sign_idx") + if type(asc) is not int or not 0 <= asc < 12: + return ap.block("blocked", DOUBLE_TRANSIT, reason="缺少本命上升,无法确定主题宫位。") + segments = _transit_segments(slow, start, end) + if segments is None: + return ap.block("blocked", DOUBLE_TRANSIT, reason="木星或土星的换座记录不完整。") + check = ap._engine()._check_pac + lons = natal.get("longitudes") or {} + sign_pairs = dict.fromkeys(signs for _, _, signs in segments) + per_house = {} + for house in dict.fromkeys(house for houses in domains.values() for house in houses): + hits = _check_house(check, asc, house, lons, sign_pairs) + per_house[house] = ({"house": house, "status": "blocked", "reason": "缺少本命宫主星经度。"} + if hits is None else {"house": house, "status": "computed", "windows": _windows(segments, hits)}) + results = { + key: {"checked_houses": [house for house in houses if per_house[house]["status"] == "computed"], + "rows": [per_house[house] for house in houses]} + for key, houses in domains.items() + } + checked = sorted({house for result in results.values() for house in result["checked_houses"]}) + complete = len(checked) == len(per_house) + return ap.block("computed" if checked else "blocked", DOUBLE_TRANSIT, + producer_status="computed" if complete else "partial" if checked else "blocked", + reason=None if complete else "有宫位缺少本命宫主星经度。", + checked_houses=checked, domains=results, profile="annual_whole_sign_house_and_lord") + + +def sudarshana(natal, domains): + lons = natal.get("longitudes") or {} + if not all(_finite(lons.get(name)) for name in ("Lagna", "Moon", "Sun")): + return ap.block("blocked", SUDARSHANA, reason="缺少本命上升、月亮或太阳经度。") + calc = ap._module("sudarshana_chakra").calc_sudarshana_chakra + planets = {name: lon for name, lon in lons.items() if name != "Lagna" and _finite(lon)} + per_house = {} + for house in dict.fromkeys(house for houses in domains.values() for house in houses): + raw = calc(planets, lons["Lagna"], house=house) + row = raw["specific_house"] + per_house[house] = { + "house": house, + **{ref: row[ref] for ref in ("asc_lagna", "moon_lagna", "sun_lagna")}, + "convergence": [item for item in raw["convergence"]["items"] if item["house"] == house], + "composite_strength": row["composite_strength"], + "judgment": row["interpretation"], + } + return ap.block("computed", SUDARSHANA, producer_status="partial", profile="natal_three_reference_points", note=SUDARSHANA_NOTE, + domains={key: [per_house[house] for house in houses] for key, houses in domains.items()}) + + +def bhrigu_pada(ctx, natal, start, end): + lons = natal.get("longitudes") or {} + if start is None or end is None: + return ap.block("blocked", BHRIGU_PADA, reason="缺少报告区间。") + if not _finite(lons.get("Moon")): + return ap.block("blocked", BHRIGU_PADA, reason="缺少本命月亮经度。") + if not _finite(lons.get("Lagna")): + return ap.block("blocked", BHRIGU_PADA, reason="缺少九分盘上升资料。") + varga = ap._module("varga") + planets = {name: lon for name, lon in lons.items() if name != "Lagna" and _finite(lon)} + d9 = varga.calc_all_vargas(planets, lons["Lagna"], [9]).get("D9_Navamsa") or {} + asc = _sign_index((d9.get("Ascendant") or {}).get("sign")) + if asc is None: + return ap.block("blocked", BHRIGU_PADA, reason="缺少九分盘上升资料。") + seventh_lord = ap.SIGN_LORDS[ap.SIGNS[(asc + 6) % 12]] + lord_sign = _sign_index((d9.get(seventh_lord) or {}).get("sign")) + if lord_sign is None or "Venus" not in d9: + return ap.block("blocked", BHRIGU_PADA, reason="缺少九分盘第七宫主星或金星的位置。") + positions = {name: d9[name]["sign_idx"] * 30 + d9[name]["degree_in_sign"] for name in planets if name in d9} + birth_jd = ap._jd_from_utc(ctx.birth_utc) + calc = ap._module("bhrigu_pada_dasha").bhrigu_pada_dasha_full_report + raw = calc(lons["Moon"], birth_jd, d9_7lord_sign=lord_sign, d9_planets=positions, + target_date_jd=ap._jd_from_utc(start)) + + def age_bands(key): + rows = [] + for age in raw[key]: + # The producer emits integer-age samples. Show their annual buckets at the native year length. + left = ctx.birth_utc + timedelta(days=BHRIGU_YEAR_DAYS * age) + right = left + timedelta(days=BHRIGU_YEAR_DAYS) + if overlap := ap._clip(left, right, start, end, ctx.zone): + rows.append({"age": age, "sign": raw["dasha_sequence"][age]["progressed_sign"], **overlap}) + return rows + + coverage_end = ctx.birth_utc + timedelta(days=len(raw["dasha_sequence"]) * BHRIGU_YEAR_DAYS) + coverage = ap._clip(ctx.birth_utc, coverage_end, start, end, ctx.zone) + if coverage is None: + return ap.block("blocked", BHRIGU_PADA, reason="本期间超出原生引擎的年龄样点范围。") + return ap.block("computed", BHRIGU_PADA, producer_status="parameter_sensitive", profile="bhrigu_pada_approximate_progression", + domain="relationship", claim_boundary=BHRIGU_BOUNDARY, coverage=coverage, + coverage_status="partial" if ctx.birth_utc > start or coverage_end < end else "complete", + year_basis_days=BHRIGU_YEAR_DAYS, reference=raw["bcp_reference"], + marriage_windows=age_bands("marriage_windows"), venus_encounter_windows=age_bands("venus_encounter_windows")) + + +def build(ctx, natal, slow, start, end, domains): + def run(producer, calc): + try: + return calc() + except Exception: # noqa: BLE001 - isolate a failed appendix producer and keep its internals out of reports. + return ap.block("blocked", producer, reason="原生计算未返回可用的结果。") + + return { + "double_transit": run(DOUBLE_TRANSIT, lambda: double_transit( + natal, slow, ap.instant(start, ctx.zone) if start else None, ap.instant(end, ctx.zone) if end else None, domains, + )), + "sudarshana": run(SUDARSHANA, lambda: sudarshana(natal, domains)), + "bhrigu_pada": run(BHRIGU_PADA, lambda: bhrigu_pada(ctx, natal, start, end)), + } diff --git a/tests/test_annual_audit_axes_20261112.py b/tests/test_annual_audit_axes_20261112.py index b7bba675..791c513f 100644 --- a/tests/test_annual_audit_axes_20261112.py +++ b/tests/test_annual_audit_axes_20261112.py @@ -67,7 +67,8 @@ def test_annual_audit_lists_route_techniques_and_distinguishes_unused_from_block assert by_name["Functional Benefic/Malefic"]["profile"] == "pvr_table30_v1" assert by_name["Tajika Yogas"]["raw_path"] == "package.year_charts[*].blocks.tajika_yogas" assert by_name["Tajika Yogas"]["interpretation_status"] == "not_used" - assert by_name["Double Transit PAC"]["calculation_status"] == "not_built" + assert (by_name["Double Transit PAC"]["calculation_status"], + by_name["Double Transit PAC"]["interpretation_status"]) == ("executed", "observation_only") assert by_name["Yoga / Nakshatra Pravesha"]["calculation_status"] == "not_applicable" assert {row["technique"] for row in rows if row.get("visible_in_appendix")} == EXTERNAL assert by_name["实时联网"]["calculation_status"] == "not_built" diff --git a/tests/test_annual_transit_checks_20261114.py b/tests/test_annual_transit_checks_20261114.py new file mode 100644 index 00000000..9ce936b6 --- /dev/null +++ b/tests/test_annual_transit_checks_20261114.py @@ -0,0 +1,164 @@ +"""Regression coverage for the three appendix-only annual checks.""" +from datetime import datetime, timedelta +import json +from pathlib import Path +import subprocess + +import pytest + +from tests.test_annual_report_facts import _body, _facts, annual_api +from tests.test_annual_audit_axes_20261112 import public_facts +from annual_service import package as ap, transit_checks as checks +from annual_service.report_facts import ANNUAL_TRANSIT_CHECK_HOUSES, HOUSE_DOMAINS + +FRONTEND = Path(__file__).resolve().parents[1] / "frontend" +TSX = FRONTEND / "node_modules/tsx/dist/cli.mjs" + + +@pytest.mark.parametrize("planet", ["Jupiter", "Saturn"]) +def test_retrograde_ingress_opens_then_reopens_a_window(planet): + ctx = ap._Context(annual_api._request(_body())) + + def at(day): + return ap.instant(ctx.birth_utc + timedelta(days=day), ctx.zone) + + slow = { + "status": "computed", + "positions_at_range_start": {body: {"sign": "Aries"} for body in checks.SLOW_PAIR}, + "events": [ + {"body": planet, "kind": "ingress", "instant": at(2), "from_sign": "Aries", "to_sign": "Taurus"}, + {"body": planet, "kind": "station", "instant": at(3), "sign": "Taurus"}, + {"body": planet, "kind": "ingress", "instant": at(4), "from_sign": "Taurus", "to_sign": "Aries", "retrograde": True}, + ], + } + natal = {"lagna_sign_idx": 0, "longitudes": {"Mars": 15.0, "Venus": 45.0}} + field = checks.double_transit(natal, slow, at(0), at(6), {"energy": (1,), "wealth": (2,), "relationship": (7,)}) + assert field["checked_houses"] == [1, 2, 7] + energy = field["domains"]["energy"] + assert energy["checked_houses"] == [1] + windows = energy["rows"][0]["windows"] + assert [(row["start"], row["end"]) for row in windows] == [(at(0), at(2)), (at(4), at(6))] + for window in windows: + contacts = window["segments"][0]["contacts"] + assert {contact["target"] for contact in contacts} == {"house", "house_lord"} + for contact in contacts: + assert all(row["type"] == "Position" for hit in contact["effects"].values() for row in hit) + assert not field["domains"]["wealth"]["rows"][0]["windows"] + aspect = field["domains"]["relationship"]["rows"][0]["windows"][0]["segments"][0]["contacts"][0] + assert aspect["effects"] == {body: [{"type": "Aspect", "offset": 7}] for body in checks.SLOW_PAIR} + + +def test_same_house_keeps_one_continuous_window_when_the_target_changes(): + ctx = ap._Context(annual_api._request(_body())) + edges = [ap.instant(ctx.birth_utc + timedelta(days=i), ctx.zone) for i in range(3)] + intervals = [(edges[0], edges[1], (0, 0)), (edges[1], edges[2], (1, 0))] + hits = {(0, 0): [{"target": "house"}], (1, 0): [{"target": "house_lord"}]} + window, = checks._windows(intervals, hits) + assert (window["start"], window["end"]) == (edges[0], edges[2]) + assert [row["contacts"][0]["target"] for row in window["segments"]] == ["house", "house_lord"] + + +def test_public_chart_has_native_boundaries_and_all_six_themes(public_facts): + result = public_facts["page_appendix"]["transit_checks"] + double = result["double_transit"] + assert double["status"] == "computed" + assert ANNUAL_TRANSIT_CHECK_HOUSES == HOUSE_DOMAINS + assert double["checked_houses"] == sorted({house for group in HOUSE_DOMAINS.values() for house in group}) + birth = public_facts["parameters"]["birth"] + local = datetime.fromisoformat(birth["local_datetime"]) + ctx = ap._Context(annual_api._request(_body( + year=local.year, month=local.month, day=local.day, hour=local.hour, minute=local.minute, second=local.second, + lat=birth["latitude"], lon=birth["longitude"], tz=birth["utc_offset_hours"], timezone_id=birth["timezone_id"], + ayanamsa=public_facts["parameters"]["ayanamsa"], node_mode=public_facts["parameters"]["node_mode"], + ))) + start, end = (ctx.return_utc(year) for year in (2026, 2027)) + slow = ap._slow_transits(ctx, ap._natal_chart(ctx), start, end) + expected = {public_facts["report_range"][kind]["jd_ut"] for kind in ("start", "end")} + for event in slow["events"]: + if event["kind"] == "ingress" and event["body"] in checks.SLOW_PAIR: + expected.add(event["instant"]["jd_ut"]) + for domain, houses in HOUSE_DOMAINS.items(): + field = double["domains"][domain] + assert field["checked_houses"] == list(houses) + assert [row["house"] for row in field["rows"]] == list(houses) + for row in field["rows"]: + assert row["status"] == "computed" + for window in row["windows"]: + assert window["start"]["jd_ut"] in expected and window["end"]["jd_ut"] in expected + assert window["start"]["jd_ut"] < window["end"]["jd_ut"] + audit = {r["technique"]: r for r in public_facts["technique_audit_axes"]} + assert result["sudarshana"]["producer_status"] == audit["Sudarshana"]["calculation_status"] == "partial" + assert "逐年推进没有实现" in audit["Sudarshana"]["note"] + assert result["bhrigu_pada"]["producer_status"] == "parameter_sensitive" + assert audit["Bhrigu Pada"]["calculation_status"] == "executed" + assert audit["Bhrigu Pada"]["interpretation_status"] == "observation_only" + + +def test_sudarshana_reuses_native_house_and_convergence(): + ctx = ap._Context(annual_api._request(_body())) + natal = ap._natal_chart(ctx) + planets = dict(natal["longitudes"]) + asc = planets.pop("Lagna") + raw = ap._module("sudarshana_chakra").calc_sudarshana_chakra(planets, asc, house=7) + row = checks.sudarshana(natal, {"relationship": (7,)})["domains"]["relationship"][0] + assert row["judgment"] == raw["specific_house"]["interpretation"] + assert row["convergence"] == [item for item in raw["convergence"]["items"] if item["house"] == 7] + for ref in ("asc_lagna", "moon_lagna", "sun_lagna"): + assert row[ref] == raw["specific_house"][ref] + + +def test_bhrigu_requires_moon_and_d9_and_clips_native_age_samples(): + ctx = ap._Context(annual_api._request(_body())) + natal = ap._natal_chart(ctx) + start = ctx.birth_utc + timedelta(days=42 * 365.25 + 1) + end = start + timedelta(days=30) + for missing in ("Moon", "Lagna", "Venus"): + partial = {"longitudes": {name: lon for name, lon in natal["longitudes"].items() if name != missing}} + field = checks.bhrigu_pada(ctx, partial, start, end) + assert field["status"] == "blocked" and field["reason"] + field = checks.bhrigu_pada(ctx, natal, start, end) + assert field["producer_status"] == "parameter_sensitive" + assert field["claim_boundary"] == "不能据此确定事件时间或婚姻结论" + window, = field["marriage_windows"] + assert window["age"] == 42 + assert (window["start"], window["end"]) == (ap.instant(start, ctx.zone), ap.instant(end, ctx.zone)) + assert not field["venus_encounter_windows"] + + +def _frontend_fixture(): + facts = _facts(report_range={"kind": "birthday_year", "year": 2032}) + return {"checks": facts["page_appendix"]["transit_checks"], + "audit": [r for r in facts["technique_audit_axes"] if "transit_checks" in str(r["raw_path"])]} + + +def test_frontend_fixture_is_captured_from_native_annual_facts(): + fixture = FRONTEND / "tests/fixtures/annual-transit-checks-20261114.json" + assert json.loads(fixture.read_bytes()) == _frontend_fixture() + + +@pytest.mark.skipif(not TSX.is_file(), reason="frontend dependencies required") +def test_writer_evidence_keeps_all_ten_hashes(public_facts): + script = """ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { EMPTY_ANNUAL_SITUATION as situation } from "./src/lib/annual-report-shared.ts"; +import { buildAnnualPlan, sectionEvidence } from "./src/lib/annual-report-sections.ts"; +const current = JSON.parse(readFileSync(0, "utf8")); +const previous = structuredClone(current); +delete previous.page_appendix.transit_checks; +for (const row of previous.technique_audit_axes) { + if (row.raw_path?.includes("transit_checks")) Object.assign(row, { + calculation_status: "not_built", interpretation_status: "not_used", raw_path: null, profile: null, + }); +} +const hashes = facts => buildAnnualPlan(facts, situation).map(entry => { + const evidence = JSON.stringify(sectionEvidence(facts, entry, situation)); + assert.doesNotMatch(evidence, /transit_checks|natal_three_reference_points|bhrigu_pada_approximate_progression/); + return [entry.id, createHash("sha256").update(evidence).digest("hex")]; +}); +assert.equal(hashes(current).length, 10); +assert.deepEqual(hashes(current), hashes(previous)); +""" + subprocess.run(["node", str(TSX), "-e", script], cwd=FRONTEND, check=True, timeout=45, + input=json.dumps(public_facts, ensure_ascii=False).encode("utf-8"))