From 17df8fef7b33e76ecb5f673a995ca8bf9986d300 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Sat, 14 Nov 2026 00:47:47 +0800 Subject: [PATCH] Add four independent Muntha appendix records --- BLOCKED.md | 4 + CHANGELOG.md | 4 + docs/BUG_HISTORY.md | 12 ++ ...PROGRESS-annual-muntha-records-20261113.md | 15 ++ frontend/DESIGN.md | 1 + frontend/src/lib/annual-report-generate.ts | 46 +++++ .../annual-muntha-records-20261113.test.tsx | 50 +++++ .../annual-muntha-records-20261113.json | 195 ++++++++++++++++++ scripts/annual_service/package.py | 6 +- scripts/annual_service/report_facts.py | 88 +++++++- tests/test_annual_muntha_records_20261113.py | 120 +++++++++++ 11 files changed, 539 insertions(+), 2 deletions(-) create mode 100644 docs/tasks/PROGRESS-annual-muntha-records-20261113.md create mode 100644 frontend/tests/annual-muntha-records-20261113.test.tsx create mode 100644 frontend/tests/fixtures/annual-muntha-records-20261113.json create mode 100644 tests/test_annual_muntha_records_20261113.py diff --git a/BLOCKED.md b/BLOCKED.md index c16eb6c3..d6a684d8 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -1,5 +1,9 @@ # BLOCKED +## #257 年运 Muntha 验收环境 + +- Windows Docker Desktop Linux 引擎不可用。碎片扫描和预检定向套件超时,远端和适配器诊断通过。替代证据见 [#257 进度](docs/tasks/PROGRESS-annual-muntha-records-20261113.md)。 + ## #255 年运独立验收环境 - Windows 的 Docker Desktop Linux 引擎返回 HTTP 500,碎片扫描及预检定向套件超时。Docker 用例和完整门禁留待独立验收环境复核。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0148f796..452daa95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-11-14 — 年运附录分列 Muntha 四项记录 + +- 计算依据新增年盘结构、Muntha 主状态、慢星换座与停滞、逐年推进。沿用当前 Skill 版本(BUG-1880)。 + ## 2026-11-13 — 年运报告可导出 Markdown - 写完后从顶栏导出整篇或选取章节,计算依据可整组带走。旧报告也可导出。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 39af822c..72cb7eac 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -25383,3 +25383,15 @@ - 验证及防复发:实际引擎输出回归核对口径、附录及错误配置。旧审计和十段写作证据逐字节对照。详见 [#255 进度](tasks/PROGRESS-annual-indian-only-balance-20261113.md)。 - 关联记录:#255、BUG-1216、BUG-1708、BUG-1722、BUG-1856。 - 修复版本:`codex/annual-indian-only-balance-20261113`,待发布。 + +## BUG-1880 | 年运未分列 Muntha 四项记录及慢星换座 + +- 状态:fixed-pending-verify(待独立验收)。 +- 首次发现 / 最近更新:2026-11-13 / 2026-11-14。 +- 影响面:年运报告计算依据和 Markdown 附录。 +- 现象和触发:读取年运报告时,Muntha 事实仅给位置及其主星,缺少同星座结构、主星状态、覆盖期内慢星事件及逐年推进。 +- 根因:原写作事实只投影年盘结构摘要。慢星扫描结果未按每张年盘的 Muntha 星座和有效区间投影。 +- 修复:附录分四组,复用年盘字段和慢星扫描,按口径与有效区间过滤事件。逐年推进复用原生产者。 +- 验证及防复发:公开盘和虚构盘核对四项、逆行往返、停滞、空区间、岁差与交点、旧快照及写作证据。详见 [#257 进度](tasks/PROGRESS-annual-muntha-records-20261113.md)。 +- 关联记录:#257、BUG-1212、BUG-1219、BUG-1817、BUG-1868。 +- 修复版本:`codex/annual-muntha-records-20261113`,待发布。 diff --git a/docs/tasks/PROGRESS-annual-muntha-records-20261113.md b/docs/tasks/PROGRESS-annual-muntha-records-20261113.md new file mode 100644 index 00000000..c0710458 --- /dev/null +++ b/docs/tasks/PROGRESS-annual-muntha-records-20261113.md @@ -0,0 +1,15 @@ +# #257 年运 Muntha 四项记录 + +执行者 Codex。开工基线 `881fb75ef`,可信任务书提交 `aaddf783`。沿用预留编号 BUG-1880。 + +## 实现 + +- 年盘结构记录 Muntha 星座、从年盘上升数的宫位、同星座行星及起盘地点方案。核对 `annual_tajika_pack.py`、`solar_return.py` 及上游 `3509ccd7` 的 `skills/tajika-annual`。这些入口没有本场景可用的 Muntha 星座相位规则或 Muntha 度数,故相位留为 `not_built`。 +- Muntha 主的落座、落宫、尊贵、逆行、五分力使用同一年盘字段。返照生产者当前没有给出燃烧状态,显示未取得。 +- 慢星行运从现有完整区间扫描中筛选每张年盘与报告重叠区间内的进出和停滞,左含右不含。保存时刻和逆行标记。岁差与交点对照返照引擎回传的年盘口径,月亮和快星不在本轮范围。上游 11 月 6 日审查未批准通用行运容许度,本单使用实际星座边界和停滞。 +- 逐年推进沿用本命上升加已满年数的生产者,列出覆盖的各年及下一年。报告和 Markdown 共用同一附录投影。 + +## 验证 + +- 同一虚构盘在 staging 和本分支以 `PYTHONHASHSEED=0` 实算。十个写作区段的 evidence 哈希一致。区段名和哈希清单 SHA-256 为 `0fbe3e4b5c89dc9330794f4a754e7b19aac262a39899318d184ccc096a24ae78`。 +- 公开盘、现居地虚构盘和事件过滤控制覆盖四项独立、跨年、逆行重复、停滞、空事件、口径不匹配与旧附录。既有断言未改。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index c7cfeb91..08b4ef0f 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1407,6 +1407,7 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting` - **本命瑜伽(BUG-1857):** 年运写作材料中的瑜伽按原有顺序提供完整清单及状态词,沿用本命段落的版式和篇幅。 - **年运附录(BUG-1855):** Narayana 标为条件参考。种子未决时在口径说明中列出候选数,每支单独一行。 - **年运附录(BUG-1878):** 「这份报告的口径」在年内展开说明下列出 Mudda 主结果和两种对照口径。跨年结果不同时按年盘分行。技法执行表保留西方占星各行,计算栏显示「不适用」,使用栏附理由。 +- **年运 Muntha(#257,BUG-1880):** 计算依据按年盘列「Muntha 四项记录」。年盘结构、Muntha 主、慢星进出与停滞、逐年推进各自列出,导出沿用同一顺序。 - **入口**:「我的报告」最上面两个横条并排(手机上下排,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 b9e9473e..3abb6a95 100644 --- a/frontend/src/lib/annual-report-generate.ts +++ b/frontend/src/lib/annual-report-generate.ts @@ -251,6 +251,51 @@ const YEAR_LORD_ROLES: Readonly>> = { varsha_lagna_lord: "年盘上升主", tri_rasi_lord: "Tri-Rasi 主", }; +function munthaRecordLines(value: unknown): string[] { + const data = record(value); + if (!Object.keys(data).length) return []; + const structure = record(data.structure); + const lord = record(data.munthesh); + const transits = record(data.transits); + const progression = record(data.progression); + const zodiac = (value: unknown) => str(value) ? `${signZh(str(value))}座` : "未取得星座"; + const house = (value: unknown) => typeof value === "number" ? `第 ${value} 宫` : "宫位未取得"; + const sameSign = rows(structure.same_sign).map((row) => planetZh(str(row.planet))).filter(Boolean); + const site = structure.return_location_mode === "current_city" ? "按现居地起盘。" : structure.return_location_mode === "birth_place" ? "按出生地起盘。" : ""; + const aspects = str(record(structure.aspects).reason); + const dignity = str(lord.dignity).replace(/\([^)]*\)$/, "").trim(); + const flags = [ + lord.combust === true ? "燃烧" : lord.combust === false ? "未燃烧" : "燃烧状态未取得", + lord.retrograde === true ? "逆行" : lord.retrograde === false ? "顺行" : "顺逆状态未取得", + typeof lord.panchavargiya === "number" ? `五分力 ${lord.panchavargiya}` : "五分力未取得", + ]; + const node = transits.node_mode === "true" ? "真交点" : transits.node_mode === "mean" ? "平均交点" : "交点未标明"; + const events = rows(transits.events).flatMap((event) => { + const when = localMinute(event.instant); + const planet = str(event.body); + if (!when || !planet) return []; + const motion = event.retrograde === true ? "逆行" : ""; + const sign = zodiac(event.sign); + const station = event.motion === "direct_to_retrograde" ? "停滞转逆行" + : event.motion === "retrograde_to_direct" ? "停滞转顺行" : "停滞"; + const action = event.kind === "entry" ? `${motion}进入 Muntha 所在的${sign}` + : event.kind === "exit" ? `${motion}退出 Muntha 所在的${sign}` + : event.kind === "station" ? `在 Muntha 所在的${sign}${station}` : ""; + const offset = str(record(event.instant).utc_offset); + return action ? [`${planetZh(planet)} ${when}${offset ? `(UTC${offset})` : ""} ${action}。`] : []; + }); + const transitLines = transits.status === "computed" + ? (events.length ? events : ["本期间没有慢星进出 Muntha 星座,也没有在该星座停滞。"]) + : [transits.reason === "muntha_scan_frame_mismatch" ? "行运与年盘口径不一致,本期间的行运未列出。" : "本期间的行运未算出。"]; + return [ + `年盘结构:Muntha 在${zodiac(structure.sign)},从年盘上升数为${house(structure.house)}。${site}${sameSign.length ? `同星座:${sameSign.join("、")}。` : structure.sign ? "没有同星座的行星。" : ""}${aspects}`, + `Muntha 主:${str(lord.planet) ? planetZh(str(lord.planet)) : "未取得主星"},${zodiac(lord.sign)},${house(lord.house)},${dignity || "尊贵状态未取得"},${flags.join(",")}。`, + `慢星行运:岁差 ${str(transits.ayanamsa) || "未标明"},${node}。${str(transits.note)}`, + ...transitLines, + `逐年推进:${rows(progression.years).map((row) => `${row.target_year} 年${zodiac(row.sign)}`).join("、") || "未算出"}。`, + ]; +} + function appendicesByYear(root: Row): AnnualAppendixBlock[] { const appendix = record(root.page_appendix); const overview = yearOverviews(root); @@ -271,6 +316,7 @@ function appendicesByYear(root: Row): AnnualAppendixBlock[] { }); if (str(lord.selection_basis_text)) candidates.push(`年主为${planetZh(str(lord.planet))}。${str(lord.selection_basis_text)}`); add("year-lord", "年主星怎么选出", candidates); + add("muntha-records", "Muntha 四项记录", munthaRecordLines(year.muntha_records)); add("tajika-yogas", "Tajika 格局", rows(year.tajika_yogas).map((row) => `${str(row.name)}(${Array.isArray(row.planets) ? row.planets.map((item) => planetZh(str(item))).join("、") : ""})${str(row.state_text)}。`)); const points = record(overview.find((row) => row.target_year === year.target_year)?.special_points); diff --git a/frontend/tests/annual-muntha-records-20261113.test.tsx b/frontend/tests/annual-muntha-records-20261113.test.tsx new file mode 100644 index 00000000..a17e73ab --- /dev/null +++ b/frontend/tests/annual-muntha-records-20261113.test.tsx @@ -0,0 +1,50 @@ +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 { year, yearWithEvents } = JSON.parse(readFileSync(new URL("./fixtures/annual-muntha-records-20261113.json", import.meta.url), "utf8")); +const range = { kind: "birthday_year", key: "annual:birthday_year:2026", label: "生日年度", + span: "2026 到 2027", crossesBirthday: false, birthdays: [] } as const; +const birth = { known: false, beforeMinutes: null, afterMinutes: null, source: null, rectification: "none" } as const; +const makeAppendix = (annualYear: unknown) => buildAppendix({ + cards: { year_overview: [{ target_year: year.target_year }] }, + page_appendix: { years: [annualYear] }, +}, range, birth); + +test("Muntha appendix shows four distinct records with no-event and missing-data wording", () => { + const appendix = makeAppendix(year); + const block = appendix.find((entry) => entry.id === "muntha-records-2026"); + assert.ok(block); + assert.equal(block.title, "Muntha 四项记录"); + const markdown = annualAppendixMarkdown(appendix); + 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 })); + const section = html.split("

