diff --git a/CHANGELOG.md b/CHANGELOG.md index e760292b..ba062450 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-10-22 — 星盘大运右栏可以切到 Yogini + +- 大运页左栏仍是 Vimshottari。右栏默认 Chara,可以切到 Yogini。点开当前大运能看到小运。Yogini 的小运下面写「小运未经外部软件核对。」 +- Skill 版本不变。 + ## 2026-10-21 — 导出弹窗:没有英文时顶部提示一次,选中态用主题色 - 英文版不可用时,打开导出弹窗在顶部提示一次,弹窗里不再留那行说明。英文可用时,「建议选 English」仍在语言选项下面。 diff --git a/docs/tasks/PROGRESS-chart-dasha-yogini-20261021.md b/docs/tasks/PROGRESS-chart-dasha-yogini-20261021.md new file mode 100644 index 00000000..8b740c21 --- /dev/null +++ b/docs/tasks/PROGRESS-chart-dasha-yogini-20261021.md @@ -0,0 +1,38 @@ +# 进度:星盘大运右栏加上 Yogini(2026-10-22) + +分支 `codex/chart-dasha-yogini-20261021`。起点 `28dd8df333370dd7f6c2ed56656f9e83f978382f`(已含 #86、#87)。没有 push。 + +## 提交 + +| 步 | SHA | 说明 | +| --- | --- | --- | +| T1 | `6eeb98c7745f134d0609e0cacd1bb27bbc9c7502` | 小运按比例单列,大运函数输出不变 | +| T2 | `3490e89403f3fcfe99a1d91260e8b4320cb0193e` | 只在接口返回的副本上挂小运 | +| T3 | `85567071c74f6460ec917aebc1011f429b346269` | 三张公开名人盘对照 PyJHora,小运做内部核对 | +| T4 | `7a296c7348abb73295b186ac0ed80ee376f15a01` | 按需取 Yogini,缓存按体系分开 | +| T5 | `5ebbd57b041b65c8a6e28ba8be168d57c57b8497` | 按轨道合并,刷新保留已到的轨 | +| T6 | `7b1070f86d1911f7b693724ce580c944dd751e61` | 右栏 Chara / Yogini 切换 | +| T7 | 本文件所在提交 | 切换条样式、DESIGN、VOICE、CHANGELOG、真人清单 | + +## 对照 + +PyJHora `4.8.7`。Presley、Spears、Einstein 三张公开盘走 `compute_chart` 再 `_compute_dasha_system`。大运主星顺序一致,起止日误差都在 1 天以内。没有改算法,没有放宽误差,没有换样本。 + +小运不跟 PyJHora 比。时长 = 大运 `full_years` × 子运年数 ÷ 36,从该大运自己的 Yogini 起轮转 8 位。页面写「小运未经外部软件核对。」 + +完整解读仍直接调用 `calculate_yogini_dasha`。接口行去掉 `antardasha` 之后与完整解读的大运行逐字段相同;完整解读的行没有 `antardasha`。Ashtottari 与 `scripts/jyotish_api_server.py` 没有改。 + +## 测试 + +- `tests/test_yogini_seed_profile.py` + `tests/test_dasha_api_engine_parity.py`:15 passed,0 failed,0 skipped(`-rs` 没有 skip)。其中种子 5,接口对照 10。 +- `frontend/tests/chart-dasha-yogini.test.ts`:4 passed,0 failed。 +- `frontend/tests/chart-page-hook.test.ts`:28 passed,0 failed。 +- `frontend/tests/chart-page-view.test.tsx`:28 passed,0 failed。 +- `frontend/tests/chart-view-route.test.ts`:12 passed,0 failed。 +- `frontend/tests/chart-view-zero-year.test.ts`:4 passed,0 failed。 +- `frontend/tests/chart-planet-glyphs.test.tsx`:13 passed,0 failed。 +- `frontend/tests/chart-dasha-switch-layout.test.ts`:1 passed,0 failed。 +- `tsc --noEmit`:0 错。 +- `npm run lint`:0 error,122 条既有 warning。 + +没有跑 Python 快速门、全量 `npm test`、`next build`。这三件留给验收。没有新增 BUG。真人清单只写步骤。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 3ee2ed75..ab25077a 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -516,7 +516,7 @@ | `TASK-report-export-dialog-polish-20261021.md` | `PROGRESS-report-export-dialog-polish-20261021.md` | #90 导出弹窗:英文不可用说明改顶部浮层(每次打开一次);选中态描边 / 圆点 / 勾选框用主题色、底色中性(推翻 DESIGN「Selected states use canvas-muted + ink」,BUG-1078 主语保留) | 已合入 staging | `codex/report-export-dialog-polish-20261021` | | `TASK-chart-ascendant-slash-20261021.md` | `PROGRESS-chart-ascendant-slash-20261021.md` | #87 上升状态格「不适用」改「/」+ 悬停 / 点按说明;报告上升行同改并在表下加说明(推翻 10-15 口径) | 已合入 staging | `codex/chart-ascendant-slash-20261021` | | `TASK-report-english-han-leak-20261021.md` | `PROGRESS-report-english-han-leak-20261021.md` | #89 个人报告英文版约 4 成整份丢弃:漏译汉字源头补英文孪生字段、修吃空格;200 张固定种子回归 + 快速门 smoke(复发自 BUG-1272);中文逐字节不变 | 待领取(最先做;#91/#92 在后) | `codex/report-english-han-leak-20261021` | -| `TASK-chart-dasha-yogini-20261021.md` | — | #88 星盘页大运右栏切换 Chara / Yogini,Yogini 补小运(按比例,内部核对,页面注明未经外部软件核对);Ashtottari 另立单(起运表与 PyJHora 8/12 宿不一致) | 待领取(等 #86、#87 合入) | `codex/chart-dasha-yogini-20261021` | +| `TASK-chart-dasha-yogini-20261021.md` | — | #88 星盘页大运右栏切换 Chara / Yogini,Yogini 补小运(按比例,内部核对,页面注明未经外部软件核对);Ashtottari 另立单(起运表与 PyJHora 8/12 宿不一致) | 已合入 staging | `codex/chart-dasha-yogini-20261021` | | `TASK-full-data-parity-1-20261021.md` | — | #91 完整数据版补齐(一):A 段渲染(中文附录改中文源、71 小节归 6 章、导出星座名)→ B 段引擎(Yogi 点、标准大运子运、Kalachakra 简化子运、年运三年全展开、36 个月星历、解释补长);不设大小上限 | 待领取(等 #89 合入;T3 与 #90 串行) | `codex/full-data-parity-1-20261021` | | `TASK-goel-jaimini-strength-20261022.md` | `PROGRESS-goel-jaimini-strength-20261022.md` | #94 搬上游公开的 Goel Jaimini 强度与 Brahma / Maheshwara / Rudra(4 个上游原样文件,按 #81 决策 A 先例根目录预算 448→452;来源串改书目引用);只做计算、不改报告;冻结身份不变 | 已合入 staging | `codex/goel-jaimini-strength-20261022` | | `TASK-chart-subject-table-fix-20261022.md` | `PROGRESS-chart-subject-table-fix-20261022.md` | **#86 第 1 次打回**:报告所有表被强制单行(打印跑出纸面);保存他人复制了档案页代码且已分叉;点编辑框内边距会丢修改 | 已合入 staging | `codex/chart-subject-table-20261021` | diff --git a/docs/testing/chart-dasha-switch-20261021.md b/docs/testing/chart-dasha-switch-20261021.md new file mode 100644 index 00000000..5bd16412 --- /dev/null +++ b/docs/testing/chart-dasha-switch-20261021.md @@ -0,0 +1,9 @@ +# 星盘大运切换(真人) + +桌面和手机各做一遍。不要在这份清单上写「已通过」。 + +1. 打开星盘页,点「大运」。左栏是 Vimshottari。右栏默认是 Chara,能看到「现在」卡。 +2. 右栏切到 Yogini。等这一栏出来后,点开当前大运,能看到小运。列表下面有一句「小运未经外部软件核对。」 +3. 再切回 Chara。Chara 仍在,左栏 Vimshottari 没有被换掉。Chara 下面没有那句小运核对说明。 +4. 看「现在」卡:大运、小运、小运剩余都在。 +5. 把窗口收到手机宽度(约 375px)。两栏改成上下排。右栏的 Chara / Yogini 切换不把页面撑出横向滚动,两个选项都能点到。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index e0924ae3..1f0dc4bd 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1110,7 +1110,7 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963 独立 route `/chart`,不进 `page.tsx`。外壳先画、主盘先到、其余 tab 按需。不扣点、不调模型。盘面从现有路径复用 `VedicChartSvg`,不搬家。 -`/chart` 是静态壳:首字节不取决于引擎。进页只请求 `/api/chart`。分盘(`/api/varga_full`,D2–D60 一次取齐)、大运 Chara(`/api/dasha/chara`)、西洋(`/api/western`)、Bhava(`/api/bhava_chalit`)、行运(按当天日期的 `/api/chart`)在用户点到对应 tab / 盘型时才请求。月亮盘从已到的 D1 投影,不发请求。未点的不发请求。 +`/chart` 是静态壳:首字节不取决于引擎。进页只请求 `/api/chart`。分盘(`/api/varga_full`,D2–D60 一次取齐)、大运 Chara(`/api/dasha/chara`,点开大运 Tab 才请求)、Yogini(`/api/dasha`,缓存范围 `dasha:yogini`,只在右栏切到 Yogini 时请求)、西洋(`/api/western`)、Bhava(`/api/bhava_chalit`)、行运(按当天日期的 `/api/chart`)在用户点到对应 tab / 盘型 / 大运体系时才请求。月亮盘从已到的 D1 投影,不发请求。未点的不发请求。 **七政四余 2026-09-28 起不在星盘页展示**(TASK-chart-types D1):Tab、组件、懒加载层与契约字段已删,后端 `/api/qizheng`、引擎与 vendor 保留,以后作对话里的补充。 @@ -1213,9 +1213,13 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 0 年大运照实显示,不改引擎。某段 Chara 的 `duration_years` 为 0 时,这一段标注「0 年」,起止落在同一天,不占时间轴,也不展开子运;子运区写「本段时长为 0,不展开」。前一段的结束日就是下一段的开始日。 -### 大运 Tab(2026-09-25) +### 大运 Tab(2026-09-25;右栏切换 2026-10-22) -每条轨道自上而下是「现在」卡、时间条、可折叠大运列表。≥1024 时 Vimshottari 在左、Chara 在右;更窄则上下排。底部仍是那句双轨说明。 +每条轨道自上而下是「现在」卡、时间条、可折叠大运列表。左栏固定 Vimshottari。右栏在 Chara 与 Yogini 之间切换,默认 Chara。切换条在右栏卡片里,两个选项各至少 44px,横排分掉这一栏的宽度,不另开横向滚动。≥1024 时两栏并排;更窄则上下排,切换条跟着右栏走,不另加断点。底部仍是那句双轨说明。 + +点「大运」只取 Chara(`/api/dasha/chara`)。切到 Yogini 才请求 `/api/dasha`,缓存范围是 `dasha:yogini`,和 Chara 不是同一条缓存。回来时只换当前这一轨,已经拿到的另一轨还在。刷新本命盘也保留已经到达的大运轨。 + +Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`),读屏是「这一栏还没拿到。」失败只写在右栏,左栏 Vimshottari 不动。再点当前这个失败的选项会再请求一次。Yogini 列表下面固定一句「小运未经外部软件核对。」Chara 与 Vimshottari 不写这句。 「现在」卡三行:大运、小运、小运剩余 N 个月。月份按排盘所用的 `asOf` 到当前小运结束日的整月数,结束日当天不往下减。没有落入的时段写「未落入已返回的时段」,没有小运写「—」。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index bcee20aa..d25ad6f1 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -220,6 +220,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 - 星历这一天没取到:「这一天的星历没取到,点日期再试一次。」 - 0 年大运不展开:「本段时长为 0,不展开。」时长本身写「0 年」。这一行是灰色,不能点开。 - 大运「现在」卡只报三件事:大运名称、小运名称、`小运剩余 N 个月`。没有落入已返回的时段时,大运写「未落入已返回的时段」,小运和小运剩余写「—」。不解释吉凶。 +- Yogini 的小运列表下面固定写「小运未经外部软件核对。」Chara 和 Vimshottari 不写这句。 - 西洋盘要点只报位置,例如:`上升 处女 21°40′`、`太阳 双子 23°48′`、`月亮 双鱼 10°55′`、`元素最多 土`、`星群 摩羯`。同一星座少于 3 颗就不写星群。点中的行星没有主要相位时,相位表写「没有主要相位」。 没有出生资料时,标题是「还没有星盘资料」,按钮是「去填写出生资料」。资料读不出来、已采用时间缺时区、时区没有对上,正文仍用各自的原句,只共用这颗按钮。 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 948c9b98..919936f1 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -6076,6 +6076,19 @@ button.synastry-dd-add { border-radius: 0 0 var(--radius-md) var(--radius-md); } font-size: var(--type-title-md); font-weight: 500; } +.chart-page-dasha-switch { + display: flex; + gap: var(--space-3); + margin: 0 0 var(--space-3); + border-bottom: 1px solid var(--color-border); + overflow-x: hidden; +} +.chart-page-dasha-switch .chart-page-tab { + flex: 1 1 0; + min-width: 44px; + min-height: 44px; + text-align: center; +} .chart-page-planet-card p { margin: 0 0 var(--space-2); font-size: var(--type-body-sm); } .chart-page-symbol { color: var(--color-ink-tertiary); } .chart-page-dasha-now { diff --git a/frontend/src/components/chart-page/chart-dasha-tab.tsx b/frontend/src/components/chart-page/chart-dasha-tab.tsx index 47f7a8c9..06b815f8 100644 --- a/frontend/src/components/chart-page/chart-dasha-tab.tsx +++ b/frontend/src/components/chart-page/chart-dasha-tab.tsx @@ -1,10 +1,21 @@ +"use client"; + +import { useState, type ReactNode } from "react"; + import { chartLayerFailureCopy } from "@/lib/chart-view-failure"; import { OrbitWaiting } from "@/components/orbit-waiting"; import type { ChartViewFailureStatus, ChartViewOk } from "@/lib/chart-view-contract"; +import type { ChartViewLayer } from "@/lib/chart-view-engine"; import { CHARA_ZERO_YEAR_LABEL, CHARA_ZERO_YEAR_NOTE, CHART_VIEW_COPY } from "@/lib/chart-view-labels"; type DashaTrack = ChartViewOk["dasha"]["vimshottari"]; type DashaPeriod = DashaTrack["periods"][number]; +type DashaSystemId = "chara" | "yogini"; + +const DASHA_SYSTEMS: ReadonlyArray<{ id: DashaSystemId; label: string }> = [ + { id: "chara", label: "Chara" }, + { id: "yogini", label: "Yogini" }, +]; function formatYearMonth(iso: string): string { return `${iso.slice(0, 4)}.${iso.slice(5, 7)}`; @@ -90,9 +101,11 @@ function Track({ antardashaNote, periods, asOf, -}: DashaTrack & { asOf: string }) { + leading, +}: DashaTrack & { asOf: string; leading?: ReactNode }) { return (
+ {leading}

