Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
004cf9d88f | ||
|
|
6c774a4f28 | ||
|
|
35c87349e3 | ||
|
|
7505c841b2 | ||
|
|
c19baf033b | ||
|
|
09a67aad5a | ||
|
|
78c34c09ab | ||
|
|
21b1fe1aae | ||
|
|
636b24f081 |
@@ -24518,3 +24518,32 @@
|
||||
- 防复发:上述测试;合同 docstring 写明后续 #174–#178 的注册步骤。
|
||||
- 关联记录:占星师 2026-11-07 Q9、2026-11-08 第五轮第 9 条;FIX-ondemand-transit-date-1(产品拍板 C 方案)
|
||||
- 修复版本:分支 codex/ondemand-transit-date-20261109
|
||||
|
||||
## BUG-1817 | 没有「一段时间内的行运变化」:换座、停滞、逆行往返、目标经度都算不出来
|
||||
|
||||
- 状态:fixed-pending-verify(分支 codex/ondemand-transit-window-20261109)
|
||||
- 首次发现 / 最近更新:2026-11-07(占星师 Q9)/ 2026-11-09(后端修复)
|
||||
- 影响面:新增 scripts/ondemand_calc/transit_window.py;scripts/ondemand_calc/registry.py 加两行注册。
|
||||
- 现象:只能问「某一天行运如何」,问「接下来多久换座 / 什么时候停滞 / 这次逆行什么时候往返 / 什么时候到某个度数」都得自己猜日期。
|
||||
- 根因:按需计算骨架里只有单日期工具;现成的 ephemeris_events 按 UT 正午逐日采样且不认 node_mode、没有罗睺计都;transit_trigger.find_exact_transit_date 只返回第一次到达、岁差写死;cmd_transit 的 --retrograde-calendar / --find-conjunction 依赖不存在的 transit_search.py。
|
||||
- 修复:新增 transit_window 工具(ingress / station / retrograde_loop / target_longitude),黄经与日速度来自 jyotish_engine._calc_sidereal_planets_for_jd,口径由调用方显式传;事件二分到 ≤1 分钟;逆行往返给两个停滞、retrograde_span、影子期进出,允许区间外 400 天搜索并标 outside_requested_window;目标经度按方向列出每一次到达;节点不报停滞(partial + gap);失败只留缺口不编日期。
|
||||
- 验证:tests/test_ondemand_transit_window_20261109.py(17 条)与 transit_date / contract / api growth 合计 47 条全过;2026 水星逆行两个停滞与直接 SwissEph 速度二分的差为 1.41 分钟。
|
||||
- 防复发:上述测试;合同 docstring 的注册步骤已含本工具。
|
||||
- 验收修正(Claude 2026-11-09,codex/accept-grok-batch2-1109):换座的 to_sign 原来把 JD 当黄经取星座(每次换座的新星座都是错的),direction 把 0° 白羊处的顺行换座判成 retrograde,两处都改为按粗扫步的黄经计算;逆行往返里两个停滞与影子期进出各自标 outside_requested_window;区间终点按终点那天的 UTC 偏移换算。补 3 条回归测试(共 20 条)。
|
||||
- 关联记录:占星师 2026-11-07 Q9、2026-11-08 第五轮第 9 条;#173(共用骨架)
|
||||
- 修复版本:分支 codex/ondemand-transit-window-20261109
|
||||
|
||||
## BUG-1824 | 「解读依据」把候选格局显示成「成立」
|
||||
|
||||
- 状态:fixed-pending-verify(分支 codex/basis-candidate-status-20261109)
|
||||
- 首次发现 / 最近更新:2026-11-09(第七轮验收包,页面阶段)/ 2026-11-09(修复)
|
||||
- 影响面:frontend/src/lib/consultation-technique-evidence.ts 的 yogaLine;frontend/src/lib/consult-answer-basis.ts 的「格局」兜底文案;新增 frontend/src/lib/yoga-status-words.ts。
|
||||
- 现象:简明解读的「解读依据」里,「格局」一行写「成立:Sunaphaa Yoga、Vosi Yoga」,而卡上这些格局的状态是 candidate / computed,卡上的规则写明 none is confirmed。
|
||||
- 触发条件:任何带数据卡的简明解读回答。
|
||||
- 根因:yogaLine 只读领域段的 yogas.hits(检测器匹配到的规则名,BUG-1157),一律写成「成立:」;真正的状态在 card.base.natal.yogas_detail.rows[] 的 status / formation.status 里,从来没被这一行读过。
|
||||
- 修复:新增 yoga-status-words.ts(conditions_not_met → 不成立;candidate → 候选;computed → 未确认;其他 → 未标状态;结构观察不进这一行);yogaLine 改为按名字到 yogas_detail.rows 取状态词并按状态分组(候选:A、B;未确认:C),没有可列的写「本题没有检测到相关格局」;兜底文案由「盘上成立和落空的格局已核对」改为「盘上检测到的格局已列出各自状态」。行状态(已算 / 有条件)与数据卡事实记录不动。
|
||||
- 验证:frontend/tests/consult-basis-candidate-status-1824.test.ts(8 条:三种状态词、formed_under_source 不写成成立、结构观察不出现、未标状态、灰字与兜底都不含「成立:」和「已确认」、计算状态与解读状态分开、空 hits 文案、词表本身不提供「成立」);consult-technique-evidence-1793(2)、consult-answer-basis-receipt-20261108(4)、consult-answer-mode-ui-20261108(14)继续通过。
|
||||
- 防复发:上述测试;词表单独成文件,后续 #182 复用同一套词。
|
||||
- 验收修正(Claude 2026-11-09,codex/accept-grok-batch2-1109):占星师第七轮回复 §5 P0.2 给出了确切映射,初版词表(候选 / 未确认 / 不成立 / 未标状态,formed_under_source 显示为候选)与之不同,验收时替换为原表:candidate / support_only_candidate → 命中候选规则,尚未完整确认;computed(未完成来源条件治理)→ 检测器检出,待核实;formed_under_source → 按所选来源的形成条件成立;强度和结果另评;conditions_not_met → 所选来源的必要条件未满足;blocked / not_available_yet / 卡上无状态行 / 状态值不在表内 → 当前无法确认,缺:<缺项>。只有 formation.status(完整条件检查)能给出「成立」或「必要条件未满足」;只在 hits 里出现的名字一律落到「当前无法确认」。灰字按「名字:状态文字」分组、组间用「。」,放不下的整项写「另 N 项未列」,状态文字不被 80 字截断。新增 5 行映射各一条与「只在 hits 里的格局不显示成立」一条回归测试(共 13 条)。#182(模型侧措辞)必须 import 同一个 yoga-status-words.ts 的 YOGA_STATUS_MAPPING / yogaStatus。
|
||||
- 关联记录:BUG-1793、BUG-1157、#182
|
||||
- 修复版本:分支 codex/basis-candidate-status-20261109
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# PROGRESS · 「解读依据」候选格局不再写成「成立」(#181,BUG-1824,2026-11-09)
|
||||
|
||||
- 分支:`codex/basis-candidate-status-20261109`(基线 `afac4ea9`)
|
||||
- 任务书:`docs/tasks/TASK-basis-candidate-status-20261109.md`
|
||||
- Bug:`docs/BUG_HISTORY.md` 末尾追加 BUG-1824
|
||||
|
||||
## 改了什么
|
||||
|
||||
| 文件 | 内容 |
|
||||
| --- | --- |
|
||||
| `frontend/src/lib/yoga-status-words.ts`(新) | `YOGA_STATUS_WORDS` 与 `yogaStatusWord(row)`:`formation.status = conditions_not_met` → 不成立;`status = candidate` → 候选;`status = computed` → 未确认;其他 → 未标状态。另导出 `isStructuralObservation(row)` |
|
||||
| `frontend/src/lib/consultation-technique-evidence.ts` | 只改 `yogaLine` 与一行 import:仍取领域段 `yogas.hits` 的前 3 个名字,逐个到 `card.base.natal.yogas_detail.rows` 按 `name` 找状态词;结构观察跳过;按状态词分组(组按首次出现排、组内保持原顺序);找不到行 → 未标状态;没有可列的 → 「本题没有检测到相关格局」 |
|
||||
| `frontend/src/lib/consult-answer-basis.ts` | 「格局」的兜底文案改为「盘上检测到的格局已列出各自状态」,改动处写三栏注释(原因:BUG-1824:候选不得显示为成立) |
|
||||
|
||||
没动:数据卡(`yogas.hits` / `yogas_detail` 事实记录一字未改)、回执 schema、`display_status` 取值、页面组件、答题提示词、`references/**`、`scripts/**`。
|
||||
|
||||
## 状态词表
|
||||
|
||||
| 卡上的值 | 显示 |
|
||||
| --- | --- |
|
||||
| `formation.status = "conditions_not_met"` | 不成立 |
|
||||
| `status = "candidate"` | 候选 |
|
||||
| `status = "computed"` | 未确认 |
|
||||
| 其他 / 缺失 | 未标状态 |
|
||||
|
||||
`formed_under_source` 不单独显示为「成立」——卡上规则是 `none is confirmed`,按 `status` 显示。
|
||||
词表里没有「成立」「已确认」这两个词(「不成立」是否定式,是任务书要求的)。
|
||||
|
||||
## 自测
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `node ... --test tests/consult-basis-candidate-status-1824.test.ts` | 8 过 / 0 败 |
|
||||
| `tests/consult-technique-evidence-1793.test.ts` | 2 过 / 0 败 |
|
||||
| `tests/consult-answer-basis-receipt-20261108.test.tsx` | 4 过 / 0 败 |
|
||||
| `tests/consult-answer-mode-ui-20261108.test.tsx` | 14 过 / 0 败 |
|
||||
| `tsc --noEmit -p frontend` | 0 错 |
|
||||
| `npm run lint` | 0 error |
|
||||
|
||||
新测试覆盖任务书的 7 条:三种状态词各对各;`formed_under_source` 显示候选、不写成立;结构观察不出现;卡上没有对应行的名字 → 未标状态;灰字与兜底都不含「成立:」「已确认」;`consultAnswerBasisRows` 走一遍时「格局」行 `status === "done"` 且 `result` 是分组灰字(计算状态与解读状态分开);空 `hits` → 「本题没有检测到相关格局」。既有断言没有改动,所以没有三栏注释。
|
||||
|
||||
## 说明
|
||||
|
||||
- 「格局」行要显示成 `已算`,测试里的审计行用 `interpretation_status: "supporting"`(引擎把 yoga 行标为 observation/supporting 时 `display_status` 才是 firm)。这是既有判定,本单没有改。
|
||||
- 页面组件 `consult-answer-basis.tsx` 不用改:它只渲染 `result` 与状态标签。
|
||||
@@ -0,0 +1,45 @@
|
||||
# PROGRESS · 按需计算(二)一段时间内的行运变化(#174,BUG-1817,2026-11-09)
|
||||
|
||||
- 分支:`codex/ondemand-transit-window-20261109`(基线 `afac4ea9`)
|
||||
- 任务书:`docs/tasks/TASK-ondemand-transit-window-20261109.md`
|
||||
- Bug:`docs/BUG_HISTORY.md` 新增 BUG-1817
|
||||
|
||||
## 做了什么
|
||||
|
||||
| 文件 | 内容 |
|
||||
| --- | --- |
|
||||
| `scripts/ondemand_calc/transit_window.py`(新) | `TOOL = 'transit_window'`,`run(body)`。四类事件:换座 `ingress`、停滞 `station`、逆行往返 `retrograde_loop`、到达目标经度 `target_longitude` |
|
||||
| `scripts/ondemand_calc/registry.py` | 加了 `from . import transit_window` 与 `TOOLS['transit_window']` 两行(其余未动) |
|
||||
| `tests/test_ondemand_transit_window_20261109.py`(新) | 17 条:本单专有 5 条 + 通用 12 条 |
|
||||
| `docs/BUG_HISTORY.md` | 末尾追加 BUG-1817 |
|
||||
|
||||
口径与数值都照任务书:黄经与日速度全部来自 `jyotish_engine._calc_sidereal_planets_for_jd`(调用前先 `_configure_ephemeris_path()`),岁差/交点/位置口径由调用方传;`position_mode=mean` 返回 `blocked` + `position_mode_unsupported`;缺参数返回 `blocked` 信封并点名缺项(HTTP 仍是 200,按合同)。没有改合同、没有改 API 服务器、没有建前端目录。
|
||||
|
||||
## 实测(验收第 1 条的对照)
|
||||
|
||||
2026 年水星逆行(区间 2026-01-01 至 2026-03-31,lahiri / mean):
|
||||
|
||||
| 项 | 本工具 | 直接用 SwissEph 速度二分 | 差 |
|
||||
| --- | --- | --- | ---: |
|
||||
| 逆行停滞 | 2026-02-26T06:49Z | 2026-02-26T06:49Z | **1.41 分钟** |
|
||||
| 顺行停滞 | 2026-03-20T19:32Z | 2026-03-20T19:32Z | **1.41 分钟** |
|
||||
|
||||
逆行段:`retrograde_span = [314.265716, 328.340901]`,影子期 `shadow_entry 2026-02-11T22:11Z` 早于逆行停滞、`shadow_exit 2026-04-09T11:44Z` 晚于顺行停滞,符合要求。
|
||||
目标经度 320°(区间放宽到 2026-04-30 才够三次):2026-02-15T16:04Z 直行、2026-03-10T02:26Z 逆行、2026-04-01T14:43Z 直行。
|
||||
|
||||
收敛:每个事件二分到 ≤ 1/1440 天(1 分钟);粗扫月亮 6 小时、其余 1 天;逆行往返每侧最多 400 天,写在 `raw.precision`。
|
||||
|
||||
## 自测
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `pytest tests/test_ondemand_transit_window_20261109.py tests/test_ondemand_transit_date_20261109.py tests/test_ondemand_calc_contract_20261109.py tests/test_api_server_growth_contract.py` | **47 过 / 0 败**(本单 17 条) |
|
||||
|
||||
覆盖本单专有 5 条:停滞对照 ≤ 2 分钟;目标经度三次方向 direct/retrograde/direct;影子期先后;lahiri 与 raman 换座时刻不同且回显;罗睺 mean 与 true 换座时刻不同;伦敦跨夏令时那天的偏移为 0/1。通用 12 条:缺项点名(start_date / end_date / 地点 / 时区 / 岁差 / events)、超 366 天与倒序日期、未知事件与未知行星、`position_mode=mean` 阻塞、`profile_source` 标记、指纹稳定与敏感、引擎失败不给日期、节点不报停滞(`partial` + gap)、`natal_point` 缺失阻塞、注册表分派。
|
||||
|
||||
## 偏离与取舍
|
||||
|
||||
- **目标经度测试的区间**:任务书示例区间 1–3 月只够两次到齐(第二次逆行的第三次要等 4 月顺行恢复),所以那条测试把 `end_date` 放到 2026-04-30,并在测试里注明原因。没有改实现。
|
||||
- **换座判定的实现**:按「星座编号变化 + 二分找第一次变化」实现,顺行与逆行(退回原星座)都成立;跨过 30° 边界的就是换座,不需要另外判断方向。
|
||||
- **`ayanamsa_degrees_at_start`**:由引擎同一次调用返回的 ayanamsa 值,6 位小数。
|
||||
- 遇到 `events` 里同时要 `station` 和 `retrograde_loop` 的节点星:`gaps` 记两条 `node_station_not_supported:<星>`(一次请求一条),状态 `partial`——任务书要求「不计算并写 gap」,按星记足够定位。
|
||||
@@ -0,0 +1,106 @@
|
||||
# TASK:「解读依据」把候选格局显示成“成立”(#181,BUG-1824,2026-11-09)
|
||||
|
||||
- Gitea:#181
|
||||
- 分支:`codex/basis-candidate-status-20261109`(基于 staging `afac4ea92f293a4d2bc81f62563487d26f92e4d2`)
|
||||
- Bug 编号:BUG-1824(开工时核对 `docs/BUG_HISTORY.md` 的最大编号;1809–1830 已为这一批预留)
|
||||
- 负责验收:Claude(jyotisha-2a)
|
||||
- 范围:只改「解读依据」里「格局」这一行的灰字和兜底文案。
|
||||
|
||||
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
|
||||
|
||||
## 现象(第七轮验收包,页面阶段)
|
||||
|
||||
简明解读下的「解读依据」里,「格局」一行显示「成立:Sunaphaa Yoga、Vosi Yoga」「成立:Dharidhra Yoga…」。数据卡上这些格局的状态都是 `candidate`(或 `computed`),卡上的规则写明 `none is confirmed`。列表把“计算已执行”说成了“解读成立”。
|
||||
|
||||
## 根因(afac4ea9 实测,行号以符号为准)
|
||||
|
||||
- `frontend/src/lib/consultation-technique-evidence.ts` 的 `yogaLine(card)` 只读领域段的 `yogas.hits`(名字列表),一律写成 `成立:<名字>`;没有名字时写 `本题没有成立的格局`。
|
||||
- `hits` 是“检测器在本盘匹配到的规则名”(`consultation-evidence-card.ts` 约 :2401,BUG-1157),不是“成立”。
|
||||
- 每个格局真正的状态在同一张卡的 `card.base.natal.yogas_detail.rows[]` 里(`consultation-evidence-card.ts` 约 :2007–2028 从引擎层 `yoga_conditions` 原样拷贝):
|
||||
- `status`:`candidate` | `computed` | `structural_observation`(引擎 `scripts/consultation_native_layers.py` 的 `status_rule`);
|
||||
- `formation.status`:`formed_under_source` | `candidate` | `conditions_not_met`(`formation_rule`);
|
||||
- `claim_kind`:`structural_observation` 时不是命名格局。
|
||||
- `frontend/src/lib/consult-answer-basis.ts` 的 `BASIS_ENTRIES` 里「格局」的兜底文案是 `盘上成立和落空的格局已核对`,同样说过头。
|
||||
- 行状态(`已算` / `有条件`)来自 `display_status` / 两轴状态,是**计算**状态;这部分是对的,不改。
|
||||
|
||||
## 要做的
|
||||
|
||||
### 1. 新建状态词表 `frontend/src/lib/yoga-status-words.ts`
|
||||
|
||||
一个导出的常量和一个函数,供本单使用(#182 以后也会复用,所以单独成文件):
|
||||
|
||||
| 卡上的值 | 列表显示 |
|
||||
| --- | --- |
|
||||
| `formation.status = "conditions_not_met"` | 不成立 |
|
||||
| `status = "candidate"` | 候选 |
|
||||
| `status = "computed"` | 未确认 |
|
||||
| 其他 / 缺失 | 未标状态 |
|
||||
|
||||
- 判定顺序:先看 `formation.status === "conditions_not_met"`,否则看 `status`。
|
||||
- `formed_under_source` **不**单独显示为“成立”:卡上规则是 `none is confirmed`,照 `status` 显示(候选 / 未确认)。
|
||||
- 函数签名建议:`yogaStatusWord(row: Record<string, unknown>): "不成立" | "候选" | "未确认" | "未标状态"`。
|
||||
- 词表里**不得**出现“成立”“已确认”这类词。
|
||||
|
||||
### 2. 改 `yogaLine(card)`(`consultation-technique-evidence.ts`)
|
||||
|
||||
- 仍取领域段 `yogas.hits` 的名字(顺序不变,最多 3 个)。
|
||||
- 每个名字到 `card.base.natal.yogas_detail.rows` 里按 `name` 找对应行,用 `yogaStatusWord` 得到状态词。
|
||||
- `claim_kind === "structural_observation"` 或 `status === "structural_observation"` 的名字不进这一行(与 `annual-report-sections.ts` 把结构观察单列、不进「格局」的做法一致)。
|
||||
- 找不到对应行的名字显示“未标状态”。
|
||||
- 按状态词分组,组内保持原顺序,组按第一次出现的顺序排:`候选:A、B;未确认:C`。
|
||||
- 没有可列的名字时写 `本题没有检测到相关格局`。
|
||||
- `LINES` 表、其他技法的灰字、`techniqueDisplayStatus`、`withTechniqueEvidence` 的结构都不改。
|
||||
|
||||
### 3. 改兜底文案(`consult-answer-basis.ts`)
|
||||
|
||||
`BASIS_ENTRIES` 里「格局」的 `result` 由 `盘上成立和落空的格局已核对` 改为 `盘上检测到的格局已列出各自状态`。改动处写“原值 / 新值 / 原因”三栏注释,原因写 `BUG-1824:候选不得显示为成立`。文件头注释里对应描述同步改一句。其余条目不改。
|
||||
|
||||
### 4. 测试 `frontend/tests/consult-basis-candidate-status-1824.test.ts`
|
||||
|
||||
用合成卡(不写真人资料),至少覆盖:
|
||||
|
||||
1. 卡上状态分别为 `candidate`、`computed`、`formation.status = conditions_not_met` 的三个格局,`yogaLine` 产出 `候选:…;未确认:…;不成立:…`,三个词各对各;
|
||||
2. `formed_under_source` + `status = candidate` 显示“候选”;
|
||||
3. 结构观察不出现在这一行;
|
||||
4. 卡上没有对应行的名字显示“未标状态”;
|
||||
5. 生成的灰字和兜底文案里都不含“成立:”这个子串,也不含“已确认”;
|
||||
6. 经 `consultAnswerBasisRows` 走一遍:「格局」行的 `status` 仍是 `done`(已算),`result` 是第 1 条的灰字——计算状态和解读状态分开;
|
||||
7. 空 `hits` 时灰字为 `本题没有检测到相关格局`。
|
||||
|
||||
既有测试 `frontend/tests/consult-technique-evidence-1793.test.ts`、`consult-answer-basis-receipt-20261108.test.tsx`、`consult-answer-mode-ui-20261108.test.tsx` 必须照样通过;若其中有断言写了旧文案,改时写三栏注释,不得删断言。
|
||||
|
||||
### 5. 记录
|
||||
|
||||
- `docs/BUG_HISTORY.md` 末尾追加 BUG-1824(状态、现象、触发条件、根因、修复、验证、防复发、关联 BUG-1793 / BUG-1157 / #182)。只在末尾追加,不用 sed 改整个文件。
|
||||
- `docs/tasks/PROGRESS-basis-candidate-status-20261109.md`。
|
||||
|
||||
## 允许改的文件(只有这些)
|
||||
|
||||
- 新建:`frontend/src/lib/yoga-status-words.ts`、`frontend/tests/consult-basis-candidate-status-1824.test.ts`、`docs/tasks/PROGRESS-basis-candidate-status-20261109.md`
|
||||
- 修改:`frontend/src/lib/consultation-technique-evidence.ts`(只改 `yogaLine` 和必要的 import)、`frontend/src/lib/consult-answer-basis.ts`(只改「格局」兜底文案与头注释一句)、`docs/BUG_HISTORY.md`(末尾追加)
|
||||
- 页面组件 `frontend/src/components/consult-answer-basis.tsx` 预计不用改;若确实要改,停下选“需拍板”。
|
||||
|
||||
## 不要碰
|
||||
|
||||
- `references/**`(占星师方法文件),一字不改;包括 `references/product_runtime_contract.md`。
|
||||
- `frontend/src/lib/consult-preset-prompts.ts`、`frontend/src/lib/consultation-methodology.ts`、`frontend/src/mastra/product-voice.ts`:不改答题规则。
|
||||
- `frontend/src/lib/consultation-evidence-card.ts`:数据卡的事实记录一字不改(`yogas.hits`、`yogas_detail` 的字段和内容都不动;“把状态词写进卡和产品规则”归 #182)。该文件也是 #170 / #172 的范围。
|
||||
- `frontend/src/lib/consultation-answer-fact-guard.ts`:拦截检查归 #182 / #183。
|
||||
- `frontend/src/lib/consultation-agent-events.ts`(回执 schema,`display_status` 的取值不改)、`frontend/src/app/api/consult/route.ts`。
|
||||
- `frontend/src/mastra/consultation-tools.ts`(#171)、`frontend/src/mastra/consultation-workflow.ts` 与 `frontend/src/lib/consultation-route-service.ts`(#185)。
|
||||
- `scripts/**` 全部,特别是冻结文件:`scripts/jyotish_api_server.py`、`scripts/jyotish_engine.py`、`scripts/domain_calculation_service.py`、`scripts/dasha_analyzer.py`、`scripts/narayana_dasha.py`、`scripts/shadbala.py`、`scripts/ashtakavarga.py`、`scripts/jaimini.py`、`scripts/varga.py`、`scripts/functional_benefics.py`、`scripts/divisional_charts_extended.py`;以及 `scripts/consultation_native_layers.py`(引擎的状态值不改)。
|
||||
- 既有断言不得弱化。
|
||||
|
||||
## 自测命令(Windows / PowerShell)
|
||||
|
||||
- `frontend\node_modules\.bin\tsc --noEmit -p frontend`
|
||||
- 在 `frontend` 下运行 `npm run lint`
|
||||
- 在 `frontend` 下运行 `node node_modules/tsx/dist/cli.mjs --test tests/consult-basis-candidate-status-1824.test.ts tests/consult-technique-evidence-1793.test.ts tests/consult-answer-basis-receipt-20261108.test.tsx tests/consult-answer-mode-ui-20261108.test.tsx`
|
||||
|
||||
前端全量测试、Python 快速门和 eslint 由 Claude 在验收时跑。
|
||||
|
||||
## 何时停下来选“需拍板”
|
||||
|
||||
- 卡上出现上表以外的状态值,且不能归入“未标状态”时。
|
||||
- 要改数据卡、回执 schema、答题规则或页面组件才能做到时。
|
||||
- 任务书没写到的任何文案或取舍。
|
||||
@@ -0,0 +1,157 @@
|
||||
# TASK:按需计算——一段时间内的行运变化:换座、停滞、逆行往返、到达目标经度(#174,BUG-1817,2026-11-09)
|
||||
|
||||
- Gitea:#174
|
||||
- 分支:`codex/ondemand-transit-window-20261109`(基于 staging `afac4ea92f293a4d2bc81f62563487d26f92e4d2`)
|
||||
- Bug 编号:BUG-1817(开工时核对 `docs/BUG_HISTORY.md` 的最大编号;1809–1830 已为这一批预留)
|
||||
- 负责验收:Claude(jyotisha-2a)
|
||||
- 范围:**只做后端**。新增一个工具文件,在注册表里加一行。不做前端接入,不建 `frontend/src/mastra/ondemand-tools/`。
|
||||
|
||||
不得自行写“产品决定/裁定”;任务书没写的取舍一律选需拍板。
|
||||
|
||||
## 背景
|
||||
|
||||
占星师 2026-11-07 Q9:“一段时间内换位、逆行、精确经过:`transit --months`、`--retrograde-calendar`、`--find-conjunction`、`--target-longitude`。”
|
||||
2026-11-08 第五轮第 9 条:“行运窗口:支持换座、停滞、逆行往返和目标经度。”
|
||||
|
||||
#173 已在 staging 建好共用模块 `scripts/ondemand_calc/`。**开工前先读完**:
|
||||
|
||||
- `scripts/ondemand_calc/contract.py` 顶部 docstring(信封字段、错误规则、加工具的三步);
|
||||
- `scripts/ondemand_calc/registry.py`;
|
||||
- `scripts/ondemand_calc/transit_date.py`(第一个工具,本单照它的写法);
|
||||
- `docs/tasks/PROGRESS-ondemand-transit-date-20261109.md`「给 #174–#178 的注册步骤」;
|
||||
- `tests/test_ondemand_transit_date_20261109.py`(测试写法、星历路径配置)。
|
||||
|
||||
## 现状(afac4ea9 实测,行号以符号为准)
|
||||
|
||||
- 调用入口已经存在:`scripts/jyotish_api_server.py` 的 `/api/ondemand_calc` 薄路由 → `ondemand_calc.registry.ondemand_calc_api(body)` → `TOOLS[body["tool"]](body)`。**本单不需要、也不许改 API 服务器。**
|
||||
- 缺参数时合同的做法是**返回 `calculation_status = "blocked"` 的信封**(`contract.blocked_envelope`),HTTP 仍是 200,`gaps` 点名缺项。原任务书里“缺一项就 400”作废,以合同为准。
|
||||
- `contract.validate_common(body)` 只认单个 `date` 字段;本工具是区间,处理方法见下文“输入”。
|
||||
- `contract.resolve_profile`:岁差必填;`node_mode` / `position_mode` 缺省时用产品默认值 `mean` / `apparent`,并写 `profile_source = "product_default_explicit"`(显式传了写 `caller_supplied`)。**本单照用,不改。** 节点 / 位置口径何时从本命请求显式带过来,归 #185。
|
||||
- `position_mode = "mean"` 必须返回 `blocked`,`gaps` 写 `{"field": "position_mode", "reason": "position_mode_unsupported"}`,照 `transit_date.run` 的写法。
|
||||
- 计算用引擎函数 `jyotish_engine._calc_sidereal_planets_for_jd(jd, node_mode=..., include_ketu=True, ayanamsa_name=...)`,调用前先 `engine._configure_ephemeris_path()`,和 `transit_date.py` 完全一样。这样数值与指定日期行运一致,岁差和交点口径都真正生效。
|
||||
- **不要用**以下现成函数做最终时刻(可以在测试里当对照):
|
||||
- `scripts/ephemeris_events.py` 的 `build_ephemeris_events`:按 UT 正午逐日采样,`node_mode` 只接收不使用,没有罗睺 / 计都;
|
||||
- `scripts/transit_trigger.py` 的 `find_exact_transit_date`:只返回第一次到达,默认岁差是 lahiri;
|
||||
- `scripts/jyotish_engine.py` 的 `cmd_transit`、`--retrograde-calendar`、`--find-conjunction`:依赖的 `scripts/transit_search.py` 不存在。
|
||||
|
||||
## 要做的
|
||||
|
||||
### 文件
|
||||
|
||||
1. 新建 `scripts/ondemand_calc/transit_window.py`,`TOOL = 'transit_window'`,实现 `run(body: dict) -> dict`。
|
||||
2. 在 `scripts/ondemand_calc/registry.py`:
|
||||
- 顶部 `from . import transit_date` 旁边加一行 `from . import transit_window`;
|
||||
- `TOOLS` 里加一行 `'transit_window': transit_window.run,`,上方写一行注释列出必填参数(照 `transit_date` 那行注释的格式)。
|
||||
- registry.py 其他内容不改。
|
||||
3. 新测试 `tests/test_ondemand_transit_window_20261109.py`。
|
||||
4. `docs/tasks/PROGRESS-ondemand-transit-window-20261109.md`。
|
||||
5. `docs/BUG_HISTORY.md` 末尾追加 BUG-1817(只在文件末尾追加,不改其他条目)。
|
||||
|
||||
除上面 5 个文件外不改任何文件。
|
||||
|
||||
### 输入(`body`)
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `tool` | 是 | `"transit_window"` |
|
||||
| `start_date`、`end_date` | 是 | `YYYY-MM-DD`,当地日期;`end_date >= start_date`;跨度 ≤ 366 天(本文件内常量 `MAX_WINDOW_DAYS = 366`,**不改** `contract.MAX_RANGE_DAYS`) |
|
||||
| `lat`、`lon`、`tz` 或 `timezone_id` | 是 | 与 `transit_date` 相同(可放在 `place` 里) |
|
||||
| `ayanamsa` | 是 | 与 `transit_date` 相同 |
|
||||
| `node_mode`、`position_mode` | 否 | 交给 `resolve_profile`,缺省用产品默认值并如实标记 |
|
||||
| `planets` | 否 | 九曜子集;不给时为九曜全集 |
|
||||
| `events` | 是 | `ingress` / `station` / `retrograde_loop` / `target_longitude` 的非空子集 |
|
||||
| `target` | `events` 含 `target_longitude` 时必填 | `{"planet": "...", "longitude": 0–360}` 或 `{"planet": "...", "natal_point": "<星名或 Ascendant>"}` |
|
||||
| `natal` | 用 `natal_point` 时必填 | `{"points": {"Moon": 123.456, ...}}`,恒星黄经,由调用方给出;本工具**不自己排本命盘** |
|
||||
|
||||
校验顺序与写法:
|
||||
|
||||
- 先自己校验 `start_date` / `end_date`(缺失、格式、先后、跨度),出错抛 `OnDemandInputError(..., field='start_date' 或 'end_date')`;
|
||||
- 再用 `contract.validate_common({**body, 'date': body['start_date']})` 校验地点和时区,用 `contract.resolve_profile` 取口径(`profile.tz` 因此是起始日的偏移);
|
||||
- 所有 `OnDemandInputError` 都转成 `contract.blocked_envelope(tool=TOOL, body=body, error=error)`;
|
||||
- `requested` 必须是调用方原样的 `body`(不得把上面临时加的 `date` 写进 `requested` 或指纹)。
|
||||
|
||||
### 计算
|
||||
|
||||
- 每个事件的时刻用二分法精确到 **≤ 1 分钟**(区间宽度 ≤ 1/1440 天)。
|
||||
- 粗扫步长:月亮 ≤ 6 小时,其余 ≤ 1 天;在 `raw.precision` 里写明步长和收敛阈值。
|
||||
- 当地时间:给了 `timezone_id` 时,**按事件当天**用 `contract.offset_hours(day, None, timezone_id)` 换算(夏令时生效);只给 `tz` 时用固定偏移。
|
||||
- **换座(ingress)**:恒星黄经跨过 30° 整数倍的时刻;逆行导致的退回原星座也是一次换座,标 `direction: "retrograde"`。罗睺 / 计都按 `profile.node_mode`(mean 或 true)计算,不得忽略。
|
||||
- **停滞(station)**:日速度(引擎返回的 `speed`)变号的时刻,标 `station_type: "retrograde"`(顺转逆)或 `"direct"`(逆转顺)。只对 Mercury、Venus、Mars、Jupiter、Saturn 计算;太阳、月亮没有停滞。罗睺 / 计都请求 `station` 或 `retrograde_loop` 时,不计算,在 `gaps` 写 `{"field": "planets", "reason": "node_station_not_supported:<星名>"}`,状态 `partial`(其他请求正常时)。
|
||||
- **逆行往返(retrograde_loop)**:每一段逆行给出:
|
||||
- `station_retrograde`、`station_direct` 两个停滞时刻及当时黄经;
|
||||
- `retrograde_span`:`[station_direct 黄经, station_retrograde 黄经]`;
|
||||
- `shadow_entry`:逆行前首次到达 `station_direct` 黄经的时刻;`shadow_exit`:顺行恢复后最后一次离开 `station_retrograde` 黄经的时刻。
|
||||
- 只要两个停滞中有一个落在请求区间内就列出这一段;为找齐停滞和影子期,可以向区间外搜索,但每侧最多 400 天;落在请求区间外的时刻标 `outside_requested_window: true`。400 天内找不到的那一项为 `null`,并在 `gaps` 写 `loop_incomplete:<星名>:<字段>`。
|
||||
- **到达目标经度(target_longitude)**:列出区间内**每一次**精确到达(逆行可能造成三次),每次标 `direction: "direct"` 或 `"retrograde"`。`natal_point` 在 `natal.points` 里找不到时 `blocked`,`gaps` 写 `{"field": "target.natal_point", "reason": "natal_point_missing:<名>"}`,不得用行运位置或别的点顶替。
|
||||
|
||||
### 输出
|
||||
|
||||
用 `contract.build_envelope`,信封字段与合同一致,不加顶层字段,`profile` 不加字段。`raw` 形状:
|
||||
|
||||
```
|
||||
{
|
||||
"start_date": "...", "end_date": "...",
|
||||
"ayanamsa_degrees_at_start": <float>,
|
||||
"precision": {"coarse_step_hours": {...}, "convergence_minutes": 1, "search_margin_days": 400},
|
||||
"events": [
|
||||
{"type": "ingress" | "station" | "target_longitude", "planet": "...",
|
||||
"utc": "YYYY-MM-DDTHH:MMZ", "local": "YYYY-MM-DDTHH:MM", "utc_offset_hours": <float>,
|
||||
"jd_ut": <float>, "longitude": <float, 6 位>, "sign": "...", "degree_in_sign": <float>,
|
||||
...该类型自己的字段(from_sign / to_sign / direction / station_type / target_longitude)}
|
||||
],
|
||||
"retrograde_loops": [ {...见上...} ]
|
||||
}
|
||||
```
|
||||
|
||||
- `events` 按 `jd_ut` 升序。
|
||||
- 计算失败(引擎抛错、星历不可用)时:`blocked`,`raw == {}`,`gaps` 写 `engine_failed:<异常类名>` 或 `ephemeris_unavailable`;信封中除 `requested` 外不得出现任何日期或时刻字符串(`computed_at_utc` 除外,与 `transit_date` 一致)。
|
||||
- 部分行星失败、其余成功:`partial`,成功的照列,失败的在 `gaps` 点名。
|
||||
|
||||
## 本单专有验收(写进新测试)
|
||||
|
||||
1. 2026 年水星的一次逆行(公开星历):
|
||||
- 两个停滞时刻与直接用 SwissEph 求速度过零的时刻相差 ≤ 2 分钟(测试里自己用 `swe.calc_ut` + `FLG_SIDEREAL | FLG_SPEED` 二分作对照);
|
||||
- 以逆行段中间的某个黄经为目标,三次到达都列出,方向依次为 direct / retrograde / direct;
|
||||
- `shadow_entry` 早于 `station_retrograde`,`shadow_exit` 晚于 `station_direct`。
|
||||
2. lahiri 与 raman 下,同一次换座的时刻不同,且各自 `profile.ayanamsa` 如实回显。
|
||||
3. 罗睺 / 计都在 `node_mode = mean` 与 `true` 下,同一次换座的时刻不同。
|
||||
4. 用 `timezone_id = "Europe/London"` 跨夏令时切换日(2026-03-29)的区间:切换前后事件的 `utc_offset_hours` 分别为 0 和 1。
|
||||
5. 与 `transit_date` 的一致性:在某个换座时刻,用 `transit_date` 算同一时刻(同口径),该行星黄经与 30° 边界相差 ≤ 0.01°。
|
||||
|
||||
## 通用验收(写进新测试)
|
||||
|
||||
1. 缺 `start_date` / `end_date` / 地点 / 时区 / 岁差 / `events` 中任一项,返回 `blocked` 且 `gaps[0].field` 点名缺项;跨度 > 366 天、`end_date < start_date` 同样 `blocked`。
|
||||
2. `position_mode = "mean"` 返回 `blocked` + `position_mode_unsupported`。
|
||||
3. 省略 `node_mode` / `position_mode` 时 `profile.profile_source == "product_default_explicit"`;都显式传时为 `caller_supplied`。
|
||||
4. 同一输入的 `input_fingerprint` 稳定;任一参数(日期、地点、岁差、交点、planets、events、target)改变后 fingerprint 改变。
|
||||
5. 引擎抛错(monkeypatch `_calc_sidereal_planets_for_jd`)时返回 `blocked`、`raw == {}`,除 `requested` 与 `computed_at_utc` 外信封里没有日期字符串。
|
||||
6. `ondemand_calc_api({"tool": "transit_window", ...})` 能分派到本工具;`transit_date` 原有测试不受影响。
|
||||
7. 测试只用公开日期和合成坐标(不写任何真人出生资料)。
|
||||
|
||||
## 不要碰
|
||||
|
||||
- `references/**`(占星师方法文件),一字不改;包括 `references/product_runtime_contract.md`。
|
||||
- `frontend/**` 全部,特别是 `frontend/src/lib/consult-preset-prompts.ts`、`frontend/src/lib/consultation-methodology.ts`;本单不建 `frontend/src/mastra/ondemand-tools/`。
|
||||
- `scripts/ondemand_calc/contract.py`、`scripts/ondemand_calc/transit_date.py`、`scripts/ondemand_calc/muhurta_search.py`、`scripts/ondemand_calc/__init__.py`;`registry.py` 只许加上文那两行。合同不够用就停下选“需拍板”,不得改合同。
|
||||
- 冻结文件只许 import 调用,不许改:`scripts/jyotish_api_server.py`、`scripts/jyotish_engine.py`、`scripts/domain_calculation_service.py`、`scripts/dasha_analyzer.py`、`scripts/narayana_dasha.py`、`scripts/shadbala.py`、`scripts/ashtakavarga.py`、`scripts/jaimini.py`、`scripts/varga.py`、`scripts/functional_benefics.py`、`scripts/divisional_charts_extended.py`。
|
||||
- `scripts/ephemeris_events.py`、`scripts/transit_trigger.py`:不改现有行为;不补 `scripts/transit_search.py`。
|
||||
- 其他未完成单子占用的文件:`frontend/src/mastra/consultation-tools.ts`(#171)、`frontend/src/lib/consultation-evidence-card.ts`(#170 / #172)、`frontend/src/mastra/consultation-workflow.ts` 与 `frontend/src/lib/consultation-route-service.ts`(#185)。
|
||||
- 其他按需工具(#175–#178)的文件名:`solar_return.py`、`varga.py`(在 ondemand_calc 内)、`dasha_date.py`、`prashna.py`,不要预建。
|
||||
- 既有测试的断言不得弱化;若必须改,写“原值 / 新值 / 原因”三栏注释,原因写 `占星师 Q9 / 第五轮 9:按需计算显式传口径`。
|
||||
|
||||
## 自测命令(Windows / PowerShell)
|
||||
|
||||
- `.venv\Scripts\python -m pytest tests/test_ondemand_transit_window_20261109.py tests/test_ondemand_transit_date_20261109.py tests/test_ondemand_calc_contract_20261109.py tests/test_api_server_growth_contract.py -q`
|
||||
|
||||
Python 快速门、前端全量测试和 eslint 由 Claude 在验收时跑。
|
||||
|
||||
## 进度记录
|
||||
|
||||
`docs/tasks/PROGRESS-ondemand-transit-window-20261109.md`:做了什么、跑了哪些命令、通过/失败数、偏离与原因;附上验收第 1 条的实测时刻差(分钟)。
|
||||
|
||||
## 何时停下来选“需拍板”
|
||||
|
||||
- `contract.py` 的信封或校验表达不了本工具的输入输出,必须改合同时。
|
||||
- 要做到显式口径,就必须改冻结文件时。
|
||||
- 某颗星的停滞在 2026 年的测试区间内二分不能收敛到 ≤ 2 分钟(速度长期接近 0),需要决定精度口径时。
|
||||
- 任务书没写到的任何取舍。
|
||||
@@ -55,7 +55,10 @@ const BASIS_ENTRIES: readonly BasisEntry[] = [
|
||||
name: "功能吉凶",
|
||||
result: (profile) => `按 ${FUNCTIONAL_PROFILE_LABELS[profile]} 判断每颗星在这张盘里起的作用`,
|
||||
},
|
||||
{ match: /^Yogas?\b|^格局/i, name: "格局", result: () => "盘上成立和落空的格局已核对" },
|
||||
// 原值:`盘上成立和落空的格局已核对`
|
||||
// 新值:`盘上检测到的格局已列出各自状态`
|
||||
// 原因:BUG-1824:候选不得显示为成立(卡上规则是 none is confirmed)
|
||||
{ match: /^Yogas?\b|^格局/i, name: "格局", result: () => "盘上检测到的格局已列出各自状态" },
|
||||
{ match: /^Shadbala\b/i, name: "行星力量", result: () => "每颗星的强弱已算" },
|
||||
{ match: /^Ashtakavarga$/i, name: "宫位点数", result: () => "每个宫得到的点数已算" },
|
||||
{ match: /^Arudha\b/i, name: "Arudha 点位", result: () => "事业、婚姻相关的点位落在哪里" },
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
import type { TechniqueAuditRow } from "./consultation-agent-events.ts";
|
||||
import type { EvidenceCard } from "./consultation-evidence-card.ts";
|
||||
import { isStructuralObservation, yogaStatus } from "./yoga-status-words.ts";
|
||||
|
||||
type Rec = Record<string, unknown>;
|
||||
const rec = (value: unknown): Rec => (value && typeof value === "object" && !Array.isArray(value) ? value as Rec : {});
|
||||
@@ -136,11 +137,45 @@ function savLine(card: EvidenceCard) {
|
||||
});
|
||||
}
|
||||
|
||||
// 原值:`成立:${hits}` / `本题没有成立的格局`
|
||||
// 新值:按卡上 yogas_detail 的状态,用占星师第七轮 §5 P0.2 的映射表(yoga-status-words.ts)分组写
|
||||
// 「名字、名字:状态文字」,组间用「。」;没有可列的写「本题没有检测到相关格局」
|
||||
// 原因:BUG-1824:候选不得显示为成立;hits 列表不能推断成立,状态文字不得被截断
|
||||
function yogaLine(card: EvidenceCard) {
|
||||
const detail = rec(rec(rec(card.base).natal).yogas_detail);
|
||||
const rows = Array.isArray(detail.rows) ? detail.rows.map(rec) : [];
|
||||
const byName = new Map<string, Rec>();
|
||||
for (const row of rows) {
|
||||
const name = str(row.name);
|
||||
if (name && !byName.has(name)) byName.set(name, row);
|
||||
}
|
||||
const notEvaluated = new Set(
|
||||
(Array.isArray(detail.not_evaluated) ? detail.not_evaluated.map(rec) : []).map((row) => str(row.name)).filter(Boolean),
|
||||
);
|
||||
return firstOf(card, (section) => {
|
||||
if (!("yogas" in section)) return undefined;
|
||||
const hits = list(rec(section.yogas).hits);
|
||||
return hits.length ? `成立:${hits.slice(0, 3).join("、")}` : "本题没有成立的格局";
|
||||
const hits = list(rec(section.yogas).hits).slice(0, 3);
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const name of hits) {
|
||||
const row = byName.get(name);
|
||||
if (row && isStructuralObservation(row)) continue;
|
||||
const { text } = yogaStatus(row, { notEvaluated: notEvaluated.has(name) });
|
||||
const bucket = groups.get(text) ?? [];
|
||||
bucket.push(name);
|
||||
groups.set(text, bucket);
|
||||
}
|
||||
if (!groups.size) return "本题没有检测到相关格局";
|
||||
// 逐个名字往里放,放不下就写“另 N 项未列”,绝不把状态文字截半(截半会只剩“……成立”)。
|
||||
const entries = [...groups].flatMap(([text, names]) => names.map((name) => [text, name] as const));
|
||||
const render = (shown: number) => {
|
||||
const kept = new Map<string, string[]>();
|
||||
for (const [text, name] of entries.slice(0, shown)) kept.set(text, [...(kept.get(text) ?? []), name]);
|
||||
const body = [...kept].map(([text, names]) => `${names.join("、")}:${text}`).join("。");
|
||||
return shown < entries.length ? `${body}。另 ${entries.length - shown} 项未列` : body;
|
||||
};
|
||||
let shown = 1;
|
||||
while (shown < entries.length && render(shown + 1).length <= MAX) shown += 1;
|
||||
return render(shown);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* BUG-1824 / 占星师第七轮回复 §5 P0.2(2026-11-09):Yoga 状态的唯一映射表。
|
||||
* 「解读依据」页面(#181)和答题模型的状态措辞(#182)必须 import 同一张表,
|
||||
* 不能一个把 candidate 升成“成立”、另一个把 computed 降成“非成立”。
|
||||
*
|
||||
* | 原始证据 | 推荐文字 |
|
||||
* |---|---|
|
||||
* | candidate / support_only_candidate | 命中候选规则,尚未完整确认 |
|
||||
* | computed,未完成来源条件治理 | 检测器检出,待核实 |
|
||||
* | formed_under_source | 按所选来源的形成条件成立;强度和结果另评 |
|
||||
* | conditions_not_met | 所选来源的必要条件未满足 |
|
||||
* | blocked / not_available_yet | 当前无法确认,列明缺项 |
|
||||
*
|
||||
* 规则(同一处原文):用 hits 列表不能推断“成立”;没有完整条件检查不能写“落空 / 非成立”。
|
||||
* 所以:只有 `formation.status` 这一完整条件检查的结果能给出“成立”或“必要条件未满足”;
|
||||
* 卡上找不到状态行、或状态值不在表内时,一律落到 blocked(当前无法确认),并写明缺什么。
|
||||
*
|
||||
* 验收时(Claude 2026-11-09)按占星师原表替换了 #181 初版词表
|
||||
* (初版:候选 / 未确认 / 不成立 / 未标状态,formed_under_source 显示为候选)。
|
||||
*/
|
||||
|
||||
export type YogaStatusKey = "candidate" | "computed" | "formed_under_source" | "conditions_not_met" | "blocked";
|
||||
|
||||
/** 占星师原表,文字逐字照抄;`evidence` 是卡上可能出现的原始值。 */
|
||||
export const YOGA_STATUS_MAPPING: Readonly<Record<YogaStatusKey, { readonly evidence: readonly string[]; readonly text: string }>> = {
|
||||
candidate: { evidence: ["candidate", "support_only_candidate"], text: "命中候选规则,尚未完整确认" },
|
||||
computed: { evidence: ["computed"], text: "检测器检出,待核实" },
|
||||
formed_under_source: { evidence: ["formed_under_source"], text: "按所选来源的形成条件成立;强度和结果另评" },
|
||||
conditions_not_met: { evidence: ["conditions_not_met"], text: "所选来源的必要条件未满足" },
|
||||
blocked: { evidence: ["blocked", "not_available_yet"], text: "当前无法确认,列明缺项" },
|
||||
};
|
||||
|
||||
/** blocked 行实际显示的文字:把“列明缺项”换成具体缺项。 */
|
||||
export const YOGA_BLOCKED_PREFIX = "当前无法确认,缺:";
|
||||
|
||||
export const YOGA_GAP_NO_ROW = "卡上没有这条格局的状态记录";
|
||||
export const YOGA_GAP_NOT_EVALUATED = "引擎未能评估这条规则";
|
||||
|
||||
type Rec = Record<string, unknown>;
|
||||
const rec = (value: unknown): Rec => (value && typeof value === "object" && !Array.isArray(value) ? value as Rec : {});
|
||||
const text = (value: unknown) => (typeof value === "string" && value.trim() ? value.trim() : undefined);
|
||||
|
||||
export type YogaStatus = { key: YogaStatusKey; text: string };
|
||||
|
||||
function blocked(gap: string): YogaStatus {
|
||||
return { key: "blocked", text: `${YOGA_BLOCKED_PREFIX}${gap}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个格局行(`card.base.natal.yogas_detail.rows[]`)的状态。`row` 缺失 = 只在 hits 里出现、卡上没有状态行。
|
||||
* `notEvaluated` 为 true 表示该名字在 `yogas_detail.not_evaluated` 里。
|
||||
*/
|
||||
export function yogaStatus(row: Rec | undefined, options: { notEvaluated?: boolean } = {}): YogaStatus {
|
||||
if (!row) return blocked(options.notEvaluated ? YOGA_GAP_NOT_EVALUATED : YOGA_GAP_NO_ROW);
|
||||
const formation = text(rec(row.formation).status);
|
||||
// 只有完整条件检查的结果才能说“成立”或“必要条件未满足”。
|
||||
if (formation === "formed_under_source") return { key: "formed_under_source", text: YOGA_STATUS_MAPPING.formed_under_source.text };
|
||||
if (formation === "conditions_not_met") return { key: "conditions_not_met", text: YOGA_STATUS_MAPPING.conditions_not_met.text };
|
||||
const status = text(row.status);
|
||||
const claim = text(row.claim_status);
|
||||
for (const key of ["candidate", "computed", "blocked"] as const) {
|
||||
if (status && YOGA_STATUS_MAPPING[key].evidence.includes(status)) {
|
||||
return key === "blocked" ? blocked(`状态为 ${status}`) : { key, text: YOGA_STATUS_MAPPING[key].text };
|
||||
}
|
||||
}
|
||||
if ((claim && YOGA_STATUS_MAPPING.candidate.evidence.includes(claim)) || formation === "candidate") {
|
||||
return { key: "candidate", text: YOGA_STATUS_MAPPING.candidate.text };
|
||||
}
|
||||
if (options.notEvaluated) return blocked(YOGA_GAP_NOT_EVALUATED);
|
||||
return blocked(status ? `状态值 ${status} 不在映射表内` : "卡上这条格局没有状态值");
|
||||
}
|
||||
|
||||
/** 卡上这一行是不是“结构观察”(不是命名格局),不进「格局」这一行。 */
|
||||
export function isStructuralObservation(row: Rec): boolean {
|
||||
return row.claim_kind === "structural_observation" || row.status === "structural_observation";
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* BUG-1824:「解读依据」的「格局」一行不得把候选格局写成「成立」。
|
||||
* 合成卡,不含任何真人资料。
|
||||
*
|
||||
* 验收(Claude 2026-11-09):状态文字改用占星师第七轮回复 §5 P0.2 的映射表。
|
||||
* 原值 / 新值 / 原因(适用于下面每条被改写的期望值):
|
||||
* 原值:候选 / 未确认 / 不成立 / 未标状态,formed_under_source 显示为「候选」
|
||||
* 新值:命中候选规则,尚未完整确认 / 检测器检出,待核实 / 按所选来源的形成条件成立;强度和结果另评 /
|
||||
* 所选来源的必要条件未满足 / 当前无法确认,缺:…
|
||||
* 原因:占星师第七轮 §5 P0.2:模型和页面必须使用同一状态映射(#182 import 同一模块)
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { TechniqueAuditRow } from "../src/lib/consultation-agent-events.ts";
|
||||
import { consultAnswerBasisRows } from "../src/lib/consult-answer-basis.ts";
|
||||
import type { EvidenceCard } from "../src/lib/consultation-evidence-card.ts";
|
||||
import { withTechniqueEvidence } from "../src/lib/consultation-technique-evidence.ts";
|
||||
import {
|
||||
YOGA_BLOCKED_PREFIX, YOGA_GAP_NO_ROW, YOGA_GAP_NOT_EVALUATED, YOGA_STATUS_MAPPING, yogaStatus,
|
||||
} from "../src/lib/yoga-status-words.ts";
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
const CANDIDATE = "命中候选规则,尚未完整确认";
|
||||
const COMPUTED = "检测器检出,待核实";
|
||||
const FORMED = "按所选来源的形成条件成立;强度和结果另评";
|
||||
const NOT_MET = "所选来源的必要条件未满足";
|
||||
|
||||
function cardWith(rows: Row[], hits: string[], extra: Row = {}): EvidenceCard {
|
||||
return {
|
||||
domains: ["career"],
|
||||
domain_sections: { career: { yogas: { hits } } },
|
||||
base: { natal: { yogas_detail: { rows, ...extra } } },
|
||||
} as unknown as EvidenceCard;
|
||||
}
|
||||
|
||||
function yogaResult(card: EvidenceCard): string | undefined {
|
||||
const audit: TechniqueAuditRow[] = [{
|
||||
technique: "Yogas",
|
||||
calculation_status: "executed",
|
||||
interpretation_status: "observation_only",
|
||||
} as unknown as TechniqueAuditRow];
|
||||
const enriched = withTechniqueEvidence(audit, card) ?? [];
|
||||
const rows = consultAnswerBasisRows(enriched);
|
||||
return rows.find((row) => row.name === "格局")?.result;
|
||||
}
|
||||
|
||||
test("the mapping table is the astrologer's round-7 §5 P0.2 table, word for word", () => {
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(Object.entries(YOGA_STATUS_MAPPING).map(([key, row]) => [key, [row.evidence, row.text]])),
|
||||
{
|
||||
candidate: [["candidate", "support_only_candidate"], CANDIDATE],
|
||||
computed: [["computed"], COMPUTED],
|
||||
formed_under_source: [["formed_under_source"], FORMED],
|
||||
conditions_not_met: [["conditions_not_met"], NOT_MET],
|
||||
blocked: [["blocked", "not_available_yet"], "当前无法确认,列明缺项"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("row 1: candidate and support_only_candidate read 命中候选规则,尚未完整确认", () => {
|
||||
assert.equal(yogaResult(cardWith([{ name: "Sunaphaa Yoga", status: "candidate" }], ["Sunaphaa Yoga"])), `Sunaphaa Yoga:${CANDIDATE}`);
|
||||
assert.equal(yogaStatus({ name: "X", status: "support_only_candidate" }).text, CANDIDATE);
|
||||
assert.equal(yogaStatus({ name: "X", claim_status: "support_only_candidate" }).text, CANDIDATE);
|
||||
});
|
||||
|
||||
test("row 2: computed without a completed condition check reads 检测器检出,待核实", () => {
|
||||
assert.equal(yogaResult(cardWith([{ name: "Vosi Yoga", status: "computed" }], ["Vosi Yoga"])), `Vosi Yoga:${COMPUTED}`);
|
||||
assert.equal(yogaStatus({ name: "X", status: "computed", formation: { status: "candidate" } }).text, COMPUTED);
|
||||
});
|
||||
|
||||
test("row 3: formed_under_source reads the source-bound 成立 with strength and outcome left open", () => {
|
||||
const line = yogaResult(cardWith([
|
||||
{ name: "Amala Yoga", status: "candidate", formation: { status: "formed_under_source" } },
|
||||
], ["Amala Yoga"]));
|
||||
assert.equal(line, `Amala Yoga:${FORMED}`);
|
||||
});
|
||||
|
||||
test("row 4: conditions_not_met reads 所选来源的必要条件未满足, never 落空 / 非成立 / 不成立", () => {
|
||||
const line = yogaResult(cardWith([
|
||||
{ name: "Dharidhra Yoga", status: "candidate", formation: { status: "conditions_not_met" } },
|
||||
], ["Dharidhra Yoga"])) ?? "";
|
||||
assert.equal(line, `Dharidhra Yoga:${NOT_MET}`);
|
||||
assert.doesNotMatch(line, /落空|非成立|不成立/u);
|
||||
});
|
||||
|
||||
test("row 5: blocked / not_available_yet reads 当前无法确认 and names what is missing", () => {
|
||||
for (const status of ["blocked", "not_available_yet"]) {
|
||||
const result = yogaStatus({ name: "X", status });
|
||||
assert.equal(result.key, "blocked");
|
||||
assert.equal(result.text, `${YOGA_BLOCKED_PREFIX}状态为 ${status}`);
|
||||
}
|
||||
const line = yogaResult(cardWith([], ["Lakshmi Yoga"], { not_evaluated: [{ name: "Lakshmi Yoga", evaluation_status: "evaluation_failed" }] }));
|
||||
assert.equal(line, `Lakshmi Yoga:${YOGA_BLOCKED_PREFIX}${YOGA_GAP_NOT_EVALUATED}`);
|
||||
});
|
||||
|
||||
test("a yoga that appears only in the hits list never renders 成立", () => {
|
||||
const line = yogaResult(cardWith([], ["Gaja Kesari Yoga", "Budha-Aditya Yoga"])) ?? "";
|
||||
assert.equal(line, `Gaja Kesari Yoga、Budha-Aditya Yoga:${YOGA_BLOCKED_PREFIX}${YOGA_GAP_NO_ROW}`);
|
||||
assert.doesNotMatch(line, /成立/u);
|
||||
// 状态值不在表内、或没有状态值的行同样落到 blocked,不猜。
|
||||
assert.equal(yogaStatus({ name: "X", status: "confirmed" }).key, "blocked");
|
||||
assert.equal(yogaStatus({ name: "X" }).key, "blocked");
|
||||
});
|
||||
|
||||
test("mixed statuses group in first-seen order and the status text is never clipped", () => {
|
||||
const line = yogaResult(cardWith([
|
||||
{ name: "Sunaphaa Yoga", status: "candidate" },
|
||||
{ name: "Vosi Yoga", status: "computed" },
|
||||
{ name: "Gaja Kesari Yoga", status: "candidate", formation: { status: "formed_under_source" } },
|
||||
], ["Sunaphaa Yoga", "Vosi Yoga", "Gaja Kesari Yoga"])) ?? "";
|
||||
assert.ok(line.length <= 80, line);
|
||||
assert.doesNotMatch(line, /…/u);
|
||||
assert.ok(line.startsWith(`Sunaphaa Yoga:${CANDIDATE}。Vosi Yoga:${COMPUTED}`), line);
|
||||
// 放不下的整项挪到“另 N 项未列”,而不是把 FORMED 截成“……成立”。
|
||||
if (!line.includes(FORMED)) assert.match(line, /另 1 项未列$/u);
|
||||
assert.doesNotMatch(line.replace(FORMED, ""), /成立/u);
|
||||
});
|
||||
|
||||
test("structural observations stay out of the 格局 row", () => {
|
||||
const card = cardWith([
|
||||
{ name: "Neecha Bhanga witness", status: "structural_observation" },
|
||||
{ name: "Sunaphaa Yoga", status: "candidate" },
|
||||
], ["Neecha Bhanga witness", "Sunaphaa Yoga"]);
|
||||
assert.equal(yogaResult(card), `Sunaphaa Yoga:${CANDIDATE}`);
|
||||
});
|
||||
|
||||
test("neither the grey line nor the fallback copy claims 成立: or 已确认", () => {
|
||||
const card = cardWith([
|
||||
{ name: "Sunaphaa Yoga", status: "candidate" },
|
||||
{ name: "Vosi Yoga", status: "computed" },
|
||||
], ["Sunaphaa Yoga", "Vosi Yoga"]);
|
||||
const line = yogaResult(card) ?? "";
|
||||
const fallback = consultAnswerBasisRows([{
|
||||
technique: "Yogas",
|
||||
calculation_status: "executed",
|
||||
} as unknown as TechniqueAuditRow]).find((row) => row.name === "格局")?.result ?? "";
|
||||
for (const text of [line, fallback]) {
|
||||
assert.doesNotMatch(text, /成立:/u);
|
||||
assert.doesNotMatch(text, /已确认/u);
|
||||
}
|
||||
assert.equal(fallback, "盘上检测到的格局已列出各自状态");
|
||||
});
|
||||
|
||||
test("the 格局 row keeps its calculation status while the grey line carries the reading status", () => {
|
||||
const card = cardWith([{ name: "Sunaphaa Yoga", status: "candidate" }], ["Sunaphaa Yoga"]);
|
||||
const enriched = withTechniqueEvidence([{
|
||||
technique: "Yogas",
|
||||
calculation_status: "executed",
|
||||
interpretation_status: "supporting",
|
||||
} as unknown as TechniqueAuditRow], card) ?? [];
|
||||
const row = consultAnswerBasisRows(enriched).find((item) => item.name === "格局");
|
||||
assert.ok(row);
|
||||
assert.equal(row.status, "done");
|
||||
assert.equal(row.result, `Sunaphaa Yoga:${CANDIDATE}`);
|
||||
});
|
||||
|
||||
test("no hits at all says nothing was detected, not that nothing formed", () => {
|
||||
const card = cardWith([{ name: "Sunaphaa Yoga", status: "candidate" }], []);
|
||||
assert.equal(yogaResult(card), "本题没有检测到相关格局");
|
||||
});
|
||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Dict
|
||||
|
||||
from . import transit_date
|
||||
from . import transit_date, transit_window
|
||||
from .contract import OnDemandInputError, blocked_envelope
|
||||
|
||||
# 工具名 → run(body) -> envelope
|
||||
@@ -15,6 +15,9 @@ TOOLS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
|
||||
# 指定日期的行运:必填 date、地点(lat/lon)、时区(tz 或 timezone_id)、ayanamsa;
|
||||
# node_mode / position_mode 缺省用产品默认值(mean / apparent)。
|
||||
'transit_date': transit_date.run,
|
||||
# 一段时间的行运变化:必填 start_date、end_date、地点、时区、ayanamsa、events;
|
||||
# planets / target / natal 按事件类型选填。
|
||||
'transit_window': transit_window.run,
|
||||
}
|
||||
|
||||
__all__ = ['TOOLS', 'OnDemandInputError', 'ondemand_calc_api']
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
"""按需计算:一段时间内的行运变化(#174,BUG-1817)。
|
||||
|
||||
占星师 2026-11-07 Q9 / 2026-11-08 第五轮第 9 条:行运窗口要支持**换座、停滞、逆行往返、目标经度**。
|
||||
数值一律来自引擎的恒星黄经(`jyotish_engine._calc_sidereal_planets_for_jd`),岁差、交点、
|
||||
位置口径由调用方显式给出;本工具不排本命盘,`natal_point` 要调用方把恒星黄经传进来。
|
||||
|
||||
时刻用二分收敛到 ≤ 1 分钟。换座、停滞、目标经度的判定都只看恒星黄经与日速度,不另起算法。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from . import contract
|
||||
from .contract import OnDemandInputError
|
||||
|
||||
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(_SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS_DIR))
|
||||
|
||||
TOOL = 'transit_window'
|
||||
MAX_WINDOW_DAYS = 366
|
||||
SEARCH_MARGIN_DAYS = 400
|
||||
CONVERGENCE_MINUTES = 1
|
||||
CONVERGENCE_DAYS = CONVERGENCE_MINUTES / 1440.0
|
||||
PLANETS = ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu')
|
||||
STATION_PLANETS = ('Mercury', 'Venus', 'Mars', 'Jupiter', 'Saturn')
|
||||
EVENT_TYPES = ('ingress', 'station', 'retrograde_loop', 'target_longitude')
|
||||
COARSE_STEP_HOURS = {'Moon': 6.0, 'default': 24.0}
|
||||
|
||||
|
||||
def _engine():
|
||||
try:
|
||||
from scripts import jyotish_engine as engine
|
||||
except ModuleNotFoundError: # pragma: no cover - 直接跑 scripts/
|
||||
import jyotish_engine as engine # type: ignore[no-redef]
|
||||
return engine
|
||||
|
||||
|
||||
def _jd_from_datetime(moment: datetime) -> float:
|
||||
import swisseph as swe
|
||||
return swe.julday(
|
||||
moment.year, moment.month, moment.day,
|
||||
moment.hour + moment.minute / 60.0 + moment.second / 3600.0,
|
||||
)
|
||||
|
||||
|
||||
def _local_midnight_jd(day: datetime, tz: float) -> float:
|
||||
"""当地 00:00 对应的 JD(UT)。"""
|
||||
return _jd_from_datetime(datetime(day.year, day.month, day.day) - timedelta(hours=tz))
|
||||
|
||||
|
||||
class _Sky:
|
||||
"""一个区间内按需取恒星黄经与日速度,调用方给的口径固定不变。"""
|
||||
|
||||
def __init__(self, engine: Any, profile: Dict[str, Any], planets: Sequence[str]) -> None:
|
||||
self.engine = engine
|
||||
self.profile = profile
|
||||
self.planets = tuple(planets)
|
||||
if hasattr(engine, '_configure_ephemeris_path'):
|
||||
engine._configure_ephemeris_path() # noqa: SLF001
|
||||
self._cache: Dict[float, Dict[str, Dict[str, float]]] = {}
|
||||
|
||||
def at(self, jd: float) -> Dict[str, Dict[str, float]]:
|
||||
key = round(jd, 8)
|
||||
if key in self._cache:
|
||||
return self._cache[key]
|
||||
data, _ayanamsa = self.engine._calc_sidereal_planets_for_jd( # noqa: SLF001
|
||||
jd,
|
||||
node_mode=self.profile['node_mode'],
|
||||
include_ketu=True,
|
||||
ayanamsa_name=self.profile['ayanamsa'],
|
||||
)
|
||||
snapshot: Dict[str, Dict[str, float]] = {}
|
||||
for name in self.planets:
|
||||
row = data.get(name)
|
||||
if not row:
|
||||
continue
|
||||
snapshot[name] = {
|
||||
'lon': float(row.get('degree_raw', row.get('degree', 0.0))),
|
||||
'speed': float(row.get('speed') or 0.0),
|
||||
'sign': str(row.get('sign') or ''),
|
||||
}
|
||||
self._cache[key] = snapshot
|
||||
return snapshot
|
||||
|
||||
def ayanamsa(self, jd: float) -> float:
|
||||
data, ayanamsa = self.engine._calc_sidereal_planets_for_jd( # noqa: SLF001
|
||||
jd,
|
||||
node_mode=self.profile['node_mode'],
|
||||
include_ketu=True,
|
||||
ayanamsa_name=self.profile['ayanamsa'],
|
||||
)
|
||||
del data
|
||||
return float(ayanamsa or 0.0)
|
||||
|
||||
def lon(self, planet: str, jd: float) -> Optional[float]:
|
||||
row = self.at(jd).get(planet)
|
||||
return None if row is None else row['lon']
|
||||
|
||||
|
||||
def _bisect(predicate: Callable[[float], bool], low: float, high: float,
|
||||
tolerance: float = CONVERGENCE_DAYS) -> float:
|
||||
"""在 [low, high] 上找 predicate 从 False 变 True 的位置。"""
|
||||
lo, hi = low, high
|
||||
while hi - lo > tolerance:
|
||||
middle = (lo + hi) / 2.0
|
||||
if predicate(middle):
|
||||
hi = middle
|
||||
else:
|
||||
lo = middle
|
||||
return (lo + hi) / 2.0
|
||||
|
||||
|
||||
def _signed_delta(value: float, reference: float) -> float:
|
||||
"""value - reference,按 0–360 取最短方向(正为顺行方向)。"""
|
||||
return ((value - reference + 180.0) % 360.0) - 180.0
|
||||
|
||||
|
||||
def _crossed(before: float, after: float, boundary: float) -> bool:
|
||||
"""从 before 到 after(正向时间)是否跨过 boundary(模 360)。顺行逆行都算。"""
|
||||
if before == after:
|
||||
return False
|
||||
delta = _signed_delta(after, before)
|
||||
if delta == 0.0:
|
||||
return False
|
||||
if delta > 0.0:
|
||||
return 0.0 < (boundary - before) % 360.0 <= delta
|
||||
return 0.0 < (before - boundary) % 360.0 <= -delta
|
||||
|
||||
|
||||
def _sign_boundaries(before: float, after: float) -> List[float]:
|
||||
"""一步之内跨过的所有 30° 整数倍,按行进方向排序。"""
|
||||
out: List[float] = []
|
||||
forward = (after - before) % 360.0
|
||||
if forward == 0.0:
|
||||
return out
|
||||
step = 30.0 if forward < 180.0 else -30.0
|
||||
boundary = ((before // 30.0) + (1 if step > 0 else 0)) * 30.0
|
||||
travelled = 0.0
|
||||
guard = 0
|
||||
while travelled < forward - 1e-9 and guard < 24:
|
||||
guard += 1
|
||||
if step > 0:
|
||||
distance = (boundary - before) % 360.0
|
||||
if distance > forward:
|
||||
break
|
||||
out.append(boundary % 360.0)
|
||||
travelled = distance
|
||||
boundary += 30.0
|
||||
else:
|
||||
distance = (before - boundary) % 360.0
|
||||
if distance > (before - after) % 360.0:
|
||||
break
|
||||
out.append(boundary % 360.0)
|
||||
travelled = distance
|
||||
boundary -= 30.0
|
||||
return out
|
||||
|
||||
|
||||
def _sign_name(engine: Any, longitude: float) -> str:
|
||||
signs = getattr(engine, 'SIGNS', None) or []
|
||||
if not signs:
|
||||
return ''
|
||||
return str(signs[int(longitude % 360.0 / 30.0) % 12])
|
||||
|
||||
|
||||
def _utc_stamp(jd: float) -> str:
|
||||
import swisseph as swe
|
||||
year, month, day, hour = swe.revjul(jd)
|
||||
minutes = int(round(hour * 60.0))
|
||||
return f'{year:04d}-{month:02d}-{day:02d}T{(minutes // 60) % 24:02d}:{minutes % 60:02d}Z'
|
||||
|
||||
|
||||
def _local_parts(jd: float, tz: float) -> Tuple[str, int, int]:
|
||||
"""当地日期与时分。"""
|
||||
import swisseph as swe
|
||||
year, month, day, hour = swe.revjul(jd + tz / 24.0)
|
||||
minutes = int(round(hour * 60.0))
|
||||
return f'{year:04d}-{month:02d}-{day:02d}', (minutes // 60) % 24, minutes % 60
|
||||
|
||||
|
||||
def _offset_for_jd(jd: float, tz_fixed: Optional[float], timezone_id: Optional[str]) -> float:
|
||||
if not timezone_id:
|
||||
return float(tz_fixed or 0.0)
|
||||
import swisseph as swe
|
||||
year, month, day, _hour = swe.revjul(jd + (tz_fixed or 0.0) / 24.0)
|
||||
return contract.offset_hours(datetime(year, month, day), None, timezone_id)
|
||||
|
||||
|
||||
def _event(engine: Any, sky: _Sky, planet: str, jd: float, kind: str,
|
||||
tz_fixed: Optional[float], timezone_id: Optional[str]) -> Dict[str, Any]:
|
||||
row = sky.at(jd).get(planet) or {}
|
||||
longitude = float(row.get('lon') or 0.0)
|
||||
offset = _offset_for_jd(jd, tz_fixed, timezone_id)
|
||||
date_text, hour, minute = _local_parts(jd, offset)
|
||||
return {
|
||||
'type': kind,
|
||||
'planet': planet,
|
||||
'utc': _utc_stamp(jd),
|
||||
'local': f'{date_text}T{hour:02d}:{minute:02d}',
|
||||
'utc_offset_hours': round(offset, 4),
|
||||
'jd_ut': round(jd, 8),
|
||||
'longitude': round(longitude, 6),
|
||||
'sign': _sign_name(engine, longitude),
|
||||
'degree_in_sign': round(longitude % 30.0, 4),
|
||||
}
|
||||
|
||||
|
||||
def _sign_index(longitude: float) -> int:
|
||||
return int(longitude % 360.0 / 30.0) % 12
|
||||
|
||||
|
||||
def _ingress_events(engine: Any, sky: _Sky, planet: str, start_jd: float, end_jd: float,
|
||||
tz_fixed: Optional[float], timezone_id: Optional[str]) -> List[Dict[str, Any]]:
|
||||
step = COARSE_STEP_HOURS.get(planet, COARSE_STEP_HOURS['default']) / 24.0
|
||||
events: List[Dict[str, Any]] = []
|
||||
jd = start_jd
|
||||
before = sky.lon(planet, jd)
|
||||
while jd < end_jd:
|
||||
nxt = min(jd + step, end_jd)
|
||||
after = sky.lon(planet, nxt)
|
||||
if before is None or after is None:
|
||||
before, jd = after, nxt
|
||||
continue
|
||||
if _sign_index(before) != _sign_index(after):
|
||||
start_sign = _sign_index(before)
|
||||
at = _bisect(lambda x: _sign_index(sky.lon(planet, x) or before) != start_sign, jd, nxt)
|
||||
event = _event(engine, sky, planet, at, 'ingress', tz_fixed, timezone_id)
|
||||
event['from_sign'] = _sign_name(engine, before)
|
||||
# 验收修正(Claude 2026-11-09):原来 to_sign 用 JD 当黄经取星座、direction 在 0° 白羊处判反;
|
||||
# 改为按粗扫步末端的黄经取星座,方向按最短角差的正负。
|
||||
event['to_sign'] = _sign_name(engine, after)
|
||||
event['direction'] = 'direct' if _signed_delta(after, before) > 0.0 else 'retrograde'
|
||||
events.append(event)
|
||||
before, jd = after, nxt
|
||||
return events
|
||||
|
||||
|
||||
def _station_events(engine: Any, sky: _Sky, planet: str, start_jd: float, end_jd: float,
|
||||
tz_fixed: Optional[float], timezone_id: Optional[str]) -> List[Dict[str, Any]]:
|
||||
step = COARSE_STEP_HOURS['default'] / 24.0
|
||||
events: List[Dict[str, Any]] = []
|
||||
jd = start_jd
|
||||
before_row = sky.at(jd).get(planet)
|
||||
while jd < end_jd:
|
||||
nxt = min(jd + step, end_jd)
|
||||
after_row = sky.at(nxt).get(planet)
|
||||
if not before_row or not after_row:
|
||||
before_row, jd = after_row, nxt
|
||||
continue
|
||||
before_speed = before_row['speed']
|
||||
after_speed = after_row['speed']
|
||||
if before_speed == 0.0 or (before_speed < 0) != (after_speed < 0):
|
||||
at = _bisect(lambda x: (sky.at(x).get(planet, {}).get('speed', 0.0) < 0) != (before_speed < 0), jd, nxt)
|
||||
event = _event(engine, sky, planet, at, 'station', tz_fixed, timezone_id)
|
||||
event['station_type'] = 'retrograde' if before_speed >= 0 else 'direct'
|
||||
event['speed'] = round(float(sky.at(at).get(planet, {}).get('speed', 0.0)), 6)
|
||||
events.append(event)
|
||||
before_row, jd = after_row, nxt
|
||||
return events
|
||||
|
||||
|
||||
def _target_events(engine: Any, sky: _Sky, planet: str, target: float, start_jd: float, end_jd: float,
|
||||
tz_fixed: Optional[float], timezone_id: Optional[str]) -> List[Dict[str, Any]]:
|
||||
step = COARSE_STEP_HOURS.get(planet, COARSE_STEP_HOURS['default']) / 24.0
|
||||
events: List[Dict[str, Any]] = []
|
||||
jd = start_jd
|
||||
before = sky.lon(planet, jd)
|
||||
while jd < end_jd:
|
||||
nxt = min(jd + step, end_jd)
|
||||
after = sky.lon(planet, nxt)
|
||||
if before is None or after is None:
|
||||
before, jd = after, nxt
|
||||
continue
|
||||
if before != after and _crossed(before, after, target):
|
||||
at = _bisect(lambda x: _crossed(before, sky.lon(planet, x) or before, target), jd, nxt)
|
||||
event = _event(engine, sky, planet, at, 'target_longitude', tz_fixed, timezone_id)
|
||||
event['target_longitude'] = round(float(target), 6)
|
||||
event['direction'] = 'direct' if (after - before) % 360.0 < 180.0 else 'retrograde'
|
||||
events.append(event)
|
||||
before, jd = after, nxt
|
||||
return events
|
||||
|
||||
|
||||
def _retrograde_loops(engine: Any, sky: _Sky, planet: str, start_jd: float, end_jd: float,
|
||||
tz_fixed: Optional[float], timezone_id: Optional[str],
|
||||
gaps: List[Dict[str, str]]) -> List[Dict[str, Any]]:
|
||||
margin = SEARCH_MARGIN_DAYS
|
||||
stations = _station_events(engine, sky, planet, start_jd - margin, end_jd + margin, tz_fixed, timezone_id)
|
||||
loops: List[Dict[str, Any]] = []
|
||||
for index, station in enumerate(stations):
|
||||
if station.get('station_type') != 'retrograde':
|
||||
continue
|
||||
if index + 1 >= len(stations):
|
||||
continue
|
||||
direct = stations[index + 1]
|
||||
if direct.get('station_type') != 'direct':
|
||||
continue
|
||||
retro_lon = float(station['longitude'])
|
||||
direct_lon = float(direct['longitude'])
|
||||
entry = _find_crossing(engine, sky, planet, direct_lon, station['jd_ut'] - margin * 1.0,
|
||||
station['jd_ut'], tz_fixed, timezone_id, backwards=True)
|
||||
exit_ = _find_crossing(engine, sky, planet, retro_lon, direct['jd_ut'],
|
||||
direct['jd_ut'] + margin * 1.0, tz_fixed, timezone_id, backwards=False)
|
||||
inside = (start_jd <= station['jd_ut'] <= end_jd) or (start_jd <= direct['jd_ut'] <= end_jd)
|
||||
if not inside:
|
||||
continue
|
||||
for key, value in (('shadow_entry', entry), ('shadow_exit', exit_)):
|
||||
if value is None:
|
||||
gaps.append({'field': 'retrograde_loop', 'reason': f'loop_incomplete:{planet}:{key}'})
|
||||
loops.append({
|
||||
'planet': planet,
|
||||
'station_retrograde': station,
|
||||
'station_direct': direct,
|
||||
'retrograde_span': [round(direct_lon, 6), round(retro_lon, 6)],
|
||||
'shadow_entry': entry,
|
||||
'shadow_exit': exit_,
|
||||
'outside_requested_window': not (start_jd <= station['jd_ut'] <= end_jd),
|
||||
})
|
||||
return loops
|
||||
|
||||
|
||||
def _find_crossing(engine: Any, sky: _Sky, planet: str, longitude: float, low: float, high: float,
|
||||
tz_fixed: Optional[float], timezone_id: Optional[str],
|
||||
*, backwards: bool) -> Optional[Dict[str, Any]]:
|
||||
"""在 [low, high] 内找最后一次(backwards)或第一次(向前)到达 longitude 的时刻。"""
|
||||
if high <= low:
|
||||
return None
|
||||
step = COARSE_STEP_HOURS['default'] / 24.0
|
||||
found: Optional[float] = None
|
||||
jd = low
|
||||
before = sky.lon(planet, jd)
|
||||
while jd < high:
|
||||
nxt = min(jd + step, high)
|
||||
after = sky.lon(planet, nxt)
|
||||
if before is not None and after is not None and before != after and _crossed(before, after, longitude):
|
||||
at = _bisect(lambda x: _crossed(before, sky.lon(planet, x) or before, longitude), jd, nxt)
|
||||
# 向后找「最后一次」到达:从早到晚扫,保留最后一个;向前找「第一次」:命中即停。
|
||||
found = at
|
||||
if not backwards:
|
||||
break
|
||||
before, jd = after, nxt
|
||||
if found is None:
|
||||
return None
|
||||
return _event(engine, sky, planet, found, 'shadow_crossing', tz_fixed, timezone_id)
|
||||
|
||||
|
||||
def _validate(body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not isinstance(body, dict):
|
||||
raise OnDemandInputError('body must be an object')
|
||||
start = contract.parse_date(body.get('start_date'), 'start_date') if body.get('start_date') else None
|
||||
if start is None:
|
||||
raise OnDemandInputError('missing required field: start_date', 'start_date')
|
||||
end = contract.parse_date(body.get('end_date'), 'end_date') if body.get('end_date') else None
|
||||
if end is None:
|
||||
raise OnDemandInputError('missing required field: end_date', 'end_date')
|
||||
if end < start:
|
||||
raise OnDemandInputError('end_date must be on or after start_date', 'end_date')
|
||||
if (end - start).days + 1 > MAX_WINDOW_DAYS:
|
||||
raise OnDemandInputError(f'window must be at most {MAX_WINDOW_DAYS} days', 'end_date')
|
||||
events = body.get('events')
|
||||
if not isinstance(events, list) or not events:
|
||||
raise OnDemandInputError('missing required field: events', 'events')
|
||||
unknown = [item for item in events if item not in EVENT_TYPES]
|
||||
if unknown:
|
||||
raise OnDemandInputError(f'unknown event type: {unknown[0]}', 'events')
|
||||
planets = body.get('planets')
|
||||
if planets is None:
|
||||
planets = list(PLANETS)
|
||||
if not isinstance(planets, list) or not planets:
|
||||
raise OnDemandInputError('planets must be a non-empty list', 'planets')
|
||||
bad = [item for item in planets if item not in PLANETS]
|
||||
if bad:
|
||||
raise OnDemandInputError(f'unknown planet: {bad[0]}', 'planets')
|
||||
target = body.get('target')
|
||||
if 'target_longitude' in events:
|
||||
if not isinstance(target, dict):
|
||||
raise OnDemandInputError('missing required field: target', 'target')
|
||||
if str(target.get('planet')) not in PLANETS:
|
||||
raise OnDemandInputError('target.planet must be one of the nine planets', 'target')
|
||||
has_longitude = target.get('longitude') is not None
|
||||
has_point = target.get('natal_point') is not None
|
||||
if not has_longitude and not has_point:
|
||||
raise OnDemandInputError('target needs longitude or natal_point', 'target')
|
||||
if has_longitude:
|
||||
try:
|
||||
value = float(target['longitude'])
|
||||
except (TypeError, ValueError) as error:
|
||||
raise OnDemandInputError('target.longitude must be a number', 'target') from error
|
||||
if not 0.0 <= value < 360.0:
|
||||
raise OnDemandInputError('target.longitude must be 0–360', 'target')
|
||||
if has_point and not isinstance(body.get('natal'), dict):
|
||||
raise OnDemandInputError('target.natal_point needs natal.points', 'natal')
|
||||
return {
|
||||
'start': start,
|
||||
'end': end,
|
||||
'planets': list(dict.fromkeys(planets)),
|
||||
'events': list(dict.fromkeys(events)),
|
||||
'target': target,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_target(body: Dict[str, Any], inputs: Dict[str, Any]) -> Tuple[Optional[float], Optional[Dict[str, str]]]:
|
||||
target = inputs.get('target') or {}
|
||||
if 'target_longitude' not in inputs['events']:
|
||||
return None, None
|
||||
if target.get('longitude') is not None:
|
||||
return float(target['longitude']), None
|
||||
name = str(target.get('natal_point'))
|
||||
points = (body.get('natal') or {}).get('points') if isinstance(body.get('natal'), dict) else None
|
||||
if not isinstance(points, dict) or name not in points:
|
||||
return None, {'field': 'target.natal_point', 'reason': f'natal_point_missing:{name}'}
|
||||
return float(points[name]), None
|
||||
|
||||
|
||||
def run(body: Dict[str, Any]) -> Dict[str, Any]:
|
||||
try:
|
||||
inputs = _validate(body)
|
||||
common = contract.validate_common({**body, 'date': body['start_date']})
|
||||
profile = contract.resolve_profile({**body, 'date': body['start_date']}, common)
|
||||
target_longitude, target_gap = _resolve_target(body, inputs)
|
||||
except OnDemandInputError as error:
|
||||
return contract.blocked_envelope(tool=TOOL, body=body, error=error)
|
||||
|
||||
requested = dict(body)
|
||||
if target_gap is not None:
|
||||
return contract.build_envelope(
|
||||
tool=TOOL, requested=requested, profile=profile, status='blocked', raw={},
|
||||
gaps=[target_gap],
|
||||
)
|
||||
if profile['position_mode'] not in contract.SUPPORTED_POSITION_MODES:
|
||||
return contract.build_envelope(
|
||||
tool=TOOL, requested=requested, profile=profile, status='blocked', raw={},
|
||||
gaps=[{'field': 'position_mode', 'reason': 'position_mode_unsupported'}],
|
||||
)
|
||||
|
||||
tz_fixed = profile['tz'] if not profile.get('timezone_id') else None
|
||||
timezone_id = profile.get('timezone_id')
|
||||
try:
|
||||
engine = _engine()
|
||||
sky = _Sky(engine, profile, inputs['planets'])
|
||||
except Exception as error: # noqa: BLE001 - 星历不可用也只留缺口
|
||||
return contract.build_envelope(
|
||||
tool=TOOL, requested=requested, profile=profile, status='blocked', raw={},
|
||||
gaps=[{'field': 'calculation', 'reason': f'engine_failed:{type(error).__name__}'}],
|
||||
)
|
||||
start_jd = _local_midnight_jd(inputs['start'], profile['tz'])
|
||||
end_day = inputs['end'] + timedelta(days=1)
|
||||
# 验收修正(Claude 2026-11-09):区间终点按终点那天的偏移换算(原来用起始日偏移,跨夏令时差 1 小时)。
|
||||
end_offset = contract.offset_hours(end_day, None, timezone_id) if timezone_id else profile['tz']
|
||||
end_jd = _local_midnight_jd(end_day, end_offset)
|
||||
|
||||
gaps: List[Dict[str, str]] = []
|
||||
events: List[Dict[str, Any]] = []
|
||||
loops: List[Dict[str, Any]] = []
|
||||
try:
|
||||
for planet in inputs['planets']:
|
||||
if 'ingress' in inputs['events']:
|
||||
events.extend(_ingress_events(engine, sky, planet, start_jd, end_jd, tz_fixed, timezone_id))
|
||||
if 'station' in inputs['events'] or 'retrograde_loop' in inputs['events']:
|
||||
if planet in ('Rahu', 'Ketu'):
|
||||
if 'station' in inputs['events'] or 'retrograde_loop' in inputs['events']:
|
||||
gaps.append({'field': 'planets', 'reason': f'node_station_not_supported:{planet}'})
|
||||
elif planet in STATION_PLANETS:
|
||||
if 'station' in inputs['events']:
|
||||
events.extend(_station_events(engine, sky, planet, start_jd, end_jd, tz_fixed, timezone_id))
|
||||
if 'retrograde_loop' in inputs['events']:
|
||||
loops.extend(_retrograde_loops(engine, sky, planet, start_jd, end_jd, tz_fixed, timezone_id, gaps))
|
||||
if 'target_longitude' in inputs['events'] and target_longitude is not None and planet == str((inputs['target'] or {}).get('planet')):
|
||||
events.extend(_target_events(engine, sky, planet, target_longitude, start_jd, end_jd, tz_fixed, timezone_id))
|
||||
except Exception as error: # noqa: BLE001 - 失败只留缺口,不编任何日期
|
||||
return contract.build_envelope(
|
||||
tool=TOOL, requested=requested, profile=profile, status='blocked', raw={},
|
||||
gaps=[{'field': 'calculation', 'reason': f'engine_failed:{type(error).__name__}'}],
|
||||
)
|
||||
|
||||
events.sort(key=lambda item: item['jd_ut'])
|
||||
for loop in loops:
|
||||
for key in ('station_retrograde', 'station_direct'):
|
||||
stamp = loop.get(key) or {}
|
||||
loop[f'{key}_outside_window'] = not (start_jd <= float(stamp.get('jd_ut', 0.0)) <= end_jd)
|
||||
# 验收修正(Claude 2026-11-09):任务书要求落在请求区间外的每个时刻自己标 outside_requested_window。
|
||||
for key in ('station_retrograde', 'station_direct', 'shadow_entry', 'shadow_exit'):
|
||||
stamp = loop.get(key)
|
||||
if isinstance(stamp, dict):
|
||||
stamp['outside_requested_window'] = not (start_jd <= float(stamp['jd_ut']) <= end_jd)
|
||||
raw = {
|
||||
'start_date': inputs['start'].strftime('%Y-%m-%d'),
|
||||
'end_date': inputs['end'].strftime('%Y-%m-%d'),
|
||||
'ayanamsa_degrees_at_start': round(sky.ayanamsa(start_jd), 6),
|
||||
'precision': {
|
||||
'coarse_step_hours': {planet: COARSE_STEP_HOURS.get(planet, COARSE_STEP_HOURS['default']) for planet in inputs['planets']},
|
||||
'convergence_minutes': CONVERGENCE_MINUTES,
|
||||
'search_margin_days': SEARCH_MARGIN_DAYS,
|
||||
},
|
||||
'events': events,
|
||||
'retrograde_loops': loops,
|
||||
}
|
||||
status = 'partial' if gaps else 'executed'
|
||||
return contract.build_envelope(
|
||||
tool=TOOL, requested=requested, profile=profile, status=status, raw=raw, gaps=gaps,
|
||||
)
|
||||
@@ -0,0 +1,292 @@
|
||||
"""按需计算(二):一段时间内的行运变化(#174,BUG-1817)。
|
||||
|
||||
换座 / 停滞 / 逆行往返 / 目标经度,时刻二分到 ≤1 分钟,口径显式。
|
||||
只用公开日期与合成坐标,不含任何个人资料。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
for path in (str(ROOT), str(ROOT / 'scripts')):
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
|
||||
from scripts.ondemand_calc import ondemand_calc_api # noqa: E402
|
||||
from scripts.ondemand_calc import transit_window # noqa: E402
|
||||
|
||||
BASE = {
|
||||
'tool': 'transit_window',
|
||||
'start_date': '2026-01-01',
|
||||
'end_date': '2026-03-31',
|
||||
'lat': 0.0,
|
||||
'lon': 0.0,
|
||||
'tz': 0.0,
|
||||
'ayanamsa': 'lahiri',
|
||||
'node_mode': 'mean',
|
||||
'planets': ['Mercury'],
|
||||
'events': ['ingress', 'station', 'retrograde_loop'],
|
||||
}
|
||||
|
||||
|
||||
def _body(**overrides):
|
||||
body = dict(BASE)
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def _jd(iso: str) -> float:
|
||||
import swisseph as swe
|
||||
moment = datetime.strptime(iso, '%Y-%m-%dT%H:%MZ')
|
||||
return swe.julday(moment.year, moment.month, moment.day, moment.hour + moment.minute / 60.0)
|
||||
|
||||
|
||||
def _reference_stations(planet: str, ayanamsa: str, start_iso: str, end_iso: str):
|
||||
"""独立对照:直接用 SwissEph 的恒星黄经速度二分过零点。"""
|
||||
import swisseph as swe
|
||||
from ayanamsa_utils import apply_ayanamsa, sidereal_flags
|
||||
|
||||
apply_ayanamsa(ayanamsa)
|
||||
flags = sidereal_flags(swe, ayanamsa) | getattr(swe, 'FLG_SPEED', 256)
|
||||
planet_id = {'Mercury': swe.MERCURY, 'Venus': swe.VENUS, 'Mars': swe.MARS}[planet]
|
||||
|
||||
def speed(jd: float) -> float:
|
||||
values, _ = swe.calc_ut(jd, planet_id, flags)
|
||||
return values[3]
|
||||
|
||||
stations = []
|
||||
jd = _jd(start_iso)
|
||||
end = _jd(end_iso)
|
||||
step = 0.5
|
||||
before = speed(jd)
|
||||
while jd < end:
|
||||
nxt = min(jd + step, end)
|
||||
after = speed(nxt)
|
||||
if (before < 0) != (after < 0):
|
||||
lo, hi = jd, nxt
|
||||
while hi - lo > 1.0 / 1440.0:
|
||||
middle = (lo + hi) / 2.0
|
||||
if (speed(middle) < 0) == (before < 0):
|
||||
lo = middle
|
||||
else:
|
||||
hi = middle
|
||||
stations.append((lo + hi) / 2.0)
|
||||
before, jd = after, nxt
|
||||
return stations
|
||||
|
||||
|
||||
def test_mercury_retrograde_stations_match_a_direct_swisseph_bisection() -> None:
|
||||
envelope = ondemand_calc_api(_body())
|
||||
assert envelope['calculation_status'] == 'executed'
|
||||
stations = [event for event in envelope['raw']['events'] if event['type'] == 'station']
|
||||
assert len(stations) == 2, stations
|
||||
reference = _reference_stations('Mercury', 'lahiri', '2026-02-01T00:00Z', '2026-04-15T00:00Z')
|
||||
assert len(reference) == 2
|
||||
for event, expected in zip(stations, reference):
|
||||
delta_minutes = abs(event['jd_ut'] - expected) * 24 * 60
|
||||
assert delta_minutes <= 2.0, (event['utc'], delta_minutes)
|
||||
|
||||
|
||||
def test_target_longitude_lists_all_three_passes_with_directions() -> None:
|
||||
# 三次到齐要看到 4 月:第二次逆行、第三次要等顺行恢复(2026-04-01)。
|
||||
envelope = ondemand_calc_api(_body(
|
||||
end_date='2026-04-30',
|
||||
events=['target_longitude'],
|
||||
target={'planet': 'Mercury', 'longitude': 320.0},
|
||||
))
|
||||
events = [event for event in envelope['raw']['events'] if event['type'] == 'target_longitude']
|
||||
assert len(events) == 3, events
|
||||
assert [event['direction'] for event in events] == ['direct', 'retrograde', 'direct']
|
||||
assert all(abs(event['longitude'] - 320.0) <= 0.01 for event in events)
|
||||
|
||||
|
||||
def test_retrograde_loop_shadow_ordering_and_span() -> None:
|
||||
envelope = ondemand_calc_api(_body())
|
||||
loops = envelope['raw']['retrograde_loops']
|
||||
assert len(loops) == 1
|
||||
loop = loops[0]
|
||||
assert loop['shadow_entry']['jd_ut'] < loop['station_retrograde']['jd_ut']
|
||||
assert loop['shadow_exit']['jd_ut'] > loop['station_direct']['jd_ut']
|
||||
low, high = loop['retrograde_span']
|
||||
assert low < high
|
||||
|
||||
|
||||
def test_ingress_times_differ_between_ayanamsas_and_echo_the_profile() -> None:
|
||||
lahiri = ondemand_calc_api(_body(events=['ingress']))
|
||||
raman = ondemand_calc_api(_body(events=['ingress'], ayanamsa='raman'))
|
||||
assert lahiri['profile']['ayanamsa'] == 'lahiri'
|
||||
assert raman['profile']['ayanamsa'] == 'raman'
|
||||
lahiri_times = [event['utc'] for event in lahiri['raw']['events'] if event['type'] == 'ingress']
|
||||
raman_times = [event['utc'] for event in raman['raw']['events'] if event['type'] == 'ingress']
|
||||
assert lahiri_times and raman_times
|
||||
assert lahiri_times[0] != raman_times[0]
|
||||
|
||||
|
||||
def test_nodes_follow_the_node_mode() -> None:
|
||||
# 罗睺约 19°/年,三个月里不会换座,所以用一整年。
|
||||
year = {'start_date': '2026-01-01', 'end_date': '2026-12-31'}
|
||||
mean = ondemand_calc_api(_body(planets=['Rahu'], events=['ingress'], node_mode='mean', **year))
|
||||
true = ondemand_calc_api(_body(planets=['Rahu'], events=['ingress'], node_mode='true', **year))
|
||||
mean_times = [event['utc'] for event in mean['raw']['events']]
|
||||
true_times = [event['utc'] for event in true['raw']['events']]
|
||||
assert mean_times and true_times
|
||||
assert mean_times[0] != true_times[0]
|
||||
|
||||
|
||||
def test_timezone_id_offset_follows_daylight_saving_inside_the_window() -> None:
|
||||
envelope = ondemand_calc_api({
|
||||
'tool': 'transit_window',
|
||||
'start_date': '2026-03-27',
|
||||
'end_date': '2026-03-30',
|
||||
'lat': 51.5074,
|
||||
'lon': -0.1278,
|
||||
'timezone_id': 'Europe/London',
|
||||
'ayanamsa': 'lahiri',
|
||||
'planets': ['Sun'],
|
||||
'events': ['ingress'],
|
||||
})
|
||||
assert envelope['calculation_status'] in {'executed', 'partial'}
|
||||
offsets = {event['utc_offset_hours'] for event in envelope['raw']['events']}
|
||||
assert offsets <= {0.0, 1.0}
|
||||
assert envelope['raw']['precision']['convergence_minutes'] == 1
|
||||
|
||||
|
||||
def test_longitude_at_an_ingress_agrees_with_the_single_date_tool() -> None:
|
||||
window = ondemand_calc_api(_body(events=['ingress']))
|
||||
ingress = next(event for event in window['raw']['events'] if event['type'] == 'ingress')
|
||||
single = ondemand_calc_api({
|
||||
'tool': 'transit_date',
|
||||
'date': ingress['utc'][:10],
|
||||
'time': ingress['utc'][11:16],
|
||||
'lat': 0.0, 'lon': 0.0, 'tz': 0.0,
|
||||
'ayanamsa': 'lahiri', 'node_mode': 'mean',
|
||||
})
|
||||
row = next(item for item in single['raw']['planets'] if item['planet'] == ingress['planet'])
|
||||
boundary = round(row['longitude'] / 30.0) * 30.0
|
||||
assert abs(row['longitude'] - boundary) <= 0.01
|
||||
|
||||
|
||||
def test_missing_inputs_are_blocked_and_named() -> None:
|
||||
cases = [
|
||||
({'start_date': None}, 'start_date'),
|
||||
({'end_date': None}, 'end_date'),
|
||||
({'lat': None}, 'lat'),
|
||||
({'tz': None}, 'tz'),
|
||||
({'ayanamsa': None}, 'ayanamsa'),
|
||||
({'events': None}, 'events'),
|
||||
]
|
||||
for change, field in cases:
|
||||
envelope = ondemand_calc_api(_body(**change))
|
||||
assert envelope['calculation_status'] == 'blocked', change
|
||||
assert envelope['raw'] == {}
|
||||
assert envelope['gaps'][0]['field'] == field, change
|
||||
|
||||
|
||||
def test_window_limits_and_unknown_values_are_blocked() -> None:
|
||||
too_long = ondemand_calc_api(_body(start_date='2026-01-01', end_date='2027-03-01'))
|
||||
assert too_long['calculation_status'] == 'blocked'
|
||||
assert too_long['gaps'][0]['field'] == 'end_date'
|
||||
reversed_ = ondemand_calc_api(_body(start_date='2026-03-01', end_date='2026-01-01'))
|
||||
assert reversed_['calculation_status'] == 'blocked'
|
||||
unknown_event = ondemand_calc_api(_body(events=['not_an_event']))
|
||||
assert unknown_event['calculation_status'] == 'blocked'
|
||||
assert unknown_event['gaps'][0]['field'] == 'events'
|
||||
unknown_planet = ondemand_calc_api(_body(planets=['NotAPlanet']))
|
||||
assert unknown_planet['calculation_status'] == 'blocked'
|
||||
assert unknown_planet['gaps'][0]['field'] == 'planets'
|
||||
|
||||
|
||||
def test_mean_position_mode_is_blocked_with_the_named_gap() -> None:
|
||||
envelope = ondemand_calc_api(_body(position_mode='mean'))
|
||||
assert envelope['calculation_status'] == 'blocked'
|
||||
assert envelope['gaps'] == [{'field': 'position_mode', 'reason': 'position_mode_unsupported'}]
|
||||
|
||||
|
||||
def test_profile_source_marks_product_defaults() -> None:
|
||||
default = ondemand_calc_api(_body())
|
||||
assert default['profile']['profile_source'] == 'product_default_explicit'
|
||||
explicit = ondemand_calc_api(_body(node_mode='mean', position_mode='apparent'))
|
||||
assert explicit['profile']['profile_source'] == 'caller_supplied'
|
||||
|
||||
|
||||
def test_fingerprint_is_stable_and_sensitive() -> None:
|
||||
first = ondemand_calc_api(_body())
|
||||
assert ondemand_calc_api(_body())['input_fingerprint'] == first['input_fingerprint']
|
||||
seen = {first['input_fingerprint']}
|
||||
for change in (
|
||||
{'start_date': '2026-01-02'}, {'end_date': '2026-03-30'}, {'lat': 10.0}, {'ayanamsa': 'raman'},
|
||||
{'node_mode': 'true'}, {'planets': ['Venus']}, {'events': ['ingress']},
|
||||
):
|
||||
envelope = ondemand_calc_api(_body(**change))
|
||||
assert envelope['input_fingerprint'] not in seen, change
|
||||
seen.add(envelope['input_fingerprint'])
|
||||
|
||||
|
||||
def test_engine_failure_is_blocked_without_an_invented_date(monkeypatch) -> None:
|
||||
def boom(*args, **kwargs):
|
||||
raise RuntimeError('ephemeris broken')
|
||||
|
||||
monkeypatch.setattr(transit_window, '_Sky', lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError('broken')))
|
||||
envelope = ondemand_calc_api(_body())
|
||||
assert envelope['calculation_status'] == 'blocked'
|
||||
assert envelope['raw'] == {}
|
||||
assert envelope['gaps'][0]['field'] == 'calculation'
|
||||
assert '2026-01-01' not in repr(envelope['raw'])
|
||||
|
||||
|
||||
def test_nodes_do_not_get_station_events() -> None:
|
||||
envelope = ondemand_calc_api(_body(planets=['Rahu'], events=['station']))
|
||||
assert envelope['calculation_status'] == 'partial'
|
||||
assert envelope['raw']['events'] == []
|
||||
assert any('node_station_not_supported' in gap['reason'] for gap in envelope['gaps'])
|
||||
|
||||
|
||||
def test_missing_natal_point_is_blocked_and_named() -> None:
|
||||
envelope = ondemand_calc_api(_body(
|
||||
events=['target_longitude'],
|
||||
target={'planet': 'Mercury', 'natal_point': 'Moon'},
|
||||
natal={'points': {'Sun': 10.0}},
|
||||
))
|
||||
assert envelope['calculation_status'] == 'blocked'
|
||||
assert envelope['gaps'][0] == {'field': 'target.natal_point', 'reason': 'natal_point_missing:Moon'}
|
||||
|
||||
|
||||
def test_registry_dispatches_to_the_new_tool() -> None:
|
||||
envelope = ondemand_calc_api(_body(planets=['Sun'], events=['ingress'], start_date='2026-01-01', end_date='2026-01-05'))
|
||||
assert envelope['tool'] == 'transit_window'
|
||||
assert envelope['schema'] == 'jyotish.ondemand_calc.v1'
|
||||
|
||||
|
||||
# 验收补测(Claude 2026-11-09):换座的 to_sign / direction,以及区间外时刻的逐项标记。
|
||||
def test_ingress_names_the_new_sign_and_direction_across_zero_aries() -> None:
|
||||
result = transit_window.run(_body(start_date='2026-04-01', end_date='2026-04-30', planets=['Sun'], events=['ingress']))
|
||||
assert result['calculation_status'] == 'executed'
|
||||
[event] = result['raw']['events']
|
||||
assert (event['from_sign'], event['to_sign'], event['direction']) == ('Pisces', 'Aries', 'direct')
|
||||
|
||||
|
||||
def test_retrograde_ingress_back_into_the_previous_sign_is_named() -> None:
|
||||
result = transit_window.run(_body(start_date='2026-01-01', end_date='2026-12-31', events=['ingress']))
|
||||
events = result['raw']['events']
|
||||
assert events, result
|
||||
signs = list(__import__('jyotish_engine').SIGNS)
|
||||
for event in events:
|
||||
step = 1 if event['direction'] == 'direct' else -1
|
||||
assert signs.index(event['to_sign']) == (signs.index(event['from_sign']) + step) % 12, event
|
||||
assert any(event['direction'] == 'retrograde' for event in events)
|
||||
|
||||
|
||||
def test_each_time_outside_the_window_is_marked() -> None:
|
||||
result = transit_window.run(_body(start_date='2026-01-01', end_date='2026-06-30', events=['retrograde_loop']))
|
||||
loops = result['raw']['retrograde_loops']
|
||||
assert len(loops) == 2
|
||||
june = loops[1]
|
||||
assert june['station_retrograde']['outside_requested_window'] is False
|
||||
assert june['station_direct']['outside_requested_window'] is True
|
||||
assert june['shadow_exit']['outside_requested_window'] is True
|
||||
assert loops[0]['shadow_entry']['outside_requested_window'] is False
|
||||
Reference in New Issue
Block a user