Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ffeaaf830 | ||
|
|
118688fa7c |
@@ -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)。
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-11-14 — 年运附录列出另外四种本命运限
|
||||
|
||||
- 计算依据按年盘列 Chara、Yogini、Ashtottari 和 Kalachakra 的运期、主题与适用条件。占星 Skill 版本不变(BUG-1882)。
|
||||
|
||||
## 2026-11-14 — 年运附录分列 Muntha 四项记录
|
||||
|
||||
- 计算依据新增年盘结构、Muntha 主状态、慢星换座与停滞、逐年推进。沿用当前 Skill 版本(BUG-1880)。
|
||||
|
||||
@@ -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`,待验收。
|
||||
|
||||
@@ -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`,同时锁定其四个系统键 | 任务书要求新增顶层块,保留原有字段和顺序 |
|
||||
@@ -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` |
|
||||
|
||||
@@ -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 全部通过。
|
||||
@@ -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 点,写到一半断掉不扣」。价格读不到时这一行写「计费配置不可用」,按钮不可点。没有别的提示小字。
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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,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
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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"))
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user