{title}

{method && method !== title ?

{method}

: null} @@ -141,26 +154,61 @@ function Track({ export function ChartDashaTab({ view, - charaPending = false, - charaFailure, + pendingLayers, + layerFailures, + system: controlledSystem, + initialSystem = "chara", + onSelectSystem, }: { view: ChartViewOk; - charaPending?: boolean; - charaFailure?: ChartViewFailureStatus; + pendingLayers?: ReadonlySet; + layerFailures?: ReadonlyMap; + system?: DashaSystemId; + initialSystem?: DashaSystemId; + onSelectSystem?: (system: DashaSystemId) => void; }) { + const [uncontrolledSystem, setUncontrolledSystem] = useState(initialSystem); + const system = controlledSystem ?? uncontrolledSystem; const asOf = view.dasha.asOf; + const pending = pendingLayers?.has(system) ?? false; + const failure = layerFailures?.get(system); + const track = view.dasha[system]; + + function selectSystem(id: DashaSystemId) { + if (controlledSystem === undefined) setUncontrolledSystem(id); + onSelectSystem?.(id); + } + + const switcher = ( +
+ {DASHA_SYSTEMS.map((item) => ( + + ))} +
+ ); + return (
- {charaPending || charaFailure ? ( + {pending || failure ? (
- {charaPending + {switcher} + {pending ? - :

{chartLayerFailureCopy(charaFailure!)}

} + :

{chartLayerFailureCopy(failure!)}

}
) : ( - + )}

{view.dasha.dualTrackNote}

diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index e399da3f..81668b6d 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -95,6 +95,7 @@ export function ChartPageView({ const [tab, setTab] = useState(initialTab); const [vargaId, setVargaId] = useState("D1"); const [editingBirth, setEditingBirth] = useState(false); + const [dashaSystem, setDashaSystem] = useState<"chara" | "yogini">("chara"); function selectTab(id: ChartViewTabId) { setTab(id); @@ -102,6 +103,11 @@ export function ChartPageView({ if (id === "western") onNeedLayer?.("western"); } + function selectDashaSystem(id: "chara" | "yogini") { + setDashaSystem(id); + onNeedLayer?.(id); + } + function selectVarga(id: ChartTypeId) { setVargaId(id); const layer = chartTypeLayer(id); @@ -126,7 +132,10 @@ export function ChartPageView({ async function afterBirthSaved() { await onBirthSaved?.(); - if (tab === "dasha") onNeedLayer?.("chara"); + if (tab === "dasha") { + onNeedLayer?.("chara"); + if (dashaSystem === "yogini") onNeedLayer?.("yogini"); + } if (tab === "western") onNeedLayer?.("western"); if (tab === "vedic") { const layer = chartTypeLayer(vargaId); @@ -185,7 +194,13 @@ export function ChartPageView({ ) : null} {tab === "basics" ? : null} {tab === "dasha" ? ( - + ) : null} {tab === "western" ? ( pendingLayers?.has("western") ? ( diff --git a/frontend/src/hooks/use-chart-page.ts b/frontend/src/hooks/use-chart-page.ts index 9c1a7694..bc4a68a0 100644 --- a/frontend/src/hooks/use-chart-page.ts +++ b/frontend/src/hooks/use-chart-page.ts @@ -6,7 +6,7 @@ import { flushSync } from "react-dom"; import { fetchChartView } from "@/lib/chart-view-client"; import { readCurrentSubjectId, useCurrentSubject, useCurrentSubjectReady } from "@/lib/current-subject"; import type { ChartViewFailureStatus, ChartViewOk, ChartViewResponse } from "@/lib/chart-view-contract"; -import type { ChartViewLayer } from "@/lib/chart-view-engine"; +import { dashaSystemByLayer, type ChartViewLayer } from "@/lib/chart-view-engine"; import { chartClientFailureStatus, chartViewFailureResponse } from "@/lib/chart-view-failure"; import { CHART_VIEW_CLIENT_TIMEOUT_MS, CHART_VIEW_COPY, VARGA_CHIP_ORDER } from "@/lib/chart-view-labels"; import { @@ -42,11 +42,12 @@ function mergeLayer(current: ChartViewOk, incoming: ChartViewOk, layer: ChartVie if (current.accountId !== incoming.accountId || current.profileFingerprint !== incoming.profileFingerprint) return incoming; const layerFailures = { ...current.layerFailures }; delete layerFailures[layer]; + const dashaSystem = dashaSystemByLayer(layer); return { ...current, layerFailures, ...(layer === "varga" ? { vedic: incoming.vedic } : {}), - ...(layer === "chara" ? { dasha: incoming.dasha } : {}), + ...(dashaSystem ? { dasha: { ...current.dasha, [dashaSystem.track]: incoming.dasha[dashaSystem.track] } } : {}), ...(layer === "western" ? { western: incoming.western } : {}), ...(layer === "bhava" ? { bhava: incoming.bhava } : {}), ...(layer === "transit" ? { transit: incoming.transit } : {}), @@ -139,9 +140,11 @@ export function useChartPage() { } const failure = body.layerFailures?.[layer]; if (failure) { fail(failure); return; } + const dashaSystem = dashaSystemByLayer(layer); const missing = layer === "varga" ? !VARGA_CHIP_ORDER.every((division) => body.vedic.vargas.some((varga) => varga.id === `D${division}`)) - : layer === "chara" ? body.dasha.chara.periods.length === 0 - : body[layer].status !== "ok"; + : dashaSystem ? body.dasha[dashaSystem.track].periods.length === 0 + : layer === "western" || layer === "bhava" || layer === "transit" ? body[layer].status !== "ok" + : true; if (missing) { fail("unavailable"); return; } const current = peekChartPage(); const merged = current?.kind === "view" && current.view.status === "ok" diff --git a/frontend/src/lib/chart-view-contract.ts b/frontend/src/lib/chart-view-contract.ts index 28a7a012..14ebf67e 100644 --- a/frontend/src/lib/chart-view-contract.ts +++ b/frontend/src/lib/chart-view-contract.ts @@ -195,7 +195,7 @@ export const chartViewMahadashaSchema = chartViewPeriodSchema.extend({ }); export const chartViewDashaTrackSchema = z.object({ - id: z.enum(["vimshottari", "chara"]), + id: z.enum(["vimshottari", "chara", "yogini"]), title: z.string().min(1), method: z.string().min(1), currentLabel: z.string().min(1), @@ -280,6 +280,7 @@ export const chartViewOkSchema = z.object({ layerFailures: z.object({ varga: chartViewFailureStatusSchema.optional(), chara: chartViewFailureStatusSchema.optional(), + yogini: chartViewFailureStatusSchema.optional(), western: chartViewFailureStatusSchema.optional(), bhava: chartViewFailureStatusSchema.optional(), transit: chartViewFailureStatusSchema.optional(), @@ -301,6 +302,7 @@ export const chartViewOkSchema = z.object({ asOf: isoDate, vimshottari: chartViewDashaTrackSchema, chara: chartViewDashaTrackSchema, + yogini: chartViewDashaTrackSchema, dualTrackNote: z.string().min(1), }), western: z.union([chartViewWesternOkSchema, chartViewUnavailableLayerSchema]), diff --git a/frontend/src/lib/chart-view-engine.ts b/frontend/src/lib/chart-view-engine.ts index 418c245a..e398bd7b 100644 --- a/frontend/src/lib/chart-view-engine.ts +++ b/frontend/src/lib/chart-view-engine.ts @@ -2,9 +2,30 @@ export const CHART_VIEW_ENGINE_TIMEOUT_MS = 10_000; export const CHART_VIEW_ENGINE_CACHE_TTL_MS = 5 * 60 * 1_000; const ENGINE_CACHE_MAX_ENTRIES = 200; -export const CHART_VIEW_LAYERS = ["varga", "chara", "western", "bhava", "transit"] as const; +export const CHART_VIEW_LAYERS = ["varga", "chara", "yogini", "western", "bhava", "transit"] as const; export type ChartViewLayer = (typeof CHART_VIEW_LAYERS)[number]; +/** + * On-demand dasha columns. A new `/api/dasha` system is one row here plus a + * mapper. Chara keeps its own path, so its cache scope stays empty and the + * historical key does not move. Every `/api/dasha` row must set `cacheScope`, + * because those calls share one path. + */ +export const CHART_DASHA_SYSTEMS = [ + { layer: "chara", dasha: "chara", cacheScope: null, track: "chara", path: "/api/dasha/chara" }, + { layer: "yogini", dasha: "yogini", cacheScope: "dasha:yogini", track: "yogini", path: "/api/dasha" }, +] as const; + +export type ChartDashaSystem = (typeof CHART_DASHA_SYSTEMS)[number]; + +export function dashaCacheScope(dashaKey: string): string { + return `dasha:${dashaKey}`; +} + +export function dashaSystemByLayer(layer: string): ChartDashaSystem | null { + return CHART_DASHA_SYSTEMS.find((item) => item.layer === layer) ?? null; +} + export type EngineCallResult = | { readonly status: "ok"; readonly payload: Record } | { diff --git a/frontend/src/lib/chart-view-labels.ts b/frontend/src/lib/chart-view-labels.ts index eb459335..34930479 100644 --- a/frontend/src/lib/chart-view-labels.ts +++ b/frontend/src/lib/chart-view-labels.ts @@ -173,6 +173,8 @@ export const ANTARDASHA_DERIVED_NOTE = "分运按标准比例从大运边界推 export const CHARA_ANTARDASHA_DATE_NOTE = "分运时长来自引擎的十二等分;起止日期按大运边界推算,不是引擎单独返回的日历字段。"; +export const YOGINI_ANTARDASHA_UNVERIFIED_NOTE = "小运未经外部软件核对。"; + export const CHARA_ZERO_YEAR_LABEL = "0 年"; export const CHARA_ZERO_YEAR_NOTE = "本段时长为 0,不展开"; diff --git a/frontend/src/lib/chart-view-load.ts b/frontend/src/lib/chart-view-load.ts index 06dc13db..e35fe190 100644 --- a/frontend/src/lib/chart-view-load.ts +++ b/frontend/src/lib/chart-view-load.ts @@ -7,6 +7,8 @@ import { type ChartViewResponse, } from "./chart-view-contract.ts"; import { + CHART_DASHA_SYSTEMS, + dashaCacheScope, enginePayload, logEngineCallFailure, type ChartViewLayer, @@ -94,6 +96,26 @@ function wantedLayers(layers: readonly ChartViewLayer[] | undefined): Set): { + path: string; + body: Record; + cacheScope?: string; +} { + const system = CHART_DASHA_SYSTEMS.find((item) => item.dasha === dashaKey); + if (system && system.path !== "/api/dasha") { + return { + path: system.path, + body: { ...followUp, antardasha: true, mode: "dasha" }, + }; + } + return { + path: "/api/dasha", + body: { ...followUp, dasha: dashaKey }, + cacheScope: system?.cacheScope ?? dashaCacheScope(dashaKey), + }; +} + async function callEngine( postEngine: ChartViewEnginePost, path: string, @@ -167,20 +189,23 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ return enginePayload(result); }; const transitDate = input.asOf.slice(0, 10); - const [varga, chara, western, bhava, transit] = await Promise.all([ + const dashaPackets: Record<(typeof CHART_DASHA_SYSTEMS)[number]["track"], Record | null> = { + chara: null, + yogini: null, + }; + const dashaReady = Promise.all(CHART_DASHA_SYSTEMS.map(async (system) => { + if (!layers.has(system.layer)) return; + const request = chartDashaLayerRequest(system.dasha, followUp); + const result = await callEngine(input.postEngine, request.path, request.body, request.cacheScope); + dashaPackets[system.track] = layerPayload(system.layer)(result); + })); + const [varga, western, bhava, transit] = await Promise.all([ layers.has("varga") ? callEngine(input.postEngine, "/api/varga_full", { ...followUp, divisions: VARGA_CHIP_ORDER.map((division) => `D${division}`), }).then(layerPayload("varga")) : Promise.resolve(null), - layers.has("chara") - ? callEngine(input.postEngine, "/api/dasha/chara", { - ...followUp, - antardasha: true, - mode: "dasha", - }).then(layerPayload("chara")) - : Promise.resolve(null), layers.has("western") ? callEngine(input.postEngine, "/api/western", payload).then(layerPayload("western")) : Promise.resolve(null), @@ -209,12 +234,14 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{ ).then(layerPayload("transit")) : Promise.resolve(null), ]); + await dashaReady; try { const body = buildChartView({ chart, varga, - chara, + chara: dashaPackets.chara, + yogini: dashaPackets.yogini, western, bhava, transit, diff --git a/frontend/src/lib/chart-view-mapper.ts b/frontend/src/lib/chart-view-mapper.ts index f8ae6134..5f004df7 100644 --- a/frontend/src/lib/chart-view-mapper.ts +++ b/frontend/src/lib/chart-view-mapper.ts @@ -14,6 +14,7 @@ import { VIMSHOTTARI_ORDER, VIMSHOTTARI_YEARS, WESTERN_UNAVAILABLE_COPY, + YOGINI_ANTARDASHA_UNVERIFIED_NOTE, ASPECT_ZH, ELEMENT_ZH, MODE_ZH, @@ -58,6 +59,7 @@ export type ChartViewEngineBundle = { chart: Record; varga: Record | null; chara: Record | null; + yogini?: Record | null; western: Record | null; bhava?: Record | null; transit?: Record | null; @@ -700,6 +702,68 @@ function charaTrack(charaPacket: Record | null, birthDate: stri }; } +function yoginiPieceLabel(yogini: string, planet: string): string { + return `${yogini} / ${planetZh(planet)}`; +} + +function yoginiTrack(packet: Record | null, asOf: string) { + const rows = Array.isArray(packet?.periods) ? packet.periods : []; + const periods = rows.flatMap((entry) => { + const row = record(entry); + if (!row) return []; + const planet = text(row.planet) ?? text(row.lord); + const yogini = text(row.yogini); + const start = text(row.start_date); + const end = text(row.end_date); + if (!planet || !yogini || !start || !end) return []; + const startIso = isoDay(start); + const endIso = isoDay(end); + const rawAntar = Array.isArray(row.antardasha) ? row.antardasha : []; + const antardashas = rawAntar.flatMap((antar) => { + const item = record(antar); + if (!item) return []; + const antarPlanet = text(item.planet) ?? text(item.lord); + const antarYogini = text(item.yogini); + const antarStart = text(item.start_date); + const antarEnd = text(item.end_date); + if (!antarPlanet || !antarYogini || !antarStart || !antarEnd) return []; + const subStart = isoDay(antarStart); + const subEnd = isoDay(antarEnd); + return [{ + lord: antarPlanet, + label: yoginiPieceLabel(antarYogini, antarPlanet), + start: subStart, + end: subEnd, + current: containsDay(subStart, subEnd, asOf), + derived: false, + }]; + }); + return [{ + lord: planet, + label: yoginiPieceLabel(yogini, planet), + sign: null, + start: startIso, + end: endIso, + current: containsDay(startIso, endIso, asOf), + derived: false, + antardashas, + }]; + }); + const current = periods.find((item) => item.current); + const currentAntar = current?.antardashas.find((item) => item.current); + const currentLabel = current + ? `${current.label}${currentAntar ? ` / ${currentAntar.label}` : ""}` + : "未落入已返回的时段"; + return { + id: "yogini" as const, + title: "Yogini", + method: "Yogini", + currentLabel, + antardashaNote: YOGINI_ANTARDASHA_UNVERIFIED_NOTE, + periods, + }; +} + function unavailableWestern(reason: string): ChartViewUnavailableLayer { return { status: "unavailable", reason, copy: WESTERN_UNAVAILABLE_COPY }; } @@ -789,7 +853,7 @@ function mapWestern(raw: Record | null, ayanamsaDisplay: string } export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk { - const { chart, varga, chara, western, bhava, transit, profile, asOf } = bundle; + const { chart, varga, chara, yogini = null, western, bhava, transit, profile, asOf } = bundle; const planets = asPlanetMap(chart.planets); const moon = planets.Moon ?? {}; const ascendant = record(chart.ascendant); @@ -801,6 +865,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk { if (vargas.length === 0) throw new Error("vedic_chart_unavailable"); const vimshottari = vimshottariTrack(chart, asOf); const charaMapped = charaTrack(chara, profile.date, asOf); + const yoginiMapped = yoginiTrack(yogini, asOf); const placeLabel = profile.placeLabel?.trim() || formatCoord(profile.latitude, profile.longitude); const view = { @@ -839,6 +904,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk { asOf: isoDay(asOf), vimshottari, chara: charaMapped, + yogini: yoginiMapped, dualTrackNote: DASHA_DUAL_TRACK_NOTE, }, western: mapWestern(western, ayanamsaDisplay), diff --git a/frontend/src/lib/secondary-page-data.ts b/frontend/src/lib/secondary-page-data.ts index 07bbe32d..41220d2f 100644 --- a/frontend/src/lib/secondary-page-data.ts +++ b/frontend/src/lib/secondary-page-data.ts @@ -1,6 +1,7 @@ import { fetchChartView } from "@/lib/chart-view-client"; import { currentSubjectCatalogReady, readCurrentSubjectAccountId, readCurrentSubjectId } from "@/lib/current-subject"; -import type { ChartViewResponse } from "@/lib/chart-view-contract"; +import type { ChartViewOk, ChartViewResponse } from "@/lib/chart-view-contract"; +import { CHART_DASHA_SYSTEMS } from "@/lib/chart-view-engine"; import { chartClientFailureStatus, chartViewFailureResponse } from "@/lib/chart-view-failure"; import { CHART_VIEW_CLIENT_TIMEOUT_MS, CHART_VIEW_COPY } from "@/lib/chart-view-labels"; import { parseEphemerisOkResponse, type EphemerisOkResponse } from "@/lib/ephemeris-contract"; @@ -185,6 +186,15 @@ function chartSnapshotMatchesPin(snapshot: ChartPageSnapshot): boolean { return sameChartIdentity(identity, pinnedChartIdentity); } +function dashaKeepingArrivedTracks(prior: ChartViewOk["dasha"], next: ChartViewOk["dasha"]): ChartViewOk["dasha"] { + const dasha = { ...next }; + for (const system of CHART_DASHA_SYSTEMS) { + const track = prior[system.track]; + if (track && track.periods.length > 0) dasha[system.track] = track; + } + return dasha; +} + async function loadChartSnapshot(started: number, signal: AbortSignal): Promise { const subjectId = readCurrentSubjectId(); if (!pinnedChartIdentity || subjectOf(pinnedChartIdentity) !== subjectId) { @@ -214,7 +224,7 @@ async function loadChartSnapshot(started: number, signal: AbortSignal): Promise< snapshot = { kind: "view", view: { ...snapshot.view, ...(prior.vedic.vargas.length > 1 ? { vedic: prior.vedic } : {}), - dasha: { ...snapshot.view.dasha, ...(prior.dasha.chara.periods.length ? { chara: prior.dasha.chara } : {}) }, + dasha: dashaKeepingArrivedTracks(prior.dasha, snapshot.view.dasha), ...(prior.western.status === "ok" ? { western: prior.western } : {}), ...(prior.bhava.status === "ok" ? { bhava: prior.bhava } : {}), ...(prior.transit.status === "ok" ? { transit: prior.transit } : {}), diff --git a/frontend/tests/chart-dasha-switch-layout.test.ts b/frontend/tests/chart-dasha-switch-layout.test.ts new file mode 100644 index 00000000..a610d1b7 --- /dev/null +++ b/frontend/tests/chart-dasha-switch-layout.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { cssDeclarations } from "./css-contract-test-support.ts"; + +const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); + +test("the dasha switch stays inside the column and keeps a 44px hit area", () => { + const row = cssDeclarations(".chart-page-dasha-switch", globalStyles); + assert.match(row, /display:\s*flex/); + assert.match(row, /overflow-x:\s*hidden/); + assert.doesNotMatch(row, /overflow-x:\s*auto/); + const option = cssDeclarations(".chart-page-dasha-switch .chart-page-tab", globalStyles); + assert.match(option, /min-height:\s*44px/); + assert.match(option, /min-width:\s*44px/); + assert.match(option, /flex:\s*1 1 0/); + assert.match(globalStyles, /@media \(min-width: 1024px\)/); + assert.match(globalStyles, /\.chart-page-dasha-columns/); +}); diff --git a/frontend/tests/chart-dasha-yogini.test.ts b/frontend/tests/chart-dasha-yogini.test.ts new file mode 100644 index 00000000..325ffe24 --- /dev/null +++ b/frontend/tests/chart-dasha-yogini.test.ts @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import test from "node:test"; + +import { ChartDashaTab } from "../src/components/chart-page/chart-dasha-tab.tsx"; +import { chartViewOkSchema, type ChartViewOk } from "../src/lib/chart-view-contract.ts"; +import { + chartViewEngineCacheKey, + chartViewEngineCachePath, + dashaCacheScope, +} from "../src/lib/chart-view-engine.ts"; +import { assembleChartView, chartDashaLayerRequest } from "../src/lib/chart-view-load.ts"; +import { ANTARDASHA_DERIVED_NOTE, CHARA_ANTARDASHA_DATE_NOTE, YOGINI_ANTARDASHA_UNVERIFIED_NOTE } from "../src/lib/chart-view-labels.ts"; + +Object.assign(globalThis, { React }); + +const fixtureText = readFileSync(new URL("./fixtures/yogini-dasha-api-1990-01-01.golden.json", import.meta.url), "utf8"); +const yoginiFixture = JSON.parse(fixtureText) as { + sourceCommand: string; + ayanamsa: string; + node_mode: string; + response: Record; +}; +const golden = JSON.parse( + readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"), +) as { chart: Record; chara: Record }; + +const profile = { + name: "示例", + date: "1990-06-15", + time: "12:00", + placeLabel: "北京", + latitude: 39.9042, + longitude: 116.4074, + timezoneOffset: 8, + timezoneId: "Asia/Shanghai", + ayanamsa: "raman", + birthTimeStatus: "confirmed" as const, +}; + +const person = { + userId: "user-1", + subjectId: "self", + date: "1990-01-01", + time: "12:00", + latitude: 28.6139, + longitude: 77.209, + timezoneOffset: 5.5, + ayanamsa: "lahiri", + nodeMode: "mean", +}; + +test("the yogini fixture is a real dasha response, not a hand-built shape", () => { + assert.match(yoginiFixture.sourceCommand, /_compute_dasha_system/); + assert.match(yoginiFixture.sourceCommand, /dasha=yogini/); + assert.equal(yoginiFixture.ayanamsa, "lahiri"); + assert.equal(yoginiFixture.node_mode, "mean"); + const periods = yoginiFixture.response.periods; + assert.ok(Array.isArray(periods) && periods.length > 0); +}); + +test("the same person gets a different cache key for each dasha system", () => { + const followUp = { date: person.date, time: person.time }; + const chara = chartDashaLayerRequest("chara", followUp); + const yogini = chartDashaLayerRequest("yogini", followUp); + const kalachakra = chartDashaLayerRequest("kalachakra", followUp); + assert.equal(chara.path, "/api/dasha/chara"); + assert.equal(chara.cacheScope, undefined); + assert.equal(yogini.path, "/api/dasha"); + assert.equal(yogini.cacheScope, "dasha:yogini"); + assert.equal(yogini.body.dasha, "yogini"); + assert.equal(kalachakra.path, "/api/dasha"); + assert.equal(kalachakra.cacheScope, dashaCacheScope("kalachakra")); + assert.equal(kalachakra.body.dasha, "kalachakra"); + const key = (path: string, cacheScope?: string) => chartViewEngineCacheKey({ + ...person, + path: chartViewEngineCachePath(path, cacheScope), + }); + const keys = [ + key(chara.path, chara.cacheScope), + key(yogini.path, yogini.cacheScope), + key(kalachakra.path, kalachakra.cacheScope), + ]; + assert.equal(new Set(keys).size, 3); + assert.notEqual(JSON.stringify(chara.body), JSON.stringify(yogini.body)); + assert.notEqual(JSON.stringify(yogini.body), JSON.stringify(kalachakra.body)); +}); + +async function yoginiView(): Promise { + const scopes: Array = []; + const result = await assembleChartView({ + userId: "user-1", + profile, + asOf: "1990-06-01", + layers: ["chara", "yogini"], + postEngine: async (path, body, cacheScope) => { + if (path === "/api/chart") return { status: "ok", payload: golden.chart }; + if (path === "/api/dasha/chara") return { status: "ok", payload: golden.chara }; + if (path === "/api/dasha") { + scopes.push(cacheScope); + assert.equal(body.dasha, "yogini"); + return { status: "ok", payload: yoginiFixture.response }; + } + return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 }; + }, + }); + assert.deepEqual(scopes, ["dasha:yogini"]); + return chartViewOkSchema.parse(result.body); +} + +test("a real yogini response maps dates and labels without deriving them", async () => { + const view = await yoginiView(); + const track = view.dasha.yogini; + assert.equal(track.id, "yogini"); + const first = track.periods[0]; + assert.ok(first); + assert.equal(first.label, "Pingala / 太阳"); + assert.equal(first.derived, false); + assert.equal(first.start, "1990-01-01"); + assert.equal(first.end, "1990-01-12"); + assert.equal(first.antardashas.length, 1); + assert.equal(first.antardashas[0]?.label, "Mangala / 月亮"); + assert.equal(first.antardashas[0]?.derived, false); + assert.equal(first.antardashas[0]?.start, "1990-01-01"); + const dhanya = track.periods.find((period) => period.label === "Dhanya / 木星"); + assert.ok(dhanya); + assert.equal(dhanya.derived, false); + assert.equal(dhanya.antardashas.length, 8); + assert.equal(dhanya.antardashas.every((item) => item.derived === false), true); + assert.equal(dhanya.current, true); + assert.equal(dhanya.antardashas.some((item) => item.current), true); + assert.equal(track.antardashaNote, YOGINI_ANTARDASHA_UNVERIFIED_NOTE); +}); + +test("the yogini note sits under that track only", async () => { + const view = await yoginiView(); + const yogini = renderToStaticMarkup(React.createElement(ChartDashaTab, { view, system: "yogini" })); + assert.equal((yogini.match(/小运未经外部软件核对。/g) ?? []).length, 1); + assert.match(yogini, /
大运<\/dt>/); + assert.match(yogini, /
小运<\/dt>/); + assert.match(yogini, /
小运剩余<\/dt>
\d+ 个月<\/dd>/); + assert.match(yogini, /Dhanya \/ 木星/); + assert.match(yogini, new RegExp(ANTARDASHA_DERIVED_NOTE)); + assert.doesNotMatch(yogini, new RegExp(CHARA_ANTARDASHA_DATE_NOTE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + const chara = renderToStaticMarkup(React.createElement(ChartDashaTab, { view })); + assert.equal((chara.match(/小运未经外部软件核对。/g) ?? []).length, 0); + assert.match(chara, new RegExp(CHARA_ANTARDASHA_DATE_NOTE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.match(chara, new RegExp(ANTARDASHA_DERIVED_NOTE)); + assert.match(chara, /aria-selected="true"[^>]*>Chara { + return assembleChartView({ userId: "synthetic", accountId: "synthetic", profileFingerprint: "fixture-v1", profile, asOf: "2026-09-15", layers: layer ? [layer] : [], postEngine: async (path, body, cacheScope) => { if (path === "/api/chart" && !cacheScope) return { status: "ok", payload: golden.chart }; if (failure) return failure; + // 原值: `/api/dasha` 没有分支,落到西洋包 + // 新值: 按 body.dasha 分派;yogini 用真实接口响应,其它键回空 periods + // 原因: Chara 与 Yogini 都会打大运接口,不能按 path 共用一份包(issue #88) + if (path === "/api/dasha") { + return body.dasha === "yogini" + ? { status: "ok", payload: yoginiGolden } + : { status: "ok", payload: { periods: [] } }; + } const payload = path === "/api/varga_full" ? golden.varga_full : path === "/api/dasha/chara" ? golden.chara : path === "/api/chart" ? transitGolden : path === "/api/bhava_chalit" ? bhavaGolden.bhava_chalit : golden.western; return { status: "ok", payload }; } }); @@ -88,9 +97,10 @@ for (const cached of [false, true]) test(`chart 401 ${cached ? "cache" : "respon assert.equal(state().view?.status, "unauthenticated"); assert.deepEqual(assigned, ["/login"]); })); -// 原值: ["varga", "chara", "western", "qizheng"];新值: 去掉 qizheng、加 bhava 与 transit -// 原因: 七政懒加载层已删(TASK-chart-types D1),行运层(T4)与 Bhava 层(T5)新增 -for (const layer of ["varga", "chara", "western", "bhava", "transit"] as const) test(`real assemble to fetch to hook preserves ${layer} engine 429`, async () => harness(async (h, state) => { +// 原值: ["varga", "chara", "western", "bhava", "transit"] +// 新值: ["varga", "chara", "yogini", "western", "bhava", "transit"] +// 原因: Yogini 是按需大运层,429 只记在这一层。更早一次是去掉 qizheng、加上 bhava 与 transit(TASK-chart-types) +for (const layer of ["varga", "chara", "yogini", "western", "bhava", "transit"] as const) test(`real assemble to fetch to hook preserves ${layer} engine 429`, async () => harness(async (h, state) => { const initial = await packet(); const failed = await packet(layer, { status: "busy", path: "/api/synthetic", elapsedMs: 1, httpStatus: 429 }); globalThis.fetch = async (url) => response(String(url).includes("layers=") ? failed : initial); @@ -258,3 +268,109 @@ test("profile pin invalidation ignores an older layer completion", async () => h assert.deepEqual(peekChartPage(), { kind: "view", view: fresh }); assert.equal(state().layerFailures.size, 0); })); + +test("chara and yogini replace only their own dasha track", async () => harness(async (h, state) => { + const initial = await packet(); + const chara = await packet("chara"); + const yogini = await packet("yogini"); + writeChartPage({ kind: "view", view: initial.body }); + globalThis.fetch = async (url) => { + const text = String(url); + if (text.includes("layers=yogini")) return response(yogini); + if (text.includes("layers=chara")) return response(chara); + return response(initial); + }; + await h.render(null); + await h.update(() => state().requestLayer("chara")); + let view = state().view as ChartViewOk; + const charaPeriods = view.dasha.chara.periods.length; + assert.ok(charaPeriods > 0); + assert.equal(view.dasha.yogini.periods.length, 0); + await h.update(() => state().requestLayer("yogini")); + view = state().view as ChartViewOk; + assert.equal(view.dasha.chara.periods.length, charaPeriods); + assert.ok(view.dasha.yogini.periods.length > 0); +})); + +test("yogini then chara keeps the yogini track", async () => harness(async (h, state) => { + const initial = await packet(); + const chara = await packet("chara"); + const yogini = await packet("yogini"); + writeChartPage({ kind: "view", view: initial.body }); + globalThis.fetch = async (url) => { + const text = String(url); + if (text.includes("layers=yogini")) return response(yogini); + if (text.includes("layers=chara")) return response(chara); + return response(initial); + }; + await h.render(null); + await h.update(() => state().requestLayer("yogini")); + const yoginiPeriods = (state().view as ChartViewOk).dasha.yogini.periods.length; + assert.ok(yoginiPeriods > 0); + await h.update(() => state().requestLayer("chara")); + const view = state().view as ChartViewOk; + assert.equal(view.dasha.yogini.periods.length, yoginiPeriods); + assert.ok(view.dasha.chara.periods.length > 0); +})); + +test("yogini 429 leaves a loaded chara track in place", async () => harness(async (h, state) => { + const initial = await packet(); + const chara = await packet("chara"); + const failed = await packet("yogini", { status: "busy", path: "/api/dasha", elapsedMs: 1, httpStatus: 429 }); + writeChartPage({ kind: "view", view: initial.body }); + globalThis.fetch = async (url) => { + const text = String(url); + if (text.includes("layers=yogini")) return response(failed); + if (text.includes("layers=chara")) return response(chara); + return response(initial); + }; + await h.render(null); + await h.update(() => state().requestLayer("chara")); + const charaPeriods = (state().view as ChartViewOk).dasha.chara.periods.length; + await h.update(() => state().requestLayer("yogini")); + const view = state().view as ChartViewOk; + assert.equal(state().layerFailures.get("yogini"), "engine_busy"); + assert.equal(state().layerFailures.has("chara"), false); + assert.equal(view.dasha.chara.periods.length, charaPeriods); +})); + +test("a warm D1 refresh keeps an arrived yogini track", async () => harness(async (h, state) => { + const initial = await packet(); + const yogini = await packet("yogini"); + const refresh = deferred(); + writeChartPage({ kind: "view", view: initial.body }); + globalThis.fetch = async (url) => String(url).includes("layers=yogini") ? response(yogini) : refresh.promise; + await h.render(null); + await h.update(() => state().requestLayer("yogini")); + assert.ok((state().view as ChartViewOk).dasha.yogini.periods.length > 0); + await h.update(() => refresh.resolve(response(initial))); + assert.ok((state().view as ChartViewOk).dasha.yogini.periods.length > 0, "a D1-only refresh does not erase yogini"); +})); + +test("empty yogini periods fail only that layer and the same request can run again", async () => harness(async (h, state) => { + const initial = await packet(); + const yogini = await packet("yogini"); + assert.equal(yogini.body.status, "ok"); + if (yogini.body.status !== "ok") return; + const emptyBody = { + ...yogini.body, + dasha: { + ...yogini.body.dasha, + yogini: { ...yogini.body.dasha.yogini, periods: [] }, + }, + }; + let yoginiCalls = 0; + writeChartPage({ kind: "view", view: initial.body }); + globalThis.fetch = async (url) => { + if (!String(url).includes("layers=yogini")) return response(initial); + yoginiCalls += 1; + return response({ ...yogini, body: emptyBody }); + }; + await h.render(null); + await h.update(() => state().requestLayer("yogini")); + assert.equal(state().layerFailures.get("yogini"), "unavailable"); + assert.equal(state().layerFailures.has("chara"), false); + assert.equal((state().view as ChartViewOk).dasha.vimshottari.periods.length, (initial.body as ChartViewOk).dasha.vimshottari.periods.length); + await h.update(() => state().requestLayer("yogini")); + assert.equal(yoginiCalls, 2); +})); diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index 54a9a758..09a32d9a 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -11,14 +11,15 @@ import { EMPTY_NORTH_INDIAN_CHART, VedicChartSvg } from "../src/components/perso import { ChartPageView } from "../src/components/chart-page/chart-page-view.tsx"; import { SidebarProvider } from "../src/components/ui/sidebar.tsx"; import { assembleChartView } from "../src/lib/chart-view-load.ts"; -import { CHART_VIEW_TABS, type ChartViewOk } from "../src/lib/chart-view-contract.ts"; -import { CHART_VIEW_LAYERS } from "../src/lib/chart-view-engine.ts"; +import { CHART_VIEW_TABS, type ChartViewFailureStatus, type ChartViewOk } from "../src/lib/chart-view-contract.ts"; +import { CHART_VIEW_LAYERS, type ChartViewLayer } from "../src/lib/chart-view-engine.ts"; import { chartViewFailureResponse } from "../src/lib/chart-view-failure.ts"; import { CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts"; import { CHART_SETTINGS_HREF } from "../src/lib/settings-url.ts"; import { westernLabelBox } from "../src/components/chart-page/western-wheel-layout.ts"; import { cssDeclarations } from "./css-contract-test-support.ts"; import { goldenSky } from "./birth-sky-test-support.ts"; +import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; Object.assign(globalThis, { React }); @@ -551,3 +552,70 @@ test("birth-sky prefetch follows the current subject and the dialog is a lazy ch assert.match(cover, /await import\("\.\/render\.ts"\)/); assert.doesNotMatch(chartView, /birth-sky-dialog|lib\/birth-sky\/(draw|render)/); }); + +function buttonByLabel(h: ReturnType, label: string) { + const node = h.elements().find((item) => item.tagName === "BUTTON" && item.text === label); + assert.ok(node, `missing button ${label}`); + return node; +} + +test("opening dasha requests only chara, and Yogini is a separate request", async () => { + const view = await okView(); + const calls: string[] = []; + const h = createClientLifecycleHarness(); + try { + await h.render(React.createElement(SidebarProvider, null, React.createElement(ChartPageView, { + view, + onNeedLayer: (layer: string) => { calls.push(layer); }, + }))); + await h.event(buttonByLabel(h, "大运")); + assert.deepEqual(calls, ["chara"]); + assert.match(h.container.text, /Chara Dasha(kn_rao 变体)/); + assert.match(h.container.text, /Vimshottari/); + await h.event(buttonByLabel(h, "Yogini")); + assert.deepEqual(calls, ["chara", "yogini"]); + } finally { + await h.close(); + } +}); + +test("a yogini failure stays in the right column, and clicking Yogini again asks again", async () => { + const view = await okView(); + const calls: string[] = []; + const h = createClientLifecycleHarness(); + try { + await h.render(React.createElement(SidebarProvider, null, React.createElement(ChartPageView, { + view, + layerFailures: new Map([["yogini", "engine_busy"]]), + onNeedLayer: (layer: string) => { calls.push(layer); }, + }))); + await h.event(buttonByLabel(h, "大运")); + await h.event(buttonByLabel(h, "Yogini")); + assert.match(h.container.text, /Vimshottari/); + assert.match(h.container.text, new RegExp(CHART_VIEW_COPY.busy)); + assert.doesNotMatch(h.container.text, /小运未经外部软件核对。/); + assert.equal(h.elements().some((node) => node.hasAttribute("data-skeleton")), false); + await h.event(buttonByLabel(h, "Yogini")); + assert.deepEqual(calls, ["chara", "yogini", "yogini"]); + } finally { + await h.close(); + } +}); + +test("yogini pending reuses the orbit sentence and does not draw a skeleton", async () => { + const view = await okView(); + const h = createClientLifecycleHarness(); + try { + await h.render(React.createElement(SidebarProvider, null, React.createElement(ChartPageView, { + view, + pendingLayers: new Set(["yogini"]), + }))); + await h.event(buttonByLabel(h, "大运")); + await h.event(buttonByLabel(h, "Yogini")); + assert.match(h.container.text, /这一栏还没拿到/); + assert.match(h.container.text, /Vimshottari/); + assert.equal(h.elements().some((node) => node.hasAttribute("data-skeleton")), false); + } finally { + await h.close(); + } +}); diff --git a/frontend/tests/chart-view-route.test.ts b/frontend/tests/chart-view-route.test.ts index c2e39b11..c72883ab 100644 --- a/frontend/tests/chart-view-route.test.ts +++ b/frontend/tests/chart-view-route.test.ts @@ -94,7 +94,13 @@ test("the chart-view BFF never bills and never calls a model", () => { assert.match(serviceSource, /loadSubjectBirth/); assert.doesNotMatch(serviceSource, /clientBirth|body\.profile/); assert.match(loadSource, /\/api\/chart/); - assert.match(loadSource, /\/api\/dasha\/chara/); + // 原值: loadSource 匹配 /api/dasha/chara + // 新值: 路径写在 CHART_DASHA_SYSTEMS,loadSource 经 chartDashaLayerRequest 取用 + // 原因: Chara 与 Yogini 共用一张大运层表,Yogini 走 /api/dasha 且缓存 scope 是 dasha:yogini(issue #88) + const engineSource = readFileSync(new URL("../src/lib/chart-view-engine.ts", import.meta.url), "utf8"); + assert.match(engineSource, /\/api\/dasha\/chara/); + assert.match(engineSource, /dasha:yogini/); + assert.match(loadSource, /chartDashaLayerRequest/); assert.match(loadSource, /\/api\/varga_full/); assert.match(loadSource, /planets/); assert.match(loadSource, /ascendant/); diff --git a/frontend/tests/fixtures/yogini-dasha-api-1990-01-01.golden.json b/frontend/tests/fixtures/yogini-dasha-api-1990-01-01.golden.json new file mode 100644 index 00000000..5a125efc --- /dev/null +++ b/frontend/tests/fixtures/yogini-dasha-api-1990-01-01.golden.json @@ -0,0 +1,1970 @@ +{ + "sourceCommand": "agent-loop/.venv/Scripts/python.exe: scripts.domain_calculation_service.compute_chart then tests/test_api_server_security.py _handler()._compute_dasha_system(dasha=yogini); fictional chart 1990-01-01 12:00 lat 28.6139 lon 77.2090 tz 5.5; ayanamsa lahiri; node_mode mean", + "chart": "fictional 1990-01-01 12:00 lat 28.6139 lon 77.2090 tz 5.5", + "ayanamsa": "lahiri", + "node_mode": "mean", + "response": { + "success": true, + "key": "yogini", + "name": "Yogini", + "type": "nakshatra", + "cycle_years": 36, + "precision": "engine_module", + "periods": [ + { + "yogini": "Pingala", + "planet": "Sun", + "full_years": 2, + "years": 0.030205765412230336, + "start_date": "1990-01-01T12:00:00", + "end_date": "1990-01-12T12:47:01", + "unclipped_start_date": "1988-01-13T00:47:01", + "cycle_number": 1, + "antardasha": [ + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.03020575075417649, + "start_date": "1990-01-01T12:00:00", + "end_date": "1990-01-12T12:47:01", + "unclipped_start_date": "1989-12-23T05:47:01" + } + ] + }, + { + "yogini": "Dhanya", + "planet": "Jupiter", + "full_years": 3, + "years": 3.0, + "start_date": "1990-01-12T12:47:01", + "end_date": "1993-01-12T06:47:01", + "unclipped_start_date": "1990-01-12T12:47:01", + "cycle_number": 1, + "antardasha": [ + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.25, + "start_date": "1990-01-12T12:47:01", + "end_date": "1990-04-13T20:17:01" + }, + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.3333333333333333, + "start_date": "1990-04-13T20:17:01", + "end_date": "1990-08-13T14:17:01" + }, + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 0.4166666666666667, + "start_date": "1990-08-13T14:17:01", + "end_date": "1991-01-12T18:47:01" + }, + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 0.5, + "start_date": "1991-01-12T18:47:01", + "end_date": "1991-07-14T09:47:01" + }, + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 0.5833333333333334, + "start_date": "1991-07-14T09:47:01", + "end_date": "1992-02-12T11:17:01" + }, + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 0.6666666666666666, + "start_date": "1992-02-12T11:17:01", + "end_date": "1992-10-12T23:17:01" + }, + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.08333333333333333, + "start_date": "1992-10-12T23:17:01", + "end_date": "1992-11-12T09:47:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.16666666666666666, + "start_date": "1992-11-12T09:47:01", + "end_date": "1993-01-12T06:47:01" + } + ] + }, + { + "yogini": "Bhramari", + "planet": "Mars", + "full_years": 4, + "years": 4.0, + "start_date": "1993-01-12T06:47:01", + "end_date": "1997-01-12T06:47:01", + "unclipped_start_date": "1993-01-12T06:47:01", + "cycle_number": 1, + "antardasha": [ + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.4444444444444444, + "start_date": "1993-01-12T06:47:01", + "end_date": "1993-06-23T14:47:01" + }, + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 0.5555555555555556, + "start_date": "1993-06-23T14:47:01", + "end_date": "1994-01-12T12:47:01" + }, + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 0.6666666666666666, + "start_date": "1994-01-12T12:47:01", + "end_date": "1994-09-13T00:47:01" + }, + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 0.7777777777777778, + "start_date": "1994-09-13T00:47:01", + "end_date": "1995-06-24T02:47:01" + }, + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 0.8888888888888888, + "start_date": "1995-06-24T02:47:01", + "end_date": "1996-05-13T18:47:01" + }, + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.1111111111111111, + "start_date": "1996-05-13T18:47:01", + "end_date": "1996-06-23T08:47:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.2222222222222222, + "start_date": "1996-06-23T08:47:01", + "end_date": "1996-09-12T12:47:01" + }, + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.3333333333333333, + "start_date": "1996-09-12T12:47:01", + "end_date": "1997-01-12T06:47:01" + } + ] + }, + { + "yogini": "Bhadrika", + "planet": "Mercury", + "full_years": 5, + "years": 5.0, + "start_date": "1997-01-12T06:47:01", + "end_date": "2002-01-12T12:47:01", + "unclipped_start_date": "1997-01-12T06:47:01", + "cycle_number": 1, + "antardasha": [ + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 0.6944444444444444, + "start_date": "1997-01-12T06:47:01", + "end_date": "1997-09-22T22:17:01" + }, + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 0.8333333333333334, + "start_date": "1997-09-22T22:17:01", + "end_date": "1998-07-24T07:17:01" + }, + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 0.9722222222222222, + "start_date": "1998-07-24T07:17:01", + "end_date": "1999-07-14T09:47:01" + }, + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 1.1111111111111112, + "start_date": "1999-07-14T09:47:01", + "end_date": "2000-08-23T05:47:01" + }, + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.1388888888888889, + "start_date": "2000-08-23T05:47:01", + "end_date": "2000-10-12T23:17:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.2777777777777778, + "start_date": "2000-10-12T23:17:01", + "end_date": "2001-01-22T10:17:01" + }, + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.4166666666666667, + "start_date": "2001-01-22T10:17:01", + "end_date": "2001-06-23T14:47:01" + }, + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.5555555555555556, + "start_date": "2001-06-23T14:47:01", + "end_date": "2002-01-12T12:47:01" + } + ] + }, + { + "yogini": "Ulka", + "planet": "Saturn", + "full_years": 6, + "years": 6.0, + "start_date": "2002-01-12T12:47:01", + "end_date": "2008-01-13T00:47:01", + "unclipped_start_date": "2002-01-12T12:47:01", + "cycle_number": 1, + "antardasha": [ + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 1.0, + "start_date": "2002-01-12T12:47:01", + "end_date": "2003-01-12T18:47:01" + }, + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 1.1666666666666667, + "start_date": "2003-01-12T18:47:01", + "end_date": "2004-03-13T21:47:01" + }, + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 1.3333333333333333, + "start_date": "2004-03-13T21:47:01", + "end_date": "2005-07-13T21:47:01" + }, + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.16666666666666666, + "start_date": "2005-07-13T21:47:01", + "end_date": "2005-09-12T18:47:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.3333333333333333, + "start_date": "2005-09-12T18:47:01", + "end_date": "2006-01-12T12:47:01" + }, + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.5, + "start_date": "2006-01-12T12:47:01", + "end_date": "2006-07-14T03:47:01" + }, + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.6666666666666666, + "start_date": "2006-07-14T03:47:01", + "end_date": "2007-03-14T15:47:01" + }, + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 0.8333333333333334, + "start_date": "2007-03-14T15:47:01", + "end_date": "2008-01-13T00:47:01" + } + ] + }, + { + "yogini": "Siddha", + "planet": "Venus", + "full_years": 7, + "years": 7.0, + "start_date": "2008-01-13T00:47:01", + "end_date": "2015-01-12T18:47:01", + "unclipped_start_date": "2008-01-13T00:47:01", + "cycle_number": 1, + "antardasha": [ + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 1.3611111111111112, + "start_date": "2008-01-13T00:47:01", + "end_date": "2009-05-24T04:17:01" + }, + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 1.5555555555555556, + "start_date": "2009-05-24T04:17:01", + "end_date": "2010-12-13T08:17:01" + }, + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.19444444444444445, + "start_date": "2010-12-13T08:17:01", + "end_date": "2011-02-22T08:47:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.3888888888888889, + "start_date": "2011-02-22T08:47:01", + "end_date": "2011-07-14T09:47:01" + }, + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.5833333333333334, + "start_date": "2011-07-14T09:47:01", + "end_date": "2012-02-12T11:17:01" + }, + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.7777777777777778, + "start_date": "2012-02-12T11:17:01", + "end_date": "2012-11-22T13:17:01" + }, + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 0.9722222222222222, + "start_date": "2012-11-22T13:17:01", + "end_date": "2013-11-12T15:47:01" + }, + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 1.1666666666666667, + "start_date": "2013-11-12T15:47:01", + "end_date": "2015-01-12T18:47:01" + } + ] + }, + { + "yogini": "Sankata", + "planet": "Rahu", + "full_years": 8, + "years": 8.0, + "start_date": "2015-01-12T18:47:01", + "end_date": "2023-01-12T18:47:01", + "unclipped_start_date": "2015-01-12T18:47:01", + "cycle_number": 1, + "antardasha": [ + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 1.7777777777777777, + "start_date": "2015-01-12T18:47:01", + "end_date": "2016-10-23T02:47:01" + }, + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.2222222222222222, + "start_date": "2016-10-23T02:47:01", + "end_date": "2017-01-12T06:47:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.4444444444444444, + "start_date": "2017-01-12T06:47:01", + "end_date": "2017-06-23T14:47:01" + }, + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.6666666666666666, + "start_date": "2017-06-23T14:47:01", + "end_date": "2018-02-22T02:47:01" + }, + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.8888888888888888, + "start_date": "2018-02-22T02:47:01", + "end_date": "2019-01-12T18:47:01" + }, + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 1.1111111111111112, + "start_date": "2019-01-12T18:47:01", + "end_date": "2020-02-22T14:47:01" + }, + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 1.3333333333333333, + "start_date": "2020-02-22T14:47:01", + "end_date": "2021-06-23T14:47:01" + }, + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 1.5555555555555556, + "start_date": "2021-06-23T14:47:01", + "end_date": "2023-01-12T18:47:01" + } + ] + }, + { + "yogini": "Mangala", + "planet": "Moon", + "full_years": 1, + "years": 1.0, + "start_date": "2023-01-12T18:47:01", + "end_date": "2024-01-13T00:47:01", + "unclipped_start_date": "2023-01-12T18:47:01", + "cycle_number": 1, + "antardasha": [ + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.027777777777777776, + "start_date": "2023-01-12T18:47:01", + "end_date": "2023-01-22T22:17:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.05555555555555555, + "start_date": "2023-01-22T22:17:01", + "end_date": "2023-02-12T05:17:01" + }, + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.08333333333333333, + "start_date": "2023-02-12T05:17:01", + "end_date": "2023-03-14T15:47:01" + }, + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.1111111111111111, + "start_date": "2023-03-14T15:47:01", + "end_date": "2023-04-24T05:47:01" + }, + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 0.1388888888888889, + "start_date": "2023-04-24T05:47:01", + "end_date": "2023-06-13T23:17:01" + }, + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 0.16666666666666666, + "start_date": "2023-06-13T23:17:01", + "end_date": "2023-08-13T20:17:01" + }, + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 0.19444444444444445, + "start_date": "2023-08-13T20:17:01", + "end_date": "2023-10-23T20:47:01" + }, + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 0.2222222222222222, + "start_date": "2023-10-23T20:47:01", + "end_date": "2024-01-13T00:47:01" + } + ] + }, + { + "yogini": "Pingala", + "planet": "Sun", + "full_years": 2, + "years": 2.0, + "start_date": "2024-01-13T00:47:01", + "end_date": "2026-01-12T12:47:01", + "unclipped_start_date": "2024-01-13T00:47:01", + "cycle_number": 2, + "antardasha": [ + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.1111111111111111, + "start_date": "2024-01-13T00:47:01", + "end_date": "2024-02-22T14:47:01" + }, + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.16666666666666666, + "start_date": "2024-02-22T14:47:01", + "end_date": "2024-04-23T11:47:01" + }, + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.2222222222222222, + "start_date": "2024-04-23T11:47:01", + "end_date": "2024-07-13T15:47:01" + }, + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 0.2777777777777778, + "start_date": "2024-07-13T15:47:01", + "end_date": "2024-10-23T02:47:01" + }, + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 0.3333333333333333, + "start_date": "2024-10-23T02:47:01", + "end_date": "2025-02-21T20:47:01" + }, + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 0.3888888888888889, + "start_date": "2025-02-21T20:47:01", + "end_date": "2025-07-13T21:47:01" + }, + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 0.4444444444444444, + "start_date": "2025-07-13T21:47:01", + "end_date": "2025-12-23T05:47:01" + }, + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.05555555555555555, + "start_date": "2025-12-23T05:47:01", + "end_date": "2026-01-12T12:47:01" + } + ] + }, + { + "yogini": "Dhanya", + "planet": "Jupiter", + "full_years": 3, + "years": 3.0, + "start_date": "2026-01-12T12:47:01", + "end_date": "2029-01-12T06:47:01", + "unclipped_start_date": "2026-01-12T12:47:01", + "cycle_number": 2, + "antardasha": [ + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.25, + "start_date": "2026-01-12T12:47:01", + "end_date": "2026-04-13T20:17:01" + }, + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.3333333333333333, + "start_date": "2026-04-13T20:17:01", + "end_date": "2026-08-13T14:17:01" + }, + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 0.4166666666666667, + "start_date": "2026-08-13T14:17:01", + "end_date": "2027-01-12T18:47:01" + }, + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 0.5, + "start_date": "2027-01-12T18:47:01", + "end_date": "2027-07-14T09:47:01" + }, + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 0.5833333333333334, + "start_date": "2027-07-14T09:47:01", + "end_date": "2028-02-12T11:17:01" + }, + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 0.6666666666666666, + "start_date": "2028-02-12T11:17:01", + "end_date": "2028-10-12T23:17:01" + }, + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.08333333333333333, + "start_date": "2028-10-12T23:17:01", + "end_date": "2028-11-12T09:47:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.16666666666666666, + "start_date": "2028-11-12T09:47:01", + "end_date": "2029-01-12T06:47:01" + } + ] + }, + { + "yogini": "Bhramari", + "planet": "Mars", + "full_years": 4, + "years": 4.0, + "start_date": "2029-01-12T06:47:01", + "end_date": "2033-01-12T06:47:01", + "unclipped_start_date": "2029-01-12T06:47:01", + "cycle_number": 2, + "antardasha": [ + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.4444444444444444, + "start_date": "2029-01-12T06:47:01", + "end_date": "2029-06-23T14:47:01" + }, + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 0.5555555555555556, + "start_date": "2029-06-23T14:47:01", + "end_date": "2030-01-12T12:47:01" + }, + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 0.6666666666666666, + "start_date": "2030-01-12T12:47:01", + "end_date": "2030-09-13T00:47:01" + }, + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 0.7777777777777778, + "start_date": "2030-09-13T00:47:01", + "end_date": "2031-06-24T02:47:01" + }, + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 0.8888888888888888, + "start_date": "2031-06-24T02:47:01", + "end_date": "2032-05-13T18:47:01" + }, + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.1111111111111111, + "start_date": "2032-05-13T18:47:01", + "end_date": "2032-06-23T08:47:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.2222222222222222, + "start_date": "2032-06-23T08:47:01", + "end_date": "2032-09-12T12:47:01" + }, + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.3333333333333333, + "start_date": "2032-09-12T12:47:01", + "end_date": "2033-01-12T06:47:01" + } + ] + }, + { + "yogini": "Bhadrika", + "planet": "Mercury", + "full_years": 5, + "years": 5.0, + "start_date": "2033-01-12T06:47:01", + "end_date": "2038-01-12T12:47:01", + "unclipped_start_date": "2033-01-12T06:47:01", + "cycle_number": 2, + "antardasha": [ + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 0.6944444444444444, + "start_date": "2033-01-12T06:47:01", + "end_date": "2033-09-22T22:17:01" + }, + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 0.8333333333333334, + "start_date": "2033-09-22T22:17:01", + "end_date": "2034-07-24T07:17:01" + }, + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 0.9722222222222222, + "start_date": "2034-07-24T07:17:01", + "end_date": "2035-07-14T09:47:01" + }, + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 1.1111111111111112, + "start_date": "2035-07-14T09:47:01", + "end_date": "2036-08-23T05:47:01" + }, + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.1388888888888889, + "start_date": "2036-08-23T05:47:01", + "end_date": "2036-10-12T23:17:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.2777777777777778, + "start_date": "2036-10-12T23:17:01", + "end_date": "2037-01-22T10:17:01" + }, + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.4166666666666667, + "start_date": "2037-01-22T10:17:01", + "end_date": "2037-06-23T14:47:01" + }, + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.5555555555555556, + "start_date": "2037-06-23T14:47:01", + "end_date": "2038-01-12T12:47:01" + } + ] + }, + { + "yogini": "Ulka", + "planet": "Saturn", + "full_years": 6, + "years": 6.0, + "start_date": "2038-01-12T12:47:01", + "end_date": "2044-01-13T00:47:01", + "unclipped_start_date": "2038-01-12T12:47:01", + "cycle_number": 2, + "antardasha": [ + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 1.0, + "start_date": "2038-01-12T12:47:01", + "end_date": "2039-01-12T18:47:01" + }, + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 1.1666666666666667, + "start_date": "2039-01-12T18:47:01", + "end_date": "2040-03-13T21:47:01" + }, + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 1.3333333333333333, + "start_date": "2040-03-13T21:47:01", + "end_date": "2041-07-13T21:47:01" + }, + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.16666666666666666, + "start_date": "2041-07-13T21:47:01", + "end_date": "2041-09-12T18:47:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.3333333333333333, + "start_date": "2041-09-12T18:47:01", + "end_date": "2042-01-12T12:47:01" + }, + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.5, + "start_date": "2042-01-12T12:47:01", + "end_date": "2042-07-14T03:47:01" + }, + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.6666666666666666, + "start_date": "2042-07-14T03:47:01", + "end_date": "2043-03-14T15:47:01" + }, + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 0.8333333333333334, + "start_date": "2043-03-14T15:47:01", + "end_date": "2044-01-13T00:47:01" + } + ] + }, + { + "yogini": "Siddha", + "planet": "Venus", + "full_years": 7, + "years": 7.0, + "start_date": "2044-01-13T00:47:01", + "end_date": "2051-01-12T18:47:01", + "unclipped_start_date": "2044-01-13T00:47:01", + "cycle_number": 2, + "antardasha": [ + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 1.3611111111111112, + "start_date": "2044-01-13T00:47:01", + "end_date": "2045-05-24T04:17:01" + }, + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 1.5555555555555556, + "start_date": "2045-05-24T04:17:01", + "end_date": "2046-12-13T08:17:01" + }, + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.19444444444444445, + "start_date": "2046-12-13T08:17:01", + "end_date": "2047-02-22T08:47:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.3888888888888889, + "start_date": "2047-02-22T08:47:01", + "end_date": "2047-07-14T09:47:01" + }, + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.5833333333333334, + "start_date": "2047-07-14T09:47:01", + "end_date": "2048-02-12T11:17:01" + }, + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.7777777777777778, + "start_date": "2048-02-12T11:17:01", + "end_date": "2048-11-22T13:17:01" + }, + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 0.9722222222222222, + "start_date": "2048-11-22T13:17:01", + "end_date": "2049-11-12T15:47:01" + }, + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 1.1666666666666667, + "start_date": "2049-11-12T15:47:01", + "end_date": "2051-01-12T18:47:01" + } + ] + }, + { + "yogini": "Sankata", + "planet": "Rahu", + "full_years": 8, + "years": 8.0, + "start_date": "2051-01-12T18:47:01", + "end_date": "2059-01-12T18:47:01", + "unclipped_start_date": "2051-01-12T18:47:01", + "cycle_number": 2, + "antardasha": [ + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 1.7777777777777777, + "start_date": "2051-01-12T18:47:01", + "end_date": "2052-10-23T02:47:01" + }, + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.2222222222222222, + "start_date": "2052-10-23T02:47:01", + "end_date": "2053-01-12T06:47:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.4444444444444444, + "start_date": "2053-01-12T06:47:01", + "end_date": "2053-06-23T14:47:01" + }, + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.6666666666666666, + "start_date": "2053-06-23T14:47:01", + "end_date": "2054-02-22T02:47:01" + }, + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.8888888888888888, + "start_date": "2054-02-22T02:47:01", + "end_date": "2055-01-12T18:47:01" + }, + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 1.1111111111111112, + "start_date": "2055-01-12T18:47:01", + "end_date": "2056-02-22T14:47:01" + }, + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 1.3333333333333333, + "start_date": "2056-02-22T14:47:01", + "end_date": "2057-06-23T14:47:01" + }, + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 1.5555555555555556, + "start_date": "2057-06-23T14:47:01", + "end_date": "2059-01-12T18:47:01" + } + ] + }, + { + "yogini": "Mangala", + "planet": "Moon", + "full_years": 1, + "years": 1.0, + "start_date": "2059-01-12T18:47:01", + "end_date": "2060-01-13T00:47:01", + "unclipped_start_date": "2059-01-12T18:47:01", + "cycle_number": 2, + "antardasha": [ + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.027777777777777776, + "start_date": "2059-01-12T18:47:01", + "end_date": "2059-01-22T22:17:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.05555555555555555, + "start_date": "2059-01-22T22:17:01", + "end_date": "2059-02-12T05:17:01" + }, + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.08333333333333333, + "start_date": "2059-02-12T05:17:01", + "end_date": "2059-03-14T15:47:01" + }, + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.1111111111111111, + "start_date": "2059-03-14T15:47:01", + "end_date": "2059-04-24T05:47:01" + }, + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 0.1388888888888889, + "start_date": "2059-04-24T05:47:01", + "end_date": "2059-06-13T23:17:01" + }, + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 0.16666666666666666, + "start_date": "2059-06-13T23:17:01", + "end_date": "2059-08-13T20:17:01" + }, + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 0.19444444444444445, + "start_date": "2059-08-13T20:17:01", + "end_date": "2059-10-23T20:47:01" + }, + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 0.2222222222222222, + "start_date": "2059-10-23T20:47:01", + "end_date": "2060-01-13T00:47:01" + } + ] + }, + { + "yogini": "Pingala", + "planet": "Sun", + "full_years": 2, + "years": 2.0, + "start_date": "2060-01-13T00:47:01", + "end_date": "2062-01-12T12:47:01", + "unclipped_start_date": "2060-01-13T00:47:01", + "cycle_number": 3, + "antardasha": [ + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.1111111111111111, + "start_date": "2060-01-13T00:47:01", + "end_date": "2060-02-22T14:47:01" + }, + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.16666666666666666, + "start_date": "2060-02-22T14:47:01", + "end_date": "2060-04-23T11:47:01" + }, + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.2222222222222222, + "start_date": "2060-04-23T11:47:01", + "end_date": "2060-07-13T15:47:01" + }, + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 0.2777777777777778, + "start_date": "2060-07-13T15:47:01", + "end_date": "2060-10-23T02:47:01" + }, + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 0.3333333333333333, + "start_date": "2060-10-23T02:47:01", + "end_date": "2061-02-21T20:47:01" + }, + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 0.3888888888888889, + "start_date": "2061-02-21T20:47:01", + "end_date": "2061-07-13T21:47:01" + }, + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 0.4444444444444444, + "start_date": "2061-07-13T21:47:01", + "end_date": "2061-12-23T05:47:01" + }, + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.05555555555555555, + "start_date": "2061-12-23T05:47:01", + "end_date": "2062-01-12T12:47:01" + } + ] + }, + { + "yogini": "Dhanya", + "planet": "Jupiter", + "full_years": 3, + "years": 3.0, + "start_date": "2062-01-12T12:47:01", + "end_date": "2065-01-12T06:47:01", + "unclipped_start_date": "2062-01-12T12:47:01", + "cycle_number": 3, + "antardasha": [ + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.25, + "start_date": "2062-01-12T12:47:01", + "end_date": "2062-04-13T20:17:01" + }, + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.3333333333333333, + "start_date": "2062-04-13T20:17:01", + "end_date": "2062-08-13T14:17:01" + }, + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 0.4166666666666667, + "start_date": "2062-08-13T14:17:01", + "end_date": "2063-01-12T18:47:01" + }, + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 0.5, + "start_date": "2063-01-12T18:47:01", + "end_date": "2063-07-14T09:47:01" + }, + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 0.5833333333333334, + "start_date": "2063-07-14T09:47:01", + "end_date": "2064-02-12T11:17:01" + }, + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 0.6666666666666666, + "start_date": "2064-02-12T11:17:01", + "end_date": "2064-10-12T23:17:01" + }, + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.08333333333333333, + "start_date": "2064-10-12T23:17:01", + "end_date": "2064-11-12T09:47:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.16666666666666666, + "start_date": "2064-11-12T09:47:01", + "end_date": "2065-01-12T06:47:01" + } + ] + }, + { + "yogini": "Bhramari", + "planet": "Mars", + "full_years": 4, + "years": 4.0, + "start_date": "2065-01-12T06:47:01", + "end_date": "2069-01-12T06:47:01", + "unclipped_start_date": "2065-01-12T06:47:01", + "cycle_number": 3, + "antardasha": [ + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.4444444444444444, + "start_date": "2065-01-12T06:47:01", + "end_date": "2065-06-23T14:47:01" + }, + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 0.5555555555555556, + "start_date": "2065-06-23T14:47:01", + "end_date": "2066-01-12T12:47:01" + }, + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 0.6666666666666666, + "start_date": "2066-01-12T12:47:01", + "end_date": "2066-09-13T00:47:01" + }, + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 0.7777777777777778, + "start_date": "2066-09-13T00:47:01", + "end_date": "2067-06-24T02:47:01" + }, + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 0.8888888888888888, + "start_date": "2067-06-24T02:47:01", + "end_date": "2068-05-13T18:47:01" + }, + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.1111111111111111, + "start_date": "2068-05-13T18:47:01", + "end_date": "2068-06-23T08:47:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.2222222222222222, + "start_date": "2068-06-23T08:47:01", + "end_date": "2068-09-12T12:47:01" + }, + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.3333333333333333, + "start_date": "2068-09-12T12:47:01", + "end_date": "2069-01-12T06:47:01" + } + ] + }, + { + "yogini": "Bhadrika", + "planet": "Mercury", + "full_years": 5, + "years": 5.0, + "start_date": "2069-01-12T06:47:01", + "end_date": "2074-01-12T12:47:01", + "unclipped_start_date": "2069-01-12T06:47:01", + "cycle_number": 3, + "antardasha": [ + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 0.6944444444444444, + "start_date": "2069-01-12T06:47:01", + "end_date": "2069-09-22T22:17:01" + }, + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 0.8333333333333334, + "start_date": "2069-09-22T22:17:01", + "end_date": "2070-07-24T07:17:01" + }, + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 0.9722222222222222, + "start_date": "2070-07-24T07:17:01", + "end_date": "2071-07-14T09:47:01" + }, + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 1.1111111111111112, + "start_date": "2071-07-14T09:47:01", + "end_date": "2072-08-23T05:47:01" + }, + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.1388888888888889, + "start_date": "2072-08-23T05:47:01", + "end_date": "2072-10-12T23:17:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.2777777777777778, + "start_date": "2072-10-12T23:17:01", + "end_date": "2073-01-22T10:17:01" + }, + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.4166666666666667, + "start_date": "2073-01-22T10:17:01", + "end_date": "2073-06-23T14:47:01" + }, + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.5555555555555556, + "start_date": "2073-06-23T14:47:01", + "end_date": "2074-01-12T12:47:01" + } + ] + }, + { + "yogini": "Ulka", + "planet": "Saturn", + "full_years": 6, + "years": 6.0, + "start_date": "2074-01-12T12:47:01", + "end_date": "2080-01-13T00:47:01", + "unclipped_start_date": "2074-01-12T12:47:01", + "cycle_number": 3, + "antardasha": [ + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 1.0, + "start_date": "2074-01-12T12:47:01", + "end_date": "2075-01-12T18:47:01" + }, + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 1.1666666666666667, + "start_date": "2075-01-12T18:47:01", + "end_date": "2076-03-13T21:47:01" + }, + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 1.3333333333333333, + "start_date": "2076-03-13T21:47:01", + "end_date": "2077-07-13T21:47:01" + }, + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.16666666666666666, + "start_date": "2077-07-13T21:47:01", + "end_date": "2077-09-12T18:47:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.3333333333333333, + "start_date": "2077-09-12T18:47:01", + "end_date": "2078-01-12T12:47:01" + }, + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.5, + "start_date": "2078-01-12T12:47:01", + "end_date": "2078-07-14T03:47:01" + }, + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.6666666666666666, + "start_date": "2078-07-14T03:47:01", + "end_date": "2079-03-14T15:47:01" + }, + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 0.8333333333333334, + "start_date": "2079-03-14T15:47:01", + "end_date": "2080-01-13T00:47:01" + } + ] + }, + { + "yogini": "Siddha", + "planet": "Venus", + "full_years": 7, + "years": 7.0, + "start_date": "2080-01-13T00:47:01", + "end_date": "2087-01-12T18:47:01", + "unclipped_start_date": "2080-01-13T00:47:01", + "cycle_number": 3, + "antardasha": [ + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 1.3611111111111112, + "start_date": "2080-01-13T00:47:01", + "end_date": "2081-05-24T04:17:01" + }, + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 1.5555555555555556, + "start_date": "2081-05-24T04:17:01", + "end_date": "2082-12-13T08:17:01" + }, + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.19444444444444445, + "start_date": "2082-12-13T08:17:01", + "end_date": "2083-02-22T08:47:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.3888888888888889, + "start_date": "2083-02-22T08:47:01", + "end_date": "2083-07-14T09:47:01" + }, + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.5833333333333334, + "start_date": "2083-07-14T09:47:01", + "end_date": "2084-02-12T11:17:01" + }, + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.7777777777777778, + "start_date": "2084-02-12T11:17:01", + "end_date": "2084-11-22T13:17:01" + }, + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 0.9722222222222222, + "start_date": "2084-11-22T13:17:01", + "end_date": "2085-11-12T15:47:01" + }, + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 1.1666666666666667, + "start_date": "2085-11-12T15:47:01", + "end_date": "2087-01-12T18:47:01" + } + ] + }, + { + "yogini": "Sankata", + "planet": "Rahu", + "full_years": 8, + "years": 8.0, + "start_date": "2087-01-12T18:47:01", + "end_date": "2095-01-12T18:47:01", + "unclipped_start_date": "2087-01-12T18:47:01", + "cycle_number": 3, + "antardasha": [ + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 1.7777777777777777, + "start_date": "2087-01-12T18:47:01", + "end_date": "2088-10-23T02:47:01" + }, + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.2222222222222222, + "start_date": "2088-10-23T02:47:01", + "end_date": "2089-01-12T06:47:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.4444444444444444, + "start_date": "2089-01-12T06:47:01", + "end_date": "2089-06-23T14:47:01" + }, + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.6666666666666666, + "start_date": "2089-06-23T14:47:01", + "end_date": "2090-02-22T02:47:01" + }, + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.8888888888888888, + "start_date": "2090-02-22T02:47:01", + "end_date": "2091-01-12T18:47:01" + }, + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 1.1111111111111112, + "start_date": "2091-01-12T18:47:01", + "end_date": "2092-02-22T14:47:01" + }, + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 1.3333333333333333, + "start_date": "2092-02-22T14:47:01", + "end_date": "2093-06-23T14:47:01" + }, + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 1.5555555555555556, + "start_date": "2093-06-23T14:47:01", + "end_date": "2095-01-12T18:47:01" + } + ] + }, + { + "yogini": "Mangala", + "planet": "Moon", + "full_years": 1, + "years": 1.0, + "start_date": "2095-01-12T18:47:01", + "end_date": "2096-01-13T00:47:01", + "unclipped_start_date": "2095-01-12T18:47:01", + "cycle_number": 3, + "antardasha": [ + { + "level": "antardasha", + "planet": "Moon", + "yogini": "Mangala", + "lord": "Moon", + "years": 0.027777777777777776, + "start_date": "2095-01-12T18:47:01", + "end_date": "2095-01-22T22:17:01" + }, + { + "level": "antardasha", + "planet": "Sun", + "yogini": "Pingala", + "lord": "Sun", + "years": 0.05555555555555555, + "start_date": "2095-01-22T22:17:01", + "end_date": "2095-02-12T05:17:01" + }, + { + "level": "antardasha", + "planet": "Jupiter", + "yogini": "Dhanya", + "lord": "Jupiter", + "years": 0.08333333333333333, + "start_date": "2095-02-12T05:17:01", + "end_date": "2095-03-14T15:47:01" + }, + { + "level": "antardasha", + "planet": "Mars", + "yogini": "Bhramari", + "lord": "Mars", + "years": 0.1111111111111111, + "start_date": "2095-03-14T15:47:01", + "end_date": "2095-04-24T05:47:01" + }, + { + "level": "antardasha", + "planet": "Mercury", + "yogini": "Bhadrika", + "lord": "Mercury", + "years": 0.1388888888888889, + "start_date": "2095-04-24T05:47:01", + "end_date": "2095-06-13T23:17:01" + }, + { + "level": "antardasha", + "planet": "Saturn", + "yogini": "Ulka", + "lord": "Saturn", + "years": 0.16666666666666666, + "start_date": "2095-06-13T23:17:01", + "end_date": "2095-08-13T20:17:01" + }, + { + "level": "antardasha", + "planet": "Venus", + "yogini": "Siddha", + "lord": "Venus", + "years": 0.19444444444444445, + "start_date": "2095-08-13T20:17:01", + "end_date": "2095-10-23T20:47:01" + }, + { + "level": "antardasha", + "planet": "Rahu", + "yogini": "Sankata", + "lord": "Rahu", + "years": 0.2222222222222222, + "start_date": "2095-10-23T20:47:01", + "end_date": "2096-01-13T00:47:01" + } + ] + } + ] + } +} diff --git a/scripts/dasha_registry.py b/scripts/dasha_registry.py index 87aaa1e4..e36aac5e 100644 --- a/scripts/dasha_registry.py +++ b/scripts/dasha_registry.py @@ -12,6 +12,7 @@ This module must not import `jyotish_api_server` or PyJHora. from __future__ import annotations +import copy from datetime import datetime from typing import Any @@ -287,8 +288,22 @@ def compute_dasha( body = body or {} alt, conditional, planet_lons = build_reading_birth_info(birth_dt, planets or {}, ascendant, body) if key == "yogini": - payload = _load("yogini_dasha").calculate_yogini_dasha({**alt, "moon_longitude": conditional["moon_longitude"]}) - return {"periods": _major_rows(payload)} + module = _load("yogini_dasha") + payload = module.calculate_yogini_dasha({**alt, "moon_longitude": conditional["moon_longitude"]}) + year_days = 365.25 + if isinstance(payload, dict) and payload.get("dasha_year_days") is not None: + year_days = float(payload["dasha_year_days"]) + periods = [] + for row in _major_rows(payload): + copied = copy.deepcopy(row) + if isinstance(copied, dict): + copied["antardasha"] = module.yogini_antardashas( + copied, + birth_dt=birth_dt, + year_days=year_days, + ) + periods.append(copied) + return {"periods": periods} if key == "ashtottari": payload = _load("ashtottari_dasha").calculate_ashtottari_dasha({ **alt, diff --git a/scripts/yogini_dasha.py b/scripts/yogini_dasha.py index 00ddf62f..8be0a416 100644 --- a/scripts/yogini_dasha.py +++ b/scripts/yogini_dasha.py @@ -61,10 +61,63 @@ def calculate_yogini_dasha(birth_info: dict) -> dict: moon_longitude: sidereal degrees, required as_of / today: observation instant; report callers should pass it dasha_year_days: fixed-day year, default 365.25 + + Major rows stay free of sub-periods. Callers that need antardashas use + ``yogini_antardashas`` on a copy. """ return _calculate_moon_balanced_yogini(birth_info) +def _as_datetime(value): + if isinstance(value, datetime): + return value + return datetime.fromisoformat(str(value)) + + +def yogini_antardashas(major_row: dict, *, birth_dt, year_days: float) -> list: + """Proportional antardashas inside one Yogini mahadasha. + + Eight sub-periods start at this mahadasha's own Yogini and follow Goel + Table I. Each length is ``full_years * sub_years / 36``. They are laid out + from ``unclipped_start_date``. Sub-periods that end at or before birth are + dropped. The one that crosses birth keeps ``unclipped_start_date`` and + starts at birth. The last sub-period ends on the mahadasha ``end_date``. + """ + birth = _as_datetime(birth_dt) + year_length = float(year_days) + if year_length <= 0: + raise ValueError('dasha_year_days must be positive') + start_idx = next( + index for index, item in enumerate(YOGINI_SEQUENCE) if item['name'] == major_row['yogini'] + ) + full_years = float(major_row['full_years']) + cursor = _as_datetime(major_row['unclipped_start_date']) + major_end = _as_datetime(major_row['end_date']) + rows = [] + for offset in range(len(YOGINI_SEQUENCE)): + item = YOGINI_SEQUENCE[(start_idx + offset) % len(YOGINI_SEQUENCE)] + span_days = full_years * item['years'] / TOTAL_CYCLE * year_length + end = major_end if offset == len(YOGINI_SEQUENCE) - 1 else cursor + timedelta(days=span_days) + if end <= birth: + cursor = end + continue + visible_start = birth if cursor < birth else cursor + row = { + 'level': 'antardasha', + 'planet': item['planet'], + 'yogini': item['name'], + 'lord': item['planet'], + 'years': (end - visible_start).total_seconds() / (86400 * year_length), + 'start_date': visible_start.isoformat(timespec='seconds'), + 'end_date': end.isoformat(timespec='seconds'), + } + if cursor < birth: + row['unclipped_start_date'] = cursor.isoformat(timespec='seconds') + rows.append(row) + cursor = end + return rows + + def _calculate_moon_balanced_yogini(birth_info): """Goel Table I order, angular birth balance, explicit fixed-day year.""" birth_dt = birth_info.get("birth_datetime") diff --git a/tests/test_dasha_api_engine_parity.py b/tests/test_dasha_api_engine_parity.py index 991c9c75..82ba876b 100644 --- a/tests/test_dasha_api_engine_parity.py +++ b/tests/test_dasha_api_engine_parity.py @@ -16,7 +16,7 @@ import os import random import re import sys -from datetime import date, datetime +from datetime import date, datetime, timedelta from functools import lru_cache from pathlib import Path @@ -201,6 +201,18 @@ def _period_covering(majors, moment: datetime): return None +def _without_antardasha(rows): + cleaned = [] + for row in rows: + if isinstance(row, dict) and "antardasha" in row: + item = dict(row) + item.pop("antardasha") + cleaned.append(item) + else: + cleaned.append(row) + return cleaned + + def test_api_major_periods_match_full_reading_modules() -> None: report = _fiction_reading() modules = report["modules"] @@ -210,7 +222,16 @@ def test_api_major_periods_match_full_reading_modules() -> None: result = handler._compute_dasha_system(_chart_body(report, key)) assert result["precision"] == "engine_module", key assert result["precision"] not in {"generic", "calculator"} - assert result["periods"] == engine_rows, key + if key == "yogini": + # 原值:result["periods"] == engine_rows + # 新值:去掉 antardasha 后相等 + # 原因:小运只挂在接口返回的副本上,完整解读的 Yogini 行保持原字段 + assert _without_antardasha(result["periods"]) == engine_rows, key + assert result["periods"] + assert all(isinstance(row, dict) and row.get("antardasha") for row in result["periods"]) + assert all(isinstance(row, dict) and "antardasha" not in row for row in engine_rows) + else: + assert result["periods"] == engine_rows, key def test_unimplemented_dasha_keys_are_rejected() -> None: @@ -425,6 +446,172 @@ def test_ashtottari_birth_balance_matches_pyjhora_within_one_day() -> None: _public_case("spears", spears, "moon") +YOGINI_PUBLIC_CHARTS = ( + ("presley", { + "year": 1935, + "month": 1, + "day": 8, + "hour": 4, + "minute": 35, + "lat": 34.3, + "lon": -88.4, + "tz": -6.0, + }), + ("spears", { + "year": 1981, + "month": 12, + "day": 2, + "hour": 19, + "minute": 55, + "lat": 31.17, + "lon": -89.18, + "tz": 0.0, + }), + ("einstein", { + "year": 1879, + "month": 3, + "day": 14, + "hour": 11, + "minute": 30, + "lat": 48.4, + "lon": 9.98, + "tz": 0.89, + }), +) + + +def _api_yogini_periods(birth: dict) -> list: + from scripts.domain_calculation_service import compute_chart + + chart = compute_chart({**birth, "second": 0, "node_mode": "mean", "ayanamsa": "lahiri"}) + planets = { + name: {"lon": row["lon"]} + for name, row in chart["planets"].items() + if isinstance(row, dict) and isinstance(row.get("lon"), (int, float)) + } + body = { + "dasha": "yogini", + "year": birth["year"], + "month": birth["month"], + "day": birth["day"], + "hour": birth["hour"], + "minute": birth["minute"], + "second": 0, + "lat": birth["lat"], + "lon": birth["lon"], + "tz": birth["tz"], + "today": "2026-10-22", + "ayanamsa": "lahiri", + "node_mode": "mean", + "planets": planets, + "ascendant": {"lon": chart["ascendant"]["lon"]}, + } + result = _handler()._compute_dasha_system(body) + assert result["precision"] == "engine_module" + return result["periods"] + + +def _pyjhora_yogini_end(row, following) -> date: + if following is not None: + return date(*following[1][:3]) + from jhora import const + + parts = row[1] + hours = float(parts[3]) if len(parts) > 3 else 0.0 + start = datetime(int(parts[0]), int(parts[1]), int(parts[2])) + timedelta(hours=hours) + return (start + timedelta(days=float(row[2]) * float(const.sidereal_year))).date() + + +def _assert_yogini_majors_against_pyjhora(name: str, periods: list, py_rows: list) -> None: + assert len(py_rows) >= 24, name + assert len(periods) >= 24, name + mismatches = [] + for index, (engine, oracle) in enumerate(zip(periods[:24], py_rows[:24])): + engine_lord = engine.get("planet") + oracle_lord = PYJHORA_LORDS[oracle[0][0]] + engine_start = _as_date(engine["unclipped_start_date"]) + oracle_start = date(*oracle[1][:3]) + following = py_rows[index + 1] if index + 1 < 24 else None + engine_end = _as_date(engine["end_date"]) + oracle_end = _pyjhora_yogini_end(oracle, following) + start_delta = abs((engine_start - oracle_start).days) + end_delta = abs((engine_end - oracle_end).days) + if engine_lord != oracle_lord or start_delta > 1 or end_delta > 1: + mismatches.append( + f"{name} major[{index}] engine={engine_lord} {engine_start}..{engine_end} " + f"pyjhora={oracle_lord} {oracle_start}..{oracle_end} " + f"start_delta_days={start_delta} end_delta_days={end_delta}" + ) + assert not mismatches, "\n".join(mismatches) + + +def _assert_yogini_antardasha_consistency(name: str, periods: list, birth: datetime) -> None: + from scripts.yogini_dasha import TOTAL_CYCLE, YOGINI_SEQUENCE + + year_days = 365.25 + problems = [] + for index, major in enumerate(periods): + subs = major.get("antardasha") or [] + if not subs: + problems.append(f"{name} major[{index}] {major.get('yogini')} has no antardasha") + continue + start_idx = next(item_index for item_index, item in enumerate(YOGINI_SEQUENCE) if item["name"] == major["yogini"]) + expected = [YOGINI_SEQUENCE[(start_idx + offset) % 8] for offset in range(8)] + got = [row["yogini"] for row in subs] + suffixes = [[item["name"] for item in expected[cut:]] for cut in range(8)] + if got not in suffixes: + problems.append(f"{name} major[{index}] order {got} is not a Goel suffix of {major['yogini']}") + continue + matched = expected[8 - len(got):] + for row, item in zip(subs, matched): + if row.get("level") != "antardasha" or row.get("planet") != item["planet"] or row.get("lord") != item["planet"]: + problems.append(f"{name} major[{index}] {row.get('yogini')} fields {row.get('level')} {row.get('planet')} {row.get('lord')}") + raw_start = datetime.fromisoformat(row.get("unclipped_start_date") or row["start_date"]) + raw_end = datetime.fromisoformat(row["end_date"]) + expected_seconds = float(major["full_years"]) * item["years"] / TOTAL_CYCLE * year_days * 86400 + delta_seconds = abs((raw_end - raw_start).total_seconds() - expected_seconds) + if delta_seconds > 2: + problems.append( + f"{name} major[{index}] {item['name']} span_delta_seconds={delta_seconds:.3f}" + ) + for left, right in zip(subs, subs[1:]): + if left["end_date"] != right["start_date"]: + problems.append(f"{name} major[{index}] gap {left['end_date']} -> {right['start_date']}") + if subs[0]["start_date"] != major["start_date"]: + problems.append(f"{name} major[{index}] first sub {subs[0]['start_date']} != major {major['start_date']}") + if index == 0 and datetime.fromisoformat(subs[0]["start_date"]) != birth: + problems.append(f"{name} first sub {subs[0]['start_date']} != birth {birth.isoformat(timespec='seconds')}") + if index > 0 and any("unclipped_start_date" in row for row in subs): + problems.append(f"{name} major[{index}] later mahadasha was clipped") + visible = sum( + (datetime.fromisoformat(row["end_date"]) - datetime.fromisoformat(row["start_date"])).total_seconds() + for row in subs + ) + major_span = (datetime.fromisoformat(major["end_date"]) - datetime.fromisoformat(major["start_date"])).total_seconds() + if abs(visible - major_span) > 86400: + problems.append(f"{name} major[{index}] sub_sum_delta_seconds={visible - major_span:.3f}") + if index == 0 and major["unclipped_start_date"] == major["start_date"]: + if len(subs) != 8: + problems.append(f"{name} unclipped first major has {len(subs)} subs") + for row, item in zip(subs, expected): + formula = float(major["full_years"]) * item["years"] / TOTAL_CYCLE + if abs(float(row["years"]) - formula) > 1e-6: + problems.append(f"{name} unclipped {item['name']} years {row['years']} != {formula}") + assert not problems, "\n".join(problems) + + +def test_yogini_api_majors_match_pyjhora_and_antardashas_are_proportional() -> None: + import importlib.metadata as metadata + + version = metadata.version("PyJHora") + assert version == "4.8.7", version + for name, birth in YOGINI_PUBLIC_CHARTS: + periods = _api_yogini_periods(birth) + born = datetime(birth["year"], birth["month"], birth["day"], birth["hour"], birth["minute"]) + _assert_yogini_majors_against_pyjhora(name, periods, _pyjhora_rows("yogini", birth)) + _assert_yogini_antardasha_consistency(name, periods, born) + + def test_placeholder_modules_are_gone() -> None: assert not (SCRIPTS / "extended_dashas.py").exists() assert not (SCRIPTS / "conditional_dashas.py").exists() diff --git a/tests/test_yogini_seed_profile.py b/tests/test_yogini_seed_profile.py index 52a7e85d..caeaa6e0 100644 --- a/tests/test_yogini_seed_profile.py +++ b/tests/test_yogini_seed_profile.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta import pytest -from scripts.yogini_dasha import YOGINI_SEQUENCE, calculate_yogini_dasha +from scripts.yogini_dasha import YOGINI_SEQUENCE, TOTAL_CYCLE, calculate_yogini_dasha, yogini_antardashas def test_yogini_classical_sequence_and_balance(): @@ -57,3 +57,77 @@ def test_yogini_explicit_reference_controls_current_cycle(): # Ashwini start: (0 + 3) % 8 is Bhramari / Mars, and the balance is the whole period. assert first['starting_planet'] == 'Mars' assert first['birth_balance_years'] == pytest.approx(4.0) + + +def _sub_names(start_name): + start = next(index for index, item in enumerate(YOGINI_SEQUENCE) if item['name'] == start_name) + return [YOGINI_SEQUENCE[(start + offset) % 8]['name'] for offset in range(8)] + + +def test_yogini_antardasha_proportional_on_unclipped_seed(): + # Moon at Ashwini 0°: Bhramari opens on the birth instant, so nothing is clipped. + birth = datetime(2000, 1, 1) + year_days = 360 + result = calculate_yogini_dasha({ + 'birth_datetime': birth, + 'moon_longitude': 0.0, + 'as_of': '2000-01-02', + 'dasha_year_days': year_days, + }) + assert all('antardasha' not in row for row in result['major']) + major = result['major'][0] + assert major['unclipped_start_date'] == major['start_date'] + subs = yogini_antardashas(major, birth_dt=birth, year_days=year_days) + assert [row['yogini'] for row in subs] == _sub_names('Bhramari') + assert all('unclipped_start_date' not in row for row in subs) + assert subs[0]['start_date'] == major['start_date'] + assert subs[-1]['end_date'] == major['end_date'] + assert all(left['end_date'] == right['start_date'] for left, right in zip(subs, subs[1:])) + for row, item in zip(subs, YOGINI_SEQUENCE[3:] + YOGINI_SEQUENCE[:3]): + assert row['level'] == 'antardasha' + assert row['planet'] == row['lord'] == item['planet'] + assert row['years'] == pytest.approx(major['full_years'] * item['years'] / TOTAL_CYCLE) + visible = sum( + (datetime.fromisoformat(row['end_date']) - datetime.fromisoformat(row['start_date'])).total_seconds() + for row in subs + ) + major_span = ( + datetime.fromisoformat(major['end_date']) - datetime.fromisoformat(major['start_date']) + ).total_seconds() + assert abs(visible - major_span) <= 86400 + + +def test_yogini_antardasha_clips_the_birth_crossing_subperiod(): + # Mid-Pushya is the same seed as test_yogini_classical_sequence_and_balance, + # shifted so the elapsed balance falls inside Siddha rather than on a boundary. + birth = datetime(2000, 1, 1, 12) + year_days = 360 + moon = (7 + 0.6) * (360 / 27) + result = calculate_yogini_dasha({ + 'birth_datetime': birth, + 'moon_longitude': moon, + 'as_of': '2000-01-02', + 'dasha_year_days': year_days, + }) + first = result['major'][0] + later = result['major'][1] + first_subs = yogini_antardashas(first, birth_dt=birth, year_days=year_days) + later_subs = yogini_antardashas(later, birth_dt=birth, year_days=year_days) + assert first_subs[0]['yogini'] == 'Siddha' + assert first_subs[0]['start_date'] == first['start_date'] == birth.isoformat(timespec='seconds') + assert datetime.fromisoformat(first_subs[0]['unclipped_start_date']) < birth + assert [row['yogini'] for row in first_subs] == _sub_names('Dhanya')[-4:] + assert later_subs[0]['start_date'] == later['start_date'] + assert [row['yogini'] for row in later_subs] == _sub_names(later['yogini']) + assert 'unclipped_start_date' not in later_subs[0] + for major, subs in ((first, first_subs), (later, later_subs)): + assert all(left['end_date'] == right['start_date'] for left, right in zip(subs, subs[1:])) + assert subs[-1]['end_date'] == major['end_date'] + visible = sum( + (datetime.fromisoformat(row['end_date']) - datetime.fromisoformat(row['start_date'])).total_seconds() + for row in subs + ) + major_span = ( + datetime.fromisoformat(major['end_date']) - datetime.fromisoformat(major['start_date']) + ).total_seconds() + assert abs(visible - major_span) == 0