Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6baff3475 | ||
|
|
4fed3a959f |
@@ -1,5 +1,9 @@
|
||||
# BLOCKED
|
||||
|
||||
## #264 年运 KP 验收环境
|
||||
|
||||
- Windows Docker Linux 引擎不可用,预检碎片扫描及定向套件超时。用同机 staging 对照和针对性回归提供替代证据。见 [#264 进度](docs/tasks/PROGRESS-annual-kp-20261114.md)。
|
||||
|
||||
## #257 年运 Muntha 验收环境
|
||||
|
||||
- Windows Docker Desktop Linux 引擎不可用。碎片扫描和预检定向套件超时,远端和适配器诊断通过。替代证据见 [#257 进度](docs/tasks/PROGRESS-annual-muntha-records-20261113.md)。
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-11-14 — 年运附录增加 KP 宫头和子主
|
||||
|
||||
- 按主题列出 Placidus 宫头及子主。记录出生时间误差范围内的变化,误差未知时标注参考取样。沿用当前 Skill 版本(BUG-1884)。
|
||||
|
||||
## 2026-11-14 — 年运附录分列 Muntha 四项记录
|
||||
|
||||
- 计算依据新增年盘结构、Muntha 主状态、慢星换座与停滞、逐年推进。沿用当前 Skill 版本(BUG-1880)。
|
||||
|
||||
@@ -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-1884 | 年运未记录 KP 宫头子主和出生精度分支
|
||||
|
||||
- 状态:fixed-pending-verify(待独立验收)。
|
||||
- 首次发现 / 最近更新:2026-11-12 / 2026-11-14。
|
||||
- 影响面:年运计算依据、技法执行表及 Markdown 导出。
|
||||
- 现象和触发:读取年运技法执行表,KP 宫头及子主一直列为未完成。
|
||||
- 根因:年包未把 Placidus 真宫头、原生 KP 代表星和出生时间范围接入独立观察层。
|
||||
- 修复:使用与生时纠正相同的 Placidus 计算口径,保留原始精度,按宫头至下一宫头判落宫。按主题记录子主分钟样点和分支,实际误差未知时区分参考取样。无真宫头时阻塞。
|
||||
- 验证和防复发:公开盘和生时纠正引擎对照,合成盘覆盖稳定、跨界、误差未知、极区无宫头,另有页面、审计及写作哈希回归。见 [#264 进度](tasks/PROGRESS-annual-kp-20261114.md)。
|
||||
- 关联记录:#264、BUG-1319、BUG-1383、BUG-1543、BUG-1709。
|
||||
- 修复版本:`codex/annual-kp-20261114`。
|
||||
|
||||
@@ -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 · #264 年运 KP 预检
|
||||
|
||||
- 本机 Python、Gitea 远端可见性与引擎适配器诊断通过。碎片扫描和定向套件分别在 90 秒、45 秒超时。见 [#264 进度](../tasks/PROGRESS-annual-kp-20261114.md)。
|
||||
|
||||
## 2026-11-13 · #239 选盘续问预检
|
||||
|
||||
- 目录扫描和定向预检分别在 90 秒和 45 秒超时,Gitea 远端、Python 和适配器检查通过。并发运行 Next 构建时,Windows 快速门的来源清单扫描遇到被构建替换的 .next 文件,快速门改用独立 worktree。
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# #264 年运 KP 宫头子主与出生精度
|
||||
|
||||
- [任务书](TASK-annual-kp-20261114.md)
|
||||
- 基线 `a09fbfd3e3e0cbe15342a00b31569329abfde209`
|
||||
- 执行分支 `codex/annual-kp-20261114`
|
||||
- BUG-1884 按任务书预留。BUG-1881 至 BUG-1883 由并行工单使用。
|
||||
|
||||
## 实现
|
||||
|
||||
- 使用与生时纠正一致的恒星黄道 Placidus 宫头算法和报告请求的岁差参数。经度保留原值,行星按宫头至下一宫头归属。真实宫头不可得时该层阻塞。
|
||||
- 按六个主题列出宫头、星宿主星、子主和原生 ABCD 代表星。逐分钟取样,记录同一子主的连续样点,跨界时保留全部分支。误差未知仍写未知,前后各 30 分钟只供参考。区间端点是取样时刻。
|
||||
- 附录独立折叠展示 KP 宫头,注明与正文整宫制的区别。双轴审计记录跨界宫位和实际用途。页面与 Markdown 共用文案。
|
||||
|
||||
## 验证
|
||||
|
||||
- 公开盘对照生时纠正的宫头星宿主星与子主,合成盘覆盖稳定、跨界、误差未知及极区无法计算。页面与 Markdown 核对同一份真实引擎样本。
|
||||
- 年运 Python 定向回归 51 项通过。公开盘和合成盘各十个写作区段的 evidence 哈希与开工 staging 逐项一致,旧单轴审计哈希通过。
|
||||
- TypeScript、lint 和生产构建通过。lint 为 0 error、62 warnings。基线和分支首页均为静态,首页 gzip 均为 JS 516100 B、CSS 92069 B。
|
||||
- 前端无 Docker 全量 3734 项,3652 通过、45 失败、37 跳过、0 取消。基线 3733 项,少一项新通过用例,失败名称逐项一致。
|
||||
- Python 快速门 1489 项通过、2 项跳过,另有 29 个子测试通过。三项 Kaala / PyJHora 失败与同机 staging 一致。另有一项咨询缓存时序断言在全量运行失败,分支和基线各自定向复跑均通过。快速门退出 1,完整绿灯留待独立验收。
|
||||
- 仓库隐私检查 62 项通过。
|
||||
- 远端可见性和外部适配器诊断通过。Windows 碎片扫描和预检定向套件超时,Docker Linux 引擎不可用。
|
||||
|
||||
## 交接
|
||||
|
||||
- 既有断言保持原样,Skill 版本沿用。请以 PR head 做独立验收。
|
||||
@@ -33,6 +33,7 @@
|
||||
|
||||
| 任务书 | 进度 | 主题 | 状态 | 落点 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [#264 任务书](TASK-annual-kp-20261114.md) | [执行进度](PROGRESS-annual-kp-20261114.md) | 年运 KP 宫头子主与出生精度(BUG-1884) | 待验收 | `codex/annual-kp-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` |
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# TASK:年运 KP 宫头子主与出生精度分支(2026-11-14)
|
||||
|
||||
- Gitea:#264
|
||||
- 分支:`codex/annual-kp-20261114`,从 staging `a09fbfd3` 切出。
|
||||
- Bug 编号:BUG-1884。开工时核对 `docs/BUG_HISTORY.md` 末尾;已被占用就顺延,并在 PROGRESS 写明。
|
||||
- 负责验收:Claude(jyotisha-99)。
|
||||
|
||||
任务书没写到的取舍,不得写成"产品决定"或"裁定",一律标「需拍板」。不得修改或删除既有断言来让测试通过。确需修改的,在 PROGRESS 逐条写明原值、新值和原因。
|
||||
|
||||
同时在做的还有 #262、#263,它们改同样几个文件。各单只改自己的函数;后合入的负责 rebase。
|
||||
|
||||
## 背景
|
||||
|
||||
占星师 2026-11-12 年运 Q3(原意):
|
||||
- KP 在涉及事件或细时窗时执行,保留独立参数。
|
||||
- 出生精度不支持时,精确结论要分支或阻塞,不伪造精度。
|
||||
- KP 与整宫制的结果不同,写成方法差异(R-05)。
|
||||
|
||||
现状:
|
||||
- 审计「KP / Sub-lord」是 `not_built`。
|
||||
- 引擎:`kp_system.calc_kp_analysis(planet_positions, asc_sign, house_cusps=None, house_occupancy_basis=None)`,输出 `house_basis`(`explicit_cusps` 或 `whole_sign_proxy`)、宫和星的 `kp_lords` 与 significators。
|
||||
- 真实宫头可以对照生时纠正链路:`chart_choice/astrologer_engine.py:112 kp_placidus_cusps`,以及 `astrologer.py` 226、244、311 行(没有宫头时 kp_layer 写 blocked)。
|
||||
- 精度机制:`report_facts._birth_survey` / `_stability`,取法沿用 #224(用户自报误差,否则 ±30 分钟)。
|
||||
|
||||
## 要做的
|
||||
|
||||
1. **只用真实宫头**:用与生时纠正相同的 KP Placidus 宫头算法。拿不到宫头时整块写 blocked,不退回 `whole_sign_proxy`。
|
||||
2. **只看主题宫**:用 `HOUSE_DOMAINS` 里各主题的宫位,记录每个宫的宫头星座、星主、子主和 significators(引擎原样输出)。
|
||||
3. **精度分支**
|
||||
- 在出生时间窗口内取样,看每个主题宫的宫头子主会不会变。
|
||||
- 窗口内不变:标 `stable_in_window`。
|
||||
- 会变:标 `boundary_sensitive`,列出所有出现过的子主和对应的出生时刻区间,不选其中一个。
|
||||
- 窗口未知:照 `_birth_survey` 的 `range_unknown` 写,并说明「出生时间误差未知,子主是否稳定无法判断」。
|
||||
4. **与整宫制分开**:KP 的宫位结果单独一节,写明「KP 宫头制,与正文的整宫制是不同方法」,不和整宫结论比对,也不合并。
|
||||
5. **审计**:「KP / Sub-lord」写实际状态。
|
||||
- 宫头可得:`executed` + `observation_only`。有跨界的宫,note 里写出来。
|
||||
- 拿不到宫头:blocked。
|
||||
6. **页面附录**新增小节「KP 宫头(另一种分宫方法)」,按主题列出宫头子主和稳定性。行数多时折叠。
|
||||
7. **写作输入不变**:十个写作区段的 evidence 哈希不变。
|
||||
|
||||
## 不要碰
|
||||
|
||||
- 冻结文件只能 import。`kp_system.py` 和生时纠正链路的文件不改,只调用。
|
||||
- `references/**`、`consult-preset-prompts.ts`。
|
||||
|
||||
## 要改的文件
|
||||
|
||||
- `scripts/annual_service/package.py`、`scripts/annual_service/report_facts.py`
|
||||
- `frontend/src/lib/annual-report-generate.ts`
|
||||
- 测试:`tests/test_annual_kp_20261114.py`、`frontend/tests/annual-kp-20261114.test.tsx`
|
||||
- 覆盖:窗口内稳定;跨子主边界;窗口未知;拿不到宫头时 blocked、不退回整宫;写作输入哈希不变。
|
||||
- `docs/BUG_HISTORY.md`,只在末尾追加
|
||||
- `docs/tasks/PROGRESS-annual-kp-20261114.md`
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 公开名人盘上各主题宫的子主和稳定性都列出来了,`house_basis` 是真实宫头。
|
||||
2. 合成盘测试覆盖第 3 节的三种情况,以及拿不到宫头的情况。
|
||||
3. 附录明确写着这是另一种分宫方法;正文没有变化。
|
||||
4. 用户可见文字里没有「·」、字段名或下划线。
|
||||
5. tsc、eslint(0 错)、`npm test`(基线失败除外)、quick gate 全部通过。
|
||||
@@ -1409,6 +1409,7 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`
|
||||
- **年运附录(BUG-1855):** Narayana 标为条件参考。种子未决时在口径说明中列出候选数,每支单独一行。
|
||||
- **年运附录(BUG-1878):** 「这份报告的口径」在年内展开说明下列出 Mudda 主结果和两种对照口径。跨年结果不同时按年盘分行。技法执行表保留西方占星各行,计算栏显示「不适用」,使用栏附理由。
|
||||
- **年运 Muntha(#257,BUG-1880):** 计算依据按年盘列「Muntha 四项记录」。年盘结构、Muntha 主、慢星进出与停滞、逐年推进各自列出,导出沿用同一顺序。
|
||||
- **年运 KP 宫头(#264,BUG-1884):** 计算依据增加独立小节,首行说明分宫口径及未知误差,按主题折叠宫头和子主分钟样点。审计列出子主变化的宫位,Markdown 导出使用同一文案。
|
||||
- **入口**:「我的报告」最上面两个横条并排(手机上下排,2026-11-03 方案 A,见 Personal report centre):「年运」横条是 `Sun` 图标方块、标题、一行「这一年每段怎么安排,几个要留意的时间点」,右边描边按钮「打开年运」(手机「打开」),直接打开 `/reports/annual`。没有单独的入口页。个人报告横条上不写份数。
|
||||
- **地址**:`/reports/annual`(`?report=<id>` 打开某一份),放在 `/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 点,写到一半断掉不扣」。价格读不到时这一行写「计费配置不可用」,按钮不可点。没有别的提示小字。
|
||||
|
||||
@@ -397,6 +397,7 @@ const AUDIT_NAMES: Readonly<Record<string, string>> = {
|
||||
"Tajika Aspects / Panchavargiya Bala": "五分力",
|
||||
"Tajika Aspects": "Tajika 相位",
|
||||
"Functional Benefic/Malefic": "功能属性",
|
||||
"KP / Sub-lord": "KP 宫头子主",
|
||||
};
|
||||
|
||||
function annualAudit(root: Row): AnnualAppendixBlock {
|
||||
@@ -407,7 +408,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 reason = name === "KP / Sub-lord" || ["blocked", "not_applicable", "not_built"].includes(axes.calculation_status)
|
||||
? str(row.note) : "";
|
||||
return {
|
||||
method: AUDIT_NAMES[name] ?? displayTechniqueAuditLabel(name),
|
||||
calculation: techniqueCalculationStatusLabel(axes.calculation_status),
|
||||
@@ -469,6 +471,44 @@ function muddaBalanceLines(root: Row): string[] {
|
||||
return lines.map(({ year, text }) => `${year} 年 Mudda 余额口径:${text}`);
|
||||
}
|
||||
|
||||
const KP_THEMES: Readonly<Record<string, string>> = {
|
||||
career: "事业", wealth: "财富", relationship: "关系", home: "家宅", energy: "身心", learning: "学习",
|
||||
};
|
||||
|
||||
function kpCuspAppendix(root: Row): AnnualAppendixBlock[] {
|
||||
const kp = record(record(root.page_appendix).kp_cusps);
|
||||
if (!kp.status) return [];
|
||||
const precision = record(kp.precision);
|
||||
const domains = record(kp.domains);
|
||||
const ayanamsa = AYANAMSA_NAMES[str(kp.ayanamsa)] ?? "未标明";
|
||||
const node = kp.node_mode === "true" ? "真交点" : "平交点";
|
||||
const time = (edge: unknown) => str(record(edge).local).slice(0, 19).replace("T", " ");
|
||||
const band = (branch: Row) => {
|
||||
const start = time(branch.first_sample);
|
||||
const end = time(branch.last_sample);
|
||||
return `${start === end ? start : `${start} 至 ${end}`}为${planetZh(str(branch.sub_lord))}`;
|
||||
};
|
||||
const lines = Object.entries(KP_THEMES).flatMap(([domain, title]) => rows(domains[domain]).map((row) => {
|
||||
const lords = record(row.kp_lords);
|
||||
const branches = rows(row.branches);
|
||||
const cusp = `${title}第 ${row.house} 宫。记录时刻的宫头在${signZh(str(row.sign))}座,星宿主星为${planetZh(str(lords.nakshatra_lord))}。`;
|
||||
if (row.stability === "boundary_sensitive") return `${cusp}出生时间范围内子主有变化。逐分钟取样,${branches.map(band).join(";")}。`;
|
||||
const lord = `记录时刻的子主为${planetZh(str(lords.sub_lord))}。`;
|
||||
if (row.stability === "stable_in_window") return `${cusp}${lord}出生时间范围内的分钟样点一致。`;
|
||||
const variations = branches.length > 1 ? `参考取样中,${branches.map(band).join(";")}。` : "";
|
||||
return `${cusp}${lord}误差未知,稳定性未判定。${variations}`;
|
||||
}));
|
||||
return [{
|
||||
id: "kp-cusps", title: "KP 宫头(另一种分宫方法)", collapseAfterFirst: true,
|
||||
lines: kp.status === "computed" ? [
|
||||
`${str(kp.method_note)}。${precision.status === "range_unknown"
|
||||
? `${str(precision.note)}。参考取样用记录时间的前后各 30 分钟。` : ""}`,
|
||||
`采用 Placidus 宫头、${ayanamsa} 岁差与${node}。`,
|
||||
...lines,
|
||||
] : [str(kp.reason) || "KP 宫头未算出。"],
|
||||
}];
|
||||
}
|
||||
|
||||
export function buildAppendix(facts: unknown, range: AnnualSnapshot["range"], birth: AnnualBirthTime, cityLabel?: string | null): AnnualAppendixBlock[] {
|
||||
const root = record(facts);
|
||||
const parameters = record(root.parameters);
|
||||
@@ -515,6 +555,7 @@ export function buildAppendix(facts: unknown, range: AnnualSnapshot["range"], bi
|
||||
},
|
||||
...muddaCrossingBlocks(root),
|
||||
...appendicesByYear(root),
|
||||
...kpCuspAppendix(root),
|
||||
annualAudit(root),
|
||||
{
|
||||
id: "conflicts",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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 { known, audit, unknown, blocked } = JSON.parse(readFileSync(
|
||||
new URL("./fixtures/annual-kp-20261114.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 appendixFor = (kp: unknown) => buildAppendix({
|
||||
page_appendix: { kp_cusps: kp }, technique_audit_axes: [audit],
|
||||
}, range, birth);
|
||||
|
||||
test("annual KP appendix distinguishes stable and crossing sub-lords from an unknown or blocked window", () => {
|
||||
const appendix = appendixFor(known);
|
||||
const block = appendix.find((row) => row.id === "kp-cusps");
|
||||
assert.ok(block?.collapseAfterFirst);
|
||||
assert.equal(block.title, "KP 宫头(另一种分宫方法)");
|
||||
const markdown = annualAppendixMarkdown([block]);
|
||||
assert.match(markdown, /KP 宫头制,与正文的整宫制是不同方法/);
|
||||
assert.match(markdown, /采用 Placidus 宫头、Raman 岁差与平交点/);
|
||||
assert.match(markdown, /事业第 10 宫.*分钟样点一致/);
|
||||
assert.match(markdown, /事业第 6 宫.*子主有变化.*逐分钟取样.*\d{2}:\d{2}:\d{2}.*;/);
|
||||
assert.match(markdown, /关系第 7 宫/);
|
||||
assert.doesNotMatch(markdown, /[·_]|stable_in_window|boundary_sensitive|range_unknown/);
|
||||
const auditRow = appendix.find((row) => row.id === "execution")?.audit?.[0];
|
||||
assert.ok(auditRow?.usage.includes("子主有变化"));
|
||||
const unknownLines = appendixFor(unknown).find((row) => row.id === "kp-cusps")?.lines.join("\n") ?? "";
|
||||
assert.match(unknownLines, /出生时间误差未知,子主是否稳定无法判断/);
|
||||
assert.match(unknownLines, /前后各 30 分钟/);
|
||||
assert.deepEqual(appendixFor(blocked).find((row) => row.id === "kp-cusps")?.lines,
|
||||
["出生地的 Placidus 宫头无法计算。"]);
|
||||
assert.ok(!appendixFor(undefined).some((row) => row.id === "kp-cusps"));
|
||||
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(html.includes(block.title));
|
||||
assert.match(html, /展开其余/);
|
||||
});
|
||||
+1115
File diff suppressed because it is too large
Load Diff
@@ -131,7 +131,8 @@ def compute_annual_package(body: dict[str, Any]) -> dict[str, Any]:
|
||||
detail = body.get("detail") or "full"
|
||||
if detail not in ("full", "slim"):
|
||||
raise AnnualServiceInputError("detail must be full or slim")
|
||||
package = _package.build_annual_package(request, detail=detail)
|
||||
window = _birth_window(body.get("birth_window")) if detail == "full" else None
|
||||
package = _package.build_annual_package(request, detail=detail, birth_window=window)
|
||||
return {
|
||||
"success": True,
|
||||
"endpoint": "annual",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Natal KP cusps and minute-resolution birth-window observations for the appendix."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta, timezone
|
||||
from math import isfinite
|
||||
|
||||
from . import package as ap
|
||||
|
||||
PRODUCER = "kp_system.calc_kp_analysis"
|
||||
METHOD_NOTE = "KP 宫头制,与正文的整宫制是不同方法"
|
||||
UNKNOWN_NOTE = "出生时间误差未知,子主是否稳定无法判断"
|
||||
NO_CUSPS = "出生地的 Placidus 宫头无法计算。"
|
||||
|
||||
|
||||
def _cusps(calculator, ctx, jd):
|
||||
# The rectification KP helper uses the same Swiss Ephemeris Placidus
|
||||
# calculation and sidereal offset, but rounds the returned longitude.
|
||||
values = calculator.calculate_cusps(0.0, 0.0, "placidus", jd, ctx.birth["lat"], ctx.birth["lon"])
|
||||
if len(values) != 12 or not all(isfinite(value) and 0 <= value < 360 for value in values):
|
||||
raise ValueError("invalid Placidus cusps")
|
||||
return values
|
||||
|
||||
|
||||
def _runs(samples, birth_utc, zone):
|
||||
intervals = []
|
||||
for offset, lord in samples:
|
||||
at = ap.instant(birth_utc + timedelta(minutes=offset), zone)
|
||||
if intervals and intervals[-1]["sub_lord"] == lord:
|
||||
intervals[-1]["last_sample"] = at
|
||||
else:
|
||||
intervals.append({"sub_lord": lord, "first_sample": at, "last_sample": at})
|
||||
return intervals
|
||||
|
||||
|
||||
def _calculate(ctx, natal, window, domains):
|
||||
if abs(ctx.birth["lat"]) >= 66:
|
||||
return ap.block("blocked", PRODUCER, reason=NO_CUSPS)
|
||||
lons = natal.get("longitudes") or {}
|
||||
planets = ap.NATAL_POINTS[1:]
|
||||
if natal.get("status") != "computed" or any(name not in lons or not isfinite(lons[name]) for name in planets):
|
||||
return ap.block("blocked", PRODUCER, reason="缺少本命行星经度,无法计算 KP 宫位的代表星。")
|
||||
|
||||
kp = ap._module("kp_system")
|
||||
ayanamsa = ap._module("ayanamsa_utils")
|
||||
lock = ap._module("domain_calculation_service").swiss_ephemeris_lock
|
||||
calculator = ap._module("bhava_chalit").BhavaChalitCalculator()
|
||||
houses = tuple(dict.fromkeys(house for group in domains.values() for house in group))
|
||||
known = window is not None and window["known"]
|
||||
before, after = (window["before_minutes"], window["after_minutes"]) if known else (30, 30)
|
||||
zone = timezone(timedelta(hours=ctx.tz_hours))
|
||||
samples = {house: [] for house in houses}
|
||||
birth_jd = ap._jd_from_utc(ctx.birth_utc)
|
||||
with lock(), ayanamsa.temporary_ayanamsa(ctx.ayanamsa):
|
||||
natal_cusps = _cusps(calculator, ctx, birth_jd)
|
||||
for offset in range(-before, after + 1):
|
||||
moment = ctx.birth_utc + timedelta(minutes=offset)
|
||||
cusps = _cusps(calculator, ctx, ap._jd_from_utc(moment)) if offset else natal_cusps
|
||||
for house in houses:
|
||||
samples[house].append((offset, kp.get_kp_lords(cusps[house - 1])["sub_lord"]))
|
||||
|
||||
positions = {
|
||||
name: {"sign": ap.SIGNS[ap._sign_idx(lons[name])], "degree": lons[name] % 30,
|
||||
"house": calculator._planet_bhava_from_cusps(lons[name], natal_cusps)}
|
||||
for name in planets
|
||||
}
|
||||
result = kp.calc_kp_analysis(
|
||||
positions, ap.SIGNS[ap._sign_idx(natal_cusps[0])], house_cusps=natal_cusps, house_occupancy_basis="cusp_to_cusp",
|
||||
)
|
||||
if result.get("house_basis") != "explicit_cusps":
|
||||
return ap.block("blocked", PRODUCER, reason=NO_CUSPS)
|
||||
rows = {}
|
||||
changed = []
|
||||
for house in houses:
|
||||
intervals = _runs(samples[house], ctx.birth_utc, zone)
|
||||
if len(intervals) > 1:
|
||||
changed.append(house)
|
||||
stability = "range_unknown"
|
||||
if known:
|
||||
stability = "boundary_sensitive" if len(intervals) > 1 else "stable_in_window"
|
||||
rows[house] = {"house": house, **result["houses"][house], "stability": stability, "branches": intervals}
|
||||
|
||||
return ap.block(
|
||||
"computed", PRODUCER, profile="sidereal_placidus_cusp_to_cusp",
|
||||
house_basis=result["house_basis"], house_occupancy_basis=result["house_occupancy_basis"],
|
||||
house_system="placidus", ayanamsa=ctx.ayanamsa, node_mode=ctx.node_mode, method_note=METHOD_NOTE,
|
||||
precision={"source": "declared" if known else "reference",
|
||||
"before_minutes": before, "after_minutes": after, "resolution_minutes": 1,
|
||||
"status": "checked" if known else "range_unknown",
|
||||
"note": None if known else UNKNOWN_NOTE, "changing_houses": sorted(changed)},
|
||||
domains={domain: [rows[house] for house in group] for domain, group in domains.items()},
|
||||
)
|
||||
|
||||
|
||||
def build(ctx, natal, window, domains):
|
||||
try:
|
||||
return _calculate(ctx, natal, window, domains)
|
||||
except Exception: # noqa: BLE001 - a failed cusp sample blocks the appendix and no proxy can replace it.
|
||||
return ap.block("blocked", PRODUCER, reason=NO_CUSPS)
|
||||
@@ -480,7 +480,8 @@ class _Context:
|
||||
)
|
||||
|
||||
|
||||
def build_annual_package(request: dict[str, Any], *, detail: str = "full") -> dict[str, Any]:
|
||||
def build_annual_package(request: dict[str, Any], *, detail: str = "full",
|
||||
birth_window: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Build the annual package from a ``normalize_request`` result.
|
||||
|
||||
``detail="slim"`` (#154 entry overlay) returns only the report range, each
|
||||
@@ -518,6 +519,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 .kp_appendix import build as build_kp_appendix
|
||||
from .report_facts import HOUSE_DOMAINS
|
||||
|
||||
package = {
|
||||
"schema": SCHEMA,
|
||||
"annual_package_version": ANNUAL_PACKAGE_VERSION,
|
||||
@@ -528,6 +532,7 @@ def build_annual_package(request: dict[str, Any], *, detail: str = "full") -> di
|
||||
"slow_transits": slow,
|
||||
"transit_context": transit_context,
|
||||
"independent_annual_views": {
|
||||
"kp_cusps": build_kp_appendix(ctx, natal, birth_window, HOUSE_DOMAINS),
|
||||
"tithi_pravesha": block(
|
||||
"computed" if tithi else "blocked",
|
||||
"annual_native.tithi_pravesh.build_tithi_pravesh",
|
||||
|
||||
@@ -1806,13 +1806,24 @@ 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")
|
||||
kp = annual_views.get("kp_cusps") or {}
|
||||
kp_precision = kp.get("precision") or {}
|
||||
changed = "、".join(str(house) for house in kp_precision.get("changing_houses", ()))
|
||||
kp_note = kp_precision.get("note") or "已计算真实宫头。"
|
||||
if changed:
|
||||
source = "参考取样内" if kp_precision.get("source") == "reference" else "出生时间范围内"
|
||||
kp_note = f"{kp_note.rstrip('。')}。{source}第 {changed} 宫的子主有变化。"
|
||||
rows.append(_audit_axes_row(
|
||||
"KP / Sub-lord", kp.get("status", "blocked"), kp.get("reason") or kp_note,
|
||||
raw_path="package.independent_annual_views.kp_cusps" if kp else None,
|
||||
profile=kp.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"),
|
||||
@@ -2146,14 +2157,15 @@ 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, "kp_cusps": views.get("kp_cusps") or {}}
|
||||
|
||||
|
||||
def build_annual_report_facts(request: dict[str, Any], *, birth_window: Any = None) -> dict[str, Any]:
|
||||
package = ap.build_annual_package(request)
|
||||
ctx = ap._Context(request)
|
||||
window = normalize_birth_window(birth_window)
|
||||
package = ap.build_annual_package(request, birth_window=window)
|
||||
ctx = ap._Context(request)
|
||||
survey = ap._guard("annual_service.report_facts._birth_survey", lambda: {"status": "computed", **_birth_survey(ctx, window, package)})
|
||||
if survey.get("status") != "computed":
|
||||
survey = {"check": {"status": "range_unknown", "varies": [], "candidates": {}, "boundaries": [],
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Annual KP uses unrounded Placidus cusps and keeps its own birth-window evidence."""
|
||||
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 kp_appendix, package as ap
|
||||
from annual_service.report_facts import HOUSE_DOMAINS
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
|
||||
TSX = FRONTEND / "node_modules/tsx/dist/cli.mjs"
|
||||
WINDOW = {"known": True, "before_minutes": 1, "after_minutes": 1}
|
||||
|
||||
|
||||
def _kp(body):
|
||||
package = annual_api.compute_annual_package(body)["package"]
|
||||
return package["independent_annual_views"]["kp_cusps"]
|
||||
|
||||
|
||||
def test_public_chart_uses_real_rectification_cusps_and_cusp_to_cusp_significators(public_facts):
|
||||
field = public_facts["page_appendix"]["kp_cusps"]
|
||||
assert field["house_basis"] == "explicit_cusps"
|
||||
assert field["house_occupancy_basis"] == "cusp_to_cusp"
|
||||
assert field["house_system"] == "placidus"
|
||||
params = public_facts["parameters"]
|
||||
birth = params["birth"]
|
||||
at = datetime.fromisoformat(birth["instant"]["utc"])
|
||||
rectification = ap._module("chart_choice.astrologer_engine")
|
||||
native = rectification.kp_placidus_cusps(
|
||||
{"birth_info": {"julian_day": ap._jd_from_utc(at)}},
|
||||
birth["latitude"], birth["longitude"], field["ayanamsa"],
|
||||
)
|
||||
assert field["ayanamsa"] == params["ayanamsa"]
|
||||
for domain, houses in HOUSE_DOMAINS.items():
|
||||
rows = field["domains"][domain]
|
||||
assert tuple(row["house"] for row in rows) == houses
|
||||
for row in rows:
|
||||
cusp = native[row["house"]]
|
||||
assert row["cusp_longitude"] == pytest.approx(cusp["cusp_degree"], abs=0.000051)
|
||||
assert row["kp_lords"]["sub_lord"] == cusp["sub_lord"]
|
||||
assert row["kp_lords"]["nakshatra_lord"] == cusp["nakshatra_lord"]
|
||||
assert set(row["significators"]) == {"A", "B", "C", "D"}
|
||||
occupied = row["significators"]["B"]
|
||||
assert set(occupied) <= set(ap.NATAL_POINTS[1:])
|
||||
assert row["stability"] == "range_unknown"
|
||||
audit = next(r for r in public_facts["technique_audit_axes"] if r["technique"] == "KP / Sub-lord")
|
||||
assert (audit["calculation_status"], audit["interpretation_status"]) == ("executed", "observation_only")
|
||||
assert "参考取样内" in audit["note"] and "子主有变化" in audit["note"]
|
||||
|
||||
|
||||
def test_declared_window_keeps_every_minute_and_separates_stable_and_crossing_houses():
|
||||
field = _kp(_body(birth_window=WINDOW))
|
||||
assert field["precision"]["source"] == "declared"
|
||||
stable, changing = field["domains"]["career"]
|
||||
assert stable["house"] == 10 and stable["stability"] == "stable_in_window"
|
||||
assert len(stable["branches"]) == 1
|
||||
assert changing["house"] == 6 and changing["stability"] == "boundary_sensitive"
|
||||
assert len({band["sub_lord"] for band in changing["branches"]}) > 1
|
||||
def to_time(edge):
|
||||
return datetime.fromisoformat(edge["local"])
|
||||
|
||||
assert to_time(stable["branches"][0]["last_sample"]) - to_time(stable["branches"][0]["first_sample"]) == timedelta(minutes=2)
|
||||
for left, right in zip(changing["branches"], changing["branches"][1:]):
|
||||
assert to_time(right["first_sample"]) - to_time(left["last_sample"]) == timedelta(minutes=1)
|
||||
assert left["sub_lord"] != right["sub_lord"]
|
||||
assert changing["branches"][0]["first_sample"] == stable["branches"][0]["first_sample"]
|
||||
assert changing["branches"][-1]["last_sample"] == stable["branches"][0]["last_sample"]
|
||||
|
||||
|
||||
def test_unknown_window_preserves_uncertainty_and_labels_reference_samples():
|
||||
field = _kp(_body())
|
||||
precision = field["precision"]
|
||||
assert (precision["status"], precision["source"]) == ("range_unknown", "reference")
|
||||
assert (precision["before_minutes"], precision["after_minutes"]) == (30, 30)
|
||||
assert precision["note"] == kp_appendix.UNKNOWN_NOTE
|
||||
assert all(row["stability"] == "range_unknown" for rows in field["domains"].values() for row in rows)
|
||||
|
||||
|
||||
def test_missing_placidus_blocks_without_whole_sign_proxy():
|
||||
facts = _facts(lat=67.0, timezone_id=None)
|
||||
field = facts["page_appendix"]["kp_cusps"]
|
||||
assert field["status"] == "blocked" and "Placidus" in field["reason"]
|
||||
assert "domains" not in field and "house_basis" not in field
|
||||
audit = next(r for r in facts["technique_audit_axes"] if r["technique"] == "KP / Sub-lord")
|
||||
assert audit["calculation_status"] == "blocked"
|
||||
|
||||
|
||||
def _frontend_fixture():
|
||||
known = _facts(birth_window=WINDOW)
|
||||
kp = known["page_appendix"]["kp_cusps"]
|
||||
audit = next(row for row in known["technique_audit_axes"] if row["technique"] == "KP / Sub-lord")
|
||||
unknown = _facts()["page_appendix"]["kp_cusps"]
|
||||
blocked = _facts(lat=67.0, timezone_id=None)["page_appendix"]["kp_cusps"]
|
||||
return {"known": kp, "audit": audit, "unknown": {**unknown, "domains": {"career": unknown["domains"]["career"]}},
|
||||
"blocked": blocked}
|
||||
|
||||
|
||||
def test_frontend_fixture_comes_from_native_annual_facts():
|
||||
fixture = FRONTEND / "tests/fixtures/annual-kp-20261114.json"
|
||||
assert json.loads(fixture.read_bytes()) == _frontend_fixture()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSX.is_file(), reason="frontend dependencies are required for the writer contract")
|
||||
def test_kp_does_not_enter_any_of_ten_writer_evidence_sections(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.kp_cusps;
|
||||
Object.assign(previous.technique_audit_axes.find(row => row.technique === "KP / Sub-lord"), {
|
||||
calculation_status: "not_built", interpretation_status: "not_used", raw_path: null, profile: null,
|
||||
note: "年运未计算 KP 宫头与子主",
|
||||
});
|
||||
const hashes = facts => buildAnnualPlan(facts, situation).map(entry => {
|
||||
const evidence = JSON.stringify(sectionEvidence(facts, entry, situation));
|
||||
assert.doesNotMatch(evidence, /kp_cusps|sidereal_placidus_cusp_to_cusp|stable_in_window/);
|
||||
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"))
|
||||
Reference in New Issue
Block a user