Compare commits

..
2 Commits
Author SHA1 Message Date
jesse-ux 1ffeaaf830 feat(annual): append four natal dasha systems (BUG-1882)
Independent Staging Quality Gate / validate-python (pull_request) Successful in 6m38s
Independent Staging Quality Gate / validate-frontend (pull_request) Successful in 7m18s
Independent Staging Quality Gate / publish (pull_request) Skipped
2026-11-14 03:20:51 +08:00
Claude 118688fa7c docs(tasks): brief for #262 annual-dashas-20261114
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
2026-11-14 01:57:22 +08:00
17 changed files with 1175 additions and 12 deletions
+5
View File
@@ -1,5 +1,10 @@
# BLOCKED
## #262 年运附录四种本命运限
- Chara 和 Kalachakra 的原生日期在部分报告区间没有覆盖。区间无交集时附录给出原因,有交集时只列实际运期。跨出原生周期后的算法口径需拍板。
- 本机预检的碎片扫描与定向套件超时,Docker Linux 引擎不可用。以同机 staging 对照及针对性回归提供替代证据。见 [#262 进度](docs/tasks/PROGRESS-annual-dashas-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 — 年运附录列出另外四种本命运限
- 计算依据按年盘列 Chara、Yogini、Ashtottari 和 Kalachakra 的运期、主题与适用条件。占星 Skill 版本不变(BUG-1882)。
## 2026-11-14 — 年运附录分列 Muntha 四项记录
- 计算依据新增年盘结构、Muntha 主状态、慢星换座与停滞、逐年推进。沿用当前 Skill 版本(BUG-1880)。
+11
View File
@@ -25407,3 +25407,14 @@
- 验证及防复发:公开盘和虚构盘核对四项、逆行往返、停滞、空区间、岁差与交点、旧快照及写作证据。详见 [#257 进度](tasks/PROGRESS-annual-muntha-records-20261113.md)。
- 关联记录:#257、BUG-1212、BUG-1219、BUG-1817、BUG-1868。
- 修复版本:`codex/annual-muntha-records-20261113`,待发布。
## BUG-1882 | 年运未运行四种已有本命运限
- 状态:fixed-pending-verify(待独立验收)。
- 首次发现 / 最近更新:2026-11-12 / 2026-11-14。
- 现象与触发:打开年运技法执行表,Chara、Yogini、Ashtottari 与 Kalachakra 均列为未完成。
- 根因:年包的本命运限只接入 Vimshottari 与 Narayana。执行表为其他四种写死未完成状态。
- 修复:调用原生引擎,保留实际口径、适用条件、原始运期及报告区间内的运期,按本命宫位标注关联主题。Kalachakra 从出生时间范围取样,保留月宿分支。附录与审计读取新块。
- 验证与防复发:公开盘、合成盘、起止区间、适用条件、月宿分支及十个写作区段哈希,见 [#262 进度](tasks/PROGRESS-annual-dashas-20261114.md)。
- 关联记录:#262、BUG-1567、BUG-1656、BUG-1676、BUG-1713、BUG-1811。
- 修复版本:`codex/annual-dashas-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 · #262 年运运限接入预检
- Windows 上 Python、Gitea 远端与引擎适配器诊断通过。碎片扫描和定向预检达到各自的时间预算。功能证据另见 [#262 进度](../tasks/PROGRESS-annual-dashas-20261114.md)。
## 2026-11-13 · #239 选盘续问预检
- 目录扫描和定向预检分别在 90 秒和 45 秒超时,Gitea 远端、Python 和适配器检查通过。并发运行 Next 构建时,Windows 快速门的来源清单扫描遇到被构建替换的 .next 文件,快速门改用独立 worktree。
@@ -0,0 +1,33 @@
# #262 年运附录接入四种本命运限
- 任务书 [TASK-annual-dashas-20261114.md](TASK-annual-dashas-20261114.md)
- 基线 `a09fbfd3e3e0cbe15342a00b31569329abfde209`
- 执行分支 `codex/annual-dashas-20261114`
- BUG-1882 按任务书预留。BUG-1881 已分配给并行的 #260。
## 变更
- `extra_natal_dashas` 保留 Chara、Yogini、Ashtottari、Kalachakra 的计算口径、实际状态、原始周期与报告覆盖区间内的周期。按左含右不含裁剪大运、分运与可用的细运。
- Chara 使用 K.N. Rao 及公历周年日期,分运与细运沿用原生的等分规则。Ashtottari 先检查引擎适用条件。Kalachakra 使用古典月宿口径,按建档误差范围取样,缺省使用前后各 30 分钟。周期超出原生引擎可覆盖日期时,附录提示缺口。扩展周期的算法口径需拍板。
- 页面附录按年盘列运限和主题,多行可展开。同一套内容可导出到 Markdown。新审计表记录执行状态与原因,正文写作输入沿用现有结构。
## 验证
- 公开盘覆盖四种运限全部算出、条件不适用和超出原生周期三种结果。合成盘覆盖 Ashtottari 适用、不适用和条件未知、缺月亮经度、Kalachakra 跨月宿 pada、半开区间、保留原始行、页面和 Markdown。
- 年运 Python 定向回归 55 项通过。仓库隐私检查 62 项通过。
- 合成盘与公开盘分别运行开工 staging 和执行分支,十个写作区段的 SHA-256 均逐项一致。单轴旧审计字节哈希通过。
- 前端类型检查、lint 和生产构建通过。lint 为 0 error、62 warnings。两边首页均为静态,入口 gzip 均为 JS 516100 B、CSS 92069 B。
- 前端全量从基线 3733 项增至 3734 项。基线 3651 项通过,分支 3652 项通过。两边失败名称完全一致(45 项),均跳过 37 项、取消 0 项。首轮一项咨询截断时钟断言仅在分支失败,两侧定向复跑及分支全量复跑均通过。
- Python 快速门 1490 项通过、2 项跳过,另有 29 个子测试通过。三项 Kaala / PyJHora 断言与同机 staging 基线复跑一致,快速门退出 1。完整绿灯留待独立验收。
- 预检的远端可见性及外部引擎适配器检查通过。碎片扫描与预检定向套件超时。Windows Docker Linux 引擎不可用,前端全量使用相同无 Docker 环境对照。
## 交接
- Skill 版本未变。
- 独立验收留在 Gitea。请以 PR head 核对附录、审计与完整门禁。
## 既有断言变更
| 原值 | 新值 | 原因 |
| --- | --- | --- |
| 年包顶层字段顺序中 `natal_periods` 后为 `slow_transits` | 两者之间增加 `extra_natal_dashas`,同时锁定其四个系统键 | 任务书要求新增顶层块,保留原有字段和顺序 |
+1
View File
@@ -33,6 +33,7 @@
| 任务书 | 进度 | 主题 | 状态 | 落点 |
| --- | --- | --- | --- | --- |
| [#262 任务书](TASK-annual-dashas-20261114.md) | [执行进度](PROGRESS-annual-dashas-20261114.md) | 年运附录接入四种本命运限(BUG-1882) | 待验收 | `codex/annual-dashas-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` |
+89
View File
@@ -0,0 +1,89 @@
# TASK:年运接入 Chara、Yogini、Ashtottari、Kalachakra,按主题记录(2026-11-14)
- Gitea:#262
- 分支:`codex/annual-dashas-20261114`,从 staging `a09fbfd3` 切出。
- Bug 编号:BUG-1882(1881 已被 #260 占用)。开工时核对 `docs/BUG_HISTORY.md` 末尾;如果 1882 已被占用就顺延,并在 PROGRESS 写明。
- 负责验收:Claude(jyotisha-99)。
任务书没写到的取舍,不得写成"产品决定"或"裁定",一律标「需拍板」。不得修改或删除既有断言来让测试通过。确需修改的,在 PROGRESS 逐条写明原值、新值和原因。
同时在做的还有 #263、#264,它们都会改 `package.py`、`report_facts.py` 和 `annual-report-generate.ts`。各单只改自己的函数;后合入的负责 rebase。
## 背景
占星师 2026-11-12 年运 Q3(原意):
| 技法 | 要求 | 可以阻塞或不适用的真实条件 |
|---|---|---|
| Chara Dasha | 综合年运覆盖的相关主题要执行,profile 要写出名字 | 必需输入缺失。流派差异要保留,不直接等于不适用 |
| Yogini | 相关应期执行 | 实际输入或计算受阻,须注明 |
| Ashtottari | 先查条件,满足才执行 | 条件不满足是 not_applicable,条件未知是 blocked |
| Kalachakra | 相关应期执行,保留分支和精度敏感性 | 缺所需的月宿、参数,或实现无法运行 |
她还要求:单纯尚未开发或运行的,要写明「未完成」;先返回仓内能力清单,再逐项记录执行情况。
现状(staging `a09fbfd3`):
- `report_facts.py` 1809–1824 行把这四项都标成 `not_built`。年运的 package 只跑 Vimshottari 和 Narayana。
- 可用的引擎(都不是冻结文件,可以直接 import):
- `jaimini.calc_chara_dasha(asc_sign_idx, planet_longitudes, birth_year, birth_month, birth_day, profile="kn_rao", gender=None)`,以及 `calc_chara_dasha_with_antardasha`;
- `yogini_dasha.calculate_yogini_dasha(birth_info)`:缺 `birth_datetime` 或 `moon_longitude` 时返回 blocked;
- `ashtottari_dasha.calculate_ashtottari_dasha(birth_info)`:自带 `applicable`、`applicability` 和 `applicability_rule_family`(`ashtottari_rule_profiles.evaluate_strict_ashtottari`);
- `kalachakra_dasha.calculate_kalachakra_dasha(birth_info)`:有 `moon_longitude` 时用 `pvr_classical_nine_sign_md_ad`,没有时退回 `legacy_approximation_without_moon_longitude`;
- `annual_native/dasha_children.enrich_master_family`:可以补 Yogini 和 Kalachakra 的子运。
- 问答链路对照写法:`consultation_native_layers.py` 1794–1843 行(冻结文件,只看不改)。
- 主题映射:`report_facts.py` 的 `HOUSE_DOMAINS`、`_domains_for`、`natal["domains"]`。
## 要做的
1. **package 新增一个块 `extra_natal_dashas`**,四个系统各占一项。每项包括:
- `status`:computed、not_applicable 或 blocked;
- `reason`:中文;
- `profile`:写出名字,例如 Chara 是 `kn_rao`,Kalachakra 是 `pvr_classical_nine_sign_md_ad`;
- `year_basis`:照引擎实际用的写,不改引擎;
- 报告覆盖区间内的 MD、AD 行(有 PD 就带上),左含右不含,按区间裁剪。不裁剪的原始行另存一份。
2. **按系统的特殊处理**
- **Ashtottari**:先用引擎的 applicability。不适用时写 `not_applicable` 和中文理由,不列周期;条件未知时写 blocked。引擎在不适用时也会给出一份参考周期,这份不进年运。
- **Kalachakra**:
- 没有 `moon_longitude` 时不用 legacy 近似,直接 blocked。
- 记录 mode(savya 或 apasavya)、起始星座和月亮所在的 pada。
- 精度:用现有 `_birth_survey` 的出生时间窗口取样,看月亮 pada 在窗口内会不会变。会变就标 `boundary_sensitive`,并列出两个分支的起始星座。窗口规则沿用 #224(用户自报的误差,否则 ±30 分钟)。
- **Chara**:写出 profile。性别缺失时如果引擎要求性别,标 blocked,不猜。
- **Yogini**:缺输入时照引擎返回 blocked。
3. **按主题记录**:每个运期行加 `domains`。
- 行星运:用该星在本命盘掌管和落入的宫,经 `_domains_for` 得到主题。
- 星座运(Chara):用该星座从上升数的宫位,加上座中行星所管的宫。
- 只记录关联,不判吉凶。
4. **审计行**:四行改成实际状态。
- 算出来的:`executed` + `observation_only`,note 写「已计算,按主题列出,本报告正文未据此下结论」。
- 不适用和阻塞的:照实写,并写理由。
5. **页面附录**新增小节「其他几种运限」:
- 每个系统一组,列出这一年内正在走的 MD › AD,以及各自关联的主题。
- Ashtottari 不适用时,写一行理由。
- Kalachakra 跨分支时,写明「出生时间误差内月亮跨过 pada 边界,起始星座有两种可能」。
- 行数多时沿用「先显示第一条、其余折叠」的做法。
6. **写作输入不变**:十个写作区段的 evidence 哈希不变,新块不进写作输入。
## 不要碰
- 冻结文件(`jyotish_engine.py`、`jyotish_api_server.py`、`consultation_native_layers.py` 等)、`references/**`、`consult-preset-prompts.ts`。
- 四个引擎模块本身不改。引擎有错,记进 BLOCKED,标需拍板。
- 不改 Vimshottari 和 Narayana。
## 要改的文件
- `scripts/annual_service/package.py`
- `scripts/annual_service/report_facts.py`
- `frontend/src/lib/annual-report-generate.ts`
- 测试:
- `tests/test_annual_dashas_20261114.py`:覆盖 Ashtottari 适用和不适用两种情况、Kalachakra 跨 pada 分支、缺月亮经度时 blocked、区间裁剪、写作输入哈希不变;
- `frontend/tests/annual-dashas-20261114.test.tsx`
- `docs/BUG_HISTORY.md`:只在末尾追加。
- `docs/tasks/PROGRESS-annual-dashas-20261114.md`
## 验收标准
1. 公开名人盘上四个系统都有明确状态。算出来的有区间内的运期和主题,不适用和阻塞的有理由。
2. 合成盘测试覆盖第 2 节列出的每种分支。
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 主、慢星进出与停滞、逐年推进各自列出,导出沿用同一顺序。
- **年运其他运限(#262,BUG-1882):** 每张年盘的计算依据按 Chara、Yogini、Ashtottari、Kalachakra 分组。首行列本期大运、小运及各自主题,余行沿用折叠。适用性与出生精度的说明留在对应分组,导出沿用相同顺序。
- **入口**:「我的报告」最上面两个横条并排(手机上下排,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 点,写到一半断掉不扣」。价格读不到时这一行写「计费配置不可用」,按钮不可点。没有别的提示小字。
+46 -1
View File
@@ -9,10 +9,12 @@ import { AYANAMSA_OPTIONS } from "./ayanamsa.ts";
import { planetZh, signZh } from "./chart-view-labels.ts";
import { displayTechniqueAuditLabel, techniqueAuditAxes, techniqueAuditStatusText, techniqueCalculationStatusLabel, techniqueInterpretationStatusLabel } from "./consultation-technique-audit.ts";
import {
ANNUAL_DOMAIN_TITLES,
ANNUAL_RANGE_LABELS,
BIRTH_SOURCE_CN,
birthRangeText,
type AnnualBirthTime,
type AnnualDomain,
type AnnualRangeKind,
type AnnualSituation,
} from "./annual-report-shared.ts";
@@ -330,6 +332,40 @@ function tajikaYogaLine(row: Row): string {
return `${subject}${subject ? "," : ""}${stateText}${gap}。`;
}
const EXTRA_NATAL_DASHA_NAMES: Readonly<Record<string, string>> = {
chara: "Chara", yogini: "Yogini", ashtottari: "Ashtottari", kalachakra: "Kalachakra",
};
function extraNatalDashaLines(system: Row): string[] {
if (!str(system.status)) return [];
const precision = record(system.precision);
const lines = system.status === "computed" ? [] : [str(system.reason) || "这套运限未算出。"];
const date = (edge: unknown) => str(record(edge).local).slice(0, 16).replace("T", " ");
const periodName = (row: Row) => {
const sign = str(row.sign);
const planet = str(row.planet);
const themes = (Array.isArray(row.domains) ? row.domains : [])
.map((key) => ANNUAL_DOMAIN_TITLES[str(key) as AnnualDomain]).filter(Boolean);
return `${sign ? `${signZh(sign)}座` : planetZh(planet)}${themes.length ? `(${themes.join("、")})` : ""}`;
};
for (const major of rows(system.periods)) {
for (const minor of rows(major.children)) {
lines.push(`${date(minor.start)} 至 ${date(minor.end)} ${periodName(major)} › ${periodName(minor)}`);
}
}
if (precision.status === "boundary_sensitive") {
const signs = [...new Set(rows(precision.branches).map((row) => str(row.starting_sign)).filter(Boolean))];
const variants = signs.map((sign) => `${signZh(sign)}座`).join("、");
lines.push(`出生时间误差内月亮跨过 pada 边界${signs.length === 2 ? ",起始星座有两种可能"
: signs.length > 2 ? ",起始星座有多种可能" : ",保留跨界前后的分支"}${variants ? `(${variants})` : ""}。`);
}
if (precision.status === "blocked" || precision.sample_complete === false) {
lines.push("出生时间范围内的月宿分支未能完整核对。");
}
if (system.coverage === "partial") lines.push("现有运限只覆盖部分报告区间。");
return lines.length ? lines : ["这个年盘的报告区间内没有可列出的运限。"];
}
function appendicesByYear(root: Row): AnnualAppendixBlock[] {
const appendix = record(root.page_appendix);
const overview = yearOverviews(root);
@@ -351,6 +387,14 @@ 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));
const extra = record(year.extra_natal_dashas);
for (const [key, name] of Object.entries(EXTRA_NATAL_DASHA_NAMES)) {
const lines = extraNatalDashaLines(record(extra[key]));
if (lines.length) result.push({
id: `natal-dasha-${key}-${year.target_year}`, title: `其他几种运限 / ${name}` + suffix,
lines, collapseAfterFirst: true,
});
}
const aspects = tajikaAspectLines(year.tajika_aspect_layer);
if (aspects.length) result.push({
id: `tajika-aspects-${year.target_year}`, title: "Tajika 相位(相位联系,不是具名瑜伽)" + suffix,
@@ -407,7 +451,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 extraDasha = Object.values(EXTRA_NATAL_DASHA_NAMES).some((method) => name === `${method} Dasha`);
const reason = extraDasha || ["blocked", "not_applicable", "not_built"].includes(axes.calculation_status) ? str(row.note) : "";
return {
method: AUDIT_NAMES[name] ?? displayTechniqueAuditLabel(name),
calculation: techniqueCalculationStatusLabel(axes.calculation_status),
@@ -0,0 +1,57 @@
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, audit, boundary_precision: precision } = JSON.parse(
readFileSync(new URL("./fixtures/annual-dashas-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 = (annualYear: unknown) => buildAppendix({
cards: { year_overview: [{ target_year: year.target_year }] },
page_appendix: { years: [annualYear] },
technique_audit_axes: audit,
}, range, birth);
test("other natal periods render as four appendix groups with the audit reason and native periods", () => {
const appendix = appendixFor(year);
const blocks = appendix.filter((block) => block.id.startsWith("natal-dasha-"));
assert.deepEqual(blocks.map((block) => block.title), [
"其他几种运限 / Chara", "其他几种运限 / Yogini",
"其他几种运限 / Ashtottari", "其他几种运限 / Kalachakra",
]);
assert.ok(blocks.every((block) => block.collapseAfterFirst));
for (const name of ["chara", "yogini", "kalachakra"]) {
const lines = blocks.find((block) => block.id === `natal-dasha-${name}-2026`)?.lines ?? [];
assert.match(lines[0], /\d{4}-\d{2}-\d{2} \d{2}:\d{2} 至 .* › /);
assert.match(lines.join("\n"), /工作|事业|财务|关系|家庭|学习|精力/);
}
assert.match(blocks[2].lines.join(""), /罗睺.*不符合/);
const execution = appendix.find((block) => block.id === "execution")?.audit ?? [];
assert.equal(execution.length, 4);
assert.ok(execution.some((row) => /仅观察.*正文未据此下结论/.test(row.usage)));
assert.ok(execution.some((row) => /不适用/.test(row.calculation)));
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 markdown = annualAppendixMarkdown(blocks);
for (const block of blocks) {
assert.ok(html.includes(block.title));
assert.ok(block.lines.every((line) => markdown.includes(line)));
}
assert.doesNotMatch(markdown, /[·_]|not_applicable|calendar_year_anniversary/);
assert.ok(!appendixFor({ target_year: 2026 }).some((block) => block.id.startsWith("natal-dasha-")));
const extra = year.extra_natal_dashas;
const boundaryYear = { ...year, extra_natal_dashas: { ...extra, kalachakra: { ...extra.kalachakra, precision } } };
const kala = appendixFor(boundaryYear).find((block) => block.id === "natal-dasha-kalachakra-2026");
assert.match(kala?.lines.at(-1) ?? "", /出生时间误差内月亮跨过 pada 边界,起始星座有两种可能(金牛座、双子座)。/);
});
+432
View File
@@ -0,0 +1,432 @@
{
"year": {
"target_year": 2026,
"extra_natal_dashas": {
"chara": {
"status": "computed",
"reason": "已计算,按主题列出,本报告正文未据此下结论。",
"coverage": "complete",
"periods": [
{
"level": "MD",
"sign": "Pisces",
"planet": "Jupiter",
"yogini": null,
"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.89543259
},
"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.14754358
},
"domains": [
"home",
"learning"
],
"children": [
{
"level": "AD",
"sign": "Taurus",
"planet": "Venus",
"yogini": null,
"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.89543259
},
"end": {
"utc": "2026-12-14T12:00:00Z",
"local": "2026-12-14T20:00:00+08:00",
"utc_offset": "+08:00",
"timezone_id": "Asia/Shanghai",
"jd_ut": 2461389.0
},
"domains": [
"career",
"wealth"
],
"children": []
},
{
"level": "AD",
"sign": "Gemini",
"planet": "Mercury",
"yogini": null,
"start": {
"utc": "2026-12-14T12:00:00Z",
"local": "2026-12-14T20:00:00+08:00",
"utc_offset": "+08:00",
"timezone_id": "Asia/Shanghai",
"jd_ut": 2461389.0
},
"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.14754358
},
"domains": [
"wealth",
"relationship",
"energy",
"learning"
],
"children": []
}
]
}
]
},
"yogini": {
"status": "computed",
"reason": "已计算,按主题列出,本报告正文未据此下结论。",
"coverage": "complete",
"periods": [
{
"level": "MD",
"sign": null,
"planet": "Jupiter",
"yogini": "Dhanya",
"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.89543259
},
"end": {
"utc": "2026-10-04T17:04:15Z",
"local": "2026-10-05T01:04:15+08:00",
"utc_offset": "+08:00",
"timezone_id": "Asia/Shanghai",
"jd_ut": 2461318.21128472
},
"domains": [
"wealth",
"relationship",
"learning"
],
"children": [
{
"level": "AD",
"sign": null,
"planet": "Rahu",
"yogini": "Sankata",
"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.89543259
},
"end": {
"utc": "2026-07-05T09:34:15Z",
"local": "2026-07-05T17:34:15+08:00",
"utc_offset": "+08:00",
"timezone_id": "Asia/Shanghai",
"jd_ut": 2461226.89878472
},
"domains": [
"career",
"energy"
],
"children": []
},
{
"level": "AD",
"sign": null,
"planet": "Moon",
"yogini": "Mangala",
"start": {
"utc": "2026-07-05T09:34:15Z",
"local": "2026-07-05T17:34:15+08:00",
"utc_offset": "+08:00",
"timezone_id": "Asia/Shanghai",
"jd_ut": 2461226.89878472
},
"end": {
"utc": "2026-08-04T20:04:15Z",
"local": "2026-08-05T04:04:15+08:00",
"utc_offset": "+08:00",
"timezone_id": "Asia/Shanghai",
"jd_ut": 2461257.33628472
},
"domains": [
"relationship",
"energy"
],
"children": []
},
{
"level": "AD",
"sign": null,
"planet": "Sun",
"yogini": "Pingala",
"start": {
"utc": "2026-08-04T20:04:15Z",
"local": "2026-08-05T04:04:15+08:00",
"utc_offset": "+08:00",
"timezone_id": "Asia/Shanghai",
"jd_ut": 2461257.33628472
},
"end": {
"utc": "2026-10-04T17:04:15Z",
"local": "2026-10-05T01:04:15+08:00",
"utc_offset": "+08:00",
"timezone_id": "Asia/Shanghai",
"jd_ut": 2461318.21128472
},
"domains": [
"wealth",
"energy"
],
"children": []
}
]
},
{
"level": "MD",
"sign": null,
"planet": "Mars",
"yogini": "Bhramari",
"start": {
"utc": "2026-10-04T17:04:15Z",
"local": "2026-10-05T01:04:15+08:00",
"utc_offset": "+08:00",
"timezone_id": "Asia/Shanghai",
"jd_ut": 2461318.21128472
},
"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.14754358
},
"domains": [
"home",
"learning"
],
"children": [
{
"level": "AD",
"sign": null,
"planet": "Mars",
"yogini": "Bhramari",
"start": {
"utc": "2026-10-04T17:04:15Z",
"local": "2026-10-05T01:04:15+08:00",
"utc_offset": "+08:00",
"timezone_id": "Asia/Shanghai",
"jd_ut": 2461318.21128472
},
"end": {
"utc": "2027-03-16T01:04:15Z",
"local": "2027-03-16T09:04:15+08:00",
"utc_offset": "+08:00",
"timezone_id": "Asia/Shanghai",
"jd_ut": 2461480.54461806
},
"domains": [
"home",
"learning"
],
"children": []
},
{
"level": "AD",
"sign": null,
"planet": "Mercury",
"yogini": "Bhadrika",
"start": {
"utc": "2027-03-16T01:04:15Z",
"local": "2027-03-16T09:04:15+08:00",
"utc_offset": "+08:00",
"timezone_id": "Asia/Shanghai",
"jd_ut": 2461480.54461806
},
"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.14754358
},
"domains": [
"career",
"wealth"
],
"children": []
}
]
}
]
},
"ashtottari": {
"status": "not_applicable",
"reason": "罗睺在上升主星起算第 8 位,不符合第 4、5、7、9、10 位的条件",
"periods": []
},
"kalachakra": {
"status": "computed",
"reason": "已计算,按主题列出,本报告正文未据此下结论。",
"precision": {
"status": "samples_consistent",
"sample_complete": true,
"source": "default",
"before_minutes": 30,
"after_minutes": 30,
"resolution_minutes": 2,
"branches": [
{
"nakshatra_index": 23,
"pada": 4,
"mode": "apasavya",
"starting_sign": "Cancer"
}
]
},
"coverage": "complete",
"periods": [
{
"level": "MD",
"sign": "Aries",
"planet": "Mars",
"yogini": null,
"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.89543259
},
"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.14754358
},
"domains": [
"learning"
],
"children": [
{
"level": "AD",
"sign": "Aquarius",
"planet": "Saturn",
"yogini": null,
"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.89543259
},
"end": {
"utc": "2026-08-08T19:03:46Z",
"local": "2026-08-09T03:03:46+08:00",
"utc_offset": "+08:00",
"timezone_id": "Asia/Shanghai",
"jd_ut": 2461261.29428372
},
"domains": [
"relationship"
],
"children": []
},
{
"level": "AD",
"sign": "Pisces",
"planet": "Jupiter",
"yogini": null,
"start": {
"utc": "2026-08-08T19:03:46Z",
"local": "2026-08-09T03:03:46+08:00",
"utc_offset": "+08:00",
"timezone_id": "Asia/Shanghai",
"jd_ut": 2461261.29428372
},
"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.14754358
},
"domains": [],
"children": []
}
]
}
]
}
}
},
"audit": [
{
"technique": "Chara Dasha",
"calculation_status": "executed",
"interpretation_status": "observation_only",
"note": "已计算,按主题列出,本报告正文未据此下结论。",
"profile": "kn_rao",
"raw_path": "package.extra_natal_dashas.chara"
},
{
"technique": "Yogini Dasha",
"calculation_status": "executed",
"interpretation_status": "observation_only",
"note": "已计算,按主题列出,本报告正文未据此下结论。",
"profile": "moon_nakshatra_plus_3_mod_8_with_angular_balance",
"raw_path": "package.extra_natal_dashas.yogini"
},
{
"technique": "Ashtottari Dasha",
"calculation_status": "not_applicable",
"interpretation_status": "not_used",
"note": "罗睺在上升主星起算第 8 位,不符合第 4、5、7、9、10 位的条件",
"profile": "bphs_rahu_from_lagna_lord_excluding_lagna_v1",
"raw_path": "package.extra_natal_dashas.ashtottari"
},
{
"technique": "Kalachakra Dasha",
"calculation_status": "executed",
"interpretation_status": "observation_only",
"note": "已计算,按主题列出,本报告正文未据此下结论。",
"profile": "pvr_classical_nine_sign_md_ad",
"raw_path": "package.extra_natal_dashas.kalachakra"
}
],
"boundary_precision": {
"status": "boundary_sensitive",
"sample_complete": true,
"source": "default",
"before_minutes": 30,
"after_minutes": 30,
"resolution_minutes": 2,
"branches": [
{
"nakshatra_index": 17,
"pada": 2,
"mode": "apasavya",
"starting_sign": "Taurus"
},
{
"nakshatra_index": 17,
"pada": 3,
"mode": "apasavya",
"starting_sign": "Gemini"
}
]
}
}
+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",
+235
View File
@@ -0,0 +1,235 @@
"""Additional natal periods for the annual appendix, using native producers."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from math import isfinite
from . import package as ap
SYSTEMS = {
"chara": ("Chara Dasha", "jaimini.calc_chara_dasha_with_antardasha", "kn_rao"),
"yogini": ("Yogini Dasha", "yogini_dasha.calculate_yogini_dasha", "moon_nakshatra_plus_3_mod_8_with_angular_balance"),
"ashtottari": ("Ashtottari Dasha", "ashtottari_dasha.calculate_ashtottari_dasha", "bphs_rahu_from_lagna_lord_excluding_lagna_v1"),
"kalachakra": ("Kalachakra Dasha", "kalachakra_dasha.calculate_kalachakra_dasha", "pvr_classical_nine_sign_md_ad"),
}
OBSERVATION = "已计算,按主题列出,本报告正文未据此下结论。"
EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
SUBPERIOD_KEYS = ("antardasha", "antardashas", "pratyantardasha", "pratyantar_dashas")
def _longitude(positions, name):
value = positions.get(name)
return value if isinstance(value, (int, float)) and not isinstance(value, bool) and isfinite(value) else None
def _chara(ctx, lagna, positions):
date_module = ap._module("chart_dasha.chara_dates")
birth = ctx.birth_naive.date()
raw = ap._module("jaimini").calc_chara_dasha_with_antardasha(
lagna, positions, birth.year, birth.month, birth.day, profile=SYSTEMS["chara"][2],
)
date_module.attach(raw, birth, tz_hours=ctx.tz_hours)
for major in raw["dasha_sequence"]:
for minor in major["antardashas"]:
start, end = (EPOCH + timedelta(seconds=minor[key]) for key in ("start_ts", "end_ts"))
children = minor["pratyantar_dashas"]
parts = date_module.split_equal(start, end, len(children)) or [(start, end)] * len(children)
for row, (left, right) in zip(children, parts, strict=True):
row.update(start_ts=round(left.timestamp()), end_ts=round(right.timestamp()))
return raw["dasha_sequence"], raw["date_basis"]["year_basis"]
def _periods(raw_rows, convert, domains, start, end, zone, level=0):
clipped = []
for row in raw_rows:
left, right = convert(row, "start"), convert(row, "end")
overlap = ap._clip(left, right, start, end, zone)
if not overlap:
continue
sign = row.get("sign") or row.get("rashi")
planet = row.get("planet") or row.get("lord")
child_rows = next((row[key] for key in SUBPERIOD_KEYS if row.get(key)), [])
clipped.append({
"level": ("MD", "AD", "PD")[level],
"sign": sign, "planet": planet, "yogini": row.get("yogini"),
**overlap,
"domains": domains(sign, planet),
"children": _periods(child_rows, convert, domains, max(left, start), min(right, end), zone, level + 1)
if child_rows and level < 2 else [],
})
return clipped
def _kala_branch(engine, moon, birth):
star, pada = engine._nakshatra_and_pada_from_longitude(moon % 360)
raw = engine.calculate_kalachakra_dasha({"birth_datetime": birth, "moon_longitude": moon, "as_of": birth})
return {"nakshatra_index": star, "pada": pada, "mode": raw["mode"], "starting_sign": raw["starting_rashi"]}
def _kala_precision(ctx, window, engine, moon):
from .report_facts import _birth_sample_offsets
before, after = ((window["before_minutes"], window["after_minutes"])
if window and window["known"] else (30, 30))
offsets, step = _birth_sample_offsets(before, after)
branches = {}
missing = False
chart_engine = ap._engine()
def sample(offset):
birth = ctx.birth_naive + timedelta(minutes=offset)
if offset == 0:
longitude = moon
else:
jd = ap._jd_from_utc(ctx.birth_utc + timedelta(minutes=offset))
positions, _ = chart_engine._calc_sidereal_planets_for_jd(
jd, node_mode=ctx.node_mode, ayanamsa_name=ctx.ayanamsa,
)
longitude = _longitude({"Moon": (positions.get("Moon") or {}).get("degree_raw")}, "Moon")
return birth, longitude
samples = {offset: sample(offset) for offset in offsets}
for left, right in zip(offsets, offsets[1:]):
left_lon, right_lon = samples[left][1], samples[right][1]
if left_lon is None or right_lon is None:
continue
left_key = engine._nakshatra_and_pada_from_longitude(left_lon % 360)
if left_key == engine._nakshatra_and_pada_from_longitude(right_lon % 360):
continue
for offset in range(left + 1, right):
samples[offset] = sample(offset)
for offset, (birth, longitude) in sorted(samples.items()):
if longitude is None:
missing = True
continue
key = engine._nakshatra_and_pada_from_longitude(longitude % 360)
if key not in branches or offset == 0:
branches[key] = _kala_branch(engine, longitude, birth)
return {
"status": ("boundary_sensitive" if len(branches) > 1 else "blocked" if missing
else "stable" if step == 1 else "samples_consistent"),
"sample_complete": not missing,
"source": "declared" if window and window["known"] else "default",
"before_minutes": before, "after_minutes": after, "resolution_minutes": step,
"branches": list(branches.values()),
}
def build(ctx, natal, start, end, birth_window):
from .report_facts import GRAHAS, _domains_for, _owned
positions = natal.get("longitudes") or {}
lagna = natal.get("lagna_sign_idx")
moon = _longitude(positions, "Moon")
sun = _longitude(positions, "Sun")
location = ctx.request["return_location"]
zone = (ap._zone(location.get("timezone_id"), location.get("utc_offset_hours") or 0)
if location["mode"] == "current_city" else ctx.zone)
def domains(system, sign, planet):
if lagna is None:
return []
if sign in ap.SIGNS:
index = ap.SIGNS.index(sign)
houses = [ap._house(index, lagna)]
if system == "chara":
for name in GRAHAS:
if _longitude(positions, name) is not None and ap._sign_idx(positions[name]) == index:
houses.extend(_owned(lagna, name))
elif planet in GRAHAS:
houses = _owned(lagna, planet)
if _longitude(positions, planet) is not None:
houses.append(ap._house(ap._sign_idx(positions[planet]), lagna))
else:
return []
return _domains_for(houses)
elapsed = ap._elapsed_mapper(ctx.birth_naive, ctx.birth_utc)
def run(system):
_, producer, profile = SYSTEMS[system]
metadata = {"profile": profile, "year_basis": (
ap._module("chart_dasha.chara_dates").YEAR_BASIS if system == "chara" else {"days": 365.25}
)}
def blocked(reason):
return ap.block("blocked", producer, reason=reason, periods=[], raw_periods=[], **metadata)
if start is None or end is None:
return blocked("报告覆盖区间未算出。")
if system == "chara":
if lagna is None or any(_longitude(positions, planet) is None for planet in GRAHAS):
return blocked("缺少上升或本命行星经度,无法计算。")
raw_rows, metadata["year_basis"] = _chara(ctx, lagna, positions)
def convert(row, edge):
return EPOCH + timedelta(seconds=row[edge + "_ts"])
else:
engine = ap._module(system + "_dasha")
info = {"birth_datetime": ctx.birth_naive, "moon_longitude": moon,
"as_of": ctx.birth_naive + (end - ctx.birth_utc)}
def convert(row, edge):
return elapsed(row[edge + "_date"])
if system == "ashtottari":
rules = ap._module("ashtottari_rule_profiles")
lord = ap.SIGN_LORDS[ap.SIGNS[lagna]] if lagna is not None else None
rahu, lord_lon = (_longitude(positions, name) for name in ("Rahu", lord))
info.update(applicability_rule_family=profile, ascendant_sign_index=lagna,
rahu_sign_index=ap._sign_idx(rahu) if rahu is not None else None,
lagna_lord_sign_index=ap._sign_idx(lord_lon) if lord_lon is not None else None,
moon_nakshatra_index=int(moon % 360 / (360 / 27)) if moon is not None else None,
is_shukla_paksha=(moon - sun) % 360 < 180 if moon is not None and sun is not None else None)
gate = rules.evaluate_strict_ashtottari(info)
metadata["applicability"] = gate
if gate["status"] != "applicable":
status = "not_applicable" if gate["status"] == "not_applicable" else "blocked"
return ap.block(status, producer, reason=rules.strict_reason_zh(gate), periods=[], raw_periods=[], **metadata)
if moon is None:
return blocked("条件符合,缺少用于起运的月亮经度。")
raw = engine.calculate_ashtottari_dasha(info)
if raw.get("applicable") is not True or not raw.get("major"):
return blocked("现有引擎未返回适用的本命运限。")
raw_rows = raw["major"]
cycle = timedelta(days=raw["total_cycle"] * metadata["year_basis"]["days"])
first = convert(raw_rows[0], "start")
last = (end - first - timedelta(microseconds=1)) // cycle
raw_rows = [*raw_rows, *(engine._shift_period_tree(row, cycle * index)
for index in range(1, last + 1) for row in raw_rows)]
else:
if system == "kalachakra" and moon is None:
return blocked("缺少月亮经度,无法计算古典 Kalachakra 运限。")
raw = getattr(engine, "calculate_" + system + "_dasha")(info)
if raw.get("status") == "blocked":
return blocked("缺少出生时刻或月亮经度,无法计算 Yogini 运限。")
if system == "kalachakra" and raw.get("method_profile") != profile:
return blocked("古典 Kalachakra 运限未算出。")
metadata["year_basis"] = {"days": raw["dasha_year_days"]}
children = ap._module("annual_native.dasha_children")
raw_rows = children.enrich_master_family("kala_chakra" if system == "kalachakra" else system, raw, raw["major"])
if system == "kalachakra":
metadata.update(mode=raw["mode"], starting_sign=raw["starting_rashi"],
moon_pada=raw["variant_trace"]["pada_index"] + 1)
precision = ap._guard(producer, lambda: _kala_precision(ctx, birth_window, engine, moon))
if "branches" in precision:
metadata["precision"] = precision
else:
metadata["precision"] = {"status": "blocked", "branches": []}
clipped = _periods(raw_rows, convert, lambda sign, planet: domains(system, sign, planet), start, end, zone)
if not clipped:
return ap.block("blocked", producer, reason="现有引擎的运限日期未覆盖本次报告。",
periods=[], raw_periods=raw_rows, **metadata)
complete = (convert(raw_rows[0], "start") <= start and convert(raw_rows[-1], "end") >= end)
return ap.block("computed", producer, reason=OBSERVATION, periods=clipped, raw_periods=raw_rows,
coverage="complete" if complete else "partial", **metadata)
results = {}
for system, (_, producer, profile) in SYSTEMS.items():
result = ap._guard(producer, lambda key=system: run(key))
if "profile" not in result:
result = ap.block("blocked", producer, reason="现有引擎未能计算这套运限。", profile=profile,
year_basis=None, periods=[], raw_periods=[])
results[system] = result
return results
+5 -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
@@ -492,6 +493,8 @@ def build_annual_package(request: dict[str, Any], *, detail: str = "full") -> di
ctx = _Context(request)
if detail == "slim":
return _slim_package(ctx)
from .extra_dashas import build as build_extra_dashas
natal = _natal_chart(ctx)
report_range, years = _report_range(ctx)
range_start = report_range.pop("_start", None)
@@ -525,6 +528,7 @@ def build_annual_package(request: dict[str, Any], *, detail: str = "full") -> di
"report_range": report_range,
"year_charts": year_charts,
"natal_periods": natal_periods,
"extra_natal_dashas": build_extra_dashas(ctx, natal, range_start, range_end, birth_window),
"slow_transits": slow,
"transit_context": transit_context,
"independent_annual_views": {
+40 -8
View File
@@ -54,6 +54,7 @@ try: # scripts/ on sys.path (API server, tests)
from annual_service import package as ap
except ImportError: # pragma: no cover - repository-root import
from scripts.annual_service import package as ap
from .extra_dashas import SYSTEMS as EXTRA_DASHA_SYSTEMS
SCHEMA = "jyotish.annual_report_facts.v1"
@@ -293,6 +294,11 @@ def _snapshot(ctx: Any, years: list[int], range_start: Any, range_end: Any, with
return snap
def _birth_sample_offsets(before: int, after: int) -> tuple[list[int], int]:
step = max(1, -(-(before + after) // CONFIG["birth_sample_target"]))
return sorted(set(range(-before, after + 1, step)) | {-before, 0, after}), step
def _birth_survey(ctx: Any, window: dict[str, Any], package: dict) -> dict[str, Any]:
"""Walk the declared range (astrologer 2026-11-06 item 3): samples across it, then bisect each change.
@@ -308,8 +314,7 @@ def _birth_survey(ctx: Any, window: dict[str, Any], package: dict) -> dict[str,
return {"check": {"status": "no_range", **empty}, "samples": []}
range_start, range_end = _range_dt(package)
years = [chart["target_year"] for chart in package["year_charts"] if chart.get("status") == "computed"]
step = max(1, -(-(before + after) // CONFIG["birth_sample_target"]))
offsets = sorted(set(range(-before, after + 1, step)) | {-before, 0, after})
offsets, step = _birth_sample_offsets(before, after)
cache: dict[int, dict[str, Any]] = {}
def snap(offset: int, with_periods: bool = False) -> dict[str, Any]:
@@ -1806,11 +1811,15 @@ 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")
extra = package.get("extra_natal_dashas") or {}
for key, (name, _, profile) in EXTRA_DASHA_SYSTEMS.items():
field = extra.get(key) or {}
rows.append(_audit_axes_row(
name, field.get("status", "blocked"), field.get("reason") or "缺少计算结果。",
raw_path=f"package.extra_natal_dashas.{key}" if field else None, profile=field.get("profile", 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 条件核对"),
@@ -2119,6 +2128,28 @@ def _page_tajika(field: dict) -> dict:
}
def _appendix_dasha_rows(periods: list[dict], start: dict, end: dict) -> list[dict]:
result = []
for row in periods:
left = max((row["start"], start), key=_jd)
right = min((row["end"], end), key=_jd)
if _jd(left) >= _jd(right):
continue
result.append({**row, "start": left, "end": right,
"children": _appendix_dasha_rows(row["children"], left, right)})
return result
def _appendix_dashas(package: dict, overlap: dict | None) -> dict:
if not overlap:
return {}
result = {}
for key, field in (package.get("extra_natal_dashas") or {}).items():
metadata = {name: field[name] for name in ("status", "reason", "precision", "coverage") if name in field}
result[key] = {**metadata, "periods": _appendix_dasha_rows(field["periods"], overlap["start"], overlap["end"])}
return result
def _page_appendix(package: dict, natal: dict) -> dict:
charts = package.get("year_charts") or []
@@ -2136,6 +2167,7 @@ def _page_appendix(package: dict, natal: dict) -> dict:
"target_year": chart["target_year"],
"year_lord": _page_year_lord(blocks, natal),
"muntha_records": _muntha_records(chart, slow, progression),
"extra_natal_dashas": _appendix_dashas(package, chart.get("in_report_range")),
"mudda_balance": {
"status": mudda.get("status"),
"balance_profile": mudda.get("balance_profile"),
@@ -2151,9 +2183,9 @@ def _page_appendix(package: dict, natal: dict) -> dict:
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": [],
+207
View File
@@ -0,0 +1,207 @@
"""Annual appendix periods and their separation from the report writer."""
import json
from datetime import datetime, timedelta
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
from annual_service import extra_dashas, package as ap
FRONTEND = Path(__file__).resolve().parents[1] / "frontend"
TSX = FRONTEND / "node_modules/tsx/dist/cli.mjs"
@pytest.fixture(scope="module")
def natal_package():
return annual_api.compute_annual_package(_body())["package"]
def test_public_chart_reports_all_four_techniques(public_facts):
systems = public_facts["page_appendix"]["years"][0]["extra_natal_dashas"]
assert systems.keys() == extra_dashas.SYSTEMS.keys()
assert {key: value["status"] for key, value in systems.items()} == {
"chara": "blocked", "yogini": "computed", "ashtottari": "not_applicable", "kalachakra": "computed",
}
assert "未覆盖" in systems["chara"]["reason"]
audits = {row["technique"]: row for row in public_facts["technique_audit_axes"]}
for key, (name, _, profile) in extra_dashas.SYSTEMS.items():
system, audit = systems[key], audits[name]
assert audit["profile"] == profile
assert audit["raw_path"] == f"package.extra_natal_dashas.{key}"
assert system["reason"]
if system["status"] == "computed":
assert audit["calculation_status"] == "executed"
assert audit["interpretation_status"] == "observation_only"
assert all(row["children"] for row in system["periods"])
assert any(row["domains"] for row in system["periods"])
else:
assert audit["calculation_status"] in ("blocked", "not_applicable")
assert not system["periods"]
def test_public_chart_inside_native_coverage_computes_all_four():
source = FRONTEND.parent / "references/real_case_calibration/minute_rectification_holdout_v4.json"
cases = json.loads(source.read_bytes())["cases"]
birth = next(row["birth"] for row in cases if row["case_id"] == "angelina_jolie_1975_aa_v4_holdout")
at = datetime.fromisoformat(f"{birth['date']}T{birth['time']}")
facts = annual_api.compute_annual_report_facts(_body(
year=at.year, month=at.month, day=at.day, hour=at.hour, minute=at.minute,
lat=birth["latitude"], lon=birth["longitude"], tz=birth["timezone_offset"],
timezone_id="America/Los_Angeles", ayanamsa="lahiri", node_mode="mean",
))["facts"]
systems = facts["page_appendix"]["years"][0]["extra_natal_dashas"]
assert systems.keys() == extra_dashas.SYSTEMS.keys()
for field in systems.values():
assert field["status"] == "computed"
assert field["periods"]
assert all(row["children"] and row["domains"] for row in field["periods"])
def test_ranges_are_clipped_recursively_and_native_rows_retained(natal_package):
range_start = natal_package["report_range"]["start"]["jd_ut"]
range_end = natal_package["report_range"]["end"]["jd_ut"]
def check(rows, lo, hi):
for row in rows:
start, end = (row[edge]["jd_ut"] for edge in ("start", "end"))
assert lo <= start < end <= hi
check(row["children"], start, end)
systems = natal_package["extra_natal_dashas"]
for key in ("chara", "yogini", "kalachakra"):
result = systems[key]
assert result["status"] == "computed"
check(result["periods"], range_start, range_end)
assert result["periods"][0]["start"]["jd_ut"] == range_start
assert result["periods"][-1]["end"]["jd_ut"] == range_end
first = result["raw_periods"][0]
if key == "chara":
assert ap._jd_from_utc(extra_dashas.EPOCH + timedelta(seconds=first["start_ts"])) < range_start
assert first["antardashas"][0]["pratyantar_dashas"]
else:
assert datetime.fromisoformat(first["start_date"]).year < 2026
assert first["antardasha"]
assert result["periods"][0]["children"][0]["children"] or key == "yogini"
assert systems["chara"]["year_basis"] == "calendar_year_anniversary"
assert systems["yogini"]["year_basis"] == systems["kalachakra"]["year_basis"] == {"days": 365.25}
natal = natal_package["parameters"]["natal_chart"]
row = systems["chara"]["periods"][0]
from annual_service.report_facts import _domains_for, _owned
lagna = natal["lagna_sign_idx"]
house = ap._house(ap.SIGNS.index(row["sign"]), lagna)
occupants = [name for name, longitude in natal["longitudes"].items()
if ap._sign_idx(longitude) == ap.SIGNS.index(row["sign"])]
assert row["domains"] == _domains_for([house, *(h for name in occupants for h in _owned(lagna, name))])
@pytest.mark.parametrize(("rahu", "expected"), [(90.0, "computed"), (0.0, "not_applicable"), (None, "blocked")])
def test_ashtottari_respects_applicability_before_retaining_periods(rahu, expected):
request = annual_api._request(_body())
ctx = ap._Context(request)
natal = {"lagna_sign_idx": 0, "longitudes": {"Mars": 0, "Moon": 100, "Sun": 60, "Rahu": rahu}}
start = ctx.birth_utc + timedelta(days=365.25 * 36)
result = extra_dashas.build(ctx, natal, start, start + timedelta(days=366), {"known": True, "before_minutes": 0, "after_minutes": 0})
ashtottari = result["ashtottari"]
assert ashtottari["status"] == expected
assert ashtottari["reason"]
assert bool(ashtottari["periods"]) == bool(ashtottari["raw_periods"]) == (expected == "computed")
assert result["chara"]["status"] == "blocked"
assert result["chara"]["profile"] == "kn_rao"
if expected == "computed":
assert ashtottari["applicability"]["status"] == "applicable"
assert all(row["children"] for row in ashtottari["periods"])
def test_missing_moon_blocks_yogini_and_classical_kalachakra():
ctx = ap._Context(annual_api._request(_body()))
result = extra_dashas.build(ctx, {"lagna_sign_idx": 0, "longitudes": {"Mars": 0, "Rahu": 90}},
ctx.birth_utc, ctx.birth_utc + timedelta(days=366), None)
assert result["ashtottari"]["status"] == "blocked"
for key in ("yogini", "kalachakra"):
assert result[key]["status"] == "blocked"
assert "月亮经度" in result[key]["reason"]
assert not result[key]["periods"]
def test_kalachakra_window_keeps_both_native_pada_branches(monkeypatch):
ctx = ap._Context(annual_api._request(_body()))
engine = ap._module("kalachakra_dasha")
boundary = 360 / 108
natal = {"lagna_sign_idx": 0, "longitudes": {"Moon": boundary + 0.001}}
def positions(jd, *, node_mode, ayanamsa_name):
assert node_mode == ctx.node_mode and ayanamsa_name == ctx.ayanamsa
lon = boundary + (jd - ap._jd_from_utc(ctx.birth_utc)) * 13
return {"Moon": {"degree_raw": lon}}, None
monkeypatch.setattr(ap._engine(), "_calc_sidereal_planets_for_jd", positions)
at = ctx.birth_utc + timedelta(days=365.25 * 30)
field = extra_dashas.build(ctx, natal, at, at + timedelta(days=366), None)["kalachakra"]
precision = field["precision"]
assert field["status"] == "computed"
assert field["mode"] == "savya" and field["moon_pada"] == 2
assert field["starting_sign"] == precision["branches"][-1]["starting_sign"]
assert precision["status"] == "boundary_sensitive" and precision["source"] == "default"
assert (precision["before_minutes"], precision["after_minutes"]) == (30, 30)
assert [row["pada"] for row in precision["branches"]] == [1, 2]
expected = [extra_dashas._kala_branch(engine, boundary + shift, ctx.birth_naive)["starting_sign"]
for shift in (-0.1, 0.001)]
assert [row["starting_sign"] for row in precision["branches"]] == expected
declared = {"known": True, "before_minutes": 0, "after_minutes": 4}
one = extra_dashas.build(ctx, natal, at, at + timedelta(days=366), declared)["kalachakra"]["precision"]
assert one["source"] == "declared" and one["status"] == "stable"
assert [row["pada"] for row in one["branches"]] == [2]
def _frontend_projection(facts):
year = facts["page_appendix"]["years"][0]
systems = {}
for name, field in year["extra_natal_dashas"].items():
periods = [{**row, "children": [{**child, "children": []} for child in row["children"]]}
for row in field["periods"]]
systems[name] = {**field, "periods": periods}
audit = [row for row in facts["technique_audit_axes"] if "extra_natal_dashas" in (row["raw_path"] or "")]
return {"year": {"target_year": year["target_year"], "extra_natal_dashas": systems}, "audit": audit}
def test_fictional_frontend_fixture_comes_from_real_engine():
fixture = FRONTEND / "tests/fixtures/annual-dashas-20261114.json"
result = _frontend_projection(_facts())
result["boundary_precision"] = _facts(day=8, hour=15, minute=15)["page_appendix"]["years"][0][
"extra_natal_dashas"
]["kalachakra"]["precision"]
assert result["boundary_precision"]["status"] == "boundary_sensitive"
assert json.loads(fixture.read_bytes()) == result
@pytest.mark.skipif(not TSX.is_file(), reason="frontend dependencies are required for the writer contract")
def test_four_dasha_blocks_leave_all_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.extra_natal_dashas;
for (const audit of before.technique_audit_axes) {
if (audit.raw_path?.includes("extra_natal_dashas")) Object.assign(audit, {
calculation_status: "not_built", interpretation_status: "not_used", profile: null, raw_path: null,
});
}
const hashes = facts => buildAnnualPlan(facts, situation).map(entry => {
const evidence = JSON.stringify(sectionEvidence(facts, entry, situation));
assert.doesNotMatch(evidence, /extra_natal_dashas|pvr_classical_nine_sign_md_ad/);
return [entry.id, createHash("sha256").update(evidence).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"))
+3 -1
View File
@@ -90,7 +90,8 @@ def test_package_shape_and_version() -> None:
# 原因:占星师第三轮审查 2026-11-07
assert list(package) == [
"schema", "annual_package_version", "parameters", "report_range", "year_charts",
"natal_periods", "slow_transits", "transit_context", "independent_annual_views", "computation_boundaries",
"natal_periods", "extra_natal_dashas", "slow_transits", "transit_context", "independent_annual_views",
"computation_boundaries",
"not_included", "timeline_continuity", "block_index", "blocked_count",
]
json.dumps(package) # plain JSON, no datetime / circular references
@@ -103,6 +104,7 @@ def test_package_shape_and_version() -> None:
if item["status"] != "computed":
assert item["reason"], name
assert set(package["natal_periods"]) == {"vimshottari", "narayana"}
assert set(package["extra_natal_dashas"]) == {"chara", "yogini", "ashtottari", "kalachakra"}
assert package["independent_annual_views"]["tithi_pravesha"]["status"] in VOCAB
assert package["block_index"], "every block is listed"
assert all(row["status"] in VOCAB for row in package["block_index"])