Muntha 四项记录

")[1]?.split("")[0] ?? ""; + const positions = ["年盘结构", "Muntha 主", "慢星行运", "逐年推进"].map((label) => section.indexOf(label)); + assert.ok(positions[0]! >= 0 && positions.every((position, i) => i === 0 || position > positions[i - 1]!)); + assert.match(section, /同星座:火星/); + assert.match(section, /燃烧状态未取得/); + assert.match(section, /本期间没有慢星进出 Muntha 星座/); + assert.match(section, /平均交点/); + assert.doesNotMatch(section, /[·_]/); + assert.ok(block.lines.every((line) => markdown.includes(line))); +}); + +test("Muntha appendix formats actual sign boundaries and keeps old snapshots readable", () => { + const block = makeAppendix(yearWithEvents).find((entry) => entry.id === "muntha-records-2026"); + assert.ok(block); + const lines = block.lines.join("\n"); + assert.match(lines, /真交点/); + assert.match(lines, /\d{4}-\d{2}-\d{2} \d{2}:\d{2}(UTC[+-]\d{2}:\d{2}) (?:逆行)?进入 Muntha 所在的/); + assert.match(lines, /退出 Muntha 所在的/); + assert.doesNotMatch(lines, /度数交会|容许度|[·_]/); + assert.equal(makeAppendix({ target_year: 2026 }).some((entry) => entry.id.startsWith("muntha-records-")), false); +}); diff --git a/frontend/tests/fixtures/annual-muntha-records-20261113.json b/frontend/tests/fixtures/annual-muntha-records-20261113.json new file mode 100644 index 00000000..62f92d19 --- /dev/null +++ b/frontend/tests/fixtures/annual-muntha-records-20261113.json @@ -0,0 +1,195 @@ +{ + "year": { + "target_year": 2026, + "muntha_records": { + "structure": { + "sign": "Gemini", + "house": 10, + "return_location_mode": "birth_place", + "same_sign": [ + { + "planet": "Mars", + "relation": "same_sign" + } + ], + "aspects": { + "status": "not_built", + "reason": "现有年盘规则没有可用于此处的 Muntha 星座相位口径。" + } + }, + "munthesh": { + "planet": "Mercury", + "sign": "Cancer", + "house": 11, + "dignity": "中性(Neutral)", + "combust": null, + "retrograde": false, + "panchavargiya": 8.91, + "missing": [ + "combust" + ] + }, + "transits": { + "basis": "sign_boundary", + "ayanamsa": "raman", + "node_mode": "mean", + "coverage": { + "start": { + "utc": "2026-08-05T21:09:24Z", + "local": "2026-08-05T11:09:24-10:00", + "utc_offset": "-10:00", + "timezone_id": "Pacific/Honolulu", + "jd_ut": 2461258.38152408 + }, + "end": { + "utc": "2027-08-06T03:16:30Z", + "local": "2027-08-05T17:16:30-10:00", + "utc_offset": "-10:00", + "timezone_id": "Pacific/Honolulu", + "jd_ut": 2461623.63645874 + } + }, + "events": [], + "note": "按星座边界记录木星、土星、罗睺和计都的换座及停滞。本次不扫描月亮和快星。", + "status": "computed" + }, + "progression": { + "status": "computed", + "basis": "natal_ascendant_plus_completed_years", + "years": [ + { + "target_year": 2026, + "sign": "Gemini" + }, + { + "target_year": 2027, + "sign": "Cancer" + } + ] + } + } + }, + "yearWithEvents": { + "target_year": 2026, + "muntha_records": { + "structure": { + "sign": "Leo", + "house": 10, + "return_location_mode": "birth_place", + "same_sign": [ + { + "planet": "Ketu", + "relation": "same_sign" + } + ], + "aspects": { + "status": "not_built", + "reason": "现有年盘规则没有可用于此处的 Muntha 星座相位口径。" + } + }, + "munthesh": { + "planet": "Sun", + "sign": "Gemini", + "house": 8, + "dignity": "入敌(Enemy Sign)", + "combust": null, + "retrograde": false, + "panchavargiya": 8.3, + "missing": [ + "combust" + ] + }, + "transits": { + "basis": "sign_boundary", + "ayanamsa": "lahiri", + "node_mode": "true", + "coverage": { + "start": { + "utc": "2026-06-15T09:29:25Z", + "local": "2026-06-15T17:29:25+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89543255 + }, + "end": { + "utc": "2027-06-15T15:32:28Z", + "local": "2027-06-15T23:32:28+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461572.14754354 + } + }, + "events": [ + { + "body": "Jupiter", + "instant": { + "utc": "2026-10-31T06:32:44Z", + "local": "2026-10-31T14:32:44+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461344.77272701 + }, + "sign": "Leo", + "kind": "entry", + "retrograde": false + }, + { + "body": "Ketu", + "instant": { + "utc": "2026-11-25T12:16:07Z", + "local": "2026-11-25T20:16:07+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461370.01119518 + }, + "sign": "Leo", + "kind": "exit", + "retrograde": true + }, + { + "body": "Jupiter", + "instant": { + "utc": "2026-12-13T00:56:40Z", + "local": "2026-12-13T08:56:40+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461387.53935623 + }, + "sign": "Leo", + "kind": "station", + "motion": "direct_to_retrograde" + }, + { + "body": "Jupiter", + "instant": { + "utc": "2027-01-24T20:01:29Z", + "local": "2027-01-25T04:01:29+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461430.33436108 + }, + "sign": "Leo", + "kind": "exit", + "retrograde": true + } + ], + "note": "按星座边界记录木星、土星、罗睺和计都的换座及停滞。本次不扫描月亮和快星。", + "status": "computed" + }, + "progression": { + "status": "computed", + "basis": "natal_ascendant_plus_completed_years", + "years": [ + { + "target_year": 2026, + "sign": "Leo" + }, + { + "target_year": 2027, + "sign": "Virgo" + } + ] + } + } + } +} diff --git a/scripts/annual_service/package.py b/scripts/annual_service/package.py index e5d26dd5..a2eacccd 100644 --- a/scripts/annual_service/package.py +++ b/scripts/annual_service/package.py @@ -948,6 +948,7 @@ def _chart_block(report: dict, houses: dict, sr: dict) -> dict[str, Any]: chart = report.get("sr_chart") if isinstance(report.get("sr_chart"), dict) else None if not chart or houses.get("status") == "blocked": return block("blocked", "solar_return.calc_solar_return_chart", reason=str(houses.get("reason") or "annual_chart_missing")) + frame = chart.get("birth_info") or {} asc_lon = float(houses["ascendant_longitude"]) asc_idx = _sign_idx(asc_lon) planets = {} @@ -962,6 +963,8 @@ def _chart_block(report: dict, houses: dict, sr: dict) -> dict[str, Any]: "house": _house(_sign_idx(lon), asc_idx), "speed": row.get("speed"), "retrograde": row.get("retrograde"), + "dignity": row.get("status"), + "combust": row.get("combust"), "nakshatra": row.get("nakshatra"), } lord = SIGN_LORDS[SIGNS[asc_idx]] @@ -974,7 +977,8 @@ def _chart_block(report: dict, houses: dict, sr: dict) -> dict[str, Any]: planets=planets, houses=_plain(houses.get("houses")), house_system="whole_sign", - node_mode_of_chart=report.get("node_mode", "mean"), + ayanamsa=frame.get("ayanamsa_name"), + node_mode_of_chart=frame.get("node_mode") or report.get("node_mode", "mean"), ) diff --git a/scripts/annual_service/report_facts.py b/scripts/annual_service/report_facts.py index 2e2eec82..03c60c7b 100644 --- a/scripts/annual_service/report_facts.py +++ b/scripts/annual_service/report_facts.py @@ -1893,13 +1893,98 @@ def _page_year_lord(blocks: dict, natal: dict) -> dict: } +def _muntha_transits(slow: dict, annual_chart: dict, sign: str | None, coverage: dict) -> dict: + frame = {"ayanamsa": annual_chart.get("ayanamsa"), "node_mode": annual_chart.get("node_mode_of_chart")} + note = "按星座边界记录木星、土星、罗睺和计都的换座及停滞。本次不扫描月亮和快星。" + result = {"basis": "sign_boundary", **frame, "coverage": coverage, "events": [], "note": note} + if slow.get("status") != "computed" or sign not in ap.SIGNS or not coverage: + return {"status": "blocked", "reason": "muntha_scan_inputs_missing", **result} + if not all(value and slow.get(key) == value for key, value in frame.items()): + return {"status": "blocked", "reason": "muntha_scan_frame_mismatch", **result} + start, end = _jd(coverage.get("start")), _jd(coverage.get("end")) + if start is None or end is None or start >= end: + return {"status": "blocked", "reason": "muntha_scan_coverage_missing", **result} + events = [] + for row in slow.get("events") or []: + at = _jd(row.get("instant")) + if at is None or not start <= at < end or row.get("body") not in slow.get("bodies", []): + continue + if row.get("kind") == "ingress": + entered, exited = row.get("to_sign") == sign, row.get("from_sign") == sign + if entered == exited: + continue + detail = {"kind": "entry" if entered else "exit", "retrograde": row.get("retrograde")} + elif row.get("kind") == "station" and row.get("sign") == sign: + detail = {"kind": "station", "motion": row.get("motion")} + else: + continue + events.append({"body": row["body"], "instant": row["instant"], "sign": sign, **detail}) + events.sort(key=lambda row: (row["instant"]["jd_ut"], row["body"], row["kind"])) + return {**result, "status": "computed", "events": events} + + +def _muntha_progression(charts: list[dict]) -> dict: + years, anchor = [], None + for chart in charts: + field = (chart.get("blocks") or {}).get("muntha") or {} + data = (field.get("data") or {}) if field.get("status") == "computed" else {} + years.append({"target_year": chart["target_year"], "sign": data.get("muntha_sign")}) + if isinstance(data.get("natal_asc_sign_idx"), int) and isinstance(data.get("age"), int): + anchor = chart["target_year"], data["natal_asc_sign_idx"], data["age"] + if not years: + return {"status": "blocked", "years": []} + next_year = charts[-1]["target_year"] + 1 + next_sign = None + # A natal ascendant from any available covered return supplies the same annual progression. + if anchor is not None: + anchor_year, natal_ascendant, age = anchor + next_sign = ap._module("solar_return")._muntha_from_natal_ascendant( + natal_ascendant, age + next_year - anchor_year)["muntha_sign"] + years.append({"target_year": next_year, "sign": next_sign}) + return {"status": "computed" if all(row["sign"] for row in years) else "partial", + "basis": "natal_ascendant_plus_completed_years", "years": years} + + +def _muntha_records(chart: dict, slow: dict, progression: dict) -> dict: + blocks = chart["blocks"] + field = blocks["muntha"] + muntha = (field.get("data") or {}) if field.get("status") == "computed" else {} + sign, lord = muntha.get("muntha_sign"), muntha.get("muntha_lord") + annual = blocks["chart"] + planets = annual.get("planets") or {} + same_sign = [{"planet": planet, "relation": "same_sign"} for planet, row in planets.items() + if sign and row.get("sign") == sign] + placement = muntha.get("muntha_lord_placement") or {} + lord_row = planets.get(lord) or {} + strength = (blocks["annual_strength"].get("data") or {}).get("panchavargiya_bala") or {} + munthesh = { + "planet": lord, "sign": placement.get("sign"), "house": placement.get("house_from_annual_ascendant"), + "dignity": lord_row.get("dignity"), "combust": lord_row.get("combust"), "retrograde": lord_row.get("retrograde"), + "panchavargiya": (strength.get(lord) or {}).get("score"), + } + return { + "structure": { + "sign": sign, "house": muntha.get("muntha_house_from_annual_ascendant"), + "return_location_mode": (chart.get("return_location") or {}).get("mode"), + "same_sign": same_sign, + "aspects": {"status": "not_built", "reason": "现有年盘规则没有可用于此处的 Muntha 星座相位口径。"}, + }, + "munthesh": {**munthesh, "missing": [key for key, value in munthesh.items() if value is None]}, + "transits": _muntha_transits(slow, annual, sign, chart.get("in_report_range") or {}), + "progression": progression, + } + + def _page_appendix(package: dict, natal: dict) -> dict: try: from report_render.yoga_status_words import YOGA_STATUS_MAPPING except ImportError: # pragma: no cover - repository-root import from scripts.report_render.yoga_status_words import YOGA_STATUS_MAPPING + charts = package.get("year_charts") or [] + slow = package.get("slow_transits") or {} + progression = _muntha_progression(charts) years = [] - for chart in package.get("year_charts") or []: + for chart in charts: if chart.get("status") != "computed": continue blocks = chart["blocks"] @@ -1910,6 +1995,7 @@ def _page_appendix(package: dict, natal: dict) -> dict: years.append({ "target_year": chart["target_year"], "year_lord": _page_year_lord(blocks, natal), + "muntha_records": _muntha_records(chart, slow, progression), "mudda_balance": { "status": mudda.get("status"), "balance_profile": mudda.get("balance_profile"), diff --git a/tests/test_annual_muntha_records_20261113.py b/tests/test_annual_muntha_records_20261113.py new file mode 100644 index 00000000..098dd2e3 --- /dev/null +++ b/tests/test_annual_muntha_records_20261113.py @@ -0,0 +1,120 @@ +"""The four Muntha appendix records use year-chart and slow-transit evidence.""" + +import json +from pathlib import Path +import subprocess + +import pytest + +from tests.test_annual_audit_axes_20261112 import public_facts +from tests.test_annual_report_facts import _body, _facts, annual_api, annual_report_facts as rf + + +FRONTEND = Path(__file__).resolve().parents[1] / "frontend" +TSX = FRONTEND / "node_modules/tsx/dist/cli.mjs" + + +def test_public_muntha_records_are_independent(public_facts): + year = public_facts["page_appendix"]["years"][0] + records = year["muntha_records"] + fixture = FRONTEND / "tests/fixtures/annual-muntha-records-20261113.json" + assert json.loads(fixture.read_text(encoding="utf-8"))["year"] == { + key: year[key] for key in ("target_year", "muntha_records") + } + assert set(records) == {"structure", "munthesh", "transits", "progression"} + structure, lord = records["structure"], records["munthesh"] + assert structure["aspects"]["status"] == "not_built" + assert all(row["relation"] == "same_sign" for row in structure["same_sign"]) + overview = public_facts["cards"]["year_overview"][0] + assert structure["sign"] == overview["muntha"]["sign"] + assert structure["house"] == overview["muntha"]["annual_house"] + assert lord["planet"] == overview["muntha"]["lord"] + assert lord["dignity"] and type(lord["retrograde"]) is bool and isinstance(lord["panchavargiya"], (int, float)) + assert lord["combust"] is None and "combust" in lord["missing"] + assert "muntha_records" not in overview + assert [row["target_year"] for row in records["progression"]["years"]] == [2026, 2027] + + +def test_fictional_cross_year_report_matches_annual_chart_frame_and_progression(): + single_year = _facts(ayanamsa="lahiri", node_mode="true")["page_appendix"]["years"][0] + fixture = FRONTEND / "tests/fixtures/annual-muntha-records-20261113.json" + assert json.loads(fixture.read_text(encoding="utf-8"))["yearWithEvents"] == { + key: single_year[key] for key in ("target_year", "muntha_records") + } + inputs = dict(report_range={"kind": "calendar_year", "year": 2026}, ayanamsa="lahiri", node_mode="true", + return_location={"mode": "current_city", "latitude": 51.5074, "longitude": -0.1278, + "timezone_id": "Europe/London"}) + facts = _facts(**inputs) + package = annual_api.compute_annual_package(_body(**inputs))["package"] + years = facts["page_appendix"]["years"] + assert [year["target_year"] for year in years] == [2025, 2026] + for year, annual in zip(years, package["year_charts"], strict=True): + records = year["muntha_records"] + assert set(records) == {"structure", "munthesh", "transits", "progression"} + assert records["structure"]["return_location_mode"] == "current_city" + scan = records["transits"] + assert scan["status"] == "computed" and scan["basis"] == "sign_boundary" + assert scan["coverage"] == annual["in_report_range"] + assert scan["ayanamsa"] == annual["blocks"]["chart"]["ayanamsa"] == package["slow_transits"]["ayanamsa"] == "lahiri" + assert scan["node_mode"] == annual["blocks"]["chart"]["node_mode_of_chart"] == package["slow_transits"]["node_mode"] == "true" + assert all(event["sign"] == records["structure"]["sign"] for event in scan["events"]) + assert all("longitude" not in event and "orb" not in event for event in scan["events"]) + progression = records["progression"]["years"] + assert [row["target_year"] for row in progression] == [2025, 2026, 2027] + assert progression[year["target_year"] - 2025]["sign"] == records["structure"]["sign"] + first = rf.ap.SIGNS.index(progression[0]["sign"]) + assert [row["sign"] for row in progression] == [rf.ap.SIGNS[(first + offset) % 12] for offset in range(3)] + + +def test_muntha_sign_filter_preserves_repeated_entries_exits_and_station(): + frame = {"ayanamsa": "raman", "node_mode_of_chart": "true"} + scan = {"status": "computed", "ayanamsa": "raman", "node_mode": "true", "bodies": ["Saturn"], "events": []} + def instant(n): + return {"jd_ut": n} + + def crossing(n, source, target, retrograde): + return {"kind": "ingress", "body": "Saturn", "instant": instant(n), + "from_sign": source, "to_sign": target, "retrograde": retrograde} + + scan["events"] = [ + crossing(5, "Capricorn", "Aquarius", False), + crossing(3, "Aquarius", "Capricorn", True), + crossing(1, "Capricorn", "Aquarius", False), + {"kind": "station", "body": "Saturn", "instant": instant(2), "sign": "Aquarius", + "motion": "direct_to_retrograde", "longitude": 305.0}, + {"kind": "station", "body": "Saturn", "instant": instant(4), "sign": "Capricorn", "motion": "retrograde_to_direct"}, + crossing(7, "Aquarius", "Pisces", False), + ] + coverage = {"start": instant(1), "end": instant(7)} + result = rf._muntha_transits(scan, frame, "Aquarius", coverage) + assert [(row["instant"]["jd_ut"], row["kind"]) for row in result["events"]] == [ + (1, "entry"), (2, "station"), (3, "exit"), (5, "entry"), + ] + assert result["events"][2]["retrograde"] is True + assert "longitude" not in result["events"][1] + empty = rf._muntha_transits(scan, frame, "Leo", coverage) + assert empty["status"] == "computed" and empty["events"] == [] + blocked = rf._muntha_transits(scan, {**frame, "node_mode_of_chart": "mean"}, "Aquarius", coverage) + assert blocked["status"] == "blocked" and blocked["events"] == [] + assert rf._muntha_transits(scan, {**frame, "ayanamsa": "lahiri"}, "Aquarius", coverage)["status"] == "blocked" + + +@pytest.mark.skipif(not TSX.is_file(), reason="frontend dependencies are required for the writer contract") +def test_muntha_appendix_leaves_ten_writer_evidence_hashes_unchanged(public_facts): + program = """ +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 after = JSON.parse(readFileSync(0, "utf8")); +const before = structuredClone(after); +for (const year of before.page_appendix.years) delete year.muntha_records; +const hashes = facts => buildAnnualPlan(facts, situation).map(entry => [ + entry.id, createHash("sha256").update(JSON.stringify(sectionEvidence(facts, entry, situation))).digest("hex"), +]); +assert.equal(hashes(after).length, 10); +assert.deepEqual(hashes(after), hashes(before)); +""" + subprocess.run(["node", str(TSX), "-e", program], cwd=FRONTEND, check=True, timeout=45, + input=json.dumps(public_facts, ensure_ascii=False).encode("utf-8"))