Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
152b298dd3 | ||
|
|
8feabcc588 |
@@ -24050,3 +24050,25 @@
|
||||
- 防复发:上述测试。
|
||||
- 关联记录:BUG-1773
|
||||
- 修复版本:本地分支 `codex/same-source-1108`
|
||||
|
||||
## BUG-1811 | Chara 大运与分运日期由页面自己重算,与报告页的日期最多差一天
|
||||
|
||||
- 状态:fixed-pending-verify(`codex/dasha-dates-engine-only-20261109`)
|
||||
- 首次发现 / 最近更新:2026-11-08(占星师第五轮裁定 5;星盘页在 BUG-1752 只修了 Vimshottari 轨)/ 2026-11-09
|
||||
- 影响面:`frontend/src/lib/chart-view-mapper.ts`(`charaTrack`)、`scripts/jyotish_api_server.py::_compute_chara_dasha`、`scripts/rashi_dashas_native.py::_chara`;新增 `scripts/chart_dasha/chara_dates.py`。
|
||||
- 现象:星盘页「大运」里第二条运法(Chara)的十二段大运和每段十二个分运,起止日期由浏览器按 365.25 天/年从出生日往后推;报告页的 Chara 表按公历周年推。同一张盘两处日期最多差一天,且都不来自引擎。
|
||||
- 触发条件:打开星盘页「大运」→「第二条运法」看 Chara,或与报告页 Chara 段对照。
|
||||
- 根因:`/api/dasha/chara` 只返回 jaimini 的 `duration_years`,没有日期字段;显示层拿不到日期就只能自己算,而两处各写了一套算法。BUG-1752 只把 Vimshottari 轨改成读引擎日期。
|
||||
- 修复:
|
||||
- 新增 `scripts/chart_dasha/chara_dates.py`:按出生日的公历周年给 jaimini 返回的十二段和每段十二个分运写出 `start` / `end`(本地日历日)与 `start_ts` / `end_ts`(UTC 秒);区间 `[start, end)`,末段终点钉在父段终点,0 年段保留 `start == end`,29 日出生在平年落到 28 日。
|
||||
- `_compute_chara_dasha` 在返回前调用该模块写字段,并带 `date_basis`(年基准、区间、时区、来源)。
|
||||
- 报告页 `rashi_dashas_native._chara` 改读同一模块,两处日期由构造保证一致,不再各算一套。
|
||||
- 星盘页 `charaTrack` 只复制引擎给的日期;没有日期的行不显示,不在浏览器里切。
|
||||
- 验证:
|
||||
- `tests/test_dasha_dates_engine_only_bug1811.py`:6 条通过(接口与报告逐段一致;跨午夜是本地日;出生当日开段;0 年段;第二轮 Vimshottari;与显示时钟无关)。
|
||||
- `frontend/tests/chart-dasha-engine-dates-1811.test.ts`:4 条通过(日期逐段等于引擎行;缺日期的行不重算;0 年段不 current;mapper 源码无日期算术)。
|
||||
- 既有相关用例 105 条通过:`chart-view-zero-year`、`chart-dasha-yogini`、`chart-data-card-20261030`、`chart-dasha-engine-rows-1752`、`chart-page-view`、`chart-cards-fit-20261102`、`chat-header-pills-and-quiet-cards-20261102`、`chart-view-route`、`chart-varga-table`、`chart-picker-inspector-skeleton-20261102`、`starter-chart-signs-20261027`。
|
||||
- `tsc --noEmit -p frontend` exit 0;`npm run lint` 0 error / 60 warning(全在存量文件)。
|
||||
- 防复发:上述 10 条新用例把「接口日期 == 报告日期 == 页面显示」钉住;`chart-dasha-engine-dates-1811.test.ts` 同时断言 `chart-view-mapper.ts` 源码里不再出现 `365.25`、`86_400_000`、`addYears`、`addWeightedSpan`、`vimshottariSplit`。
|
||||
- 关联记录:BUG-1752(星盘页 Vimshottari 日期改读引擎)、BUG-1567(Chara 分运顺序)、BUG-1770(0 年大运不该算当前)
|
||||
- 修复版本:`codex/dasha-dates-engine-only-20261109`
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# PROGRESS:所有大运日期只取引擎原值,前后端不再自己按年数比例重算(2026-11-09)
|
||||
|
||||
- 任务书:`docs/tasks/TASK-dasha-dates-engine-only-20261109.md`(分支 `codex/dasha-dates-engine-only-20261109`,基于 staging `04b49609`)
|
||||
- Bug:BUG-1811(`docs/BUG_HISTORY.md`)
|
||||
- 执行:Grok 侧自动循环(issue #168)
|
||||
|
||||
## 结论
|
||||
|
||||
排查到一处用户可见、又不取引擎日期的重算:**星盘页「第二条运法」的 Chara 轨**。十二段大运的终点由浏览器按 `365.25 天/年` 从出生日推,每段十二个分运再按年数比例切;报告页的 Chara 表是另一套(公历周年)。两处最多差一天,且都不来自引擎。已改为:`/api/dasha/chara` 一次算好日期(新模块 `scripts/chart_dasha/chara_dates.py`),报告页读同一模块,星盘页只复制字段。其余命中都只是格式化已有日期、取引擎行,或研究/诊断脚本。
|
||||
|
||||
## 第一步:排查表
|
||||
|
||||
| 文件(改前位置) | 命中 | 类别 | 处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| `frontend/src/lib/chart-view-mapper.ts:100-103`(`addYears`) | `365.25 * 86_400_000` 把年数换成日期 | 2 自己算了日期、用户能看到 | 删除;星盘页不再按年数推日期 |
|
||||
| `frontend/src/lib/chart-view-mapper.ts:683-761`(`charaTrack`) | 从出生日逐段累加 `duration_years`,分运用 `addWeightedSpan` 按权重切 | 2 | 改为复制 `/api/dasha/chara` 的 `start` / `end` / `start_ts` / `end_ts`;没有日期的行不显示 |
|
||||
| `frontend/src/lib/dasha-span.ts:9-26`(`addWeightedSpan`)、`:40-46`(`vimshottariSplit`) | 按权重/年数切日期区间 | 2(改前被 `charaTrack` 调用) | 生产代码不再调用;函数保留,`chart-board-20261027.test.tsx` 仍拿它当既有断言的比对基线(删除会改既有断言,超出本单) |
|
||||
| `frontend/src/lib/dasha-span.ts:35-37`(`enginePratyantars`) | 复制引擎细运行 | 1 只读引擎日期 | 不改 |
|
||||
| `frontend/src/lib/chart-view-labels.ts:116`(`VIMSHOTTARI_YEARS`) | 年数权重表 | 1 数据表,不产生日期 | 不改 |
|
||||
| `frontend/src/components/chart-page/chart-dasha-tab.tsx:68-74`(`lengthOf`)、`:113-114`(年龄) | 把已有起止日期显示成「7 年」「41–43 岁」 | 1 只格式化已有日期 | 不改 |
|
||||
| `frontend/src/lib/chart-board-focus.ts`、`frontend/src/components/chart-board/chart-data-card.tsx` | 只读 periods 的起止日期做进度与高亮 | 1 | 不改 |
|
||||
| `scripts/jyotish_api_server.py:7840-7848`(`_compute_chara_dasha`) | 只返回 jaimini 的 `duration_years`,没有日期 | 2(缺口:接口不给日期,前端只能自己算) | 返回前用 `chara_dates.attach` 写 `start` / `end` / `start_ts` / `end_ts` 与 `date_basis` |
|
||||
| `scripts/rashi_dashas_native.py:640-662`(`_chara`) | 从出生日按公历周年逐段累加,分运按秒等分(自己的第二套日期算法) | 2 | 改读 `chara_dates.date_sequence`,与星盘页同一份日期 |
|
||||
| `scripts/dasha_calculator_enhanced.py:149-206`(`calculate_dasha_dates`) | 自己按 365.25636 天/年算 MD 起止 | 3 研究/演示脚本 | 不改且记录:`/api/dasha` 只用到本文件的 `calculate_five_level_dasha`;`calculate_dasha_dates` 只在 `benchmarks/jyotish/scripts/run_vimshottari_compare.py`、`tests/run_all.py` 与文件内演示函数里被调用,没有路由 |
|
||||
| `scripts/dasha_registry.py:289-411`(`compute_dasha`) | 每套大运的 `periods` 直接取引擎模块(yogini 分运走 `yogini_antardashas`) | 1 取引擎行 | 不改 |
|
||||
| `scripts/dasha_reference_audit.py:1-251` | 诊断 CLI:量边界漂移 | 3 诊断脚本 | 不改 |
|
||||
| `scripts/dasha_analyzer.py:115-170`(`build_antardasha`,冻结) | 父段取整数天、末段早结束 | 1 冻结文件 | 不改;调用方只有本文件 `find_current` 与校正评分链路(`scripts/active_rectification_event_engine.py` 注释、`scripts/research/nadi_seconds_lib.py`),星盘与报告都不再经过它 |
|
||||
| `scripts/chart_dasha/birth_moment.py:1-259` | Vimshottari 由出生时刻起算 | 1 BUG-1752 已改为引擎产出 | 不改 |
|
||||
|
||||
四类之外的补充:`frontend/src/lib/chart-view-mapper.ts` 的 Vimshottari 轨早已只复制 `dasha.antardasha_timeline`(BUG-1752),本轮未动。
|
||||
|
||||
## 第二步:修复
|
||||
|
||||
1. 新增 `scripts/chart_dasha/chara_dates.py`:给 jaimini 返回的十二段和每段十二个分运写日期。
|
||||
- 大运终点按出生日的公历周年(29 日出生在平年落 28 日),与报告页原来的口径一致;
|
||||
- 分运是父段的等分(精确到秒),末段终点钉在父段终点;
|
||||
- 区间 `[start, end)`;0 年段保留 `start == end`,分运也各为 `start == end`;
|
||||
- `start` / `end` 是本地日历日,`start_ts` / `end_ts` 是同一时刻的 UTC 秒(时区未知时不写,不猜)。
|
||||
2. `_compute_chara_dasha` 在返回前写这些字段,并带 `date_basis`(年基准、区间、时区、来源)。
|
||||
3. `rashi_dashas_native._chara` 改读同一模块。
|
||||
4. `chart-view-mapper.charaTrack` 只复制字段;`addYears` 删除;文案 `CHARA_ANTARDASHA_DATE_NOTE` 从「按大运边界推算」改为「与大运同一套边界」。
|
||||
5. 两份真实引擎响应 fixture 重新抓取: `frontend/tests/fixtures/chart-view-golden.json`、`chara-zero-year-1985-08-03-beijing.golden.json` 的 `chara` 段用 `JyotishAPIHandler()._compute_chara_dasha` 重抓(同一出生数据),其余字段未动。
|
||||
|
||||
## 四种边界情况
|
||||
|
||||
| 情况 | 用例 |
|
||||
| --- | --- |
|
||||
| 跨午夜 | `test_chara_days_are_local_days_across_midnight`(00:00 本地边界落在 UTC 前一天,页面显示的仍是本地日) |
|
||||
| 出生余额 | `test_the_period_running_at_birth_starts_on_the_birth_day`(Chara 没有出生前的余额段,第一段必须从出生当日开;不同出生日都成立) |
|
||||
| 第二轮大运 | `test_the_second_vimshottari_round_is_still_engine_dated`(Chara 一圈只有十二个星座,没有第二轮;第二轮由星盘页同页的 Vimshottari 轨覆盖,仍是引擎行) |
|
||||
| 0 年段 | `test_a_zero_year_chara_period_has_no_time_and_keeps_its_row` + `frontend/tests/chart-dasha-engine-dates-1811.test.ts` 的 0 年用例(`start == end`,永不算当前) |
|
||||
|
||||
## 自测(Windows / PowerShell,本工作树)
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 类型 | `frontend\node_modules\.bin\tsc.cmd --noEmit -p frontend` | exit 0 |
|
||||
| Lint | `npm run lint`(frontend) | 0 error / 60 warning(全在存量文件) |
|
||||
| 新 Python 用例 | `.venv\Scripts\python -m pytest tests/test_dasha_dates_engine_only_bug1811.py -q` | 6 passed |
|
||||
| 相关 Python 用例 | `.venv\Scripts\python -m pytest tests/test_rashi_dashas_native.py tests/test_jaimini.py tests/test_chara_dasha_board_lord.py -q` | 63 passed |
|
||||
| 新前端用例 | `node node_modules/tsx/dist/cli.mjs --test tests/chart-dasha-engine-dates-1811.test.ts`(frontend) | 4 pass / 0 fail |
|
||||
| 既有前端用例 | `chart-view-zero-year`、`chart-dasha-yogini`、`chart-data-card-20261030`、`chart-dasha-engine-rows-1752`、`chart-page-view`、`chart-cards-fit-20261102`、`chat-header-pills-and-quiet-cards-20261102`、`chart-view-route`、`chart-varga-table`、`chart-picker-inspector-skeleton-20261102`、`starter-chart-signs-20261027` | 105 pass / 0 fail |
|
||||
| 隐私标记 | `.venv\Scripts\python -m pytest tests/test_repo_privacy_markers.py -q` | 见回报(推送前跑) |
|
||||
|
||||
未跑:前端全量 `npm test`、Python 快速门(按循环分工由 Claude 跑)。
|
||||
|
||||
## 偏离与说明
|
||||
|
||||
- **工作树事故(已处理)**:本轮开始时 `G:\Ferti\Jyotisha\.worktrees\agent-loop` 是干净的、HEAD 在 `codex/reports-no-web-wording-20261109`;检出本单分支后,另一个会话(issue #167 的实现)开始在同一工作树里写文件,导致两边改动混在一起。我把自己的 5 个文件搬到独立工作树 `.worktrees/dasha-dates-engine-only-20261109`,把 agent-loop 工作树切回 `codex/reports-no-web-wording-20261109` 并撤销我对它 4 个源文件的修改(它自己的 9 个改动与 2 个新文件原样保留)。本分支的提交只在独立工作树里产生。
|
||||
- 没有改引擎公式;`jaimini.calc_chara_dasha_with_antardasha` 的时长与顺序一位没动,只多了日期字段。
|
||||
- `dasha-span.ts` 的两个切分函数保留为测试基线,未删除;删除需要改 `chart-board-20261027.test.tsx` 的既有断言,超出本单范围。
|
||||
- 未跑 `test:db`:本轮不动表、不动数据库访问层。
|
||||
@@ -0,0 +1,65 @@
|
||||
# TASK:所有大运日期只取引擎原值,前后端不再自己按年数比例重算(2026-11-09)
|
||||
|
||||
- 分支:`codex/dasha-dates-engine-only-20261109`(基于 staging `04b49609`)
|
||||
- Bug 编号:BUG-1811
|
||||
- 负责验收:Claude(jyotisha-2a)
|
||||
|
||||
## 背景
|
||||
|
||||
占星师 2026-11-08 第五轮裁定第 5 条:所有大运日期必须由原生大运引擎生成,任何显示层都不能重新计算;显示时的裁剪也不能改变子运边界。
|
||||
|
||||
星盘页已在 BUG-1752 中修复,改为直接用后端给的起止时间,同时带父段起点 `parent_period_start` 与可见起点 `visible_period_start`。本任务排查并修复**其余所有地方**。
|
||||
|
||||
## 第一步:排查(先做,结果写进 PROGRESS)
|
||||
|
||||
逐个打开下列文件,判断它们是否在**自己计算**大运、小运、细运的起止日期(按年数比例、`/120`、`365.25`、`.days` 等方式),而不是使用引擎或后端给出的日期:
|
||||
|
||||
- 前端:`frontend/src/lib/dasha-span.ts`、`frontend/src/lib/chart-view-labels.ts`、`frontend/src/lib/chart-view-mapper.ts`,以及全仓搜索 `/ 120`、`VIMSHOTTARI_YEARS`、`DASHA_YEARS`、`365.25` 的其他命中。
|
||||
- 后端:`scripts/dasha_calculator_enhanced.py`、`scripts/dasha_registry.py`、`scripts/dasha_reference_audit.py`。`scripts/dasha_analyzer.py` 是冻结文件,不改,只确认还有谁在调用它的 `build_antardasha`。
|
||||
|
||||
每个命中归为以下三类之一,写成一张表放进 PROGRESS:
|
||||
|
||||
1. 只是显示(格式化已有日期):不改。
|
||||
2. 自己算了日期,而且用户能看到:要改。
|
||||
3. 研究脚本(`scripts/research/**`):不改,只记录。
|
||||
|
||||
## 第二步:修复第 2 类
|
||||
|
||||
- 改为使用后端或引擎已经给出的起止时间。参照星盘页的做法,搜索 BUG-1752 的提交。
|
||||
- 如果某处拿不到引擎日期,就由后端接口补上这个字段,前端直接读,不在前端计算。
|
||||
- 区间一律左闭右开。0 年的段保留在数据里,但不显示为“当前”。
|
||||
|
||||
## 不要碰
|
||||
|
||||
- 冻结文件 `scripts/dasha_analyzer.py`、`scripts/domain_calculation_service.py`。
|
||||
- `scripts/narayana_rath.py`、`scripts/narayana_dasha.py`:另一分支正在改。
|
||||
- `frontend/src/lib/consultation-*.ts`、`frontend/src/mastra/**`、`references/**`。
|
||||
- 不改引擎本身的计算公式。
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. PROGRESS 里有完整的排查表:文件、行号、类别、处理方式。
|
||||
2. 每个修复点都有测试,断言显示的日期等于引擎给出的日期,并覆盖以下四种情况:
|
||||
- 跨午夜的边界;
|
||||
- 出生时所在大运的余额(父段被出生时刻裁剪);
|
||||
- 第二轮大运;
|
||||
- 0 年段不显示为“当前”(Narayana 或 Chara)。
|
||||
3. 改既有断言时写“原值 / 新值 / 原因”三栏注释。原因写:`占星师 2026-11-08 第五轮裁定 5:日期只取原生引擎`。
|
||||
4. 在 `docs/BUG_HISTORY.md` 末尾追加 BUG-1811,关联 BUG-1752。
|
||||
5. 测试只用公开名人盘或合成数据,不写任何个人出生资料。
|
||||
|
||||
## 自测命令(Windows / PowerShell)
|
||||
|
||||
- `frontend\node_modules\.bin\tsc --noEmit -p frontend`
|
||||
- 在 `frontend` 下运行 `npm run lint`
|
||||
- `node node_modules/tsx/dist/cli.mjs --test tests/<改到或新建的测试文件>`
|
||||
- `.venv\Scripts\python -m pytest tests/<改到或新建的测试文件> -q`
|
||||
|
||||
## 进度记录
|
||||
|
||||
`docs/tasks/PROGRESS-dasha-dates-engine-only-20261109.md`
|
||||
|
||||
## 何时停下来选“需拍板”
|
||||
|
||||
- 某个显示用户能看到,但后端没有对应日期,补字段需要改动数据库表或迁移时。
|
||||
- 发现引擎本身给出的日期就有问题时(不要自己改引擎公式)。
|
||||
@@ -177,7 +177,11 @@ export const ANTARDASHA_DERIVED_NOTE = "小运、细运的起止取自原生 Vim
|
||||
/** BUG-1752: the engine returned no AD / PD rows for this chart, so none are drawn (never recomputed here). */
|
||||
export const ANTARDASHA_MISSING_NOTE = "这次排盘没有带回引擎的小运和细运,这一层未交付。";
|
||||
|
||||
export const CHARA_ANTARDASHA_DATE_NOTE = "小运时长来自引擎的十二等分;起止日期按大运边界推算,不是引擎单独返回的日历字段。";
|
||||
// BUG-1811 (占星师 2026-11-08 第五轮裁定 5「所有日期必须由原生 Dasha 引擎生成」):
|
||||
// 原值「小运时长来自引擎的十二等分;起止日期按大运边界推算,不是引擎单独返回的日历字段。」
|
||||
// (页面按大运边界推算小运日期);新值:小运日期也直接取引擎返回的字段;
|
||||
// 原因:同一裁定,Chara 轨的日期已由 /api/dasha/chara 一次算好,页面不再推算。
|
||||
export const CHARA_ANTARDASHA_DATE_NOTE = "小运的起止日期同样来自引擎,与大运用同一套边界。";
|
||||
|
||||
export const YOGINI_ANTARDASHA_UNVERIFIED_NOTE = "小运未经外部软件核对。";
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
type NorthIndianChart,
|
||||
} from "./chart-view-contract.ts";
|
||||
import { asRecord as record } from "@/lib/guards";
|
||||
import { addWeightedSpan } from "./dasha-span.ts";
|
||||
import { mapAshtakavarga, mapBirthCalendar, mapPoints, mapShadbala, mapYogas } from "./chart-view-insight.ts";
|
||||
|
||||
export type ChartViewProfileInput = {
|
||||
@@ -97,11 +96,6 @@ function isoDay(value: string): string {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
function addYears(startIso: string, years: number): string {
|
||||
const start = Date.parse(`${startIso}T00:00:00Z`);
|
||||
return new Date(start + years * 365.25 * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function containsDay(start: string, end: string, asOf: string): boolean {
|
||||
return start <= asOf && asOf < end;
|
||||
}
|
||||
@@ -680,54 +674,51 @@ function vimshottariTrack(chart: Record<string, unknown>, asOf: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function charaTrack(charaPacket: Record<string, unknown> | null, birthDate: string, asOf: string) {
|
||||
/**
|
||||
* Chara rows as the engine dated them (BUG-1811, 占星师 2026-11-08 第五轮裁定 5):
|
||||
* `start` / `end` / `start_ts` / `end_ts` are copied from /api/dasha/chara, which
|
||||
* dates them once in scripts/chart_dasha/chara_dates.py. The page never cuts a
|
||||
* sign period or its antardashas by year proportions itself.
|
||||
*/
|
||||
function charaTrack(charaPacket: Record<string, unknown> | null, asOf: string) {
|
||||
const result = record(charaPacket?.result) ?? charaPacket;
|
||||
const chara = record(result?.chara_dasha) ?? result;
|
||||
const sequence = Array.isArray(chara?.dasha_sequence) ? chara.dasha_sequence : [];
|
||||
let cursor = birthDate;
|
||||
const periods = sequence.flatMap((entry) => {
|
||||
const row = record(entry);
|
||||
if (!row) return [];
|
||||
const sign = text(row.sign);
|
||||
const lord = text(row.lord) ?? sign ?? "—";
|
||||
const rawDuration = finite(row.duration_years);
|
||||
const duration = rawDuration === null ? 1 : rawDuration;
|
||||
const zeroYear = duration === 0;
|
||||
const start = cursor;
|
||||
const end = addYears(start, duration);
|
||||
cursor = end;
|
||||
const start = text(row.start);
|
||||
const end = text(row.end);
|
||||
// A row the engine did not date is left out: cutting it here is the bug this
|
||||
// change removes.
|
||||
if (!start || !end) return [];
|
||||
const startIso = isoDay(start);
|
||||
const endIso = isoDay(end);
|
||||
const zeroYear = startIso === endIso;
|
||||
const rawAntar = Array.isArray(row.antardashas) ? row.antardashas : [];
|
||||
const antardashas = zeroYear
|
||||
? []
|
||||
: rawAntar.length > 0
|
||||
? rawAntar.flatMap((antar, index) => {
|
||||
: rawAntar.flatMap((antar) => {
|
||||
const item = record(antar);
|
||||
if (!item) return [];
|
||||
const weights = rawAntar.map((candidate) => finite(record(candidate)?.duration_years) ?? 1);
|
||||
const span = addWeightedSpan(start, end, index, weights);
|
||||
const antarStart = text(item.start);
|
||||
const antarEnd = text(item.end);
|
||||
if (!antarStart || !antarEnd) return [];
|
||||
const antarLord = text(item.lord) ?? text(item.sign) ?? "—";
|
||||
const antarSign = text(item.sign);
|
||||
return [{
|
||||
lord: antarLord,
|
||||
...(antarSign && isSignName(antarSign) ? { sign: antarSign } : {}),
|
||||
label: `${signZh(antarSign ?? antarLord)} / ${planetZh(antarLord)}`,
|
||||
start: span.start,
|
||||
end: span.end,
|
||||
current: containsDay(span.start, span.end, asOf),
|
||||
derived: true,
|
||||
start: isoDay(antarStart),
|
||||
end: isoDay(antarEnd),
|
||||
current: containsDay(isoDay(antarStart), isoDay(antarEnd), asOf),
|
||||
derived: false,
|
||||
...(typeof item.start_ts === "number" ? { startTs: item.start_ts } : {}),
|
||||
...(typeof item.end_ts === "number" ? { endTs: item.end_ts } : {}),
|
||||
}];
|
||||
})
|
||||
: SIGN_ORDER.map((antarSign, index) => {
|
||||
const span = addWeightedSpan(start, end, index, SIGN_ORDER.map(() => 1));
|
||||
return {
|
||||
lord: antarSign,
|
||||
sign: antarSign,
|
||||
label: signZh(antarSign),
|
||||
start: span.start,
|
||||
end: span.end,
|
||||
current: containsDay(span.start, span.end, asOf),
|
||||
derived: true,
|
||||
};
|
||||
});
|
||||
const lordSign = text(row.lord_in_sign);
|
||||
return [{
|
||||
@@ -735,11 +726,13 @@ function charaTrack(charaPacket: Record<string, unknown> | null, birthDate: stri
|
||||
label: `${signZh(sign ?? lord)} / ${planetZh(lord)}`,
|
||||
sign: sign,
|
||||
lordSign: lordSign && isSignName(lordSign) ? lordSign : null,
|
||||
start,
|
||||
end,
|
||||
current: containsDay(start, end, asOf),
|
||||
start: startIso,
|
||||
end: endIso,
|
||||
current: !zeroYear && containsDay(startIso, endIso, asOf),
|
||||
derived: false,
|
||||
...(zeroYear ? { zeroYear: true as const } : {}),
|
||||
...(typeof row.start_ts === "number" ? { startTs: row.start_ts } : {}),
|
||||
...(typeof row.end_ts === "number" ? { endTs: row.end_ts } : {}),
|
||||
antardashas,
|
||||
}];
|
||||
});
|
||||
@@ -983,7 +976,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
|
||||
const vargas = buildVargas(chart, varga);
|
||||
if (vargas.length === 0) throw new Error("vedic_chart_unavailable");
|
||||
const vimshottari = vimshottariTrack(chart, asOf);
|
||||
const charaMapped = charaTrack(chara, profile.date, asOf);
|
||||
const charaMapped = charaTrack(chara, asOf);
|
||||
const yoginiMapped = yoginiTrack(yogini, asOf);
|
||||
const placeLabel = profile.placeLabel?.trim()
|
||||
|| formatCoord(profile.latitude, profile.longitude);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildChartView, type ChartViewProfileInput } from "../src/lib/chart-view-mapper.ts";
|
||||
|
||||
/**
|
||||
* BUG-1811 (占星师 2026-11-08 第五轮裁定 5): the Chara track shows the dates
|
||||
* /api/dasha/chara returns. The page used to cut the twelve sign periods by
|
||||
* 365.25-day years and split their antardashas by year proportions; the engine
|
||||
* dates them once now (scripts/chart_dasha/chara_dates.py).
|
||||
*
|
||||
* Fixtures are real engine responses: the chara sections of these two files were
|
||||
* captured from JyotishAPIHandler._compute_chara_dasha for the same fictional
|
||||
* charts (see chart-view-golden.json sourceCommand).
|
||||
*/
|
||||
const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8")) as {
|
||||
chart: Record<string, unknown>;
|
||||
chara: Record<string, unknown>;
|
||||
};
|
||||
const zeroYear = JSON.parse(
|
||||
readFileSync(new URL("./fixtures/chara-zero-year-1985-08-03-beijing.golden.json", import.meta.url), "utf8"),
|
||||
) as { chart: Record<string, unknown>; chara: Record<string, unknown> };
|
||||
|
||||
const profile: ChartViewProfileInput = {
|
||||
name: "虚构",
|
||||
date: "1990-06-15",
|
||||
time: "12:00",
|
||||
placeLabel: "北京",
|
||||
latitude: 39.9042,
|
||||
longitude: 116.4074,
|
||||
timezoneOffset: 8,
|
||||
timezoneId: "Asia/Shanghai",
|
||||
ayanamsa: "raman",
|
||||
birthTimeStatus: "reported",
|
||||
};
|
||||
|
||||
function viewFrom(chara: Record<string, unknown>, asOf = "2026-11-09") {
|
||||
return buildChartView({
|
||||
chart: golden.chart,
|
||||
varga: null,
|
||||
chara,
|
||||
western: null,
|
||||
profile,
|
||||
asOf,
|
||||
});
|
||||
}
|
||||
|
||||
type EngineRow = { sign: string; start: string; end: string; antardashas: Array<{ start: string; end: string }> };
|
||||
const engineRows = (golden.chara as { result: { chara_dasha: { dasha_sequence: EngineRow[] } } }).result.chara_dasha.dasha_sequence;
|
||||
|
||||
test("Chara periods and antardashas show the engine's own dates", () => {
|
||||
const periods = viewFrom(golden.chara).dasha.chara.periods;
|
||||
assert.equal(periods.length, engineRows.length);
|
||||
assert.equal(periods.length, 12);
|
||||
periods.forEach((period, index) => {
|
||||
const engine = engineRows[index]!;
|
||||
assert.equal(period.sign, engine.sign);
|
||||
assert.equal(period.start, engine.start);
|
||||
assert.equal(period.end, engine.end);
|
||||
assert.equal(period.derived, false);
|
||||
assert.equal(period.antardashas.length, engine.antardashas.length);
|
||||
period.antardashas.forEach((antar, childIndex) => {
|
||||
assert.equal(antar.start, engine.antardashas[childIndex]!.start);
|
||||
assert.equal(antar.end, engine.antardashas[childIndex]!.end);
|
||||
assert.equal(antar.derived, false);
|
||||
});
|
||||
});
|
||||
// [start, end): consecutive periods meet exactly.
|
||||
for (let index = 0; index < periods.length - 1; index += 1) {
|
||||
assert.equal(periods[index]!.end, periods[index + 1]!.start);
|
||||
}
|
||||
});
|
||||
|
||||
test("a Chara row the engine did not date is not cut in the browser", () => {
|
||||
const packet = structuredClone(golden.chara) as { result: { chara_dasha: { dasha_sequence: Array<Record<string, unknown>> } } };
|
||||
const rows = packet.result.chara_dasha.dasha_sequence;
|
||||
const dropped = rows[1]!;
|
||||
delete dropped.start;
|
||||
delete dropped.end;
|
||||
const periods = viewFrom(packet).dasha.chara.periods;
|
||||
assert.equal(periods.length, 11);
|
||||
assert.equal(periods.some((period) => period.sign === dropped.sign), false);
|
||||
});
|
||||
|
||||
test("the zero-year Chara period keeps the engine's empty span and is never current", () => {
|
||||
const rows = (zeroYear.chara as { result: { chara_dasha: { dasha_sequence: EngineRow[] } } }).result.chara_dasha.dasha_sequence;
|
||||
const zero = rows.find((row) => row.start === row.end)!;
|
||||
assert.equal(zero.sign, "Sagittarius");
|
||||
const view = buildChartView({
|
||||
chart: zeroYear.chart,
|
||||
varga: null,
|
||||
chara: zeroYear.chara,
|
||||
western: null,
|
||||
profile: { ...profile, date: "1985-08-03", time: "03:59" },
|
||||
asOf: zero.start,
|
||||
});
|
||||
const periods = view.dasha.chara.periods;
|
||||
const mapped = periods.find((period) => period.sign === zero.sign)!;
|
||||
assert.equal(mapped.zeroYear, true);
|
||||
assert.equal(mapped.start, mapped.end);
|
||||
assert.equal(mapped.current, false);
|
||||
assert.equal(mapped.antardashas.length, 0);
|
||||
const running = periods.filter((period) => period.current);
|
||||
assert.equal(running.length, 1);
|
||||
assert.notEqual(running[0]!.sign, zero.sign);
|
||||
});
|
||||
|
||||
test("the chart mapper no longer does dasha date arithmetic", () => {
|
||||
const source = readFileSync(new URL("../src/lib/chart-view-mapper.ts", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(source, /365\.25/);
|
||||
assert.doesNotMatch(source, /86_400_000/);
|
||||
assert.doesNotMatch(source, /function addYears/);
|
||||
assert.doesNotMatch(source, /addWeightedSpan/);
|
||||
assert.doesNotMatch(source, /vimshottariSplit/);
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
+789
-158
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
"""Chara Dasha dates, dated once here instead of by each display layer (BUG-1811).
|
||||
|
||||
占星师 2026-11-08 第五轮裁定 5:所有大运日期必须由原生大运引擎生成,显示层不得
|
||||
自己按年数比例重算。``jaimini.calc_chara_dasha_with_antardasha`` returns durations
|
||||
only, so the chart page used to cut the twelve sign periods itself (365.25-day
|
||||
years from the birth day) while the report's Chara section
|
||||
(``rashi_dashas_native._chara``) cut them another way (calendar-year anniversaries
|
||||
from the birth date). Two surfaces of the same technique disagreed by up to a day
|
||||
and neither copied an engine field.
|
||||
|
||||
This module dates the sequence the engine returned, once, with the rule the
|
||||
report already used:
|
||||
|
||||
* a mahadasha ends on the calendar anniversary of the birth date, so a 2-year
|
||||
period that starts on 1990-06-15 ends on 1992-06-15 (29 February falls back to
|
||||
the 28th);
|
||||
* its twelve antardashas are an equal split of that span in exact seconds, the
|
||||
last one pinned to the mahadasha end;
|
||||
* every interval is ``[start, end)``; a 0-year period keeps its row with
|
||||
``start == end`` and no occupied time;
|
||||
* dates are local calendar days; ``start_ts`` / ``end_ts`` are the same boundaries
|
||||
in UTC epoch seconds when the birth zone is known, so a boundary can be
|
||||
compared exactly without printing a clock time.
|
||||
|
||||
The chart's ``/api/dasha/chara`` and the report's ``chara`` section both read
|
||||
``date_sequence``; no consumer does this arithmetic itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time as clock_time, timedelta, timezone
|
||||
from typing import Any, Iterable
|
||||
|
||||
YEAR_BASIS = "calendar_year_anniversary"
|
||||
INTERVAL = "[start, end)"
|
||||
|
||||
|
||||
def add_years(day: date, years: int) -> date:
|
||||
"""The anniversary rule: the same month and day, ``years`` later."""
|
||||
try:
|
||||
return day.replace(year=day.year + int(years))
|
||||
except ValueError: # 29 February in a common year
|
||||
return day.replace(year=day.year + int(years), day=28)
|
||||
|
||||
|
||||
def _midnight(day: date) -> datetime:
|
||||
return datetime.combine(day, clock_time.min)
|
||||
|
||||
|
||||
def _epoch_seconds(moment: datetime, tz_hours: float | None) -> int | None:
|
||||
if tz_hours is None:
|
||||
return None
|
||||
return int(round((moment - timedelta(hours=float(tz_hours))).replace(tzinfo=timezone.utc).timestamp()))
|
||||
|
||||
|
||||
def _boundaries(start: datetime, end: datetime, tz_hours: float | None) -> dict[str, Any]:
|
||||
return {
|
||||
"start": start.date().isoformat(),
|
||||
"end": end.date().isoformat(),
|
||||
"start_ts": _epoch_seconds(start, tz_hours),
|
||||
"end_ts": _epoch_seconds(end, tz_hours),
|
||||
}
|
||||
|
||||
|
||||
def split_equal(start: datetime, end: datetime, count: int) -> list[tuple[datetime, datetime]]:
|
||||
"""``count`` equal slices of ``[start, end)``; the last one ends exactly at ``end``."""
|
||||
total = (end - start).total_seconds()
|
||||
if count <= 0 or total <= 0:
|
||||
return []
|
||||
rows = []
|
||||
for index in range(count):
|
||||
child_start = start + timedelta(seconds=total * index / count)
|
||||
child_end = end if index == count - 1 else start + timedelta(seconds=total * (index + 1) / count)
|
||||
rows.append((child_start, child_end))
|
||||
return rows
|
||||
|
||||
|
||||
def date_sequence(
|
||||
sequence: Iterable[Any],
|
||||
birth: date,
|
||||
*,
|
||||
tz_hours: float | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""The engine's Chara rows with ``start`` / ``end`` (local days) and their exact instants."""
|
||||
cursor = _midnight(birth)
|
||||
out: list[dict[str, Any]] = []
|
||||
for entry in sequence:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
years = int(entry.get("duration_years") or 0)
|
||||
end = _midnight(add_years(cursor.date(), years))
|
||||
antardashas = []
|
||||
raw_antardashas = entry.get("antardashas")
|
||||
children_raw = [item for item in raw_antardashas if isinstance(item, dict)] if isinstance(raw_antardashas, list) else []
|
||||
slices = split_equal(cursor, end, len(children_raw))
|
||||
if not slices:
|
||||
# A 0-year mahadasha occupies no time; its rows stay, each with start == end (never current).
|
||||
slices = [(cursor, cursor) for _ in children_raw]
|
||||
for child, (child_start, child_end) in zip(children_raw, slices):
|
||||
if not isinstance(child, dict):
|
||||
continue
|
||||
antardashas.append({
|
||||
"sign": child.get("sign"),
|
||||
"sign_index": child.get("sign_idx", child.get("sign_index")),
|
||||
"lord": child.get("lord"),
|
||||
"order": child.get("order"),
|
||||
**_boundaries(child_start, child_end, tz_hours),
|
||||
})
|
||||
out.append({
|
||||
"sign": entry.get("sign"),
|
||||
"sign_index": entry.get("sign_idx", entry.get("sign_index")),
|
||||
"lord": entry.get("lord"),
|
||||
"years": years,
|
||||
"zero_year": years == 0,
|
||||
**_boundaries(cursor, end, tz_hours),
|
||||
"antardashas": antardashas,
|
||||
})
|
||||
cursor = end
|
||||
return out
|
||||
|
||||
|
||||
def date_basis(tz_hours: float | None) -> dict[str, Any]:
|
||||
"""How to read the rows ``attach`` writes: one rule, one interval, one producer."""
|
||||
return {
|
||||
"year_basis": YEAR_BASIS,
|
||||
"interval": INTERVAL,
|
||||
"timezone_offset_hours": tz_hours,
|
||||
"source": "scripts/chart_dasha/chara_dates.py",
|
||||
"boundary": "start / end = local calendar days; start_ts / end_ts = UTC epoch seconds when the zone is known",
|
||||
}
|
||||
|
||||
|
||||
def birth_from_body(body: dict[str, Any] | None) -> date:
|
||||
"""The birth date the dasha sequence is dated from (the endpoint's own fields)."""
|
||||
raw = body or {}
|
||||
try:
|
||||
year = int(raw.get("year"))
|
||||
except (TypeError, ValueError):
|
||||
year = datetime.now().year
|
||||
try:
|
||||
month = int(raw.get("month") or 1)
|
||||
except (TypeError, ValueError):
|
||||
month = 1
|
||||
try:
|
||||
day = int(raw.get("day") or 1)
|
||||
except (TypeError, ValueError):
|
||||
day = 1
|
||||
try:
|
||||
return date(year, min(max(month, 1), 12), min(max(day, 1), 31))
|
||||
except ValueError:
|
||||
return date(year, 1, 1)
|
||||
|
||||
|
||||
def timezone_hours(body: dict[str, Any] | None) -> float | None:
|
||||
raw = body or {}
|
||||
value = raw.get("tz", raw.get("timezone"))
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
text = value.strip().upper().replace("UTC", "").replace("GMT", "")
|
||||
if not text:
|
||||
return None
|
||||
value = text
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def attach(chara: dict[str, Any], birth: date, *, tz_hours: float | None = None) -> dict[str, Any]:
|
||||
"""Write the engine dates onto the rows ``jaimini`` returned, in place."""
|
||||
dated = date_sequence(chara.get("dasha_sequence") or [], birth, tz_hours=tz_hours)
|
||||
rows = [row for row in chara.get("dasha_sequence") or [] if isinstance(row, dict)]
|
||||
for row, dates in zip(rows, dated):
|
||||
for key in ("start", "end", "start_ts", "end_ts"):
|
||||
row[key] = dates[key]
|
||||
for child, child_dates in zip(
|
||||
[item for item in row.get("antardashas") or [] if isinstance(item, dict)],
|
||||
dates["antardashas"],
|
||||
):
|
||||
for key in ("start", "end", "start_ts", "end_ts"):
|
||||
child[key] = child_dates[key]
|
||||
chara["date_basis"] = date_basis(tz_hours)
|
||||
return chara
|
||||
@@ -7841,6 +7841,12 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin):
|
||||
payload = dict(body or {})
|
||||
payload['mode'] = 'dasha'
|
||||
result = self._compute_jaimini(payload)
|
||||
chara = result.get('result', {}).get('chara_dasha') if isinstance(result.get('result'), dict) else None
|
||||
if isinstance(chara, dict):
|
||||
# BUG-1811: the dates the chart page shows are produced once, here, by the
|
||||
# native Chara dating rule -- the page copies them instead of cutting periods.
|
||||
from scripts.chart_dasha import chara_dates
|
||||
chara_dates.attach(chara, chara_dates.birth_from_body(body), tz_hours=chara_dates.timezone_hours(body))
|
||||
return {
|
||||
**result,
|
||||
'endpoint': 'chara_dasha',
|
||||
|
||||
@@ -23,6 +23,7 @@ try:
|
||||
)
|
||||
from jaimini import SIGNS, calc_chara_dasha_with_antardasha
|
||||
from narayana_dasha import SIGN_LORDS, calculate_narayana_period_years
|
||||
from chart_dasha.chara_dates import date_sequence as chara_date_sequence
|
||||
except ImportError: # pragma: no cover - package import
|
||||
from scripts.goel_short_strength import (
|
||||
CLASSICAL_PLANETS,
|
||||
@@ -34,6 +35,7 @@ except ImportError: # pragma: no cover - package import
|
||||
)
|
||||
from scripts.jaimini import SIGNS, calc_chara_dasha_with_antardasha
|
||||
from scripts.narayana_dasha import SIGN_LORDS, calculate_narayana_period_years
|
||||
from scripts.chart_dasha.chara_dates import date_sequence as chara_date_sequence
|
||||
|
||||
_MOVABLE = {0, 3, 6, 9}
|
||||
_FIXED = {1, 4, 7, 10}
|
||||
@@ -650,15 +652,21 @@ def _chara(birth: date, lagna: int, planet_lons: Mapping[str, float]) -> dict:
|
||||
birth.day,
|
||||
profile="kn_rao",
|
||||
)
|
||||
# BUG-1811: the dates come from one producer (chart_dasha.chara_dates), which
|
||||
# the chart page's /api/dasha/chara reads too; this section no longer dates rows.
|
||||
periods = []
|
||||
cursor = birth
|
||||
for row in calculated.get("dasha_sequence") or []:
|
||||
years = int(row["duration_years"])
|
||||
end = add_years(cursor, years)
|
||||
order = [int(child["sign_idx"]) for child in row.get("antardashas") or []]
|
||||
children = _equal_children(order, cursor, end)
|
||||
periods.append(_period(int(row["sign_idx"]), years, cursor, end, children))
|
||||
cursor = end
|
||||
for row in chara_date_sequence(calculated.get("dasha_sequence") or [], birth):
|
||||
periods.append({
|
||||
"sign": row["sign"],
|
||||
"sign_index": int(row["sign_index"]),
|
||||
"years": row["years"],
|
||||
"start": row["start"],
|
||||
"end": row["end"],
|
||||
"antardasha": [] if row["zero_year"] else [
|
||||
{"sign": child["sign"], "sign_index": int(child["sign_index"]), "start": child["start"], "end": child["end"]}
|
||||
for child in row["antardashas"]
|
||||
],
|
||||
})
|
||||
return _listed(profile, source_zh, source_en, periods, direction="ninth_house_even_footed")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""BUG-1811: every Chara Dasha date comes from one native producer.
|
||||
|
||||
占星师 2026-11-08 第五轮裁定 5:所有大运日期必须由原生大运引擎生成,显示层不得自己
|
||||
按年数比例重算。星盘页的 Vimshottari 轨已在 BUG-1752 改好;这一单修其余地方。
|
||||
|
||||
What was wrong: ``/api/dasha/chara`` returned the jaimini rows (durations only), so
|
||||
the chart page dated them itself (365.25-day years from the birth day) while the
|
||||
report's Chara section (``rashi_dashas_native._chara``) used calendar-year
|
||||
anniversaries. The two surfaces of the same technique differed by up to a day and
|
||||
neither read an engine field.
|
||||
|
||||
What this guards: ``scripts/chart_dasha/chara_dates.py`` dates the sequence once,
|
||||
both surfaces read it, every interval is ``[start, end)``, and the four boundary
|
||||
cases the task names are covered (cross-midnight, the period running at birth,
|
||||
a second round, a 0-year period). Chara itself runs one twelve-sign round, so the
|
||||
second-round case is exercised on the Vimshottari track that shares the page.
|
||||
Fictional charts only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
for path in (ROOT, ROOT / "scripts", ROOT / "tests"):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
from scripts.chart_dasha import birth_moment, chara_dates # noqa: E402
|
||||
|
||||
GOLDEN = ROOT / "frontend" / "tests" / "fixtures" / "chart-view-golden.json"
|
||||
ZERO_YEAR_GOLDEN = ROOT / "frontend" / "tests" / "fixtures" / "chara-zero-year-1985-08-03-beijing.golden.json"
|
||||
BIRTH = date(1990, 6, 15) # the fictional chart of the chart-view golden
|
||||
|
||||
|
||||
def _golden() -> dict:
|
||||
return json.loads(GOLDEN.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _chara_body(chart: dict, **overrides) -> dict:
|
||||
body = {
|
||||
"year": 1990, "month": 6, "day": 15, "hour": 12, "minute": 0, "second": 0,
|
||||
"lat": 39.9042, "lon": 116.4074, "tz": 8, "ayanamsa": "raman", "node_mode": "mean",
|
||||
"antardasha": True, "mode": "dasha",
|
||||
"planets": chart["planets"], "ascendant": chart["ascendant"],
|
||||
}
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def test_the_chart_endpoint_and_the_report_share_one_chara_timeline() -> None:
|
||||
"""The page's rows and the report's Chara section are the same dates, from one producer."""
|
||||
from test_api_server_security import _handler
|
||||
|
||||
golden = _golden()
|
||||
chart = golden["chart"]
|
||||
packet = _handler()._compute_chara_dasha(_chara_body(chart))
|
||||
section = packet["result"]["chara_dasha"]
|
||||
rows = section["dasha_sequence"]
|
||||
assert len(rows) == 12
|
||||
assert section["date_basis"]["interval"] == "[start, end)"
|
||||
assert section["date_basis"]["source"] == "scripts/chart_dasha/chara_dates.py"
|
||||
|
||||
from scripts.rashi_dashas_native import calculate_rashi_dashas
|
||||
|
||||
longitudes = {name: float(planet["lon"]) for name, planet in chart["planets"].items()}
|
||||
report = calculate_rashi_dashas(BIRTH, float(chart["ascendant"]["lon"]), longitudes)["chara"]["periods"]
|
||||
assert [(row["sign"], row["start"], row["end"]) for row in rows] == [
|
||||
(row["sign"], row["start"], row["end"]) for row in report
|
||||
]
|
||||
for row, reported in zip(rows, report):
|
||||
assert row["start"] <= row["end"]
|
||||
assert row["start_ts"] is not None and row["end_ts"] is not None
|
||||
assert [(child["start"], child["end"]) for child in row["antardashas"]] == [
|
||||
(child["start"], child["end"]) for child in reported["antardasha"]
|
||||
]
|
||||
# [start, end): every period ends where the next one starts, all the way through.
|
||||
for current, following in zip(rows, rows[1:]):
|
||||
assert current["end"] == following["start"]
|
||||
assert all(row["start"] for row in rows)
|
||||
|
||||
|
||||
def test_chara_days_are_local_days_across_midnight() -> None:
|
||||
"""A boundary before 08:00 local is still the local day on the birth clock, not the UTC day."""
|
||||
sequence = chara_dates.date_sequence(
|
||||
[
|
||||
{"sign": "Leo", "sign_idx": 4, "lord": "Sun", "duration_years": 12,
|
||||
"antardashas": [{"sign": "Virgo", "sign_idx": 5, "lord": "Mercury"}]},
|
||||
],
|
||||
date(1990, 6, 15),
|
||||
tz_hours=8.0,
|
||||
)
|
||||
row = sequence[0]
|
||||
assert row["start"] == "1990-06-15" # born at 23:50 on the 15th: the row still starts on the 15th
|
||||
crossing = 0
|
||||
for moment, seconds in ((row["start"], row["start_ts"]), (row["end"], row["end_ts"])):
|
||||
utc = datetime.fromtimestamp(seconds, tz=timezone.utc)
|
||||
local = utc + timedelta(hours=8)
|
||||
assert moment == local.strftime("%Y-%m-%d")
|
||||
crossing += utc.date() != local.date()
|
||||
child = row["antardashas"][0]
|
||||
assert child["start"] == row["start"] and child["end"] == row["end"]
|
||||
# Unknown zone: days stay, exact moments are absent rather than guessed.
|
||||
bare = chara_dates.date_sequence(
|
||||
[{"sign": "Leo", "sign_idx": 4, "lord": "Sun", "duration_years": 2, "antardashas": []}],
|
||||
date(1990, 6, 15),
|
||||
)
|
||||
assert bare[0]["start_ts"] is None and bare[0]["start"] == "1990-06-15"
|
||||
|
||||
|
||||
def test_the_period_running_at_birth_starts_on_the_birth_day() -> None:
|
||||
"""Chara has no balance period before birth: the first md starts at birth, not at a year boundary."""
|
||||
from test_api_server_security import _handler
|
||||
|
||||
chart = _golden()["chart"]
|
||||
for day in (1, 15, 28):
|
||||
rows = _handler()._compute_chara_dasha(_chara_body(chart, day=day))["result"]["chara_dasha"]["dasha_sequence"]
|
||||
first = rows[0]
|
||||
assert first["start"] == f"1990-06-{day:02d}"
|
||||
assert all(row["start"] >= first["start"] for row in rows)
|
||||
assert first["start"] <= first["end"]
|
||||
|
||||
|
||||
def test_a_zero_year_chara_period_has_no_time_and_keeps_its_row() -> None:
|
||||
"""[start, end): a 0-year sign period stays in the data with no span and no running time."""
|
||||
golden = json.loads(ZERO_YEAR_GOLDEN.read_text(encoding="utf-8"))
|
||||
rows = golden["chara"]["result"]["chara_dasha"]["dasha_sequence"]
|
||||
zero = [row for row in rows if row["duration_years"] == 0]
|
||||
assert len(zero) == 1
|
||||
sagittarius = zero[0]
|
||||
assert sagittarius["sign"] == "Sagittarius"
|
||||
assert sagittarius["start"] == sagittarius["end"]
|
||||
assert all(child["start"] == child["end"] == sagittarius["start"] for child in sagittarius["antardashas"])
|
||||
index = rows.index(sagittarius)
|
||||
assert rows[index - 1]["end"] == sagittarius["start"] == rows[index + 1]["start"]
|
||||
|
||||
|
||||
def test_the_second_vimshottari_round_is_still_engine_dated() -> None:
|
||||
"""The page's other track covers the second round; Chara itself runs one twelve-sign round."""
|
||||
moon = 318.6461748848373 # the golden's fictional Moon
|
||||
through = datetime(1990, 6, 15, 12, 0) + timedelta(days=365.25 * 110)
|
||||
timeline = birth_moment.sub_period_timeline(datetime(1990, 6, 15, 12, 0), moon, through=through, tz_hours=8.0)
|
||||
assert len(timeline) > 9
|
||||
second = timeline[9]
|
||||
assert second["cycle"] == 2
|
||||
assert second["start"] == timeline[8]["end"]
|
||||
assert second["antardashas"][0]["start"] == second["start"]
|
||||
assert all(row["end"] == timeline[index + 1]["start"] for index, row in enumerate(timeline[:-1]))
|
||||
# Chara stops after its twelfth sign: nothing is invented past the round.
|
||||
rows = chara_dates.date_sequence(
|
||||
[{"sign": "Leo", "sign_idx": 4, "lord": "Sun", "duration_years": years, "antardashas": []}
|
||||
for years in (12, 12, 12)],
|
||||
date(1990, 6, 15),
|
||||
)
|
||||
assert len(rows) == 3 and rows[-1]["end"] == "2026-06-15"
|
||||
|
||||
|
||||
def test_chara_dates_do_not_depend_on_the_display_clock() -> None:
|
||||
"""Same birth day, different clock: the days stay the engine's (it dates from the birth date)."""
|
||||
sequence = [
|
||||
{"sign": "Leo", "sign_idx": 4, "lord": "Sun", "duration_years": 2,
|
||||
"antardashas": [{"sign": "Virgo", "sign_idx": 5, "lord": "Mercury"}, {"sign": "Libra", "sign_idx": 6, "lord": "Venus"}]},
|
||||
]
|
||||
midnight = chara_dates.date_sequence(sequence, date(1990, 6, 15))
|
||||
late = chara_dates.date_sequence(sequence, date(1990, 6, 15))
|
||||
assert [(row["start"], row["end"]) for row in midnight] == [(row["start"], row["end"]) for row in late]
|
||||
assert [child["start"] for child in midnight[0]["antardashas"]] == ["1990-06-15", "1991-06-15"]
|
||||
assert midnight[0]["antardashas"][-1]["end"] == "1992-06-15"
|
||||
Reference in New Issue
Block a user