Compare commits

..
2 Commits
Author SHA1 Message Date
jesse-ux b6baff3475 feat(annual): add KP cusp and birth-window appendix (BUG-1884)
Independent Staging Quality Gate / validate-python (pull_request) Successful in 6m49s
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 7m32s
Independent Staging Quality Gate / publish (pull_request) Skipped
2026-11-14 04:46:54 +08:00
Claude 4fed3a959f docs(tasks): brief for #264 annual-kp-20261114
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-14 01:57:44 +08:00
16 changed files with 1571 additions and 8 deletions
+4
View File
@@ -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)。
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-11-14 — 年运附录增加 KP 宫头和子主
- 按主题列出 Placidus 宫头及子主。记录出生时间误差范围内的变化,误差未知时标注参考取样。沿用当前 Skill 版本(BUG-1884)。
## 2026-11-14 — 年运附录分列 Muntha 四项记录
- 计算依据新增年盘结构、Muntha 主状态、慢星换座与停滞、逐年推进。沿用当前 Skill 版本(BUG-1880)。
+12
View File
@@ -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`。
+4
View File
@@ -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。
+26
View File
@@ -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 做独立验收。
+1
View File
@@ -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` |
+61
View File
@@ -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 全部通过。
+1
View File
@@ -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 点,写到一半断掉不扣」。价格读不到时这一行写「计费配置不可用」,按钮不可点。没有别的提示小字。
+42 -1
View File
@@ -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, /展开其余/);
});
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -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",
+98
View File
@@ -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)
+6 -1
View File
@@ -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",
+17 -5
View File
@@ -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": [],
+131
View File
@@ -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"))