From 340f0f2b85953158bff2de4a51f8d704881ff783 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 17:07:54 +0800 Subject: [PATCH] feat(annual): annual data package v1 (#152 ruling, phase 1 of #153) POST /api/annual with mode=package (or /api/annual/package) returns one versioned package built from the existing producers: year charts with ascendant/lord, Muntha, Panchadhikari year lord, annual strength, Sahams, Tajika yogas, Tripataki, Mudda, native Patyayini, Tajika monthly charts; natal Vimshottari MD/AD/PD and Narayana (legacy + Rath); slow-planet ingresses, stations, retrograde loops and natal-point crossings; Tithi Pravesha as an independent view. Report range, year-chart validity and each period system's own year are separate, every boundary is an exact instant with timezone, and every block carries computed/blocked/not_applicable with a reason. The default /api/annual response is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y --- CHANGELOG.md | 11 + docs/engine/annual-package-v1.md | 98 + frontend/src/lib/annual-package-contract.ts | 299 ++ .../tests/annual-package-contract.test.ts | 76 + .../tests/fixtures/annual-package-v1.json | 4060 +++++++++++++++++ scripts/annual_package.py | 1268 +++++ scripts/jyotish_api_server.py | 57 + tests/test_annual_package.py | 450 ++ 8 files changed, 6319 insertions(+) create mode 100644 docs/engine/annual-package-v1.md create mode 100644 frontend/src/lib/annual-package-contract.ts create mode 100644 frontend/tests/annual-package-contract.test.ts create mode 100644 frontend/tests/fixtures/annual-package-v1.json create mode 100644 scripts/annual_package.py create mode 100644 tests/test_annual_package.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b4b68824..7c700dc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # 印度占星 Skill 更新日志 +## 2026-11-01 — 年运数据接口:一次给出完整年度包 + +- `/api/annual` 加 `mode: "package"`(或 `/api/annual/package`),一次返回年运报告要用的全部数据:年盘及上升、上升主,年主(Panchadhikari),Muntha 及 Muntha 主,年度力量(Panchavargiya、Harsha Bala),Sahams,Tajika Yoga 结构,Tripataki,Mudda,Patyayini,Tajika 月盘,本命 Vimshottari 大运 / 小运 / 细运和 Narayana(现用旧算法与 Sanjay Rath 版并列),木星、土星、罗睺、计都的换宫、停滞、逆行回路和与本命点的精确经过。Tithi Pravesha 作为独立年度视角单列。 +- 全部复用已有计算,不另写算法。Patyayini 用年度原生入口。 +- 时间分开表示:报告范围(生日年度 / 公历年 / 未来十二个月)、每张年盘的有效期(本次返照到下次返照)、各运限自己的年制(Mudda、Patyayini 固定 365.25 天,只裁到报告范围,不拉伸)。每个边界都带精确时刻和时区。公历年和未来十二个月跨生日时,自动接上相邻两张年盘。 +- 出生秒、交点、岁差都传到每一项计算。返照地点 v1 只有出生地,字段先留着。 +- 每一项都写明「已计算 / 未计算 / 不适用」及原因;某一项算不出不影响其它项。Varsha Narayana 本仓没有原生实现,没有放进来。 +- 修正:选真交点时年盘的罗睺 / 计都以前总是平均交点,现在跟随选择(BUG-1501)。 +- 原来不带 `mode` 的 `/api/annual` 返回不变。说明见 `docs/engine/annual-package-v1.md`。 +- Skill 版本不变(issue #152、#153)。 + ## 2026-10-30 — 星盘页:盘旁「盘面数据」卡,表格和盘联动 - 盘的右边(手机上是盘的下面)新增「盘面数据」卡,九个页签:行星、虚星、特殊点、映射、八分点、星力、大运、敌友、瑜伽。原来散在「九曜」「Vimshottari 大运」两张卡和深读「命性格局」「排盘底册」里的表都搬到这里,不再重复。 diff --git a/docs/engine/annual-package-v1.md b/docs/engine/annual-package-v1.md new file mode 100644 index 00000000..85ccb3b5 --- /dev/null +++ b/docs/engine/annual-package-v1.md @@ -0,0 +1,98 @@ +# 年度数据包 v1(annual_package_version = 1) + +依据:Gitea #152 裁定(jesse 2026-11-01 确认)「落地前要先做的四件事」。这是 #153(年运报告)和 #154(入口遮罩页)的数据接口。只做计算和数据搬运,不含解读文字,不建候选窗口。 + +## 入口 + +- `POST /api/annual`,请求体加 `"mode": "package"`;或 `POST /api/annual/package`(同一个处理函数)。 +- 不带 `mode` 的 `/api/annual` 仍返回原来的基础太阳返照报告(`report`),形状不变;只多传了 `node_mode`(默认 mean,结果不变)。 +- 代码:`scripts/annual_package.py`(`normalize_request`、`build_annual_package`),API 处理在 `scripts/jyotish_api_server.py::_compute_annual_package`。前端合同:`frontend/src/lib/annual-package-contract.ts`(zod + 映射,无界面)。 + +### 请求字段 + +| 字段 | 说明 | +| --- | --- | +| `year` `month` `day` `hour` `minute` `second` | 出生时间,秒会传到所有生产器 | +| `lat` `lon` `tz` | 出生地;`tz` 缺省时按经纬度推算 | +| `timezone_id` / `timezone` | 可选 IANA 时区;有则返照本地时间、报告范围按该时区(含夏令时) | +| `ayanamsa` | 岁差,默认同其他接口 | +| `node_mode` | `mean` / `true` | +| `report_range` | `{kind, year?, start_date?}`,`kind` 为 `birthday_year`(生日年度)/ `calendar_year`(公历年)/ `next_12_months`(未来十二个月) | +| `observation_date` | 报告观测日期,默认今天;`next_12_months` 不给 `start_date` 时从这天起 | +| `return_location` | `{mode: "birth_place"}`。v1 只开放出生地,其它值返回 400;字段保留,以后加所在地对照不改形状 | + +## 响应结构 + +`{success, endpoint: "annual", mode: "package", annual_package_version: 1, package}`,`package` 的键序: + +1. `schema` = `jyotish.annual_package.v1`、`annual_package_version` +2. `parameters`:出生时刻(UTC、出生固定偏移下的本地时间)、岁差、交点、宫制、回归地点、观测日期、本命盘经度(`natal_chart`) +3. `report_range`:报告范围 +4. `year_charts[]`:覆盖报告范围的每张年盘 +5. `natal_periods`:`vimshottari`、`narayana`(`legacy` + `rath`) +6. `slow_transits`:木星、土星、罗睺、计都 +7. `independent_annual_views.tithi_pravesha`:独立年度视角 +8. `computation_boundaries`:时间语义与计算边界的说明 +9. `not_included`:没放进来的项和原因 +10. `block_index`:所有块的路径、状态、生产器、原因;`blocked_count` + +### 块状态 + +每个块都有 `status`:`computed` / `blocked` / `not_applicable`,以及 `producer`;非 `computed` 一定有 `reason`。生产器原有的状态(如 `partial_verified`、`parameter_sensitive`)放在 `producer_status`,不提升。生产器报错、返回 blocked、两个年运生产器结果冲突(`producer_conflict:...`,两个值都在 `conflict_values`)都写成 blocked。某个块 blocked 不影响整个响应。 + +## 时间语义(四种时间分开表示) + +所有边界都是一个“时刻”:`{utc, local, utc_offset, timezone_id, jd_ut}`。`local` 用回归地点的时区(v1 即出生地;给了 IANA 就按当时的规则取偏移,否则用出生固定偏移)。出生时刻本身用出生固定偏移显示,因为排盘按这个偏移读出生时钟(例:上海 1986–1991 年有夏令时)。 + +1. **报告范围** `report_range`:`start` 含、`end` 不含。 + - 生日年度:本次太阳返照到下次太阳返照; + - 公历年:当地 1 月 1 日 0 点到次年 1 月 1 日 0 点; + - 未来十二个月:`start_date` 当地 0 点到十二个日历月后(月底按月末截)。 + - `year_chart_years` 列出与范围重叠的年盘,跨生日时是相邻两张;`crosses_birthday`、`birthday_boundaries` 给出换年盘的时刻。 + - `observation`:观测日期、在范围内 / 前 / 后,回看起点(默认三个月)。 +2. **年盘有效期** `year_charts[].validity`:本次返照(含)到下次返照(不含),`end_source = next_solar_return`;`in_report_range` 是与报告范围的交集。 +3. **各运限自己的年制**:Mudda 365.25 天、Patyayini 固定 365.25 天,都从返照时刻起;Vimshottari 365.25 天,从出生时刻起;Narayana 在年龄轴上,日期 = 出生时刻 + 年龄 × 365.25 天(与引擎算当前年龄的口径一致)。各行保留原始起止,`in_report_range` 是裁到报告范围的部分,不拉伸到返照年(返照年约 365.256 天,Mudda / Patyayini 的年末比下次返照早几分钟)。 +4. **计算边界**:生产器在本地时钟上加天数的(Mudda、Patyayini、Vimshottari),按“从锚点时刻起经过的时间”换成 UTC,跨夏令时也对。月盘第 12 张止于下次返照。 + +候选窗口不在本期,由 #153 用上面这些时刻去建。 + +## 各块由谁计算 + +| 块 | 生产器 | +| --- | --- | +| 年盘、上升及上升主、宫位 | `cmd_solar_return` → `solar_return.solar_return_full_report`(结果交给 `annual_tajika_pack.build_annual_tajika_pack`) | +| Muntha、Muntha 主落宫 | `solar_return._muntha_from_natal_ascendant`(经年运包);落宫按年盘整宫数 | +| 年主 | `tajika_year_lord.select_annual_year_lord`(Panchadhikari,经年运包冲突门) | +| 年度力量 | `tajika.calc_tajika_strength_layers`:Panchavargiya、Harsha Bala;不是本命 Shadbala | +| Sahams | `tajika.calc_all_sahams`(原样保留公式来源与昼夜依据) | +| Tajika Yoga | `tajika_named_yoga_authority`(只有结构,不翻译成事件) | +| Tripataki | `annual_native.tripataki_chakra` | +| Mudda | `tajika_mudda.annual_mudda_dasha`(出生月宿起运,与年主无关) | +| Patyayini | `annual_native.patyayini.build_native_patyayini`(`tajika-annual` skill 指定的年度原生入口;不用 `extended_dashas.py` 的等长旧函数),输入八点的经度一并给出 | +| Tajika 月盘 | `annual_native.monthly_charts.build_monthly_charts`:太阳每前进 30° 一张,不是公历月,也不是月亮回归盘 | +| Tithi Pravesha | `annual_native.tithi_pravesh.build_tithi_pravesh`,独立单列:起点规则不同于太阳回归,地点用出生地和出生固定偏移(生产器自己的规则);找到几个候选就列几个,不替用户选 | +| Vimshottari MD/AD/PD | `jyotish_engine.cmd_dasha`(出生时刻起运) | +| Narayana | `narayana_dasha.narayana_dasha_full_report`(产品现用的旧算法,`used_by_product_timeline: true`)和 `narayana_rath`(Sanjay Rath 版,占星师口径,并列展示,`false`);只列与范围重叠的 MD / AD | +| 慢星行运 | 位置取 `jyotish_engine._calc_sidereal_planets_for_jd`(与本命盘同一参考系,跟随交点和岁差);每天 12:00 UT 取样,再二分到 1 秒以内。给出:范围起点的位置(从上升和月亮各算宫)、换宫、停滞、与本命十点(上升 + 九曜)的精确合相经过及次数、木星 / 土星的逆行回路(停滞时刻与经度、回扫弧段、前后影区进出时刻、落在弧段内的本命点及每次经过) | + +## 本期修的参数缺口(BUG-1501) + +`solar_return.calc_solar_return_chart` 排出生盘和年盘时交点口径写死为 `mean`,`node_mode=true` 传不到年盘,年盘罗睺 / 计都和 Tripataki 总是平均交点。现在 `calc_solar_return_chart` / `solar_return_full_report` 接受 `node_mode`(默认 mean),`cmd_solar_return` 和 `cmd_tajika` 都会传。 + +## 没放进来的(`not_included`) + +- Varsha Narayana:本仓没有原生生产器(上游 yinduzhanxing 有,未同步)。 +- Yoga / Nakshatra Pravesha:上游只有研究版,裁定不进报告。 +- 外部引擎回放(PyJHora):只在完整数据版的子进程里跑,这里不执行。 +- 所在地返照:v1 只开放出生地。 +- 行运的 BAV / SAV、Vedha、Sade Sati 阶段:报告阶段做;BAV / SAV 来自 `/api/ashtakavarga`。 +- 候选窗口:#153。 +- Real Case Calibration:按裁定仍是 blocked,不作准确率声明。 + +## 已知限制 + +- Narayana 旧算法的 AD 年龄由生产器保留 4 位小数,换成日期约有 53 分钟误差;MD 年龄是整年相加,没有这个问题。旧算法只排一轮,范围超出这一轮时写 blocked。 +- 慢星事件按天取样,一天内相继发生的两件事可能合并(慢星实际不会出现)。真交点每天取样。 +- 回路的影区边界落在扫描窗口(范围前后各 220 天)之外时写 `null`,并在 `shadow_note` 里说明。 +- 返照、Mudda、Patyayini 的时刻都按返照求解器的精确时刻(JD)对齐;生产器内部用的是截到秒的时间,差不到 1 秒。 +- 一个公历年的完整包大约 350 KB(大头是 Mudda / Patyayini 的子运行,每行有三个时刻)。 diff --git a/frontend/src/lib/annual-package-contract.ts b/frontend/src/lib/annual-package-contract.ts new file mode 100644 index 00000000..6d080ac5 --- /dev/null +++ b/frontend/src/lib/annual-package-contract.ts @@ -0,0 +1,299 @@ +import { z } from "zod"; + +// Annual data package v1 (POST /api/annual with mode "package", or +// /api/annual/package). Producer: scripts/annual_package.py. Design note: +// docs/engine/annual-package-v1.md. Only the fields the report and the entry overlay +// read are typed; producer data stays passthrough so evidence is not dropped. + +export const ANNUAL_PACKAGE_VERSION = 1; + +export const annualBlockStatusSchema = z.enum(["computed", "blocked", "not_applicable"]); +export type AnnualBlockStatus = z.infer; + +export const annualInstantSchema = z.object({ + utc: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/), + local: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/), + utc_offset: z.string().regex(/^[+-]\d{2}:\d{2}$/), + timezone_id: z.string().nullable(), + jd_ut: z.number(), +}); +export type AnnualInstant = z.infer; + +const spanSchema = z.object({ start: annualInstantSchema, end: annualInstantSchema }); + +const blockBase = { + status: annualBlockStatusSchema, + producer: z.string().min(1), + reason: z.string().optional(), +}; +const blockSchema = z.object(blockBase).passthrough(); + +const periodRowSchema = z.object({ + start: annualInstantSchema, + end: annualInstantSchema, + in_report_range: spanSchema.nullable(), +}).passthrough(); + +const yearLengthSchema = z.object({ + basis: z.string().nullable(), + days: z.number().positive(), + start: annualInstantSchema, + end: annualInstantSchema, +}).passthrough(); + +const periodBlockSchema = z.object({ + ...blockBase, + year_length: yearLengthSchema.optional(), + periods: z.array(periodRowSchema).optional(), + antardasha_periods: z.array(periodRowSchema).optional(), +}).passthrough(); + +const monthlyRowSchema = z.object({ + month_index: z.number().int().min(1).max(12), + status: annualBlockStatusSchema, + start: annualInstantSchema.optional(), + end: annualInstantSchema.nullable().optional(), + in_report_range: spanSchema.nullable().optional(), +}).passthrough(); + +const yearChartSchema = z.object({ + ...blockBase, + target_year: z.number().int(), + completed_years: z.number().int(), + validity: z.object({ + start: annualInstantSchema, + end: annualInstantSchema, + end_exclusive: z.literal(true), + end_source: z.literal("next_solar_return"), + }).passthrough().optional(), + in_report_range: spanSchema.nullable().optional(), + blocks: z.object({ + chart: z.object({ + ...blockBase, + ascendant: z.object({ longitude: z.number(), sign: z.string(), degree_in_sign: z.number() }).optional(), + ascendant_lord: z.object({ planet: z.string(), sign: z.string().nullable(), house: z.number().int().nullable() }).passthrough().optional(), + }).passthrough(), + muntha: blockSchema, + year_lord: blockSchema, + annual_strength: blockSchema, + sahams: blockSchema, + tajika_yogas: blockSchema, + tripataki: blockSchema, + mudda_dasha: periodBlockSchema, + patyayini: periodBlockSchema, + monthly_charts: z.object({ ...blockBase, rows: z.array(monthlyRowSchema).optional() }).passthrough(), + }).optional(), +}).passthrough(); + +const natalRowSchema = z.object({ + level: z.enum(["MD", "AD", "PD"]), + lord: z.string().nullable(), + sign: z.string().nullable().optional(), + start: annualInstantSchema, + end: annualInstantSchema, + in_report_range: spanSchema.nullable(), +}).passthrough(); + +const natalBlockSchema = z.object({ ...blockBase, rows: z.array(natalRowSchema).optional() }).passthrough(); + +const transitEventSchema = z.object({ + kind: z.enum(["ingress", "station"]), + body: z.enum(["Jupiter", "Saturn", "Rahu", "Ketu"]), + instant: annualInstantSchema, + from_sign: z.string().optional(), + to_sign: z.string().optional(), + motion: z.enum(["direct_to_retrograde", "retrograde_to_direct"]).optional(), +}).passthrough(); + +export const annualPackageSchema = z.object({ + schema: z.literal("jyotish.annual_package.v1"), + annual_package_version: z.literal(ANNUAL_PACKAGE_VERSION), + parameters: z.object({ + ayanamsa: z.string(), + node_mode: z.enum(["mean", "true"]), + return_location: z.object({ mode: z.literal("birth_place") }).passthrough(), + observation_date: z.string(), + }).passthrough(), + report_range: z.object({ + ...blockBase, + kind: z.enum(["birthday_year", "calendar_year", "next_12_months"]), + start: annualInstantSchema.optional(), + end: annualInstantSchema.optional(), + year_chart_years: z.array(z.number().int()).optional(), + crosses_birthday: z.boolean().optional(), + birthday_boundaries: z.array(annualInstantSchema).optional(), + }).passthrough(), + year_charts: z.array(yearChartSchema), + natal_periods: z.object({ + vimshottari: natalBlockSchema, + narayana: z.object({ ...blockBase, legacy: natalBlockSchema.optional(), rath: natalBlockSchema.optional() }).passthrough(), + }), + slow_transits: z.object({ ...blockBase, events: z.array(transitEventSchema).optional() }).passthrough(), + independent_annual_views: z.object({ tithi_pravesha: blockSchema }).passthrough(), + computation_boundaries: z.record(z.string(), z.unknown()), + not_included: z.array(z.object({ item: z.string(), reason: z.string() })), + block_index: z.array(z.object({ + path: z.string(), + status: annualBlockStatusSchema, + producer: z.string(), + reason: z.string().nullable().optional(), + })), + blocked_count: z.number().int().min(0), +}); +export type AnnualPackage = z.infer; + +export const annualPackageResponseSchema = z.object({ + success: z.literal(true), + mode: z.literal("package"), + annual_package_version: z.literal(ANNUAL_PACKAGE_VERSION), + package: annualPackageSchema, +}); + +export function parseAnnualPackageResponse(value: unknown): AnnualPackage | null { + const parsed = annualPackageResponseSchema.safeParse(value); + return parsed.success ? parsed.data.package : null; +} + +// ── mapped view (no UI copy; labels belong to the report / overlay) ───────── + +export type AnnualSpan = { start: AnnualInstant; end: AnnualInstant }; + +export type AnnualPeriodView = { + lord: string | null; + start: AnnualInstant; + end: AnnualInstant; + inRange: AnnualSpan | null; +}; + +export type AnnualBlockView = + | { status: "computed"; value: T } + | { status: "blocked" | "not_applicable"; reason: string }; + +export type AnnualYearChartView = { + targetYear: number; + validity: AnnualSpan | null; + inRange: AnnualSpan | null; + ascendant: AnnualBlockView<{ sign: string; degreeInSign: number; lord: string | null }>; + yearLord: AnnualBlockView<{ planet: string | null }>; + muntha: AnnualBlockView<{ sign: string | null; lord: string | null; house: number | null }>; + mudda: AnnualBlockView<{ yearDays: number; periods: AnnualPeriodView[] }>; + patyayini: AnnualBlockView<{ yearDays: number; periods: AnnualPeriodView[] }>; + monthlyCharts: AnnualBlockView<{ months: { index: number; start: AnnualInstant | null; end: AnnualInstant | null }[] }>; +}; + +export type AnnualPackageView = { + version: number; + range: AnnualBlockView<{ + kind: AnnualPackage["report_range"]["kind"]; + start: AnnualInstant; + end: AnnualInstant; + yearChartYears: number[]; + birthdays: AnnualInstant[]; + }>; + yearCharts: AnnualYearChartView[]; + vimshottari: AnnualBlockView<{ level: "MD" | "AD" | "PD"; lord: string | null; start: AnnualInstant; end: AnnualInstant; inRange: AnnualSpan | null }[]>; + narayana: { legacy: AnnualBlockView; rath: AnnualBlockView }; + transitEvents: AnnualBlockView<{ kind: "ingress" | "station"; body: string; instant: AnnualInstant; toSign: string | null; motion: string | null }[]>; + blocked: { path: string; reason: string }[]; +}; + +type Block = { status: AnnualBlockStatus; reason?: string; [key: string]: unknown }; + +function view(block: Block | undefined, value: (computed: Block) => T): AnnualBlockView { + if (!block) return { status: "blocked", reason: "block_missing" }; + if (block.status === "computed") return { status: "computed", value: value(block) }; + return { status: block.status, reason: block.reason ?? "unspecified" }; +} + +function periods(rows: z.infer[] | undefined): AnnualPeriodView[] { + return (rows ?? []).map((row) => ({ + lord: typeof row.lord === "string" ? row.lord : null, + start: row.start, + end: row.end, + inRange: row.in_report_range, + })); +} + +function data(block: Block): Record { + return (block.data && typeof block.data === "object" ? block.data : {}) as Record; +} + +function text(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function yearChart(chart: z.infer): AnnualYearChartView { + const blocks = chart.blocks; + const missing = { status: chart.status === "computed" ? "blocked" as const : chart.status, reason: chart.reason ?? "year_chart_blocked" }; + return { + targetYear: chart.target_year, + validity: chart.validity ? { start: chart.validity.start, end: chart.validity.end } : null, + inRange: chart.in_report_range ?? null, + ascendant: blocks ? view(blocks.chart as Block, () => ({ + sign: blocks.chart.ascendant?.sign ?? "", + degreeInSign: blocks.chart.ascendant?.degree_in_sign ?? 0, + lord: blocks.chart.ascendant_lord?.planet ?? null, + })) : missing, + yearLord: blocks ? view(blocks.year_lord as Block, (block) => ({ planet: text(data(block).year_lord) })) : missing, + muntha: blocks ? view(blocks.muntha as Block, (block) => { + const value = data(block); + return { + sign: text(value.muntha_sign), + lord: text(value.muntha_lord), + house: typeof value.muntha_house_from_annual_ascendant === "number" ? value.muntha_house_from_annual_ascendant : null, + }; + }) : missing, + mudda: blocks ? view(blocks.mudda_dasha as Block, () => ({ + yearDays: blocks.mudda_dasha.year_length?.days ?? 0, + periods: periods(blocks.mudda_dasha.periods), + })) : missing, + patyayini: blocks ? view(blocks.patyayini as Block, () => ({ + yearDays: blocks.patyayini.year_length?.days ?? 0, + periods: periods(blocks.patyayini.periods), + })) : missing, + monthlyCharts: blocks ? view(blocks.monthly_charts as Block, () => ({ + months: (blocks.monthly_charts.rows ?? []).map((row) => ({ + index: row.month_index, + start: row.start ?? null, + end: row.end ?? null, + })), + })) : missing, + }; +} + +export function toAnnualPackageView(pkg: AnnualPackage): AnnualPackageView { + const range = pkg.report_range; + const narayana = pkg.natal_periods.narayana; + const natalRows = (block: z.infer | undefined) => view(block as Block | undefined, () => + (block?.rows ?? []).map((row) => ({ lord: row.lord, start: row.start, end: row.end, inRange: row.in_report_range }))); + return { + version: pkg.annual_package_version, + range: view(range as Block, () => ({ + kind: range.kind, + start: range.start as AnnualInstant, + end: range.end as AnnualInstant, + yearChartYears: range.year_chart_years ?? [], + birthdays: range.birthday_boundaries ?? [], + })), + yearCharts: pkg.year_charts.map(yearChart), + vimshottari: view(pkg.natal_periods.vimshottari as Block, () => + (pkg.natal_periods.vimshottari.rows ?? []).map((row) => ({ + level: row.level, lord: row.lord, start: row.start, end: row.end, inRange: row.in_report_range, + }))), + narayana: { + legacy: narayana.status === "computed" ? natalRows(narayana.legacy) : { status: narayana.status, reason: narayana.reason ?? "unspecified" }, + rath: narayana.status === "computed" ? natalRows(narayana.rath) : { status: narayana.status, reason: narayana.reason ?? "unspecified" }, + }, + transitEvents: view(pkg.slow_transits as Block, () => + (pkg.slow_transits.events ?? []).map((event) => ({ + kind: event.kind, + body: event.body, + instant: event.instant, + toSign: event.to_sign ?? null, + motion: event.motion ?? null, + }))), + blocked: pkg.block_index + .filter((row) => row.status === "blocked") + .map((row) => ({ path: row.path, reason: row.reason ?? "unspecified" })), + }; +} diff --git a/frontend/tests/annual-package-contract.test.ts b/frontend/tests/annual-package-contract.test.ts new file mode 100644 index 00000000..2a6d4bcb --- /dev/null +++ b/frontend/tests/annual-package-contract.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + ANNUAL_PACKAGE_VERSION, + parseAnnualPackageResponse, + toAnnualPackageView, +} from "../src/lib/annual-package-contract.ts"; + +// Real engine output (trimmed); see the fixture's sourceCommand. +const fixture = JSON.parse(readFileSync(new URL("./fixtures/annual-package-v1.json", import.meta.url), "utf8")); + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +test("the engine's annual package v1 parses", () => { + const pkg = parseAnnualPackageResponse(fixture); + assert.ok(pkg, "fixture must satisfy the contract"); + assert.equal(pkg.annual_package_version, ANNUAL_PACKAGE_VERSION); + assert.equal(pkg.report_range.kind, "calendar_year"); + assert.deepEqual(pkg.report_range.year_chart_years, [2025, 2026]); +}); + +test("the view keeps exact boundaries and each system's own year", () => { + const pkg = parseAnnualPackageResponse(fixture); + assert.ok(pkg); + const view = toAnnualPackageView(pkg); + assert.equal(view.range.status, "computed"); + if (view.range.status !== "computed") return; + assert.equal(view.range.value.start.local, "2026-01-01T00:00:00+08:00"); + assert.equal(view.range.value.birthdays.length, 1); + const [previous, current] = view.yearCharts; + assert.equal(previous.validity?.end.utc, current.validity?.start.utc); + assert.equal(view.range.value.birthdays[0].utc, current.validity?.start.utc); + assert.equal(current.patyayini.status, "computed"); + if (current.patyayini.status === "computed") { + assert.equal(current.patyayini.value.yearDays, 365.25); + const last = current.patyayini.value.periods.at(-1); + assert.ok(last); + // The last Patyayini period ends after the calendar range: clipped, not stretched. + assert.equal(last.inRange, null); + } + assert.equal(current.monthlyCharts.status, "computed"); + if (current.monthlyCharts.status === "computed") assert.equal(current.monthlyCharts.value.months.length, 12); + assert.equal(view.vimshottari.status, "computed"); + assert.deepEqual(view.blocked, []); +}); + +test("blocked blocks map to a reason instead of data", () => { + const broken = clone(fixture); + broken.package.year_charts[1].blocks.patyayini = { + status: "blocked", + producer: "annual_native.patyayini.build_native_patyayini", + reason: "annual_chart_longitudes_missing", + }; + broken.package.slow_transits = { status: "blocked", producer: "jyotish_engine._calc_sidereal_planets_for_jd", reason: "x" }; + broken.package.block_index.push({ path: "slow_transits", status: "blocked", producer: "p", reason: "x" }); + const pkg = parseAnnualPackageResponse(broken); + assert.ok(pkg); + const view = toAnnualPackageView(pkg); + assert.deepEqual(view.yearCharts[1].patyayini, { status: "blocked", reason: "annual_chart_longitudes_missing" }); + assert.deepEqual(view.transitEvents, { status: "blocked", reason: "x" }); + assert.deepEqual(view.blocked, [{ path: "slow_transits", reason: "x" }]); +}); + +test("other versions and the legacy /api/annual report are rejected", () => { + const next = clone(fixture); + next.annual_package_version = 2; + assert.equal(parseAnnualPackageResponse(next), null); + assert.equal(parseAnnualPackageResponse({ success: true, endpoint: "annual", report: { target_year: 2026 } }), null); + const unknownStatus = clone(fixture); + unknownStatus.package.year_charts[0].blocks.muntha.status = "partial_verified"; + assert.equal(parseAnnualPackageResponse(unknownStatus), null); +}); diff --git a/frontend/tests/fixtures/annual-package-v1.json b/frontend/tests/fixtures/annual-package-v1.json new file mode 100644 index 00000000..9c26a6cd --- /dev/null +++ b/frontend/tests/fixtures/annual-package-v1.json @@ -0,0 +1,4060 @@ +{ + "sourceCommand": "POST /api/annual {mode: \"package\", 1990-06-15 12:00:00 Beijing (39.9042, 116.4074, tz 8, Asia/Shanghai), raman, mean node, report_range calendar_year 2026, observation_date 2026-11-01}; Sahams / Tajika yogas / Tripataki data, antardasha rows after the second, monthly longitudes, contacts and loops after the first are trimmed.", + "success": true, + "endpoint": "annual", + "mode": "package", + "annual_package_version": 1, + "package": { + "schema": "jyotish.annual_package.v1", + "annual_package_version": 1, + "parameters": { + "birth": { + "local_datetime": "1990-06-15T12:00:00", + "second": 0, + "utc_offset_hours": 8.0, + "timezone_id": "Asia/Shanghai", + "instant": { + "utc": "1990-06-15T04:00:00Z", + "local": "1990-06-15T12:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": null, + "jd_ut": 2448057.66666667 + }, + "latitude": 39.9042, + "longitude": 116.4074 + }, + "ayanamsa": "raman", + "node_mode": "mean", + "house_system": "whole_sign", + "return_location": { + "mode": "birth_place", + "options_v1": [ + "birth_place" + ], + "latitude": 39.9042, + "longitude": 116.4074, + "timezone_id": "Asia/Shanghai", + "utc_offset_hours": 8.0 + }, + "observation_date": "2026-11-01", + "natal_chart": { + "status": "computed", + "producer": "jyotish_engine._compute_chart_from_args", + "lagna_sign_idx": 4, + "lagna_sign": "Leo", + "moon_sign_idx": 10, + "ayanamsa_degrees": 22.281, + "longitudes": { + "Lagna": 149.3958, + "Sun": 61.530205243549254, + "Moon": 318.6426080591374, + "Mars": 348.52061344186217, + "Mercury": 42.840285719029715, + "Jupiter": 83.53626635397052, + "Venus": 26.104679286290157, + "Saturn": 271.77046289333873, + "Rahu": 287.4345515956973, + "Ketu": 107.43455159569731 + }, + "narayana_input_longitudes": { + "Sun": 61.5302, + "Moon": 318.6426, + "Mars": 348.5206, + "Mercury": 42.8403, + "Jupiter": 83.5363, + "Venus": 26.1047, + "Saturn": 271.7705, + "Rahu": 287.4346, + "Ketu": 107.4346 + } + } + }, + "report_range": { + "kind": "calendar_year", + "requested": { + "kind": "calendar_year", + "year": 2026 + }, + "basis": "local midnight 1 January to local midnight 1 January of the next year", + "end_exclusive": true, + "timezone": { + "timezone_id": "Asia/Shanghai", + "basis": "return_location_zone" + }, + "status": "computed", + "producer": "annual_package._report_range", + "start": { + "utc": "2025-12-31T16:00:00Z", + "local": "2026-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461041.16666667 + }, + "end": { + "utc": "2026-12-31T16:00:00Z", + "local": "2027-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461406.16666667 + }, + "duration_days": 365.0, + "year_chart_years": [ + 2025, + 2026 + ], + "crosses_birthday": true, + "birthday_boundaries": [ + { + "utc": "2026-06-15T09:29:26Z", + "local": "2026-06-15T17:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89544412 + } + ], + "observation": { + "date": "2026-11-01", + "instant": { + "utc": "2026-10-31T16:00:00Z", + "local": "2026-11-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461345.16666667 + }, + "position": "inside_range", + "lookback_months": 3, + "lookback_start": { + "utc": "2026-07-31T16:00:00Z", + "local": "2026-08-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461253.16666667 + }, + "rule": "lookback = [observation - 3 months, observation); prospective = [observation, range end)" + } + }, + "year_charts": [ + { + "target_year": 2025, + "completed_years": 35, + "status": "computed", + "producer": "annual_tajika_pack.build_annual_tajika_pack", + "validity": { + "start": { + "utc": "2025-06-15T03:20:58Z", + "local": "2025-06-15T11:20:58+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460841.63955908 + }, + "end": { + "utc": "2026-06-15T09:29:26Z", + "local": "2026-06-15T17:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89544412 + }, + "end_exclusive": true, + "end_source": "next_solar_return", + "duration_days": 365.255885 + }, + "in_report_range": { + "start": { + "utc": "2025-12-31T16:00:00Z", + "local": "2026-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461041.16666667 + }, + "end": { + "utc": "2026-06-15T09:29:26Z", + "local": "2026-06-15T17:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89544412 + } + }, + "return_location": { + "mode": "birth_place", + "latitude": 39.9042, + "longitude": 116.4074, + "timezone_id": "Asia/Shanghai", + "utc_offset_at_return": "+08:00" + }, + "solar_return": { + "instant": { + "utc": "2025-06-15T03:20:58Z", + "local": "2025-06-15T11:20:58+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460841.63955908 + }, + "target_sun_longitude": 61.530196900328555, + "target_sun_frame": "swe_sidereal_flags", + "solver": "swisseph_newton", + "residual_degrees": 8.343220713413757e-06 + }, + "blocks": { + "chart": { + "status": "computed", + "producer": "solar_return.calc_solar_return_chart", + "ascendant": { + "longitude": 141.5124, + "sign": "Leo", + "degree_in_sign": 21.512400000000014 + }, + "ascendant_lord": { + "planet": "Sun", + "sign": "Gemini", + "house": 11, + "longitude": 61.53019795823984 + }, + "planets": { + "Sun": { + "longitude": 61.53019795823984, + "sign": "Gemini", + "degree_in_sign": 1.5301979582398388, + "house": 11, + "speed": 0.955033, + "retrograde": false, + "nakshatra": "Mrigashira" + }, + "Moon": { + "longitude": 286.01408309831567, + "sign": "Capricorn", + "degree_in_sign": 16.014083098315666, + "house": 6, + "speed": 12.957921, + "retrograde": false, + "nakshatra": "Shravana" + } + }, + "house_system": "whole_sign", + "node_mode_of_chart": "mean" + }, + "muntha": { + "status": "computed", + "producer": "solar_return._muntha_from_natal_ascendant", + "producer_status": "partial_verified", + "data": { + "status": "used", + "muntha_sign_idx": 3, + "muntha_sign": "Cancer", + "muntha_lord": "Moon", + "natal_asc_sign_idx": 4, + "natal_asc_sign": "Leo", + "age": 35, + "formula": "(natal ascendant Leo + completed years 35) mod 12 = Cancer", + "muntha_house_from_annual_ascendant": 12, + "muntha_lord_placement": { + "planet": "Moon", + "sign": "Capricorn", + "house_from_annual_ascendant": 6, + "longitude": 286.01408309831567 + } + } + }, + "year_lord": { + "status": "computed", + "producer": "tajika_year_lord.select_annual_year_lord (Panchadhikari)", + "producer_status": "partial_verified", + "data": { + "year_lord": "Mercury", + "selection_basis": "panchadhikari_benefic_shortlist_pvb", + "status": "partial_verified" + } + }, + "annual_strength": { + "status": "computed", + "producer": "tajika.calc_tajika_strength_layers", + "producer_status": "partial_verified", + "data": { + "harsha_bala": { + "Sun": { + "score": 8.5 + }, + "Moon": { + "score": 4.5 + }, + "Mars": { + "score": 7.0 + }, + "Mercury": { + "score": 13.0 + }, + "Jupiter": { + "score": 8.5 + }, + "Venus": { + "score": 8.5 + }, + "Saturn": { + "score": 3.5 + } + }, + "panchavargiya_bala": { + "Sun": { + "score": 7.94 + }, + "Moon": { + "score": 10.15 + }, + "Mars": { + "score": 10.85 + }, + "Mercury": { + "score": 15.11 + }, + "Jupiter": { + "score": 12.7 + }, + "Venus": { + "score": 15.71 + }, + "Saturn": { + "score": 7.72 + } + } + }, + "scope": "annual chart Panchavargiya and Harsha Bala; not natal Shadbala" + }, + "sahams": { + "status": "computed", + "producer": "tajika.calc_all_sahams", + "producer_status": "partial_verified", + "data": { + "trimmed_for_fixture": true + } + }, + "tajika_yogas": { + "status": "computed", + "producer": "tajika_named_yoga_authority.build_tajika_named_yoga_authority", + "producer_status": "partial_verified", + "data": { + "trimmed_for_fixture": true + } + }, + "tripataki": { + "status": "computed", + "producer": "annual_native.tripataki_chakra.build_tripataki_chakra", + "producer_status": "partial_verified", + "data": { + "trimmed_for_fixture": true + } + }, + "mudda_dasha": { + "status": "computed", + "producer": "tajika_mudda.annual_mudda_dasha", + "producer_status": "partial_verified", + "year_length": { + "basis": "solar_year_365_25", + "days": 365.25, + "start": { + "utc": "2025-06-15T03:20:58Z", + "local": "2025-06-15T11:20:58+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460841.63955908 + }, + "end": { + "utc": "2026-06-15T09:20:58Z", + "local": "2026-06-15T17:20:58+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.88955908 + }, + "note": "Mudda keeps its own year; it is not stretched to the next solar return" + }, + "starting_lord": "Mars", + "starting_lord_basis": "completed_years_plus_natal_moon_nakshatra_minus_two_mod_nine", + "rule": "astrologer_round5_q7_completed_years_plus_natal_nakshatra_minus_two_mod_nine", + "completed_years": 35, + "natal_moon_longitude": 318.6426080591374, + "natal_moon_nakshatra_number": 24, + "balance_profile": "moon_at_birth_time", + "balance_fraction_remaining": 0.101804395564697, + "independent_of_year_lord": true, + "source": "tajika.calc_mudda_with_dasha_balance (upstream yinduzhanxing c7117f92)", + "periods": [ + { + "order": 1, + "lord": "Mars", + "duration_days": 2.169069902997685, + "full_duration_days": 21.30625, + "start": { + "utc": "2025-06-15T03:20:58Z", + "local": "2025-06-15T11:20:58+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460841.63955908 + }, + "end": { + "utc": "2025-06-17T07:24:26Z", + "local": "2025-06-17T15:24:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460843.80862898 + }, + "in_report_range": null + }, + { + "order": 2, + "lord": "Rahu", + "duration_days": 54.7875, + "full_duration_days": 54.7875, + "start": { + "utc": "2025-06-17T07:24:26Z", + "local": "2025-06-17T15:24:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460843.80862898 + }, + "end": { + "utc": "2025-08-11T02:18:26Z", + "local": "2025-08-11T10:18:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460898.59612898 + }, + "in_report_range": null + }, + { + "order": 3, + "lord": "Jupiter", + "duration_days": 48.7, + "full_duration_days": 48.7, + "start": { + "utc": "2025-08-11T02:18:26Z", + "local": "2025-08-11T10:18:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460898.59612898 + }, + "end": { + "utc": "2025-09-28T19:06:26Z", + "local": "2025-09-29T03:06:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460947.29612898 + }, + "in_report_range": null + }, + { + "order": 4, + "lord": "Saturn", + "duration_days": 57.83125, + "full_duration_days": 57.83125, + "start": { + "utc": "2025-09-28T19:06:26Z", + "local": "2025-09-29T03:06:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460947.29612898 + }, + "end": { + "utc": "2025-11-25T15:03:26Z", + "local": "2025-11-25T23:03:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461005.12737898 + }, + "in_report_range": null + }, + { + "order": 5, + "lord": "Mercury", + "duration_days": 51.74375, + "full_duration_days": 51.74375, + "start": { + "utc": "2025-11-25T15:03:26Z", + "local": "2025-11-25T23:03:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461005.12737898 + }, + "end": { + "utc": "2026-01-16T08:54:26Z", + "local": "2026-01-16T16:54:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461056.87112898 + }, + "in_report_range": { + "start": { + "utc": "2025-12-31T16:00:00Z", + "local": "2026-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461041.16666667 + }, + "end": { + "utc": "2026-01-16T08:54:26Z", + "local": "2026-01-16T16:54:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461056.87112898 + } + } + }, + { + "order": 6, + "lord": "Ketu", + "duration_days": 21.30625, + "full_duration_days": 21.30625, + "start": { + "utc": "2026-01-16T08:54:26Z", + "local": "2026-01-16T16:54:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461056.87112898 + }, + "end": { + "utc": "2026-02-06T16:15:26Z", + "local": "2026-02-07T00:15:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461078.17737898 + }, + "in_report_range": { + "start": { + "utc": "2026-01-16T08:54:26Z", + "local": "2026-01-16T16:54:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461056.87112898 + }, + "end": { + "utc": "2026-02-06T16:15:26Z", + "local": "2026-02-07T00:15:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461078.17737898 + } + } + }, + { + "order": 7, + "lord": "Venus", + "duration_days": 60.875, + "full_duration_days": 60.875, + "start": { + "utc": "2026-02-06T16:15:26Z", + "local": "2026-02-07T00:15:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461078.17737898 + }, + "end": { + "utc": "2026-04-08T13:15:26Z", + "local": "2026-04-08T21:15:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461139.05237898 + }, + "in_report_range": { + "start": { + "utc": "2026-02-06T16:15:26Z", + "local": "2026-02-07T00:15:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461078.17737898 + }, + "end": { + "utc": "2026-04-08T13:15:26Z", + "local": "2026-04-08T21:15:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461139.05237898 + } + } + }, + { + "order": 8, + "lord": "Sun", + "duration_days": 18.2625, + "full_duration_days": 18.2625, + "start": { + "utc": "2026-04-08T13:15:26Z", + "local": "2026-04-08T21:15:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461139.05237898 + }, + "end": { + "utc": "2026-04-26T19:33:26Z", + "local": "2026-04-27T03:33:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461157.31487898 + }, + "in_report_range": { + "start": { + "utc": "2026-04-08T13:15:26Z", + "local": "2026-04-08T21:15:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461139.05237898 + }, + "end": { + "utc": "2026-04-26T19:33:26Z", + "local": "2026-04-27T03:33:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461157.31487898 + } + } + }, + { + "order": 9, + "lord": "Moon", + "duration_days": 30.4375, + "full_duration_days": 30.4375, + "start": { + "utc": "2026-04-26T19:33:26Z", + "local": "2026-04-27T03:33:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461157.31487898 + }, + "end": { + "utc": "2026-05-27T06:03:26Z", + "local": "2026-05-27T14:03:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461187.75237898 + }, + "in_report_range": { + "start": { + "utc": "2026-04-26T19:33:26Z", + "local": "2026-04-27T03:33:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461157.31487898 + }, + "end": { + "utc": "2026-05-27T06:03:26Z", + "local": "2026-05-27T14:03:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461187.75237898 + } + } + }, + { + "order": 10, + "lord": "Mars", + "duration_days": 19.137180097002314, + "full_duration_days": 21.30625, + "start": { + "utc": "2026-05-27T06:03:26Z", + "local": "2026-05-27T14:03:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461187.75237898 + }, + "end": { + "utc": "2026-06-15T09:20:58Z", + "local": "2026-06-15T17:20:58+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.88955908 + }, + "in_report_range": { + "start": { + "utc": "2026-05-27T06:03:26Z", + "local": "2026-05-27T14:03:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461187.75237898 + }, + "end": { + "utc": "2026-06-15T09:20:58Z", + "local": "2026-06-15T17:20:58+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.88955908 + } + } + } + ], + "antardasha_periods": [ + { + "main_order": 1, + "sub_order": 8, + "main_lord": "Mars", + "sub_lord": "Sun", + "duration_days": 0.3935490696643519, + "start": { + "utc": "2025-06-15T03:20:58Z", + "local": "2025-06-15T11:20:58+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460841.63955908 + }, + "end": { + "utc": "2025-06-15T12:47:41Z", + "local": "2025-06-15T20:47:41+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460842.03310815 + }, + "in_report_range": null + }, + { + "main_order": 1, + "sub_order": 9, + "main_lord": "Mars", + "sub_lord": "Moon", + "duration_days": 1.7755208333333334, + "start": { + "utc": "2025-06-15T12:47:41Z", + "local": "2025-06-15T20:47:41+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460842.03310815 + }, + "end": { + "utc": "2025-06-17T07:24:26Z", + "local": "2025-06-17T15:24:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460843.80862898 + }, + "in_report_range": null + } + ] + }, + "patyayini": { + "status": "computed", + "producer": "annual_native.patyayini.build_native_patyayini", + "producer_status": "parameter_sensitive", + "model": "degree_within_sign", + "year_length": { + "basis": "fixed_365_25_days", + "days": 365.25, + "start": { + "utc": "2025-06-15T03:20:58Z", + "local": "2025-06-15T11:20:58+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460841.63955908 + }, + "end": { + "utc": "2026-06-15T09:20:58Z", + "local": "2026-06-15T17:20:58+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.88955908 + }, + "note": "the native entry's fixed 365.25-day year; clipped for display, never stretched" + }, + "inputs": [ + { + "point": "Sun", + "longitude": 61.53019795823984, + "sign": "Gemini", + "degree_in_sign": 1.5301979582398388 + }, + { + "point": "Mars", + "longitude": 125.99561456841627, + "sign": "Leo", + "degree_in_sign": 5.995614568416272 + }, + { + "point": "Jupiter", + "longitude": 68.42939418025063, + "sign": "Gemini", + "degree_in_sign": 8.429394180250625 + }, + { + "point": "Saturn", + "longitude": 338.5148049581943, + "sign": "Pisces", + "degree_in_sign": 8.514804958194304 + }, + { + "point": "Moon", + "longitude": 286.01408309831567, + "sign": "Capricorn", + "degree_in_sign": 16.014083098315666 + }, + { + "point": "Venus", + "longitude": 16.313522542695186, + "sign": "Aries", + "degree_in_sign": 16.313522542695186 + }, + { + "point": "Mercury", + "longitude": 79.07678149387236, + "sign": "Gemini", + "degree_in_sign": 19.07678149387236 + }, + { + "point": "Lagna", + "longitude": 141.5124, + "sign": "Leo", + "degree_in_sign": 21.512400000000014 + } + ], + "periods": [ + { + "order": 1, + "lord": "Sun", + "start": { + "utc": "2025-06-15T03:20:58Z", + "local": "2025-06-15T11:20:58+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460841.63955908 + }, + "end": { + "utc": "2025-07-11T02:53:00Z", + "local": "2025-07-11T10:53:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460867.62013778 + }, + "in_report_range": null + }, + { + "order": 2, + "lord": "Mars", + "start": { + "utc": "2025-07-11T02:53:00Z", + "local": "2025-07-11T10:53:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460867.62013778 + }, + "end": { + "utc": "2025-09-24T22:28:40Z", + "local": "2025-09-25T06:28:40+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460943.43657297 + }, + "in_report_range": null + }, + { + "order": 3, + "lord": "Jupiter", + "start": { + "utc": "2025-09-24T22:28:40Z", + "local": "2025-09-25T06:28:40+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460943.43657297 + }, + "end": { + "utc": "2025-11-05T06:12:31Z", + "local": "2025-11-05T14:12:31+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460984.75869102 + }, + "in_report_range": null + }, + { + "order": 4, + "lord": "Saturn", + "start": { + "utc": "2025-11-05T06:12:31Z", + "local": "2025-11-05T14:12:31+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460984.75869102 + }, + "end": { + "utc": "2025-11-06T17:00:45Z", + "local": "2025-11-07T01:00:45+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460986.20885306 + }, + "in_report_range": null + }, + { + "order": 5, + "lord": "Moon", + "start": { + "utc": "2025-11-06T17:00:45Z", + "local": "2025-11-07T01:00:45+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460986.20885306 + }, + "end": { + "utc": "2026-03-14T00:51:45Z", + "local": "2026-03-14T08:51:45+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461113.53593639 + }, + "in_report_range": { + "start": { + "utc": "2025-12-31T16:00:00Z", + "local": "2026-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461041.16666667 + }, + "end": { + "utc": "2026-03-14T00:51:45Z", + "local": "2026-03-14T08:51:45+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461113.53593639 + } + } + }, + { + "order": 6, + "lord": "Venus", + "start": { + "utc": "2026-03-14T00:51:45Z", + "local": "2026-03-14T08:51:45+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461113.53593639 + }, + "end": { + "utc": "2026-03-19T02:52:48Z", + "local": "2026-03-19T10:52:48+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461118.61999889 + }, + "in_report_range": { + "start": { + "utc": "2026-03-14T00:51:45Z", + "local": "2026-03-14T08:51:45+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461113.53593639 + }, + "end": { + "utc": "2026-03-19T02:52:48Z", + "local": "2026-03-19T10:52:48+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461118.61999889 + } + } + }, + { + "order": 7, + "lord": "Mercury", + "start": { + "utc": "2026-03-19T02:52:48Z", + "local": "2026-03-19T10:52:48+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461118.61999889 + }, + "end": { + "utc": "2026-05-05T00:52:09Z", + "local": "2026-05-05T08:52:09+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461165.53621417 + }, + "in_report_range": { + "start": { + "utc": "2026-03-19T02:52:48Z", + "local": "2026-03-19T10:52:48+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461118.61999889 + }, + "end": { + "utc": "2026-05-05T00:52:09Z", + "local": "2026-05-05T08:52:09+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461165.53621417 + } + } + }, + { + "order": 8, + "lord": "Lagna", + "start": { + "utc": "2026-05-05T00:52:09Z", + "local": "2026-05-05T08:52:09+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461165.53621417 + }, + "end": { + "utc": "2026-06-15T09:20:58Z", + "local": "2026-06-15T17:20:58+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.88955908 + }, + "in_report_range": { + "start": { + "utc": "2026-05-05T00:52:09Z", + "local": "2026-05-05T08:52:09+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461165.53621417 + }, + "end": { + "utc": "2026-06-15T09:20:58Z", + "local": "2026-06-15T17:20:58+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.88955908 + } + } + } + ], + "antardasha_periods": [ + { + "main": "Sun", + "sub": "Sun", + "main_order": 1, + "sub_order": 1, + "start": { + "utc": "2025-06-15T03:20:58Z", + "local": "2025-06-15T11:20:58+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460841.63955908 + }, + "end": { + "utc": "2025-06-16T23:42:07Z", + "local": "2025-06-17T07:42:07+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460843.48757991 + }, + "in_report_range": null + }, + { + "main": "Sun", + "sub": "Mars", + "main_order": 1, + "sub_order": 2, + "start": { + "utc": "2025-06-16T23:42:07Z", + "local": "2025-06-17T07:42:07+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460843.48757991 + }, + "end": { + "utc": "2025-06-22T09:07:53Z", + "local": "2025-06-22T17:07:53+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460848.88047343 + }, + "in_report_range": null + } + ] + }, + "monthly_charts": { + "status": "computed", + "producer": "annual_native.monthly_charts.build_monthly_charts", + "producer_status": "partial_verified", + "step": "solar_30_degrees", + "step_note": "each chart starts when the Sun has advanced another 30 degrees from the annual Sun; not a calendar month, not a lunar return", + "rows": [ + { + "month_index": 1, + "status": "computed", + "start": { + "utc": "2025-06-15T03:20:58Z", + "local": "2025-06-15T11:20:58+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460841.63955908 + }, + "end": { + "utc": "2025-07-16T14:09:10Z", + "local": "2025-07-16T22:09:10+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460873.08970333 + }, + "end_source": "next_monthly_chart", + "in_report_range": null, + "target_sun_longitude": 61.53019795823984, + "sun_longitude": 61.53018690492831, + "ascendant": { + "sign": "Leo", + "degree_in_sign": 21.5091, + "longitude": 141.5091 + } + }, + { + "month_index": 2, + "status": "computed", + "start": { + "utc": "2025-07-16T14:09:10Z", + "local": "2025-07-16T22:09:10+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460873.08970333 + }, + "end": { + "utc": "2025-08-16T22:28:35Z", + "local": "2025-08-17T06:28:35+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460904.43651825 + }, + "end_source": "next_monthly_chart", + "in_report_range": null, + "target_sun_longitude": 91.53019795823984, + "sun_longitude": 91.53027832058748, + "ascendant": { + "sign": "Aquarius", + "degree_in_sign": 25.9449, + "longitude": 325.9449 + } + }, + { + "month_index": 3, + "status": "computed", + "start": { + "utc": "2025-08-16T22:28:35Z", + "local": "2025-08-17T06:28:35+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460904.43651825 + }, + "end": { + "utc": "2025-09-16T22:21:28Z", + "local": "2025-09-17T06:21:28+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460935.43157552 + }, + "end_source": "next_monthly_chart", + "in_report_range": null, + "target_sun_longitude": 121.53019795823984, + "sun_longitude": 121.53019599769118, + "ascendant": { + "sign": "Leo", + "degree_in_sign": 12.6107, + "longitude": 132.6107 + } + }, + { + "month_index": 4, + "status": "computed", + "start": { + "utc": "2025-09-16T22:21:28Z", + "local": "2025-09-17T06:21:28+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460935.43157552 + }, + "end": { + "utc": "2025-10-17T10:18:01Z", + "local": "2025-10-17T18:18:01+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460965.92917318 + }, + "end_source": "next_monthly_chart", + "in_report_range": null, + "target_sun_longitude": 151.53019795823985, + "sun_longitude": 151.53019656990605, + "ascendant": { + "sign": "Virgo", + "degree_in_sign": 5.541, + "longitude": 155.541 + } + }, + { + "month_index": 5, + "status": "computed", + "start": { + "utc": "2025-10-17T10:18:01Z", + "local": "2025-10-17T18:18:01+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460965.92917318 + }, + "end": { + "utc": "2025-11-16T10:07:30Z", + "local": "2025-11-16T18:07:30+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460995.92187218 + }, + "end_source": "next_monthly_chart", + "in_report_range": null, + "target_sun_longitude": 181.53019795823985, + "sun_longitude": 181.53019152411457, + "ascendant": { + "sign": "Aries", + "degree_in_sign": 20.4617, + "longitude": 20.4617 + } + }, + { + "month_index": 6, + "status": "computed", + "start": { + "utc": "2025-11-16T10:07:30Z", + "local": "2025-11-16T18:07:30+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460995.92187218 + }, + "end": { + "utc": "2025-12-16T00:48:50Z", + "local": "2025-12-16T08:48:50+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461025.53390652 + }, + "end_source": "next_monthly_chart", + "in_report_range": null, + "target_sun_longitude": 211.53019795823985, + "sun_longitude": 211.53018914987683, + "ascendant": { + "sign": "Taurus", + "degree_in_sign": 22.9141, + "longitude": 52.9141 + } + }, + { + "month_index": 7, + "status": "computed", + "start": { + "utc": "2025-12-16T00:48:50Z", + "local": "2025-12-16T08:48:50+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461025.53390652 + }, + "end": { + "utc": "2026-01-14T11:35:54Z", + "local": "2026-01-14T19:35:54+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461054.98326966 + }, + "end_source": "next_monthly_chart", + "in_report_range": { + "start": { + "utc": "2025-12-31T16:00:00Z", + "local": "2026-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461041.16666667 + }, + "end": { + "utc": "2026-01-14T11:35:54Z", + "local": "2026-01-14T19:35:54+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461054.98326966 + } + }, + "target_sun_longitude": 241.53019795823985, + "sun_longitude": 241.53019179854113, + "ascendant": { + "sign": "Sagittarius", + "degree_in_sign": 19.0335, + "longitude": 259.0335 + } + }, + { + "month_index": 8, + "status": "computed", + "start": { + "utc": "2026-01-14T11:35:54Z", + "local": "2026-01-14T19:35:54+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461054.98326966 + }, + "end": { + "utc": "2026-02-13T00:38:21Z", + "local": "2026-02-13T08:38:21+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461084.52663613 + }, + "end_source": "next_monthly_chart", + "in_report_range": { + "start": { + "utc": "2026-01-14T11:35:54Z", + "local": "2026-01-14T19:35:54+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461054.98326966 + }, + "end": { + "utc": "2026-02-13T00:38:21Z", + "local": "2026-02-13T08:38:21+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461084.52663613 + } + }, + "target_sun_longitude": 271.53019795823985, + "sun_longitude": 271.530190200837, + "ascendant": { + "sign": "Leo", + "degree_in_sign": 0.9263, + "longitude": 120.9263 + } + }, + { + "month_index": 9, + "status": "computed", + "start": { + "utc": "2026-02-13T00:38:21Z", + "local": "2026-02-13T08:38:21+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461084.52663613 + }, + "end": { + "utc": "2026-03-14T21:34:15Z", + "local": "2026-03-15T05:34:15+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461114.39878115 + }, + "end_source": "next_monthly_chart", + "in_report_range": { + "start": { + "utc": "2026-02-13T00:38:21Z", + "local": "2026-02-13T08:38:21+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461084.52663613 + }, + "end": { + "utc": "2026-03-14T21:34:15Z", + "local": "2026-03-15T05:34:15+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461114.39878115 + } + }, + "target_sun_longitude": 301.53019795823985, + "sun_longitude": 301.5301280088764, + "ascendant": { + "sign": "Pisces", + "degree_in_sign": 5.6267, + "longitude": 335.6267 + } + }, + { + "month_index": 10, + "status": "computed", + "start": { + "utc": "2026-03-14T21:34:15Z", + "local": "2026-03-15T05:34:15+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461114.39878115 + }, + "end": { + "utc": "2026-04-14T06:05:55Z", + "local": "2026-04-14T14:05:55+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461144.75411354 + }, + "end_source": "next_monthly_chart", + "in_report_range": { + "start": { + "utc": "2026-03-14T21:34:15Z", + "local": "2026-03-15T05:34:15+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461114.39878115 + }, + "end": { + "utc": "2026-04-14T06:05:55Z", + "local": "2026-04-14T14:05:55+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461144.75411354 + } + }, + "target_sun_longitude": 331.53019795823985, + "sun_longitude": 331.5301899792209, + "ascendant": { + "sign": "Aquarius", + "degree_in_sign": 8.1871, + "longitude": 308.1871 + } + }, + { + "month_index": 11, + "status": "computed", + "start": { + "utc": "2026-04-14T06:05:55Z", + "local": "2026-04-14T14:05:55+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461144.75411354 + }, + "end": { + "utc": "2026-05-15T02:57:13Z", + "local": "2026-05-15T10:57:13+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461175.62307203 + }, + "end_source": "next_monthly_chart", + "in_report_range": { + "start": { + "utc": "2026-04-14T06:05:55Z", + "local": "2026-04-14T14:05:55+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461144.75411354 + }, + "end": { + "utc": "2026-05-15T02:57:13Z", + "local": "2026-05-15T10:57:13+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461175.62307203 + } + }, + "target_sun_longitude": 1.530197958239853, + "sun_longitude": 1.530193313862604, + "ascendant": { + "sign": "Leo", + "degree_in_sign": 5.643, + "longitude": 125.643 + } + }, + { + "month_index": 12, + "status": "computed", + "start": { + "utc": "2026-05-15T02:57:13Z", + "local": "2026-05-15T10:57:13+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461175.62307203 + }, + "end": { + "utc": "2026-06-15T09:29:26Z", + "local": "2026-06-15T17:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89544412 + }, + "end_source": "next_solar_return", + "in_report_range": { + "start": { + "utc": "2026-05-15T02:57:13Z", + "local": "2026-05-15T10:57:13+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461175.62307203 + }, + "end": { + "utc": "2026-06-15T09:29:26Z", + "local": "2026-06-15T17:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89544412 + } + }, + "target_sun_longitude": 31.530197958239853, + "sun_longitude": 31.53019323082921, + "ascendant": { + "sign": "Cancer", + "degree_in_sign": 22.4518, + "longitude": 112.4518 + } + } + ] + } + } + }, + { + "target_year": 2026, + "completed_years": 36, + "status": "computed", + "producer": "annual_tajika_pack.build_annual_tajika_pack", + "validity": { + "start": { + "utc": "2026-06-15T09:29:26Z", + "local": "2026-06-15T17:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89544412 + }, + "end": { + "utc": "2027-06-15T15:32:29Z", + "local": "2027-06-15T23:32:29+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461572.14755382 + }, + "end_exclusive": true, + "end_source": "next_solar_return", + "duration_days": 365.25211 + }, + "in_report_range": { + "start": { + "utc": "2026-06-15T09:29:26Z", + "local": "2026-06-15T17:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89544412 + }, + "end": { + "utc": "2026-12-31T16:00:00Z", + "local": "2027-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461406.16666667 + } + }, + "return_location": { + "mode": "birth_place", + "latitude": 39.9042, + "longitude": 116.4074, + "timezone_id": "Asia/Shanghai", + "utc_offset_at_return": "+08:00" + }, + "solar_return": { + "instant": { + "utc": "2026-06-15T09:29:26Z", + "local": "2026-06-15T17:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89544412 + }, + "target_sun_longitude": 61.53020384317301, + "target_sun_frame": "swe_sidereal_flags", + "solver": "swisseph_newton", + "residual_degrees": 1.4003762487391214e-06 + }, + "blocks": { + "chart": { + "status": "computed", + "producer": "solar_return.calc_solar_return_chart", + "ascendant": { + "longitude": 214.638, + "sign": "Scorpio", + "degree_in_sign": 4.638000000000005 + }, + "ascendant_lord": { + "planet": "Mars", + "sign": "Aries", + "house": 6, + "longitude": 27.550854608075635 + }, + "planets": { + "Sun": { + "longitude": 61.530199731378914, + "sign": "Gemini", + "degree_in_sign": 1.5301997313789144, + "house": 8, + "speed": 0.955726, + "retrograde": false, + "nakshatra": "Mrigashira" + }, + "Moon": { + "longitude": 65.46804020261318, + "sign": "Gemini", + "degree_in_sign": 5.468040202613182, + "house": 8, + "speed": 15.291528, + "retrograde": false, + "nakshatra": "Mrigashira" + } + }, + "house_system": "whole_sign", + "node_mode_of_chart": "mean" + }, + "muntha": { + "status": "computed", + "producer": "solar_return._muntha_from_natal_ascendant", + "producer_status": "partial_verified", + "data": { + "status": "used", + "muntha_sign_idx": 4, + "muntha_sign": "Leo", + "muntha_lord": "Sun", + "natal_asc_sign_idx": 4, + "natal_asc_sign": "Leo", + "age": 36, + "formula": "(natal ascendant Leo + completed years 36) mod 12 = Leo", + "muntha_house_from_annual_ascendant": 10, + "muntha_lord_placement": { + "planet": "Sun", + "sign": "Gemini", + "house_from_annual_ascendant": 8, + "longitude": 61.530199731378914 + } + } + }, + "year_lord": { + "status": "computed", + "producer": "tajika_year_lord.select_annual_year_lord (Panchadhikari)", + "producer_status": "partial_verified", + "data": { + "year_lord": "Mars", + "selection_basis": "panchadhikari_pvb_fallback_all_candidates", + "status": "partial_verified" + } + }, + "annual_strength": { + "status": "computed", + "producer": "tajika.calc_tajika_strength_layers", + "producer_status": "partial_verified", + "data": { + "harsha_bala": { + "Sun": { + "score": 3.5 + }, + "Moon": { + "score": 3.5 + }, + "Mars": { + "score": 12.0 + }, + "Mercury": { + "score": 5.0 + }, + "Jupiter": { + "score": 11.0 + }, + "Venus": { + "score": 8.5 + }, + "Saturn": { + "score": 4.5 + } + }, + "panchavargiya_bala": { + "Sun": { + "score": 8.26 + }, + "Moon": { + "score": 9.1 + }, + "Mars": { + "score": 12.83 + }, + "Mercury": { + "score": 12.49 + }, + "Jupiter": { + "score": 10.91 + }, + "Venus": { + "score": 12.78 + }, + "Saturn": { + "score": 10.82 + } + } + }, + "scope": "annual chart Panchavargiya and Harsha Bala; not natal Shadbala" + }, + "sahams": { + "status": "computed", + "producer": "tajika.calc_all_sahams", + "producer_status": "partial_verified", + "data": { + "trimmed_for_fixture": true + } + }, + "tajika_yogas": { + "status": "computed", + "producer": "tajika_named_yoga_authority.build_tajika_named_yoga_authority", + "producer_status": "partial_verified", + "data": { + "trimmed_for_fixture": true + } + }, + "tripataki": { + "status": "computed", + "producer": "annual_native.tripataki_chakra.build_tripataki_chakra", + "producer_status": "partial_verified", + "data": { + "trimmed_for_fixture": true + } + }, + "mudda_dasha": { + "status": "computed", + "producer": "tajika_mudda.annual_mudda_dasha", + "producer_status": "partial_verified", + "year_length": { + "basis": "solar_year_365_25", + "days": 365.25, + "start": { + "utc": "2026-06-15T09:29:26Z", + "local": "2026-06-15T17:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89544412 + }, + "end": { + "utc": "2027-06-15T15:29:26Z", + "local": "2027-06-15T23:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461572.14544412 + }, + "note": "Mudda keeps its own year; it is not stretched to the next solar return" + }, + "starting_lord": "Rahu", + "starting_lord_basis": "completed_years_plus_natal_moon_nakshatra_minus_two_mod_nine", + "rule": "astrologer_round5_q7_completed_years_plus_natal_nakshatra_minus_two_mod_nine", + "completed_years": 36, + "natal_moon_longitude": 318.6426080591374, + "natal_moon_nakshatra_number": 24, + "balance_profile": "moon_at_birth_time", + "balance_fraction_remaining": 0.101804395564697, + "independent_of_year_lord": true, + "source": "tajika.calc_mudda_with_dasha_balance (upstream yinduzhanxing c7117f92)", + "periods": [ + { + "order": 1, + "lord": "Rahu", + "duration_days": 5.577608322002314, + "full_duration_days": 54.7875, + "start": { + "utc": "2026-06-15T09:29:26Z", + "local": "2026-06-15T17:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89544412 + }, + "end": { + "utc": "2026-06-20T23:21:12Z", + "local": "2026-06-21T07:21:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461212.47305244 + }, + "in_report_range": { + "start": { + "utc": "2026-06-15T09:29:26Z", + "local": "2026-06-15T17:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89544412 + }, + "end": { + "utc": "2026-06-20T23:21:12Z", + "local": "2026-06-21T07:21:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461212.47305244 + } + } + }, + { + "order": 2, + "lord": "Jupiter", + "duration_days": 48.7, + "full_duration_days": 48.7, + "start": { + "utc": "2026-06-20T23:21:12Z", + "local": "2026-06-21T07:21:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461212.47305244 + }, + "end": { + "utc": "2026-08-08T16:09:12Z", + "local": "2026-08-09T00:09:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461261.17305244 + }, + "in_report_range": { + "start": { + "utc": "2026-06-20T23:21:12Z", + "local": "2026-06-21T07:21:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461212.47305244 + }, + "end": { + "utc": "2026-08-08T16:09:12Z", + "local": "2026-08-09T00:09:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461261.17305244 + } + } + }, + { + "order": 3, + "lord": "Saturn", + "duration_days": 57.83125, + "full_duration_days": 57.83125, + "start": { + "utc": "2026-08-08T16:09:12Z", + "local": "2026-08-09T00:09:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461261.17305244 + }, + "end": { + "utc": "2026-10-05T12:06:12Z", + "local": "2026-10-05T20:06:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461319.00430244 + }, + "in_report_range": { + "start": { + "utc": "2026-08-08T16:09:12Z", + "local": "2026-08-09T00:09:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461261.17305244 + }, + "end": { + "utc": "2026-10-05T12:06:12Z", + "local": "2026-10-05T20:06:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461319.00430244 + } + } + }, + { + "order": 4, + "lord": "Mercury", + "duration_days": 51.74375, + "full_duration_days": 51.74375, + "start": { + "utc": "2026-10-05T12:06:12Z", + "local": "2026-10-05T20:06:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461319.00430244 + }, + "end": { + "utc": "2026-11-26T05:57:12Z", + "local": "2026-11-26T13:57:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461370.74805244 + }, + "in_report_range": { + "start": { + "utc": "2026-10-05T12:06:12Z", + "local": "2026-10-05T20:06:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461319.00430244 + }, + "end": { + "utc": "2026-11-26T05:57:12Z", + "local": "2026-11-26T13:57:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461370.74805244 + } + } + }, + { + "order": 5, + "lord": "Ketu", + "duration_days": 21.30625, + "full_duration_days": 21.30625, + "start": { + "utc": "2026-11-26T05:57:12Z", + "local": "2026-11-26T13:57:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461370.74805244 + }, + "end": { + "utc": "2026-12-17T13:18:12Z", + "local": "2026-12-17T21:18:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461392.05430244 + }, + "in_report_range": { + "start": { + "utc": "2026-11-26T05:57:12Z", + "local": "2026-11-26T13:57:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461370.74805244 + }, + "end": { + "utc": "2026-12-17T13:18:12Z", + "local": "2026-12-17T21:18:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461392.05430244 + } + } + }, + { + "order": 6, + "lord": "Venus", + "duration_days": 60.875, + "full_duration_days": 60.875, + "start": { + "utc": "2026-12-17T13:18:12Z", + "local": "2026-12-17T21:18:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461392.05430244 + }, + "end": { + "utc": "2027-02-16T10:18:12Z", + "local": "2027-02-16T18:18:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461452.92930244 + }, + "in_report_range": { + "start": { + "utc": "2026-12-17T13:18:12Z", + "local": "2026-12-17T21:18:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461392.05430244 + }, + "end": { + "utc": "2026-12-31T16:00:00Z", + "local": "2027-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461406.16666667 + } + } + }, + { + "order": 7, + "lord": "Sun", + "duration_days": 18.2625, + "full_duration_days": 18.2625, + "start": { + "utc": "2027-02-16T10:18:12Z", + "local": "2027-02-16T18:18:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461452.92930244 + }, + "end": { + "utc": "2027-03-06T16:36:12Z", + "local": "2027-03-07T00:36:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461471.19180244 + }, + "in_report_range": null + }, + { + "order": 8, + "lord": "Moon", + "duration_days": 30.4375, + "full_duration_days": 30.4375, + "start": { + "utc": "2027-03-06T16:36:12Z", + "local": "2027-03-07T00:36:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461471.19180244 + }, + "end": { + "utc": "2027-04-06T03:06:12Z", + "local": "2027-04-06T11:06:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461501.62930244 + }, + "in_report_range": null + }, + { + "order": 9, + "lord": "Mars", + "duration_days": 21.30625, + "full_duration_days": 21.30625, + "start": { + "utc": "2027-04-06T03:06:12Z", + "local": "2027-04-06T11:06:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461501.62930244 + }, + "end": { + "utc": "2027-04-27T10:27:12Z", + "local": "2027-04-27T18:27:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461522.93555244 + }, + "in_report_range": null + }, + { + "order": 10, + "lord": "Rahu", + "duration_days": 49.20989167799769, + "full_duration_days": 54.7875, + "start": { + "utc": "2027-04-27T10:27:12Z", + "local": "2027-04-27T18:27:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461522.93555244 + }, + "end": { + "utc": "2027-06-15T15:29:26Z", + "local": "2027-06-15T23:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461572.14544412 + }, + "in_report_range": null + } + ], + "antardasha_periods": [ + { + "main_order": 1, + "sub_order": 8, + "main_lord": "Rahu", + "sub_lord": "Moon", + "duration_days": 2.381670822002315, + "start": { + "utc": "2026-06-15T09:29:26Z", + "local": "2026-06-15T17:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89544412 + }, + "end": { + "utc": "2026-06-17T18:39:03Z", + "local": "2026-06-18T02:39:03+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461209.27711494 + }, + "in_report_range": { + "start": { + "utc": "2026-06-15T09:29:26Z", + "local": "2026-06-15T17:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89544412 + }, + "end": { + "utc": "2026-06-17T18:39:03Z", + "local": "2026-06-18T02:39:03+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461209.27711494 + } + } + }, + { + "main_order": 1, + "sub_order": 9, + "main_lord": "Rahu", + "sub_lord": "Mars", + "duration_days": 3.1959375, + "start": { + "utc": "2026-06-17T18:39:03Z", + "local": "2026-06-18T02:39:03+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461209.27711494 + }, + "end": { + "utc": "2026-06-20T23:21:12Z", + "local": "2026-06-21T07:21:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461212.47305244 + }, + "in_report_range": { + "start": { + "utc": "2026-06-17T18:39:03Z", + "local": "2026-06-18T02:39:03+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461209.27711494 + }, + "end": { + "utc": "2026-06-20T23:21:12Z", + "local": "2026-06-21T07:21:12+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461212.47305244 + } + } + } + ] + }, + "patyayini": { + "status": "computed", + "producer": "annual_native.patyayini.build_native_patyayini", + "producer_status": "parameter_sensitive", + "model": "degree_within_sign", + "year_length": { + "basis": "fixed_365_25_days", + "days": 365.25, + "start": { + "utc": "2026-06-15T09:29:26Z", + "local": "2026-06-15T17:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89544412 + }, + "end": { + "utc": "2027-06-15T15:29:26Z", + "local": "2027-06-15T23:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461572.14544412 + }, + "note": "the native entry's fixed 365.25-day year; clipped for display, never stretched" + }, + "inputs": [ + { + "point": "Sun", + "longitude": 61.530199731378914, + "sign": "Gemini", + "degree_in_sign": 1.5301997313789144 + }, + { + "point": "Jupiter", + "longitude": 94.11364204883067, + "sign": "Cancer", + "degree_in_sign": 4.11364204883067 + }, + { + "point": "Lagna", + "longitude": 214.638, + "sign": "Scorpio", + "degree_in_sign": 4.638000000000005 + }, + { + "point": "Moon", + "longitude": 65.46804020261318, + "sign": "Gemini", + "degree_in_sign": 5.468040202613182 + }, + { + "point": "Venus", + "longitude": 99.48247070531748, + "sign": "Cancer", + "degree_in_sign": 9.482470705317482 + }, + { + "point": "Saturn", + "longitude": 350.54969013265935, + "sign": "Pisces", + "degree_in_sign": 20.54969013265935 + }, + { + "point": "Mercury", + "longitude": 86.01931617197006, + "sign": "Gemini", + "degree_in_sign": 26.01931617197006 + }, + { + "point": "Mars", + "longitude": 27.550854608075635, + "sign": "Aries", + "degree_in_sign": 27.550854608075635 + } + ], + "periods": [ + { + "order": 1, + "lord": "Sun", + "start": { + "utc": "2026-06-15T09:29:26Z", + "local": "2026-06-15T17:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89544412 + }, + "end": { + "utc": "2026-07-05T16:21:44Z", + "local": "2026-07-06T00:21:44+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461227.18176356 + }, + "in_report_range": { + "start": { + "utc": "2026-06-15T09:29:26Z", + "local": "2026-06-15T17:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89544412 + }, + "end": { + "utc": "2026-07-05T16:21:44Z", + "local": "2026-07-06T00:21:44+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461227.18176356 + } + } + }, + { + "order": 2, + "lord": "Jupiter", + "start": { + "utc": "2026-07-05T16:21:44Z", + "local": "2026-07-06T00:21:44+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461227.18176356 + }, + "end": { + "utc": "2026-08-08T22:20:58Z", + "local": "2026-08-09T06:20:58+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461261.43123115 + }, + "in_report_range": { + "start": { + "utc": "2026-07-05T16:21:44Z", + "local": "2026-07-06T00:21:44+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461227.18176356 + }, + "end": { + "utc": "2026-08-08T22:20:58Z", + "local": "2026-08-09T06:20:58+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461261.43123115 + } + } + }, + { + "order": 3, + "lord": "Lagna", + "start": { + "utc": "2026-08-08T22:20:58Z", + "local": "2026-08-09T06:20:58+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461261.43123115 + }, + "end": { + "utc": "2026-08-15T21:11:14Z", + "local": "2026-08-16T05:11:14+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461268.38280523 + }, + "in_report_range": { + "start": { + "utc": "2026-08-08T22:20:58Z", + "local": "2026-08-09T06:20:58+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461261.43123115 + }, + "end": { + "utc": "2026-08-15T21:11:14Z", + "local": "2026-08-16T05:11:14+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461268.38280523 + } + } + }, + { + "order": 4, + "lord": "Moon", + "start": { + "utc": "2026-08-15T21:11:14Z", + "local": "2026-08-16T05:11:14+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461268.38280523 + }, + "end": { + "utc": "2026-08-26T21:17:08Z", + "local": "2026-08-27T05:17:08+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461279.38690245 + }, + "in_report_range": { + "start": { + "utc": "2026-08-15T21:11:14Z", + "local": "2026-08-16T05:11:14+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461268.38280523 + }, + "end": { + "utc": "2026-08-26T21:17:08Z", + "local": "2026-08-27T05:17:08+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461279.38690245 + } + } + }, + { + "order": 5, + "lord": "Venus", + "start": { + "utc": "2026-08-26T21:17:08Z", + "local": "2026-08-27T05:17:08+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461279.38690245 + }, + "end": { + "utc": "2026-10-19T02:34:40Z", + "local": "2026-10-19T10:34:40+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461332.60741171 + }, + "in_report_range": { + "start": { + "utc": "2026-08-26T21:17:08Z", + "local": "2026-08-27T05:17:08+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461279.38690245 + }, + "end": { + "utc": "2026-10-19T02:34:40Z", + "local": "2026-10-19T10:34:40+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461332.60741171 + } + } + }, + { + "order": 6, + "lord": "Saturn", + "start": { + "utc": "2026-10-19T02:34:40Z", + "local": "2026-10-19T10:34:40+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461332.60741171 + }, + "end": { + "utc": "2027-03-14T19:53:35Z", + "local": "2027-03-15T03:53:35+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461479.32888162 + }, + "in_report_range": { + "start": { + "utc": "2026-10-19T02:34:40Z", + "local": "2026-10-19T10:34:40+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461332.60741171 + }, + "end": { + "utc": "2026-12-31T16:00:00Z", + "local": "2027-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461406.16666667 + } + } + }, + { + "order": 7, + "lord": "Mercury", + "start": { + "utc": "2027-03-14T19:53:35Z", + "local": "2027-03-15T03:53:35+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461479.32888162 + }, + "end": { + "utc": "2027-05-26T08:11:34Z", + "local": "2027-05-26T16:11:34+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461551.84137004 + }, + "in_report_range": null + }, + { + "order": 8, + "lord": "Mars", + "start": { + "utc": "2027-05-26T08:11:34Z", + "local": "2027-05-26T16:11:34+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461551.84137004 + }, + "end": { + "utc": "2027-06-15T15:29:25Z", + "local": "2027-06-15T23:29:25+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461572.14543254 + }, + "in_report_range": null + } + ], + "antardasha_periods": [ + { + "main": "Sun", + "sub": "Sun", + "main_order": 1, + "sub_order": 1, + "start": { + "utc": "2026-06-15T09:29:26Z", + "local": "2026-06-15T17:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89544412 + }, + "end": { + "utc": "2026-06-16T12:31:54Z", + "local": "2026-06-16T20:31:54+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461208.02215708 + }, + "in_report_range": { + "start": { + "utc": "2026-06-15T09:29:26Z", + "local": "2026-06-15T17:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89544412 + }, + "end": { + "utc": "2026-06-16T12:31:54Z", + "local": "2026-06-16T20:31:54+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461208.02215708 + } + } + }, + { + "main": "Sun", + "sub": "Jupiter", + "main_order": 1, + "sub_order": 2, + "start": { + "utc": "2026-06-16T12:31:54Z", + "local": "2026-06-16T20:31:54+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461208.02215708 + }, + "end": { + "utc": "2026-06-18T10:11:08Z", + "local": "2026-06-18T18:11:08+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461209.92440245 + }, + "in_report_range": { + "start": { + "utc": "2026-06-16T12:31:54Z", + "local": "2026-06-16T20:31:54+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461208.02215708 + }, + "end": { + "utc": "2026-06-18T10:11:08Z", + "local": "2026-06-18T18:11:08+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461209.92440245 + } + } + } + ] + }, + "monthly_charts": { + "status": "computed", + "producer": "annual_native.monthly_charts.build_monthly_charts", + "producer_status": "partial_verified", + "step": "solar_30_degrees", + "step_note": "each chart starts when the Sun has advanced another 30 degrees from the annual Sun; not a calendar month, not a lunar return", + "rows": [ + { + "month_index": 1, + "status": "computed", + "start": { + "utc": "2026-06-15T09:29:26Z", + "local": "2026-06-15T17:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89544412 + }, + "end": { + "utc": "2026-07-16T20:16:01Z", + "local": "2026-07-17T04:16:01+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461238.34445478 + }, + "end_source": "next_monthly_chart", + "in_report_range": { + "start": { + "utc": "2026-06-15T09:29:26Z", + "local": "2026-06-15T17:29:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461206.89544412 + }, + "end": { + "utc": "2026-07-16T20:16:01Z", + "local": "2026-07-17T04:16:01+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461238.34445478 + } + }, + "target_sun_longitude": 61.530199731378914, + "sun_longitude": 61.530199731378914, + "ascendant": { + "sign": "Scorpio", + "degree_in_sign": 4.638, + "longitude": 214.638 + } + }, + { + "month_index": 2, + "status": "computed", + "start": { + "utc": "2026-07-16T20:16:01Z", + "local": "2026-07-17T04:16:01+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461238.34445478 + }, + "end": { + "utc": "2026-08-17T04:34:24Z", + "local": "2026-08-17T12:34:24+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461269.69055833 + }, + "end_source": "next_monthly_chart", + "in_report_range": { + "start": { + "utc": "2026-07-16T20:16:01Z", + "local": "2026-07-17T04:16:01+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461238.34445478 + }, + "end": { + "utc": "2026-08-17T04:34:24Z", + "local": "2026-08-17T12:34:24+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461269.69055833 + } + }, + "target_sun_longitude": 91.53019973137891, + "sun_longitude": 91.53021873142865, + "ascendant": { + "sign": "Gemini", + "degree_in_sign": 21.523, + "longitude": 81.523 + } + }, + { + "month_index": 3, + "status": "computed", + "start": { + "utc": "2026-08-17T04:34:24Z", + "local": "2026-08-17T12:34:24+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461269.69055833 + }, + "end": { + "utc": "2026-09-17T04:26:49Z", + "local": "2026-09-17T12:26:49+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461300.68528919 + }, + "end_source": "next_monthly_chart", + "in_report_range": { + "start": { + "utc": "2026-08-17T04:34:24Z", + "local": "2026-08-17T12:34:24+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461269.69055833 + }, + "end": { + "utc": "2026-09-17T04:26:49Z", + "local": "2026-09-17T12:26:49+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461300.68528919 + } + }, + "target_sun_longitude": 121.53019973137891, + "sun_longitude": 121.5301970694081, + "ascendant": { + "sign": "Libra", + "degree_in_sign": 25.2552, + "longitude": 205.2552 + } + }, + { + "month_index": 4, + "status": "computed", + "start": { + "utc": "2026-09-17T04:26:49Z", + "local": "2026-09-17T12:26:49+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461300.68528919 + }, + "end": { + "utc": "2026-10-17T16:23:27Z", + "local": "2026-10-18T00:23:27+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461331.1829562 + }, + "end_source": "next_monthly_chart", + "in_report_range": { + "start": { + "utc": "2026-09-17T04:26:49Z", + "local": "2026-09-17T12:26:49+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461300.68528919 + }, + "end": { + "utc": "2026-10-17T16:23:27Z", + "local": "2026-10-18T00:23:27+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461331.1829562 + } + }, + "target_sun_longitude": 151.5301997313789, + "sun_longitude": 151.53018861038845, + "ascendant": { + "sign": "Scorpio", + "degree_in_sign": 18.1846, + "longitude": 228.1846 + } + }, + { + "month_index": 5, + "status": "computed", + "start": { + "utc": "2026-10-17T16:23:27Z", + "local": "2026-10-18T00:23:27+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461331.1829562 + }, + "end": { + "utc": "2026-11-16T16:13:06Z", + "local": "2026-11-17T00:13:06+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461361.17576952 + }, + "end_source": "next_monthly_chart", + "in_report_range": { + "start": { + "utc": "2026-10-17T16:23:27Z", + "local": "2026-10-18T00:23:27+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461331.1829562 + }, + "end": { + "utc": "2026-11-16T16:13:06Z", + "local": "2026-11-17T00:13:06+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461361.17576952 + } + }, + "target_sun_longitude": 181.5301997313789, + "sun_longitude": 181.53019497590174, + "ascendant": { + "sign": "Cancer", + "degree_in_sign": 18.419, + "longitude": 108.419 + } + }, + { + "month_index": 6, + "status": "computed", + "start": { + "utc": "2026-11-16T16:13:06Z", + "local": "2026-11-17T00:13:06+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461361.17576952 + }, + "end": { + "utc": "2026-12-16T06:53:48Z", + "local": "2026-12-16T14:53:48+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461390.78735609 + }, + "end_source": "next_monthly_chart", + "in_report_range": { + "start": { + "utc": "2026-11-16T16:13:06Z", + "local": "2026-11-17T00:13:06+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461361.17576952 + }, + "end": { + "utc": "2026-12-16T06:53:48Z", + "local": "2026-12-16T14:53:48+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461390.78735609 + } + }, + "target_sun_longitude": 211.5301997313789, + "sun_longitude": 211.5301940674034, + "ascendant": { + "sign": "Leo", + "degree_in_sign": 9.6734, + "longitude": 129.6734 + } + }, + { + "month_index": 7, + "status": "computed", + "start": { + "utc": "2026-12-16T06:53:48Z", + "local": "2026-12-16T14:53:48+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461390.78735609 + }, + "end": { + "utc": "2027-01-14T17:39:00Z", + "local": "2027-01-15T01:39:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461420.23542198 + }, + "end_source": "next_monthly_chart", + "in_report_range": { + "start": { + "utc": "2026-12-16T06:53:48Z", + "local": "2026-12-16T14:53:48+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461390.78735609 + }, + "end": { + "utc": "2026-12-31T16:00:00Z", + "local": "2027-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461406.16666667 + } + }, + "target_sun_longitude": 241.5301997313789, + "sun_longitude": 241.5301930687868, + "ascendant": { + "sign": "Taurus", + "degree_in_sign": 0.7973, + "longitude": 30.7973 + } + }, + { + "month_index": 8, + "status": "computed", + "start": { + "utc": "2027-01-14T17:39:00Z", + "local": "2027-01-15T01:39:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461420.23542198 + }, + "end": { + "utc": "2027-02-13T06:38:26Z", + "local": "2027-02-13T14:38:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461449.77668465 + }, + "end_source": "next_monthly_chart", + "in_report_range": null, + "target_sun_longitude": 271.5301997313789, + "sun_longitude": 271.53018796763575, + "ascendant": { + "sign": "Libra", + "degree_in_sign": 13.0769, + "longitude": 193.0769 + } + }, + { + "month_index": 9, + "status": "computed", + "start": { + "utc": "2027-02-13T06:38:26Z", + "local": "2027-02-13T14:38:26+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461449.77668465 + }, + "end": { + "utc": "2027-03-15T03:31:18Z", + "local": "2027-03-15T11:31:18+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461479.64673465 + }, + "end_source": "next_monthly_chart", + "in_report_range": null, + "target_sun_longitude": 301.5301997313789, + "sun_longitude": 301.5300983263996, + "ascendant": { + "sign": "Gemini", + "degree_in_sign": 24.8544, + "longitude": 84.8544 + } + }, + { + "month_index": 10, + "status": "computed", + "start": { + "utc": "2027-03-15T03:31:18Z", + "local": "2027-03-15T11:31:18+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461479.64673465 + }, + "end": { + "utc": "2027-04-14T12:01:42Z", + "local": "2027-04-14T20:01:42+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461510.00118309 + }, + "end_source": "next_monthly_chart", + "in_report_range": null, + "target_sun_longitude": 331.5301997313789, + "sun_longitude": 331.5301896577988, + "ascendant": { + "sign": "Gemini", + "degree_in_sign": 9.9369, + "longitude": 69.9369 + } + }, + { + "month_index": 11, + "status": "computed", + "start": { + "utc": "2027-04-14T12:01:42Z", + "local": "2027-04-14T20:01:42+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461510.00118309 + }, + "end": { + "utc": "2027-05-15T08:55:01Z", + "local": "2027-05-15T16:55:01+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461540.87153968 + }, + "end_source": "next_monthly_chart", + "in_report_range": null, + "target_sun_longitude": 1.5301997313789002, + "sun_longitude": 1.5301972540025197, + "ascendant": { + "sign": "Libra", + "degree_in_sign": 16.343, + "longitude": 196.343 + } + }, + { + "month_index": 12, + "status": "computed", + "start": { + "utc": "2027-05-15T08:55:01Z", + "local": "2027-05-15T16:55:01+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461540.87153968 + }, + "end": { + "utc": "2027-06-15T15:32:29Z", + "local": "2027-06-15T23:32:29+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461572.14755382 + }, + "end_source": "next_solar_return", + "in_report_range": null, + "target_sun_longitude": 31.5301997313789, + "sun_longitude": 31.53019941361721, + "ascendant": { + "sign": "Libra", + "degree_in_sign": 3.5251, + "longitude": 183.5251 + } + } + ] + } + } + } + ], + "natal_periods": { + "vimshottari": { + "status": "computed", + "producer": "jyotish_engine.cmd_dasha", + "moon_nakshatra": "Shatabhisha", + "start_basis": "birth_moment", + "year_length": { + "days": 365.25, + "basis": "cmd_dasha 365.25-day year" + }, + "boundary_time_basis": "birth local clock (fixed birth offset) mapped to UTC by elapsed time", + "rows": [ + { + "level": "MD", + "lord": "Saturn", + "start": { + "utc": "2008-04-14T11:30:43Z", + "local": "2008-04-14T19:30:43+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2454570.97966435 + }, + "end": { + "utc": "2027-04-15T05:30:43Z", + "local": "2027-04-15T13:30:43+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461510.72966435 + }, + "in_report_range": { + "start": { + "utc": "2025-12-31T16:00:00Z", + "local": "2026-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461041.16666667 + }, + "end": { + "utc": "2026-12-31T16:00:00Z", + "local": "2027-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461406.16666667 + } + } + }, + { + "level": "AD", + "lord": "Jupiter", + "start": { + "utc": "2024-10-01T22:18:43Z", + "local": "2024-10-02T06:18:43+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460585.42966435 + }, + "end": { + "utc": "2027-04-15T05:30:43Z", + "local": "2027-04-15T13:30:43+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461510.72966435 + }, + "in_report_range": { + "start": { + "utc": "2025-12-31T16:00:00Z", + "local": "2026-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461041.16666667 + }, + "end": { + "utc": "2026-12-31T16:00:00Z", + "local": "2027-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461406.16666667 + } + }, + "mahadasha_lord": "Saturn" + }, + { + "level": "PD", + "lord": "Venus", + "start": { + "utc": "2025-12-30T20:51:07Z", + "local": "2025-12-31T04:51:07+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461040.36883102 + }, + "end": { + "utc": "2026-06-03T02:03:07Z", + "local": "2026-06-03T10:03:07+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461194.58549769 + }, + "in_report_range": { + "start": { + "utc": "2025-12-31T16:00:00Z", + "local": "2026-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461041.16666667 + }, + "end": { + "utc": "2026-06-03T02:03:07Z", + "local": "2026-06-03T10:03:07+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461194.58549769 + } + }, + "mahadasha_lord": "Saturn", + "antardasha_lord": "Jupiter" + }, + { + "level": "PD", + "lord": "Sun", + "start": { + "utc": "2026-06-03T02:03:07Z", + "local": "2026-06-03T10:03:07+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461194.58549769 + }, + "end": { + "utc": "2026-07-19T08:24:43Z", + "local": "2026-07-19T16:24:43+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461240.85049769 + }, + "in_report_range": { + "start": { + "utc": "2026-06-03T02:03:07Z", + "local": "2026-06-03T10:03:07+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461194.58549769 + }, + "end": { + "utc": "2026-07-19T08:24:43Z", + "local": "2026-07-19T16:24:43+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461240.85049769 + } + }, + "mahadasha_lord": "Saturn", + "antardasha_lord": "Jupiter" + }, + { + "level": "PD", + "lord": "Moon", + "start": { + "utc": "2026-07-19T08:24:43Z", + "local": "2026-07-19T16:24:43+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461240.85049769 + }, + "end": { + "utc": "2026-10-04T11:00:43Z", + "local": "2026-10-04T19:00:43+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461317.95883102 + }, + "in_report_range": { + "start": { + "utc": "2026-07-19T08:24:43Z", + "local": "2026-07-19T16:24:43+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461240.85049769 + }, + "end": { + "utc": "2026-10-04T11:00:43Z", + "local": "2026-10-04T19:00:43+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461317.95883102 + } + }, + "mahadasha_lord": "Saturn", + "antardasha_lord": "Jupiter" + }, + { + "level": "PD", + "lord": "Mars", + "start": { + "utc": "2026-10-04T11:00:43Z", + "local": "2026-10-04T19:00:43+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461317.95883102 + }, + "end": { + "utc": "2026-11-27T10:25:55Z", + "local": "2026-11-27T18:25:55+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461371.93466435 + }, + "in_report_range": { + "start": { + "utc": "2026-10-04T11:00:43Z", + "local": "2026-10-04T19:00:43+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461317.95883102 + }, + "end": { + "utc": "2026-11-27T10:25:55Z", + "local": "2026-11-27T18:25:55+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461371.93466435 + } + }, + "mahadasha_lord": "Saturn", + "antardasha_lord": "Jupiter" + }, + { + "level": "PD", + "lord": "Rahu", + "start": { + "utc": "2026-11-27T10:25:55Z", + "local": "2026-11-27T18:25:55+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461371.93466435 + }, + "end": { + "utc": "2027-04-15T05:30:43Z", + "local": "2027-04-15T13:30:43+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461510.72966435 + }, + "in_report_range": { + "start": { + "utc": "2026-11-27T10:25:55Z", + "local": "2026-11-27T18:25:55+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461371.93466435 + }, + "end": { + "utc": "2026-12-31T16:00:00Z", + "local": "2027-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461406.16666667 + } + }, + "mahadasha_lord": "Saturn", + "antardasha_lord": "Jupiter" + } + ] + }, + "narayana": { + "status": "computed", + "producer": "narayana_dasha / narayana_rath", + "age_axis": { + "year_days": 365.25, + "basis": "date = birth instant + age x 365.25 days (the engine's current_age convention)" + }, + "legacy": { + "status": "computed", + "producer": "narayana_dasha.narayana_dasha_full_report", + "profile": "legacy_reference_only", + "profile_note": "Legacy algorithm (starts from the Lagna, always zodiacal); to be replaced by Sanjay Rath's version", + "used_by_product_timeline": true, + "age_precision": "MD ages exact sums of whole years; AD ages rounded by the producer to 4 decimals (about 53 minutes)", + "rows": [ + { + "level": "MD", + "sign": "Capricorn", + "lord": "Saturn", + "start_age": 34.0, + "end_age": 46.0, + "start": { + "utc": "2024-06-14T16:00:00Z", + "local": "2024-06-15T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460476.16666667 + }, + "end": { + "utc": "2036-06-14T16:00:00Z", + "local": "2036-06-15T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2464859.16666667 + }, + "in_report_range": { + "start": { + "utc": "2025-12-31T16:00:00Z", + "local": "2026-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461041.16666667 + }, + "end": { + "utc": "2026-12-31T16:00:00Z", + "local": "2027-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461406.16666667 + } + } + }, + { + "level": "AD", + "sign": "Aquarius", + "lord": "Saturn", + "start_age": 35.44, + "end_age": 36.76, + "start": { + "utc": "2025-11-22T15:02:24Z", + "local": "2025-11-22T23:02:24+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461002.12666667 + }, + "end": { + "utc": "2027-03-19T18:09:36Z", + "local": "2027-03-20T02:09:36+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461484.25666667 + }, + "in_report_range": { + "start": { + "utc": "2025-12-31T16:00:00Z", + "local": "2026-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461041.16666667 + }, + "end": { + "utc": "2026-12-31T16:00:00Z", + "local": "2027-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461406.16666667 + } + }, + "parent_md": "Capricorn" + } + ] + }, + "rath": { + "status": "computed", + "producer": "narayana_rath.calc_narayana_rath", + "producer_status": "partial_verified", + "seed_sign": "Aquarius", + "seed_basis": "stronger_of_lagna_seventh:level_1_occupant_count", + "direction": "table10_general", + "used_by_product_timeline": false, + "profile_note": "Sanjay Rath profile (astrologer's ruling); shown alongside, product timeline not switched", + "antardasha_blocked": [], + "rows": [ + { + "level": "MD", + "sign": "Pisces", + "lord": "Jupiter", + "start_age": 29.0, + "end_age": 38.0, + "start": { + "utc": "2019-06-15T10:00:00Z", + "local": "2019-06-15T18:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2458649.91666667 + }, + "end": { + "utc": "2028-06-14T16:00:00Z", + "local": "2028-06-15T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461937.16666667 + }, + "in_report_range": { + "start": { + "utc": "2025-12-31T16:00:00Z", + "local": "2026-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461041.16666667 + }, + "end": { + "utc": "2026-12-31T16:00:00Z", + "local": "2027-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461406.16666667 + } + } + }, + { + "level": "AD", + "sign": "Aquarius", + "lord": "Saturn", + "start_age": 35.0, + "end_age": 35.75, + "start": { + "utc": "2025-06-14T22:00:00Z", + "local": "2025-06-15T06:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460841.41666667 + }, + "end": { + "utc": "2026-03-15T20:30:00Z", + "local": "2026-03-16T04:30:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461115.35416667 + }, + "in_report_range": { + "start": { + "utc": "2025-12-31T16:00:00Z", + "local": "2026-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461041.16666667 + }, + "end": { + "utc": "2026-03-15T20:30:00Z", + "local": "2026-03-16T04:30:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461115.35416667 + } + }, + "parent_md": "Pisces" + }, + { + "level": "AD", + "sign": "Pisces", + "lord": "Jupiter", + "start_age": 35.75, + "end_age": 36.5, + "start": { + "utc": "2026-03-15T20:30:00Z", + "local": "2026-03-16T04:30:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461115.35416667 + }, + "end": { + "utc": "2026-12-14T19:00:00Z", + "local": "2026-12-15T03:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461389.29166667 + }, + "in_report_range": { + "start": { + "utc": "2026-03-15T20:30:00Z", + "local": "2026-03-16T04:30:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461115.35416667 + }, + "end": { + "utc": "2026-12-14T19:00:00Z", + "local": "2026-12-15T03:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461389.29166667 + } + }, + "parent_md": "Pisces" + }, + { + "level": "AD", + "sign": "Aries", + "lord": "Mars", + "start_age": 36.5, + "end_age": 37.25, + "start": { + "utc": "2026-12-14T19:00:00Z", + "local": "2026-12-15T03:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461389.29166667 + }, + "end": { + "utc": "2027-09-14T17:30:00Z", + "local": "2027-09-15T01:30:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461663.22916667 + }, + "in_report_range": { + "start": { + "utc": "2026-12-14T19:00:00Z", + "local": "2026-12-15T03:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461389.29166667 + }, + "end": { + "utc": "2026-12-31T16:00:00Z", + "local": "2027-01-01T00:00:00+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461406.16666667 + } + }, + "parent_md": "Pisces" + } + ] + } + } + }, + "slow_transits": { + "status": "computed", + "producer": "jyotish_engine._calc_sidereal_planets_for_jd", + "bodies": [ + "Jupiter", + "Saturn", + "Rahu", + "Ketu" + ], + "node_mode": "mean", + "ayanamsa": "raman", + "method": "daily sample at 12:00 UT, then bisection to under one second; events closer than one day apart may merge", + "reference_points": { + "lagna_sign": "Leo", + "moon_sign": "Aquarius", + "house_basis": "whole sign from natal Lagna and from natal Moon" + }, + "positions_at_range_start": { + "Jupiter": { + "longitude": 88.62565833357118, + "sign": "Gemini", + "degree_in_sign": 28.62565833357118, + "retrograde": true, + "house_from_lagna": 11, + "house_from_moon": 5 + }, + "Saturn": { + "longitude": 333.37242901311697, + "sign": "Pisces", + "degree_in_sign": 3.372429013116971, + "retrograde": false, + "house_from_lagna": 8, + "house_from_moon": 2 + }, + "Rahu": { + "longitude": 319.41287212133307, + "sign": "Aquarius", + "degree_in_sign": 19.412872121333066, + "retrograde": true, + "house_from_lagna": 7, + "house_from_moon": 1 + }, + "Ketu": { + "longitude": 139.41287212133307, + "sign": "Leo", + "degree_in_sign": 19.412872121333066, + "retrograde": true, + "house_from_lagna": 1, + "house_from_moon": 7 + } + }, + "events": [ + { + "kind": "station", + "body": "Jupiter", + "instant": { + "utc": "2026-03-11T03:30:08Z", + "local": "2026-03-11T11:30:08+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461110.64592934 + }, + "motion": "retrograde_to_direct", + "longitude": 82.30888264503972, + "sign": "Gemini" + }, + { + "kind": "ingress", + "body": "Jupiter", + "instant": { + "utc": "2026-05-25T00:34:55Z", + "local": "2026-05-25T08:34:55+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461185.52424431 + }, + "from_sign": "Gemini", + "to_sign": "Cancer", + "to_house_from_lagna": 12, + "to_house_from_moon": 6, + "retrograde": false, + "axis_group": "jupiter" + }, + { + "kind": "station", + "body": "Saturn", + "instant": { + "utc": "2026-07-26T19:56:47Z", + "local": "2026-07-27T03:56:47+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461248.33110428 + }, + "motion": "direct_to_retrograde", + "longitude": 351.9654594310723, + "sign": "Pisces" + }, + { + "kind": "ingress", + "body": "Jupiter", + "instant": { + "utc": "2026-10-20T13:08:22Z", + "local": "2026-10-20T21:08:22+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461334.04747677 + }, + "from_sign": "Cancer", + "to_sign": "Leo", + "to_house_from_lagna": 1, + "to_house_from_moon": 7, + "retrograde": false, + "axis_group": "jupiter" + }, + { + "kind": "station", + "body": "Saturn", + "instant": { + "utc": "2026-12-10T23:31:03Z", + "local": "2026-12-11T07:31:03+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461385.47989082 + }, + "motion": "retrograde_to_direct", + "longitude": 345.1412363595042, + "sign": "Pisces" + }, + { + "kind": "station", + "body": "Jupiter", + "instant": { + "utc": "2026-12-13T00:56:36Z", + "local": "2026-12-13T08:56:36+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461387.53931046 + }, + "motion": "direct_to_retrograde", + "longitude": 124.2347266041302, + "sign": "Leo" + } + ], + "natal_point_contacts": [ + { + "body": "Jupiter", + "natal_point": "Jupiter", + "natal_longitude": 83.53626635397052, + "contact": "exact_conjunction_by_longitude", + "count": 2, + "repeated": true, + "crossings": [ + { + "instant": { + "utc": "2026-02-11T12:23:14Z", + "local": "2026-02-11T20:23:14+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461083.01613331 + }, + "retrograde": true + }, + { + "instant": { + "utc": "2026-04-08T02:40:24Z", + "local": "2026-04-08T10:40:24+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461138.61139393 + }, + "retrograde": false + } + ], + "axis_group": "jupiter" + } + ], + "retrograde_loops": [ + { + "body": "Jupiter", + "station_retrograde": { + "instant": { + "utc": "2025-11-11T16:41:42Z", + "local": "2025-11-12T00:41:42+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460991.19562149 + }, + "longitude": 92.37959361349438, + "sign": "Cancer" + }, + "station_direct": { + "instant": { + "utc": "2026-03-11T03:30:08Z", + "local": "2026-03-11T11:30:08+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461110.64592934 + }, + "longitude": 82.30888264503972, + "sign": "Gemini" + }, + "retraced_arc": { + "from_longitude": 82.30888264503972, + "to_longitude": 92.37959361349438, + "degrees": 10.070710968454662 + }, + "pre_shadow_entry": { + "utc": "2025-08-17T06:32:11Z", + "local": "2025-08-17T14:32:11+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460904.77235317 + }, + "post_shadow_exit": { + "utc": "2026-06-06T16:45:41Z", + "local": "2026-06-07T00:45:41+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461198.19839382 + }, + "shadow_note": null, + "natal_points_in_arc": [ + { + "natal_point": "Jupiter", + "natal_longitude": 83.53626635397052, + "crossings": [ + { + "instant": { + "utc": "2025-08-23T13:29:48Z", + "local": "2025-08-23T21:29:48+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2460911.06236362 + }, + "retrograde": false, + "in_report_range": false + }, + { + "instant": { + "utc": "2026-02-11T12:23:14Z", + "local": "2026-02-11T20:23:14+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461083.01613331 + }, + "retrograde": true, + "in_report_range": true + }, + { + "instant": { + "utc": "2026-04-08T02:40:24Z", + "local": "2026-04-08T10:40:24+08:00", + "utc_offset": "+08:00", + "timezone_id": "Asia/Shanghai", + "jd_ut": 2461138.61139393 + }, + "retrograde": false, + "in_report_range": true + } + ] + } + ] + } + ], + "retrograde_loop_scope": "Jupiter and Saturn; Rahu/Ketu are excluded (their mean motion is retrograde)" + }, + "independent_annual_views": { + "tithi_pravesha": { + "status": "computed", + "producer": "annual_native.tithi_pravesh.build_tithi_pravesh", + "start_rule": "Sun and Moon return to the natal Sun-Moon phase near the birthday (own start rule, not the solar return)", + "location_rule": "birth place, birth UTC offset (the producer's own rule; not the Tajika return-location option)", + "years": [ + { + "target_year": 2025, + "status": "computed", + "producer": "annual_native.tithi_pravesh.build_tithi_pravesh", + "producer_status": "partial_verified", + "natal_phase_degrees": 257.11240281558815, + "candidate_count": 1, + "candidates": [ + { + "instant": { + "utc": "2025-06-17T19:03:19Z", + "local": "2025-06-18T03:03:19+08:00", + "utc_offset": "+08:00", + "timezone_id": null, + "jd_ut": 2460844.29397378 + }, + "in_report_range": false, + "tithi": { + "tithi_num": 22, + "paksha": "Krishna", + "full_name": "Krishna Saptami", + "moon_sun_diff": 257.1123539007219 + }, + "phase_degrees": 257.1123539007219, + "phase_delta_degrees": -4.8914866226823506e-05, + "planet_longitudes": { + "Sun": 64.0647293349303, + "Moon": 321.1770832356522 + } + } + ], + "candidate_note": "every candidate the producer found is listed; none is chosen here" + }, + { + "target_year": 2026, + "status": "computed", + "producer": "annual_native.tithi_pravesh.build_tithi_pravesh", + "producer_status": "partial_verified", + "natal_phase_degrees": 257.11240281558815, + "candidate_count": 1, + "candidates": [ + { + "instant": { + "utc": "2026-07-06T18:26:38Z", + "local": "2026-07-07T02:26:38+08:00", + "utc_offset": "+08:00", + "timezone_id": null, + "jd_ut": 2461228.26849624 + }, + "in_report_range": true, + "tithi": { + "tithi_num": 22, + "paksha": "Krishna", + "full_name": "Krishna Saptami", + "moon_sun_diff": 257.1123921873849 + }, + "phase_degrees": 257.1123921873849, + "phase_delta_degrees": -1.0628203256146662e-05, + "planet_longitudes": { + "Sun": 81.91837260047967, + "Moon": 339.03076478786454 + } + } + ], + "candidate_note": "every candidate the producer found is listed; none is chosen here" + } + ] + } + }, + "computation_boundaries": { + "instant_format": "every boundary has utc, local (ISO with offset), utc_offset, timezone_id and jd_ut", + "local_zone": "return location zone (birth place in v1); IANA zone when timezone_id is given, else the fixed birth offset", + "year_chart_validity": "this solar return (inclusive) to the next solar return (exclusive)", + "clipping": "period rows keep their native bounds; in_report_range is the part inside the report range; nothing is stretched", + "period_year_lengths": [ + { + "system": "mudda_dasha", + "days": 365.25, + "anchor": "solar return instant" + }, + { + "system": "patyayini", + "days": 365.25, + "anchor": "solar return instant" + }, + { + "system": "vimshottari", + "days": 365.25, + "anchor": "birth moment" + }, + { + "system": "narayana", + "days": 365.25, + "anchor": "birth moment, age axis" + } + ], + "naive_local_to_utc": "producers that add day counts to a naive local start are mapped to UTC by elapsed time from the anchor instant", + "monthly_charts": "Sun advancing 30 degrees per chart from the annual Sun; month 12 ends at the next solar return", + "slow_transit_frame": "jyotish_engine._calc_sidereal_planets_for_jd (same frame as the natal chart), node mode and ayanamsa as requested", + "candidate_windows": "not built here; the report phase builds them from these instants" + }, + "not_included": [ + { + "item": "varsha_narayana", + "reason": "no native producer in this repository (upstream yinduzhanxing has one; not synced)" + }, + { + "item": "yoga_and_nakshatra_pravesha", + "reason": "research-only upstream; the ruling keeps them out of the report" + }, + { + "item": "external_engine_replay", + "reason": "PyJHora annual replay runs only in the full-data report child process; not executed here" + }, + { + "item": "residence_return_location", + "reason": "v1 offers the birth place only; return_location keeps the shape for a later comparison" + }, + { + "item": "transit_ashtakavarga_vedha_sade_sati", + "reason": "report phase; BAV/SAV come from /api/ashtakavarga, Vedha and Sade Sati phase are not executed here" + }, + { + "item": "candidate_windows", + "reason": "report phase (#153)" + }, + { + "item": "real_case_calibration", + "reason": "blocked per the 2026-11-01 ruling; no accuracy claim" + } + ], + "block_index": [ + { + "path": "parameters.natal_chart", + "status": "computed", + "producer": "jyotish_engine._compute_chart_from_args", + "reason": null + }, + { + "path": "report_range", + "status": "computed", + "producer": "annual_package._report_range", + "reason": null + }, + { + "path": "year_charts[0]", + "status": "computed", + "producer": "annual_tajika_pack.build_annual_tajika_pack", + "reason": null + }, + { + "path": "year_charts[0].blocks.chart", + "status": "computed", + "producer": "solar_return.calc_solar_return_chart", + "reason": null + }, + { + "path": "year_charts[0].blocks.muntha", + "status": "computed", + "producer": "solar_return._muntha_from_natal_ascendant", + "reason": null + }, + { + "path": "year_charts[0].blocks.year_lord", + "status": "computed", + "producer": "tajika_year_lord.select_annual_year_lord (Panchadhikari)", + "reason": null + }, + { + "path": "year_charts[0].blocks.annual_strength", + "status": "computed", + "producer": "tajika.calc_tajika_strength_layers", + "reason": null + }, + { + "path": "year_charts[0].blocks.sahams", + "status": "computed", + "producer": "tajika.calc_all_sahams", + "reason": null + }, + { + "path": "year_charts[0].blocks.tajika_yogas", + "status": "computed", + "producer": "tajika_named_yoga_authority.build_tajika_named_yoga_authority", + "reason": null + }, + { + "path": "year_charts[0].blocks.tripataki", + "status": "computed", + "producer": "annual_native.tripataki_chakra.build_tripataki_chakra", + "reason": null + }, + { + "path": "year_charts[0].blocks.mudda_dasha", + "status": "computed", + "producer": "tajika_mudda.annual_mudda_dasha", + "reason": null + }, + { + "path": "year_charts[0].blocks.patyayini", + "status": "computed", + "producer": "annual_native.patyayini.build_native_patyayini", + "reason": null + }, + { + "path": "year_charts[0].blocks.monthly_charts", + "status": "computed", + "producer": "annual_native.monthly_charts.build_monthly_charts", + "reason": null + }, + { + "path": "year_charts[1]", + "status": "computed", + "producer": "annual_tajika_pack.build_annual_tajika_pack", + "reason": null + }, + { + "path": "year_charts[1].blocks.chart", + "status": "computed", + "producer": "solar_return.calc_solar_return_chart", + "reason": null + }, + { + "path": "year_charts[1].blocks.muntha", + "status": "computed", + "producer": "solar_return._muntha_from_natal_ascendant", + "reason": null + }, + { + "path": "year_charts[1].blocks.year_lord", + "status": "computed", + "producer": "tajika_year_lord.select_annual_year_lord (Panchadhikari)", + "reason": null + }, + { + "path": "year_charts[1].blocks.annual_strength", + "status": "computed", + "producer": "tajika.calc_tajika_strength_layers", + "reason": null + }, + { + "path": "year_charts[1].blocks.sahams", + "status": "computed", + "producer": "tajika.calc_all_sahams", + "reason": null + }, + { + "path": "year_charts[1].blocks.tajika_yogas", + "status": "computed", + "producer": "tajika_named_yoga_authority.build_tajika_named_yoga_authority", + "reason": null + }, + { + "path": "year_charts[1].blocks.tripataki", + "status": "computed", + "producer": "annual_native.tripataki_chakra.build_tripataki_chakra", + "reason": null + }, + { + "path": "year_charts[1].blocks.mudda_dasha", + "status": "computed", + "producer": "tajika_mudda.annual_mudda_dasha", + "reason": null + }, + { + "path": "year_charts[1].blocks.patyayini", + "status": "computed", + "producer": "annual_native.patyayini.build_native_patyayini", + "reason": null + }, + { + "path": "year_charts[1].blocks.monthly_charts", + "status": "computed", + "producer": "annual_native.monthly_charts.build_monthly_charts", + "reason": null + }, + { + "path": "natal_periods.vimshottari", + "status": "computed", + "producer": "jyotish_engine.cmd_dasha", + "reason": null + }, + { + "path": "natal_periods.narayana", + "status": "computed", + "producer": "narayana_dasha / narayana_rath", + "reason": null + }, + { + "path": "natal_periods.narayana.legacy", + "status": "computed", + "producer": "narayana_dasha.narayana_dasha_full_report", + "reason": null + }, + { + "path": "natal_periods.narayana.rath", + "status": "computed", + "producer": "narayana_rath.calc_narayana_rath", + "reason": null + }, + { + "path": "slow_transits", + "status": "computed", + "producer": "jyotish_engine._calc_sidereal_planets_for_jd", + "reason": null + }, + { + "path": "independent_annual_views.tithi_pravesha", + "status": "computed", + "producer": "annual_native.tithi_pravesh.build_tithi_pravesh", + "reason": null + }, + { + "path": "independent_annual_views.tithi_pravesha.years[0]", + "status": "computed", + "producer": "annual_native.tithi_pravesh.build_tithi_pravesh", + "reason": null + }, + { + "path": "independent_annual_views.tithi_pravesha.years[1]", + "status": "computed", + "producer": "annual_native.tithi_pravesh.build_tithi_pravesh", + "reason": null + } + ], + "blocked_count": 0 + } +} \ No newline at end of file diff --git a/scripts/annual_package.py b/scripts/annual_package.py new file mode 100644 index 00000000..51b36efe --- /dev/null +++ b/scripts/annual_package.py @@ -0,0 +1,1268 @@ +"""Annual data package v1 (Gitea #152 ruling 2026-11-01, phase 1 of #153 / #154). + +One export for the annual report: year charts, annual periods, natal long +periods and slow-planet transits, with separate time semantics. It only calls +existing producers; no astrology rule is defined here. + +Producers (one per block): + +- year chart, Muntha, year lord, Sahams, Tajika yogas, Tripataki, annual + strength, Mudda: ``annual_tajika_pack.build_annual_tajika_pack`` on the + report of ``cmd_solar_return`` (``solar_return.solar_return_full_report``); +- Patyayini: ``annual_native.patyayini.build_native_patyayini`` (the native + annual entry named by the ``tajika-annual`` skill; never the equal-period + function in ``extended_dashas.py``); +- Tajika monthly charts: ``annual_native.monthly_charts.build_monthly_charts`` + (the Sun advancing 30 degrees at a time); +- Tithi Pravesha (independent annual view, own start rule): + ``annual_native.tithi_pravesh.build_tithi_pravesh``; +- Vimshottari MD/AD/PD: ``jyotish_engine.cmd_dasha`` (birth moment); +- Narayana: ``narayana_dasha.narayana_dasha_full_report`` (product legacy + profile, labelled by ``narayana_legacy_label``) and ``narayana_rath`` + (Sanjay Rath profile, display only); +- slow transits: positions from ``jyotish_engine._calc_sidereal_planets_for_jd`` + (the chart engine's sidereal frame, honours node mode and ayanamsa), sampled + once a day and refined by bisection. + +Time semantics: every boundary is an instant with UTC, local time, offset and +Julian day. Local time is the return location's zone (the birth place in v1). +Each period system keeps its own year length; rows keep their native bounds +and carry ``in_report_range`` for the part inside the report range. Nothing is +stretched to fit a year chart. + +Every block has ``status`` computed | blocked | not_applicable with a reason. +A blocked block never fails the response. +""" + +from __future__ import annotations + +import json +import math +from collections.abc import Callable, Iterable +from datetime import UTC, date, datetime, timedelta, timezone +from types import SimpleNamespace +from typing import Any +from zoneinfo import ZoneInfo + +SCHEMA = "jyotish.annual_package.v1" +ANNUAL_PACKAGE_VERSION = 1 +RANGE_KINDS = ("birthday_year", "calendar_year", "next_12_months") +RETURN_LOCATION_MODES = ("birth_place",) +SLOW_BODIES = ("Jupiter", "Saturn", "Rahu", "Ketu") +LOOP_BODIES = ("Jupiter", "Saturn") +NATAL_POINTS = ("Lagna", "Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu") +SIGNS = ( + "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", +) +SIGN_LORDS = { + "Aries": "Mars", "Taurus": "Venus", "Gemini": "Mercury", "Cancer": "Moon", + "Leo": "Sun", "Virgo": "Mercury", "Libra": "Venus", "Scorpio": "Mars", + "Sagittarius": "Jupiter", "Capricorn": "Saturn", "Aquarius": "Saturn", "Pisces": "Jupiter", +} +PATYAYINI_YEAR_DAYS = 365.25 +VIMSHOTTARI_YEAR_DAYS = 365.25 +NARAYANA_AGE_YEAR_DAYS = 365.25 +TRANSIT_MARGIN_DAYS = 220 +BISECT_STEPS = 20 # one day / 2**20 ~ 0.08 s +LOOKBACK_MONTHS = 3 +_J2000 = datetime(2000, 1, 1, 12, tzinfo=UTC) +# Keys that hold reading sentences, not data. Interpretation is the report +# phase; the package keeps numbers, names, rules and bases only. +_TEXT_KEYS = frozenset({"interpretation"}) + + +class AnnualPackageInputError(ValueError): + """Bad request input; the API maps it to HTTP 400.""" + + +# ── time helpers ──────────────────────────────────────────────────────────── + +def _zone(timezone_id: str | None, tz_hours: float): + if timezone_id: + return ZoneInfo(timezone_id) + return timezone(timedelta(hours=float(tz_hours))) + + +def _zone_id(zone) -> str | None: + return getattr(zone, "key", None) + + +def _jd_from_utc(moment: datetime) -> float: + return 2451545.0 + (moment.astimezone(UTC) - _J2000).total_seconds() / 86400.0 + + +def _utc_from_jd(jd: float) -> datetime: + return _J2000 + timedelta(days=float(jd) - 2451545.0) + + +def _fmt_offset(delta: timedelta | None) -> str: + seconds = int((delta or timedelta()).total_seconds()) + sign = "+" if seconds >= 0 else "-" + seconds = abs(seconds) + return f"{sign}{seconds // 3600:02d}:{(seconds % 3600) // 60:02d}" + + +def instant(moment: datetime, zone) -> dict[str, Any]: + """An exact boundary: UTC, local time in ``zone``, its offset, Julian day.""" + utc = moment.astimezone(UTC) + rounded = (utc + timedelta(microseconds=500_000)).replace(microsecond=0) + local = rounded.astimezone(zone) + return { + "utc": rounded.strftime("%Y-%m-%dT%H:%M:%SZ"), + "local": local.isoformat(), + "utc_offset": _fmt_offset(local.utcoffset()), + "timezone_id": _zone_id(zone), + "jd_ut": round(_jd_from_utc(utc), 8), + } + + +def _parse_naive(value: Any) -> datetime | None: + if isinstance(value, datetime): + return value.replace(tzinfo=None) + if not isinstance(value, str) or not value.strip(): + return None + try: + return datetime.fromisoformat(value.strip().replace("T", " ")).replace(tzinfo=None) + except ValueError: + return None + + +def _elapsed_mapper(anchor_naive: datetime, anchor_utc: datetime) -> Callable[[Any], datetime | None]: + """Map a producer's naive local boundary to UTC by elapsed time. + + Mudda, Patyayini and Vimshottari add day counts to a naive local start. + The day count is the rule; read as elapsed time from the anchor instant it + stays right across a daylight-saving change. + """ + + def convert(value: Any) -> datetime | None: + parsed = _parse_naive(value) + if parsed is None: + return None + return anchor_utc + (parsed - anchor_naive) + + return convert + + +def _clip(start: datetime, end: datetime, range_start: datetime, range_end: datetime, zone) -> dict | None: + lo, hi = max(start, range_start), min(end, range_end) + if lo >= hi: + return None + return {"start": instant(lo, zone), "end": instant(hi, zone)} + + +def _add_months(day: date, months: int) -> date: + index = day.month - 1 + months + year, month = day.year + index // 12, index % 12 + 1 + for candidate in (day.day, 30, 29, 28): + try: + return date(year, month, min(day.day, candidate)) + except ValueError: + continue + raise ValueError("unreachable month arithmetic") + + +def _local_midnight(day: date, zone) -> datetime: + return datetime(day.year, day.month, day.day, tzinfo=zone).astimezone(UTC) + + +# ── block helpers ─────────────────────────────────────────────────────────── + +def block(status: str, producer: str, *, reason: str | None = None, **fields: Any) -> dict[str, Any]: + if status not in ("computed", "blocked", "not_applicable"): + raise ValueError(f"unknown block status {status}") + out: dict[str, Any] = {"status": status, "producer": producer} + if reason is not None or status != "computed": + out["reason"] = reason or "unspecified" + out.update(fields) + return out + + +def _strip_text(value: Any) -> Any: + if isinstance(value, dict): + return {key: _strip_text(item) for key, item in value.items() if key not in _TEXT_KEYS} + if isinstance(value, list): + return [_strip_text(item) for item in value] + return value + + +def _json_default(value: Any) -> Any: + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, date): + return value.isoformat() + return str(value) + + +def _plain(value: Any) -> Any: + return json.loads(json.dumps(_strip_text(value), default=_json_default, ensure_ascii=False)) + + +def _pack_block(field: Any, producer: str) -> dict[str, Any]: + """Normalize an ``annual_tajika_pack`` field into a package block.""" + if not isinstance(field, dict): + return block("blocked", producer, reason="pack_field_missing") + status = field.get("status") + if status == "conflict": + return block( + "blocked", producer, reason=f"producer_conflict:{field.get('reason')}", + producer_status="conflict", + conflict_values=_plain([item.get("value") for item in field.get("values") or [] if isinstance(item, dict)]), + ) + if status == "blocked": + detail = field.get("blocked_detail") if isinstance(field.get("blocked_detail"), dict) else None + extra = {"blocked_detail": _plain(detail)} if detail else {} + return block("blocked", producer, reason=str(field.get("reason") or "producer_blocked"), producer_status="blocked", **extra) + if status == "not_applicable": + return block("not_applicable", producer, reason=str(field.get("reason") or "not_applicable")) + data = field.get("data") + if isinstance(data, dict) and data.get("error"): + return block("blocked", producer, reason=f"producer_error:{data['error']}") + if isinstance(data, dict) and data.get("status") == "blocked": + return block("blocked", producer, reason=str(data.get("reason") or "producer_blocked"), blocked_detail=_plain(data)) + if data is None: + return block("blocked", producer, reason="pack_field_without_data") + return block("computed", producer, producer_status=status, data=_plain(data)) + + +def _guard(producer: str, func: Callable[[], dict[str, Any]]) -> dict[str, Any]: + try: + return func() + except Exception as exc: # noqa: BLE001 - a producer failure is a blocked block, not a 500 + return block("blocked", producer, reason=f"{exc.__class__.__name__}: {exc}") + + +def _sign_idx(longitude: float) -> int: + return int(float(longitude) % 360.0 // 30) % 12 + + +def _house(sign_idx: int | None, base_idx: int | None) -> int | None: + if sign_idx is None or base_idx is None: + return None + return (int(sign_idx) - int(base_idx)) % 12 + 1 + + +def _wrap(delta: float) -> float: + return ((float(delta) + 180.0) % 360.0) - 180.0 + + +# ── modules (scripts/ is on sys.path in the API and tests) ────────────────── + +def _engine(): + try: + import jyotish_engine + except ImportError: # pragma: no cover + from scripts import jyotish_engine + return jyotish_engine + + +def _module(name: str): + import importlib + try: + return importlib.import_module(name) + except ImportError: # pragma: no cover + return importlib.import_module(f"scripts.{name}") + + +# ── request ───────────────────────────────────────────────────────────────── + +def normalize_request( + *, + birth: dict[str, Any], + ayanamsa: str, + node_mode: str, + report_range: Any, + observation_date: Any, + return_location: Any, +) -> dict[str, Any]: + """Validate the package-specific fields. Birth fields arrive validated.""" + obs = _parse_day(observation_date, "observation_date") if observation_date else date.today() + raw_range = report_range if report_range is not None else {"kind": "birthday_year"} + if not isinstance(raw_range, dict): + raise AnnualPackageInputError("report_range must be an object") + kind = raw_range.get("kind") or "birthday_year" + if kind not in RANGE_KINDS: + raise AnnualPackageInputError(f"report_range.kind must be one of {', '.join(RANGE_KINDS)}") + year = raw_range.get("year") + if year is not None: + if isinstance(year, bool) or not isinstance(year, (int, str)) or not str(year).strip().lstrip("-").isdigit(): + raise AnnualPackageInputError("report_range.year must be an integer") + year = int(year) + if not int(birth["year"]) <= year <= 2400: + raise AnnualPackageInputError("report_range.year must be between the birth year and 2400") + start_date = raw_range.get("start_date") + if kind == "next_12_months": + start_date = _parse_day(start_date, "report_range.start_date") if start_date else obs + elif start_date is not None: + raise AnnualPackageInputError("report_range.start_date is only used with next_12_months") + raw_location = return_location if return_location is not None else {"mode": "birth_place"} + if not isinstance(raw_location, dict): + raise AnnualPackageInputError("return_location must be an object") + mode = raw_location.get("mode") or "birth_place" + if mode not in RETURN_LOCATION_MODES: + raise AnnualPackageInputError("return_location.mode must be birth_place in annual_package v1") + if node_mode not in ("mean", "true"): + raise AnnualPackageInputError("node_mode must be mean or true") + return { + "birth": dict(birth), + "ayanamsa": ayanamsa, + "node_mode": node_mode, + "report_range": {"kind": kind, "year": year, "start_date": start_date}, + "observation_date": obs, + "return_location": {"mode": mode}, + } + + +def _parse_day(value: Any, key: str) -> date: + if isinstance(value, date): + return value + if not isinstance(value, str): + raise AnnualPackageInputError(f"{key} must be YYYY-MM-DD") + try: + return datetime.strptime(value.strip()[:10], "%Y-%m-%d").date() + except ValueError as exc: + raise AnnualPackageInputError(f"{key} must be YYYY-MM-DD") from exc + + +# ── package ───────────────────────────────────────────────────────────────── + +class _Context: + def __init__(self, request: dict[str, Any]): + birth = request["birth"] + self.request = request + self.birth = birth + self.ayanamsa = request["ayanamsa"] + self.node_mode = request["node_mode"] + self.tz_hours = float(birth["tz"]) + self.timezone_id = birth.get("timezone_id") + self.zone = _zone(self.timezone_id, self.tz_hours) + self.birth_naive = datetime( + int(birth["year"]), int(birth["month"]), int(birth["day"]), + int(birth["hour"]), int(birth["minute"]), int(birth.get("second") or 0), + ) + self.birth_utc = (self.birth_naive - timedelta(hours=self.tz_hours)).replace(tzinfo=UTC) + self.observation_date: date = request["observation_date"] + self._packs: dict[int, dict[str, Any]] = {} + + # The pack payload, as `_annual_payload_from_args` builds it for reports. + def pack_payload(self, year: int) -> dict[str, Any]: + b = self.birth + payload = { + "birth": { + "date": f"{int(b['year']):04d}-{int(b['month']):02d}-{int(b['day']):02d}", + "time": f"{int(b['hour']):02d}:{int(b['minute']):02d}:{int(b.get('second') or 0):02d}", + "utc_offset": _fmt_offset(timedelta(hours=self.tz_hours)), + "latitude": float(b["lat"]), + "longitude": float(b["lon"]), + "coordinate_precision": "coordinates", + }, + "settings": { + "ayanamsa": self.ayanamsa, + "node_mode": self.node_mode, + "house_system": "whole_sign", + "position_mode": "legacy", + "dasha_year_days": 365.25, + "solar_return_location_mode": "birth_place", + "annual_year_policy": "solar_return_exact", + }, + "target_year": int(year), + "age": int(year) - int(b["year"]), + } + if self.timezone_id: + payload["birth"]["timezone_id"] = self.timezone_id + return payload + + def year_pack(self, year: int) -> dict[str, Any]: + """Solar-return report and annual pack for one target year (cached).""" + if year in self._packs: + return self._packs[year] + entry: dict[str, Any] = {"year": year} + try: + pack_mod = _module("annual_tajika_pack") + cmd_solar_return = _module("cmd_solar_return").cmd_solar_return + payload = self.pack_payload(year) + # Same args the pack builds for itself; the report is passed in so + # the full annual chart (speeds, nodes) stays readable here. + report = cmd_solar_return(pack_mod._args_from_payload(payload)) + if not isinstance(report, dict): + report = {"error": "solar_return_non_dict"} + entry["payload"] = payload + entry["report"] = report + entry["pack"] = pack_mod.build_annual_tajika_pack(payload, solar_return_report=report) + sr = report.get("solar_return") if isinstance(report.get("solar_return"), dict) else {} + jd = sr.get("jd_ut") + if report.get("error") or not isinstance(jd, (int, float)) or isinstance(jd, bool): + entry["error"] = str(report.get("error") or "solar_return_jd_missing") + else: + entry["return_utc"] = _utc_from_jd(float(jd)) + entry["return_jd"] = float(jd) + except Exception as exc: # noqa: BLE001 - recorded as a blocked year chart + entry["error"] = f"{exc.__class__.__name__}: {exc}" + self._packs[year] = entry + return entry + + def return_utc(self, year: int) -> datetime | None: + return self.year_pack(year).get("return_utc") + + def engine_args(self, today: date | None = None) -> SimpleNamespace: + b = self.birth + return SimpleNamespace( + year=int(b["year"]), month=int(b["month"]), day=int(b["day"]), + hour=int(b["hour"]), minute=int(b["minute"]), second=int(b.get("second") or 0), + lat=float(b["lat"]), lon=float(b["lon"]), tz=self.tz_hours, + node_mode=self.node_mode, ayanamsa=self.ayanamsa, + moon_lon=None, nakshatra=None, pada=None, birthdate=None, + today=(today or self.observation_date).strftime("%Y-%m-%d"), + ) + + +def build_annual_package(request: dict[str, Any]) -> dict[str, Any]: + """Build the annual package from a ``normalize_request`` result.""" + ctx = _Context(request) + natal = _natal_chart(ctx) + report_range, years = _report_range(ctx) + range_start = report_range.pop("_start", None) + range_end = report_range.pop("_end", None) + year_charts = [_year_chart(ctx, year, range_start, range_end) for year in years] + if range_start is not None and range_end is not None: + natal_periods = { + "vimshottari": _guard("jyotish_engine.cmd_dasha", lambda: _vimshottari(ctx, range_start, range_end)), + "narayana": _guard("narayana_dasha / narayana_rath", lambda: _narayana(ctx, natal, range_start, range_end)), + } + slow = _guard("jyotish_engine._calc_sidereal_planets_for_jd", lambda: _slow_transits(ctx, natal, range_start, range_end)) + tithi = [ + _guard("annual_native.tithi_pravesh.build_tithi_pravesh", lambda y=year: _tithi_pravesha(ctx, y, range_start, range_end)) + for year in years + ] + else: + reason = "report_range_blocked" + natal_periods = { + "vimshottari": block("blocked", "jyotish_engine.cmd_dasha", reason=reason), + "narayana": block("blocked", "narayana_dasha / narayana_rath", reason=reason), + } + slow = block("blocked", "jyotish_engine._calc_sidereal_planets_for_jd", reason=reason) + tithi = [] + package = { + "schema": SCHEMA, + "annual_package_version": ANNUAL_PACKAGE_VERSION, + "parameters": _parameters(ctx, natal), + "report_range": report_range, + "year_charts": year_charts, + "natal_periods": natal_periods, + "slow_transits": slow, + "independent_annual_views": { + "tithi_pravesha": block( + "computed" if tithi else "blocked", + "annual_native.tithi_pravesh.build_tithi_pravesh", + reason=None if tithi else "report_range_blocked", + start_rule="Sun and Moon return to the natal Sun-Moon phase near the birthday (own start rule, not the solar return)", + location_rule="birth place, birth UTC offset (the producer's own rule; not the Tajika return-location option)", + years=tithi, + ), + }, + "computation_boundaries": _boundaries(ctx), + "not_included": NOT_INCLUDED, + } + package = _plain(package) + package["block_index"] = list(_walk_blocks(package, "")) + package["blocked_count"] = sum(1 for row in package["block_index"] if row["status"] == "blocked") + return package + + +def _parameters(ctx: _Context, natal: dict[str, Any]) -> dict[str, Any]: + b = ctx.birth + return { + "birth": { + "local_datetime": ctx.birth_naive.isoformat(), + "second": int(b.get("second") or 0), + "utc_offset_hours": ctx.tz_hours, + "timezone_id": ctx.timezone_id, + # The chart engine reads the birth clock with the fixed offset `tz`, + # so the birth instant is shown in that offset (an IANA zone may + # have had daylight saving then, e.g. Asia/Shanghai 1986-1991). + "instant": instant(ctx.birth_utc, timezone(timedelta(hours=ctx.tz_hours))), + "latitude": float(b["lat"]), + "longitude": float(b["lon"]), + }, + "ayanamsa": ctx.ayanamsa, + "node_mode": ctx.node_mode, + "house_system": "whole_sign", + "return_location": { + "mode": ctx.request["return_location"]["mode"], + "options_v1": list(RETURN_LOCATION_MODES), + "latitude": float(b["lat"]), + "longitude": float(b["lon"]), + "timezone_id": ctx.timezone_id, + "utc_offset_hours": ctx.tz_hours, + }, + "observation_date": ctx.observation_date.isoformat(), + "natal_chart": natal, + } + + +def _natal_chart(ctx: _Context) -> dict[str, Any]: + def run() -> dict[str, Any]: + chart, asc_idx, _jd, ayanamsa_value = _engine()._compute_chart_from_args(ctx.engine_args()) + if not isinstance(chart, dict): + return block("blocked", "jyotish_engine._compute_chart_from_args", reason="natal_chart_unavailable") + planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {} + asc = chart.get("ascendant") if isinstance(chart.get("ascendant"), dict) else {} + points = {"Lagna": float(asc.get("degree_raw", asc.get("lon")))} + for name in NATAL_POINTS[1:]: + row = planets.get(name) + if isinstance(row, dict) and row.get("degree_raw") is not None: + points[name] = float(row["degree_raw"]) + return block( + "computed", "jyotish_engine._compute_chart_from_args", + lagna_sign_idx=int(asc_idx), + lagna_sign=SIGNS[int(asc_idx)], + moon_sign_idx=_sign_idx(points["Moon"]) if "Moon" in points else None, + ayanamsa_degrees=ayanamsa_value, + longitudes=points, + # Rounded `degree` is what cmd_narayana_dasha and the full reading + # hand to Narayana; kept so that block uses the same input. + narayana_input_longitudes={ + name: row["degree"] for name, row in planets.items() + if isinstance(row, dict) and isinstance(row.get("degree"), (int, float)) + }, + ) + + return _guard("jyotish_engine._compute_chart_from_args", run) + + +# ── report range ──────────────────────────────────────────────────────────── + +def _report_range(ctx: _Context) -> tuple[dict[str, Any], list[int]]: + spec = ctx.request["report_range"] + kind, zone = spec["kind"], ctx.zone + obs = ctx.observation_date + requested: dict[str, Any] = {"kind": kind} + if kind == "birthday_year": + year = spec["year"] if spec["year"] is not None else _return_year_covering(ctx, _local_midnight(obs, zone)) + requested["year"] = year + start, end = ctx.return_utc(year) if year is not None else None, ctx.return_utc(year + 1) if year is not None else None + basis = "solar return of the year (inclusive) to the next solar return (exclusive)" + elif kind == "calendar_year": + year = spec["year"] if spec["year"] is not None else obs.year + requested["year"] = year + start, end = _local_midnight(date(year, 1, 1), zone), _local_midnight(date(year + 1, 1, 1), zone) + basis = "local midnight 1 January to local midnight 1 January of the next year" + else: + first = spec["start_date"] + requested["start_date"] = first.isoformat() + start, end = _local_midnight(first, zone), _local_midnight(_add_months(first, 12), zone) + basis = "local midnight of start_date to local midnight twelve calendar months later" + out: dict[str, Any] = {"kind": kind, "requested": requested, "basis": basis, "end_exclusive": True, + "timezone": {"timezone_id": _zone_id(zone), "basis": "return_location_zone"}} + if start is None or end is None: + out.update(block("blocked", "solar_return.find_solar_return_ut", reason="solar_return_instant_unavailable")) + return out, [requested["year"]] if requested.get("year") is not None else [] + years = [] + first_year = start.astimezone(zone).year - 1 + for year in range(first_year, end.astimezone(zone).year + 1): + begin, finish = ctx.return_utc(year), ctx.return_utc(year + 1) + if begin is None or finish is None: + continue + if begin < end and finish > start: + years.append(year) + covered = _coverage_gap(ctx, years, start, end) + birthdays = [ctx.return_utc(year) for year in years if start < ctx.return_utc(year) < end] + lookback = _local_midnight(_add_months(obs, -LOOKBACK_MONTHS), zone) + observed = _local_midnight(obs, zone) + out.update(block( + "computed" if covered is None else "blocked", "annual_package._report_range", + reason=covered, + start=instant(start, zone), + end=instant(end, zone), + duration_days=round((end - start).total_seconds() / 86400.0, 6), + year_chart_years=years, + crosses_birthday=bool(birthdays), + birthday_boundaries=[instant(moment, zone) for moment in birthdays], + observation={ + "date": obs.isoformat(), + "instant": instant(observed, zone), + "position": "before_range" if observed < start else "after_range" if observed >= end else "inside_range", + "lookback_months": LOOKBACK_MONTHS, + "lookback_start": instant(lookback, zone), + "rule": "lookback = [observation - 3 months, observation); prospective = [observation, range end)", + }, + )) + out["_start"], out["_end"] = start, end + return out, years + + +def _return_year_covering(ctx: _Context, moment: datetime) -> int | None: + local_year = moment.astimezone(ctx.zone).year + for year in (local_year, local_year - 1): + begin, finish = ctx.return_utc(year), ctx.return_utc(year + 1) + if begin is not None and finish is not None and begin <= moment < finish: + return year + return None + + +def _coverage_gap(ctx: _Context, years: list[int], start: datetime, end: datetime) -> str | None: + if not years: + return "no_year_chart_covers_range" + cursor = start + for year in years: + begin = ctx.return_utc(year) + if begin > cursor: + return f"year_chart_gap_before_{year}" + cursor = max(cursor, ctx.return_utc(year + 1)) + return None if cursor >= end else "year_chart_gap_at_range_end" + + +# ── year chart ────────────────────────────────────────────────────────────── + +def _year_chart(ctx: _Context, year: int, range_start: datetime | None, range_end: datetime | None) -> dict[str, Any]: + entry = ctx.year_pack(year) + nxt = ctx.year_pack(year + 1) + base = {"target_year": year, "completed_years": year - int(ctx.birth["year"])} + if entry.get("error") or nxt.get("error"): + reason = entry.get("error") or f"next_solar_return_unavailable:{nxt.get('error')}" + return {**base, **block("blocked", "solar_return.solar_return_full_report", reason=str(reason))} + start, end = entry["return_utc"], nxt["return_utc"] + report, pack = entry["report"], entry["pack"] + location = report.get("annual_location") if isinstance(report.get("annual_location"), dict) else {} + zone = _zone(location.get("timezone_id"), float(location.get("utc_offset") or ctx.tz_hours)) + sr = report["solar_return"] + validity = { + "start": instant(start, zone), + "end": instant(end, zone), + "end_exclusive": True, + "end_source": "next_solar_return", + "duration_days": round((end - start).total_seconds() / 86400.0, 6), + } + overlap = _clip(start, end, range_start, range_end, zone) if range_start and range_end else None + houses = report.get("annual_houses") if isinstance(report.get("annual_houses"), dict) else {} + local_start = _parse_naive(sr.get("dt_local")) + to_utc = _elapsed_mapper(local_start, start) if local_start else None + blocks = { + "chart": _guard("solar_return.calc_solar_return_chart", lambda: _chart_block(report, houses, sr)), + "muntha": _guard("solar_return._muntha_from_natal_ascendant", lambda: _muntha_block(pack, houses)), + "year_lord": _pack_block(pack.get("year_lord"), "tajika_year_lord.select_annual_year_lord (Panchadhikari)"), + "annual_strength": _strength_block(pack), + "sahams": _pack_block(pack.get("sahams"), "tajika.calc_all_sahams"), + "tajika_yogas": _pack_block(pack.get("tajika_named_yogas"), "tajika_named_yoga_authority.build_tajika_named_yoga_authority"), + "tripataki": _tripataki_block(pack), + "mudda_dasha": _guard("tajika_mudda.annual_mudda_dasha", lambda: _mudda_block(pack, to_utc, zone, range_start, range_end)), + "patyayini": _guard("annual_native.patyayini.build_native_patyayini", lambda: _patyayini_block(houses, local_start, to_utc, zone, range_start, range_end)), + "monthly_charts": _guard("annual_native.monthly_charts.build_monthly_charts", lambda: _monthly_block(ctx, year, sr, houses, location, end, zone, range_start, range_end)), + } + return { + **base, + **block("computed", "annual_tajika_pack.build_annual_tajika_pack"), + "validity": validity, + "in_report_range": overlap, + "return_location": { + "mode": "birth_place", + "latitude": location.get("latitude"), + "longitude": location.get("longitude"), + "timezone_id": location.get("timezone_id"), + "utc_offset_at_return": _fmt_offset(timedelta(hours=float(location.get("utc_offset") or 0.0))), + }, + "solar_return": { + "instant": instant(start, zone), + "target_sun_longitude": sr.get("sun_lon"), + "target_sun_frame": sr.get("target_sun_frame"), + "solver": sr.get("method"), + "residual_degrees": sr.get("error_deg"), + }, + "blocks": blocks, + } + + +def _chart_block(report: dict, houses: dict, sr: dict) -> dict[str, Any]: + chart = report.get("sr_chart") if isinstance(report.get("sr_chart"), dict) else None + if not chart or houses.get("status") == "blocked": + return block("blocked", "solar_return.calc_solar_return_chart", reason=str(houses.get("reason") or "annual_chart_missing")) + asc_lon = float(houses["ascendant_longitude"]) + asc_idx = _sign_idx(asc_lon) + planets = {} + for name, row in (chart.get("planets") or {}).items(): + if not isinstance(row, dict) or row.get("degree_raw") is None: + continue + lon = float(row["degree_raw"]) % 360.0 + planets[name] = { + "longitude": lon, + "sign": SIGNS[_sign_idx(lon)], + "degree_in_sign": lon % 30.0, + "house": _house(_sign_idx(lon), asc_idx), + "speed": row.get("speed"), + "retrograde": row.get("retrograde"), + "nakshatra": row.get("nakshatra"), + } + lord = SIGN_LORDS[SIGNS[asc_idx]] + lord_row = planets.get(lord) or {} + return block( + "computed", "solar_return.calc_solar_return_chart", + ascendant={"longitude": asc_lon, "sign": SIGNS[asc_idx], "degree_in_sign": asc_lon % 30.0}, + ascendant_lord={"planet": lord, "sign": lord_row.get("sign"), "house": lord_row.get("house"), + "longitude": lord_row.get("longitude")}, + planets=planets, + houses=_plain(houses.get("houses")), + house_system="whole_sign", + node_mode_of_chart=report.get("node_mode", "mean"), + ) + + +def _muntha_block(pack: dict, houses: dict) -> dict[str, Any]: + field = _pack_block(pack.get("muntha"), "solar_return._muntha_from_natal_ascendant") + if field["status"] != "computed": + return field + data = field["data"] + asc_idx = _sign_idx(houses["ascendant_longitude"]) if houses.get("ascendant_longitude") is not None else None + lord = data.get("muntha_lord") + lord_lon = (houses.get("planet_longitudes") or {}).get(lord) + lord_idx = _sign_idx(lord_lon) if isinstance(lord_lon, (int, float)) else None + field["data"] = { + **data, + "muntha_house_from_annual_ascendant": _house(data.get("muntha_sign_idx"), asc_idx), + "muntha_lord_placement": { + "planet": lord, + "sign": SIGNS[lord_idx] if lord_idx is not None else None, + "house_from_annual_ascendant": _house(lord_idx, asc_idx), + "longitude": lord_lon, + }, + } + return field + + +def _strength_block(pack: dict) -> dict[str, Any]: + field = _pack_block(pack.get("tajika_strength"), "tajika.calc_tajika_strength_layers") + field["scope"] = "annual chart Panchavargiya and Harsha Bala; not natal Shadbala" + return field + + +def _tripataki_block(pack: dict) -> dict[str, Any]: + raw = pack.get("tripataki_chakra") + producer = "annual_native.tripataki_chakra.build_tripataki_chakra" + if not isinstance(raw, dict): + return block("blocked", producer, reason="tripataki_missing") + if raw.get("status") == "blocked": + return block("blocked", producer, reason=str(raw.get("reason") or "producer_blocked")) + return block("computed", producer, producer_status=raw.get("status"), data=_plain(raw)) + + +def _period_rows(rows: Iterable[dict], to_utc, zone, range_start, range_end, *, keys: tuple[str, ...]) -> list[dict]: + out = [] + for row in rows: + if not isinstance(row, dict): + continue + start, end = to_utc(row.get("start")), to_utc(row.get("end")) + if start is None or end is None: + continue + item = {key: row.get(key) for key in keys if key in row} + item["start"] = instant(start, zone) + item["end"] = instant(end, zone) + item["in_report_range"] = _clip(start, end, range_start, range_end, zone) if range_start and range_end else None + out.append(item) + return out + + +def _mudda_block(pack: dict, to_utc, zone, range_start, range_end) -> dict[str, Any]: + producer = "tajika_mudda.annual_mudda_dasha" + field = _pack_block(pack.get("mudda_dasha"), producer) + if field["status"] != "computed": + return field + if to_utc is None: + return block("blocked", producer, reason="solar_return_local_time_missing") + data = field["data"] + start, end = to_utc(data.get("annual_start")), to_utc(data.get("annual_end")) + return block( + "computed", producer, producer_status=data.get("status"), + year_length={ + "basis": data.get("year_length_basis"), + "days": data.get("annual_duration_days"), + "start": instant(start, zone), + "end": instant(end, zone), + "note": "Mudda keeps its own year; it is not stretched to the next solar return", + }, + starting_lord=data.get("starting_lord"), + starting_lord_basis=data.get("starting_lord_basis"), + rule=data.get("rule"), + completed_years=data.get("completed_years"), + natal_moon_longitude=data.get("natal_moon_longitude"), + natal_moon_nakshatra_number=data.get("natal_moon_nakshatra_number"), + balance_profile=data.get("balance_profile"), + balance_fraction_remaining=data.get("balance_fraction_remaining"), + independent_of_year_lord=data.get("independent_of_year_lord"), + source=data.get("source"), + periods=_period_rows(data.get("periods") or [], to_utc, zone, range_start, range_end, + keys=("order", "lord", "duration_days", "full_duration_days")), + antardasha_periods=_period_rows(data.get("antardasha_periods") or [], to_utc, zone, range_start, range_end, + keys=("main_order", "sub_order", "main_lord", "sub_lord", "duration_days")), + ) + + +def _patyayini_block(houses: dict, local_start, to_utc, zone, range_start, range_end) -> dict[str, Any]: + producer = "annual_native.patyayini.build_native_patyayini" + if houses.get("ascendant_longitude") is None or not isinstance(houses.get("planet_longitudes"), dict): + return block("blocked", producer, reason="annual_chart_longitudes_missing") + if local_start is None or to_utc is None: + return block("blocked", producer, reason="solar_return_local_time_missing") + build = _module("annual_native.patyayini").build_native_patyayini + raw = build( + ascendant_longitude=float(houses["ascendant_longitude"]), + planet_longitudes=houses["planet_longitudes"], + start=local_start, + year_days=PATYAYINI_YEAR_DAYS, + ) + end = to_utc(local_start + timedelta(days=PATYAYINI_YEAR_DAYS)) + return block( + "computed", producer, producer_status=raw.get("status"), model=raw.get("model"), + year_length={ + "basis": "fixed_365_25_days", + "days": PATYAYINI_YEAR_DAYS, + "start": instant(to_utc(local_start), zone), + "end": instant(end, zone), + "note": "the native entry's fixed 365.25-day year; clipped for display, never stretched", + }, + inputs=[ + {"point": row.get("lord"), "longitude": row.get("longitude"), "sign": row.get("sign"), + "degree_in_sign": row.get("degree_in_sign")} + for row in raw.get("periods") or [] + ], + periods=_period_rows(raw.get("periods") or [], to_utc, zone, range_start, range_end, keys=("order", "lord")), + antardasha_periods=_period_rows(raw.get("antardasha_periods") or [], to_utc, zone, range_start, range_end, + keys=("main", "sub", "main_order", "sub_order")), + ) + + +def _monthly_block(ctx, year, sr, houses, location, next_return, zone, range_start, range_end) -> dict[str, Any]: + producer = "annual_native.monthly_charts.build_monthly_charts" + annual_sun = (houses.get("planet_longitudes") or {}).get("Sun") + if not isinstance(annual_sun, (int, float)): + return block("blocked", producer, reason="annual_sun_longitude_missing") + raw = _module("annual_native.monthly_charts").build_monthly_charts( + annual_jd=float(sr["jd_ut"]), + birth_sun_longitude=float(annual_sun), + latitude=float(location.get("latitude", ctx.birth["lat"])), + longitude=float(location.get("longitude", ctx.birth["lon"])), + tz_hours=float(location.get("utc_offset") or ctx.tz_hours), + ayanamsa=ctx.ayanamsa, + node_mode=ctx.node_mode, + target_year=year, + timezone_id=location.get("timezone_id"), + ) + rows = [row for row in raw.get("rows") or [] if isinstance(row, dict)] + starts = [(_utc_from_jd(row["jd_ut"]) if isinstance(row.get("jd_ut"), (int, float)) else None) for row in rows] + out_rows = [] + for index, row in enumerate(rows): + start = starts[index] + if row.get("status") != "partial_verified" or start is None: + out_rows.append({"month_index": row.get("month_index"), + **block("blocked", producer, reason=str(row.get("reason") or "monthly_root_failed"))}) + continue + end = starts[index + 1] if index + 1 < len(starts) and starts[index + 1] is not None else ( + next_return if index == len(rows) - 1 else None) + out_rows.append({ + "month_index": row.get("month_index"), + "status": "computed", + "start": instant(start, zone), + "end": instant(end, zone) if end else None, + "end_source": "next_monthly_chart" if index < len(rows) - 1 else "next_solar_return", + "in_report_range": _clip(start, end, range_start, range_end, zone) if end and range_start and range_end else None, + "target_sun_longitude": row.get("target_sun_longitude"), + "sun_longitude": row.get("sun_longitude"), + "ascendant": row.get("ascendant"), + "planet_longitudes": row.get("planet_longitudes"), + }) + ready = sum(1 for row in out_rows if row.get("status") == "computed") + return block( + "computed" if ready == 12 else "blocked", producer, + reason=None if ready == 12 else f"expected 12 monthly charts, got {ready}", + producer_status=raw.get("status"), + step="solar_30_degrees", + step_note="each chart starts when the Sun has advanced another 30 degrees from the annual Sun; not a calendar month, not a lunar return", + rows=out_rows, + ) + + +# ── independent annual view ───────────────────────────────────────────────── + +def _tithi_pravesha(ctx: _Context, year: int, range_start, range_end) -> dict[str, Any]: + producer = "annual_native.tithi_pravesh.build_tithi_pravesh" + raw = _module("annual_native.tithi_pravesh").build_tithi_pravesh(ctx.pack_payload(year)) + zone = timezone(timedelta(hours=ctx.tz_hours)) + rows = [] + for row in raw.get("rows") or []: + if not isinstance(row, dict) or not isinstance(row.get("jd_ut"), (int, float)): + continue + moment = _utc_from_jd(row["jd_ut"]) + rows.append({ + "instant": instant(moment, zone), + "in_report_range": bool(range_start <= moment < range_end), + "tithi": row.get("tithi"), + "phase_degrees": row.get("phase_degrees"), + "phase_delta_degrees": row.get("phase_delta_degrees"), + "planet_longitudes": row.get("planet_longitudes"), + }) + if raw.get("status") == "blocked" or not rows: + return {"target_year": year, **block("blocked", producer, reason=str(raw.get("reason") or "no_candidate"))} + return { + "target_year": year, + **block("computed", producer, producer_status=raw.get("status")), + "natal_phase_degrees": raw.get("natal_phase_degrees"), + "candidate_count": len(rows), + "candidates": rows, + "candidate_note": "every candidate the producer found is listed; none is chosen here", + } + + +# ── natal long periods ────────────────────────────────────────────────────── + +def _vimshottari(ctx: _Context, range_start: datetime, range_end: datetime) -> dict[str, Any]: + producer = "jyotish_engine.cmd_dasha" + result = _engine().cmd_dasha(ctx.engine_args()) + timeline = result.get("timeline") if isinstance(result, dict) else None + if not isinstance(timeline, list) or not timeline: + return block("blocked", producer, reason=str((result or {}).get("error") or "timeline_missing")) + to_utc = _elapsed_mapper(ctx.birth_naive, ctx.birth_utc) + zone = ctx.zone + + def overlapping(rows, level): + out = [] + for row in rows or []: + start = to_utc(row.get("start_datetime")) + end = to_utc(row.get("end_datetime")) + if start is None or end is None or not (start < range_end and end > range_start): + continue + out.append((row, {"level": level, "lord": row.get("lord"), "start": instant(start, zone), + "end": instant(end, zone), "in_report_range": _clip(start, end, range_start, range_end, zone)})) + return out + + rows = [] + for md, md_row in overlapping(timeline, "MD"): + rows.append(md_row) + for ad, ad_row in overlapping(md.get("antardasha_timeline"), "AD"): + ad_row["mahadasha_lord"] = md.get("lord") + rows.append(ad_row) + for _pd, pd_row in overlapping(ad.get("pratyantar_dasha_timeline"), "PD"): + pd_row["mahadasha_lord"] = md.get("lord") + pd_row["antardasha_lord"] = ad.get("lord") + rows.append(pd_row) + return block( + "computed", producer, + moon_nakshatra=result.get("moon_nakshatra"), + start_basis="birth_moment", + year_length={"days": VIMSHOTTARI_YEAR_DAYS, "basis": "cmd_dasha 365.25-day year"}, + boundary_time_basis="birth local clock (fixed birth offset) mapped to UTC by elapsed time", + rows=rows, + ) + + +def _age_mapper(ctx: _Context) -> Callable[[Any], datetime | None]: + def convert(age: Any) -> datetime | None: + if isinstance(age, bool) or not isinstance(age, (int, float)): + return None + return ctx.birth_utc + timedelta(days=float(age) * NARAYANA_AGE_YEAR_DAYS) + + return convert + + +def _age_rows(rows, to_utc, zone, range_start, range_end, *, level: str, extra: tuple[str, ...] = ()) -> list[dict]: + out = [] + for row in rows or []: + if not isinstance(row, dict): + continue + start, end = to_utc(row.get("start_age")), to_utc(row.get("end_age")) + if start is None or end is None or not (start < range_end and end > range_start): + continue + item = {"level": level, "sign": row.get("sign"), "lord": row.get("lord"), + "start_age": row.get("start_age"), "end_age": row.get("end_age"), + "start": instant(start, zone), "end": instant(end, zone), + "in_report_range": _clip(start, end, range_start, range_end, zone)} + for key in extra: + if key in row: + item[key] = row[key] + out.append(item) + return out + + +def _narayana(ctx: _Context, natal: dict, range_start: datetime, range_end: datetime) -> dict[str, Any]: + producer = "narayana_dasha / narayana_rath" + if natal.get("status") != "computed": + return block("blocked", producer, reason="natal_chart_blocked") + lagna = int(natal["lagna_sign_idx"]) + lons = dict(natal["narayana_input_longitudes"]) + to_utc = _age_mapper(ctx) + zone = ctx.zone + age_at_obs = (_local_midnight(ctx.observation_date, zone) - ctx.birth_utc).total_seconds() / 86400.0 / NARAYANA_AGE_YEAR_DAYS + common = { + "age_axis": {"year_days": NARAYANA_AGE_YEAR_DAYS, + "basis": "date = birth instant + age x 365.25 days (the engine's current_age convention)"}, + } + + def legacy() -> dict[str, Any]: + nd = _module("narayana_dasha") + label = _module("narayana_legacy_label") + report = nd.narayana_dasha_full_report(lagna_sign_idx=lagna, planet_lons=lons, + current_age=max(age_at_obs, 0.0), birth_year=int(ctx.birth["year"])) + labelled = label.label_legacy_narayana(report) + dense = [row for row in report.get("dense_boundaries") or [] if isinstance(row, dict)] + last_end = max((float(row["end_age"]) for row in dense if isinstance(row.get("end_age"), (int, float))), default=None) + rows = (_age_rows([r for r in dense if r.get("level") == "MD"], to_utc, zone, range_start, range_end, level="MD") + + _age_rows([r for r in dense if r.get("level") == "AD"], to_utc, zone, range_start, range_end, + level="AD", extra=("parent_md",))) + if last_end is not None and to_utc(last_end) < range_end: + return block("blocked", "narayana_dasha.narayana_dasha_full_report", reason="report_range_beyond_single_cycle") + return block("computed", "narayana_dasha.narayana_dasha_full_report", + profile=labelled.get("profile", label.LEGACY_PROFILE), + profile_note=label.LEGACY_NOTE_EN, used_by_product_timeline=True, + age_precision="MD ages exact sums of whole years; AD ages rounded by the producer to 4 decimals (about 53 minutes)", + rows=rows) + + def rath() -> dict[str, Any]: + nr = _module("narayana_rath") + result = nr.calc_narayana_rath(lagna % 12, lons, cycle_count=2) + if not result.get("mahadasha"): + return block("blocked", "narayana_rath.calc_narayana_rath", + reason=str(result.get("reason") or result.get("status") or "seed_undecided"), + producer_status=result.get("status"), + alternative_seed_signs=sorted((result.get("alternatives") or {}).keys())) + md_rows = _age_rows(result["mahadasha"], to_utc, zone, range_start, range_end, level="MD") + ad_rows: list[dict] = [] + ad_blocked: list[dict] = [] + for md in result["mahadasha"]: + start, end = to_utc(md.get("start_age")), to_utc(md.get("end_age")) + if start is None or end is None or not (start < range_end and end > range_start): + continue + ad = nr.antardasha(md, lons) + if ad.get("status") != "selected": + ad_blocked.append({"parent_md": md.get("sign"), "reason": ad.get("reason")}) + continue + for row in _age_rows(ad.get("rows"), to_utc, zone, range_start, range_end, level="AD"): + row["parent_md"] = md.get("sign") + ad_rows.append(row) + return block("computed", "narayana_rath.calc_narayana_rath", + producer_status=result.get("status"), seed_sign=result.get("seed_sign"), + seed_basis=result.get("seed_basis"), direction=result.get("direction"), + used_by_product_timeline=False, + profile_note="Sanjay Rath profile (astrologer's ruling); shown alongside, product timeline not switched", + antardasha_blocked=ad_blocked, rows=md_rows + ad_rows) + + return block("computed", producer, **common, + legacy=_guard("narayana_dasha.narayana_dasha_full_report", legacy), + rath=_guard("narayana_rath.calc_narayana_rath", rath)) + + +# ── slow transits ─────────────────────────────────────────────────────────── + +class _Positions: + """Daily-sampled sidereal positions in the chart engine's frame.""" + + def __init__(self, ctx: _Context): + self.engine = _engine() + self.ctx = ctx + self.cache: dict[float, dict[str, tuple[float, bool]]] = {} + + def at(self, jd: float) -> dict[str, tuple[float, bool]]: + key = round(jd, 9) + if key not in self.cache: + data, _ = self.engine._calc_sidereal_planets_for_jd( + jd, node_mode=self.ctx.node_mode, include_ketu=True, ayanamsa_name=self.ctx.ayanamsa) + self.cache[key] = { + name: (float(row["degree_raw"]) % 360.0, bool(row.get("retrograde"))) + for name, row in (data or {}).items() if isinstance(row, dict) and row.get("degree_raw") is not None + } + return self.cache[key] + + def lon(self, jd: float, body: str) -> float: + return self.at(jd)[body][0] + + def retro(self, jd: float, body: str) -> bool: + return self.at(jd)[body][1] + + +def _bisect(pred: Callable[[float], bool], lo: float, hi: float) -> float: + """``pred(lo)`` holds, ``pred(hi)`` does not; return the first instant where it stops.""" + for _ in range(BISECT_STEPS): + mid = (lo + hi) / 2.0 + if pred(mid): + lo = mid + else: + hi = mid + return hi + + +def _slow_transits(ctx: _Context, natal: dict, range_start: datetime, range_end: datetime) -> dict[str, Any]: + producer = "jyotish_engine._calc_sidereal_planets_for_jd" + if natal.get("status") != "computed": + return block("blocked", producer, reason="natal_chart_blocked") + zone = ctx.zone + pos = _Positions(ctx) + jd_start, jd_end = _jd_from_utc(range_start), _jd_from_utc(range_end) + scan_start = math.floor(jd_start - TRANSIT_MARGIN_DAYS) + 0.5 + days = int(math.ceil(jd_end + TRANSIT_MARGIN_DAYS - scan_start)) + samples = [scan_start + offset for offset in range(days + 1)] + lagna_idx = natal["lagna_sign_idx"] + moon_idx = natal.get("moon_sign_idx") + + def in_range(jd: float) -> bool: + return jd_start <= jd < jd_end + + def at_instant(jd: float) -> dict[str, Any]: + return instant(_utc_from_jd(jd), zone) + + events: list[dict[str, Any]] = [] + stations: dict[str, list[dict[str, Any]]] = {body: [] for body in SLOW_BODIES} + for body in SLOW_BODIES: + for lo, hi in zip(samples, samples[1:], strict=False): + s0, s1 = _sign_idx(pos.lon(lo, body)), _sign_idx(pos.lon(hi, body)) + if s0 != s1: + jd = _bisect(lambda t, b=body, s=s0: _sign_idx(pos.lon(t, b)) == s, lo, hi) + if in_range(jd): + to_idx = _sign_idx(pos.lon(jd, body)) + events.append({ + "kind": "ingress", "body": body, "instant": at_instant(jd), + "from_sign": SIGNS[s0], "to_sign": SIGNS[to_idx], + "to_house_from_lagna": _house(to_idx, lagna_idx), + "to_house_from_moon": _house(to_idx, moon_idx), + "retrograde": pos.retro(jd, body), + "axis_group": "rahu_ketu" if body in ("Rahu", "Ketu") else body.lower(), + }) + r0, r1 = pos.retro(lo, body), pos.retro(hi, body) + if r0 != r1 and body != "Ketu": # Ketu is Rahu + 180 deg; its stations are Rahu's + jd = _bisect(lambda t, b=body, r=r0: pos.retro(t, b) == r, lo, hi) + station = { + "kind": "station", "body": body, "instant": at_instant(jd), "_jd": jd, + "motion": "direct_to_retrograde" if r1 else "retrograde_to_direct", + "longitude": pos.lon(jd, body), + "sign": SIGNS[_sign_idx(pos.lon(jd, body))], + } + stations[body].append(station) + if in_range(jd): + events.append({k: v for k, v in station.items() if k != "_jd"}) + events.sort(key=lambda row: (row["instant"]["jd_ut"], row["body"], row["kind"])) + + def crossings(body: str, target: float, lo_jd: float, hi_jd: float) -> list[float]: + found = [] + window = [jd for jd in samples if lo_jd - 1 <= jd <= hi_jd + 1] + for lo, hi in zip(window, window[1:], strict=False): + f0, f1 = _wrap(pos.lon(lo, body) - target), _wrap(pos.lon(hi, body) - target) + if abs(f0) < 30 and abs(f1) < 30 and ((f0 < 0 <= f1) or (f1 < 0 <= f0)): + negative = f0 < 0 + jd = _bisect(lambda t, neg=negative: (_wrap(pos.lon(t, body) - target) < 0) == neg, lo, hi) + if lo_jd <= jd < hi_jd: + found.append(jd) + return found + + natal_points = natal["longitudes"] + contact_rows = [] + for body in SLOW_BODIES: + for point, target in natal_points.items(): + hits = crossings(body, target, jd_start, jd_end) + if not hits: + continue + contact_rows.append({ + "body": body, "natal_point": point, "natal_longitude": target, + "contact": "exact_conjunction_by_longitude", + "count": len(hits), "repeated": len(hits) > 1, + "crossings": [{"instant": at_instant(jd), "retrograde": pos.retro(jd, body)} for jd in hits], + "axis_group": "rahu_ketu" if body in ("Rahu", "Ketu") else body.lower(), + }) + + loops = [] + first_sample, last_sample = samples[0], samples[-1] + for body in LOOP_BODIES: + rows = stations[body] + for index, station in enumerate(rows): + if station["motion"] != "direct_to_retrograde" or index + 1 >= len(rows): + continue + direct = rows[index + 1] + if direct["motion"] != "retrograde_to_direct": + continue + lon_r, lon_d = station["longitude"], direct["longitude"] + entry = crossings(body, lon_d, first_sample, station["_jd"]) + exit_ = crossings(body, lon_r, direct["_jd"], last_sample) + loop_start = entry[-1] if entry else station["_jd"] + loop_end = exit_[0] if exit_ else direct["_jd"] + if not (loop_start < jd_end and loop_end > jd_start): + continue + arc = (lon_r - lon_d) % 360.0 + inside = [ + {"natal_point": point, "natal_longitude": target, + "crossings": [{"instant": at_instant(jd), "retrograde": pos.retro(jd, body), + "in_report_range": in_range(jd)} for jd in crossings(body, target, loop_start - 1, loop_end + 1)]} + for point, target in natal_points.items() if (target - lon_d) % 360.0 <= arc + ] + loops.append({ + "body": body, + "station_retrograde": {"instant": station["instant"], "longitude": lon_r, "sign": station["sign"]}, + "station_direct": {"instant": direct["instant"], "longitude": lon_d, "sign": direct["sign"]}, + "retraced_arc": {"from_longitude": lon_d, "to_longitude": lon_r, "degrees": arc}, + "pre_shadow_entry": at_instant(entry[-1]) if entry else None, + "post_shadow_exit": at_instant(exit_[0]) if exit_ else None, + "shadow_note": None if entry and exit_ else "shadow boundary outside the scan window", + "natal_points_in_arc": inside, + }) + + start_positions = {} + for body in SLOW_BODIES: + lon = pos.lon(jd_start, body) + idx = _sign_idx(lon) + start_positions[body] = {"longitude": lon, "sign": SIGNS[idx], "degree_in_sign": lon % 30.0, + "retrograde": pos.retro(jd_start, body), + "house_from_lagna": _house(idx, lagna_idx), "house_from_moon": _house(idx, moon_idx)} + return block( + "computed", producer, + bodies=list(SLOW_BODIES), + node_mode=ctx.node_mode, + ayanamsa=ctx.ayanamsa, + method="daily sample at 12:00 UT, then bisection to under one second; events closer than one day apart may merge", + reference_points={"lagna_sign": SIGNS[lagna_idx], "moon_sign": SIGNS[moon_idx] if moon_idx is not None else None, + "house_basis": "whole sign from natal Lagna and from natal Moon"}, + positions_at_range_start=start_positions, + events=events, + natal_point_contacts=contact_rows, + retrograde_loops=loops, + retrograde_loop_scope="Jupiter and Saturn; Rahu/Ketu are excluded (their mean motion is retrograde)", + ) + + +# ── static boundaries ─────────────────────────────────────────────────────── + +def _boundaries(ctx: _Context) -> dict[str, Any]: + return { + "instant_format": "every boundary has utc, local (ISO with offset), utc_offset, timezone_id and jd_ut", + "local_zone": "return location zone (birth place in v1); IANA zone when timezone_id is given, else the fixed birth offset", + "year_chart_validity": "this solar return (inclusive) to the next solar return (exclusive)", + "clipping": "period rows keep their native bounds; in_report_range is the part inside the report range; nothing is stretched", + "period_year_lengths": [ + {"system": "mudda_dasha", "days": 365.25, "anchor": "solar return instant"}, + {"system": "patyayini", "days": PATYAYINI_YEAR_DAYS, "anchor": "solar return instant"}, + {"system": "vimshottari", "days": VIMSHOTTARI_YEAR_DAYS, "anchor": "birth moment"}, + {"system": "narayana", "days": NARAYANA_AGE_YEAR_DAYS, "anchor": "birth moment, age axis"}, + ], + "naive_local_to_utc": "producers that add day counts to a naive local start are mapped to UTC by elapsed time from the anchor instant", + "monthly_charts": "Sun advancing 30 degrees per chart from the annual Sun; month 12 ends at the next solar return", + "slow_transit_frame": "jyotish_engine._calc_sidereal_planets_for_jd (same frame as the natal chart), node mode and ayanamsa as requested", + "candidate_windows": "not built here; the report phase builds them from these instants", + } + + +NOT_INCLUDED = [ + {"item": "varsha_narayana", "reason": "no native producer in this repository (upstream yinduzhanxing has one; not synced)"}, + {"item": "yoga_and_nakshatra_pravesha", "reason": "research-only upstream; the ruling keeps them out of the report"}, + {"item": "external_engine_replay", "reason": "PyJHora annual replay runs only in the full-data report child process; not executed here"}, + {"item": "residence_return_location", "reason": "v1 offers the birth place only; return_location keeps the shape for a later comparison"}, + {"item": "transit_ashtakavarga_vedha_sade_sati", "reason": "report phase; BAV/SAV come from /api/ashtakavarga, Vedha and Sade Sati phase are not executed here"}, + {"item": "candidate_windows", "reason": "report phase (#153)"}, + {"item": "real_case_calibration", "reason": "blocked per the 2026-11-01 ruling; no accuracy claim"}, +] + + +def _walk_blocks(value: Any, path: str): + if isinstance(value, dict): + if value.get("status") in ("computed", "blocked", "not_applicable") and "producer" in value: + yield {"path": path or "$", "status": value["status"], "producer": value["producer"], + "reason": value.get("reason")} + for key, item in value.items(): + if key in ("block_index",): + continue + yield from _walk_blocks(item, f"{path}.{key}" if path else key) + elif isinstance(value, list): + for index, item in enumerate(value): + yield from _walk_blocks(item, f"{path}[{index}]") diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 0320b049..43312ae2 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -7733,6 +7733,8 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin): } def _compute_annual(self, body): + if isinstance(body, dict) and body.get('mode') == 'package': + return self._compute_annual_package(body) target_year = self._get_int(body, 'target_year', datetime.now().year, 1800, 2400) birth_dt = self._parse_birth_datetime(body) lat = self._get_float(body, 'lat', body.get('birth_lat', 0), -90, 90) @@ -7760,9 +7762,60 @@ class JyotishComputeService(VedastroEvidenceMixin, SynastryMixin): tz, target_year, ayanamsa_name=_request_ayanamsa(body), birth_second=birth_dt.second, + node_mode=_request_node_mode(body), ) return {'success': True, 'endpoint': 'annual', 'report': report} + def _compute_annual_package(self, body): + """Annual data package v1 (#152 ruling 2026-11-01): `mode: "package"` or /api/annual/package. + + The default `/api/annual` response (basic solar-return report) keeps its shape. + """ + if not isinstance(body, dict): + raise BadRequest('JSON body must be an object') + birth_dt = self._parse_birth_datetime(body) + lat = self._get_float(body, 'lat', body.get('birth_lat', 0), -90, 90) + lon = self._get_float(body, 'lon', body.get('birth_lon', 0), -180, 180) + tz = self._parse_timezone( + body, lat, lon, birth_dt.year, birth_dt.month, birth_dt.day, + birth_dt.hour, birth_dt.minute, birth_dt.second, + ) + timezone_id = None + for key in ('timezone_id', 'timezone'): + value = body.get(key) + if isinstance(value, str) and '/' in value: + try: + from zoneinfo import ZoneInfo + ZoneInfo(value.strip()) + except Exception as exc: # noqa: BLE001 - an unknown IANA name is a bad request + raise BadRequest(f'{key} is not a known IANA timezone') from exc + timezone_id = value.strip() + break + package_mod = _load_local_module('annual_package') + try: + request = package_mod.normalize_request( + birth={ + 'year': birth_dt.year, 'month': birth_dt.month, 'day': birth_dt.day, + 'hour': birth_dt.hour, 'minute': birth_dt.minute, 'second': birth_dt.second, + 'lat': lat, 'lon': lon, 'tz': tz, 'timezone_id': timezone_id, + }, + ayanamsa=_request_ayanamsa(body), + node_mode=_request_node_mode(body), + report_range=body.get('report_range'), + observation_date=body.get('observation_date') or body.get('today'), + return_location=body.get('return_location'), + ) + except package_mod.AnnualPackageInputError as exc: + raise BadRequest(str(exc)) from exc + package = package_mod.build_annual_package(request) + return { + 'success': True, + 'endpoint': 'annual', + 'mode': 'package', + 'annual_package_version': package['annual_package_version'], + 'package': package, + } + def _compute_tajika(self, body): result = self._compute_annual(body) return { @@ -10644,6 +10697,9 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, JyotishComputeService): elif path == '/api/annual': result = self._compute_annual(body) self._json(result) + elif path == '/api/annual/package': + result = self._compute_annual_package(body) + self._json(result) elif path == '/api/tajika': result = self._compute_tajika(body) self._json(result) @@ -10960,6 +11016,7 @@ def start_server(port=5200, host='127.0.0.1', allowed_origins=None, allowed_host print(f' POST /api/career — 事业分析') print(f' POST /api/relationship — 感情分析') print(f' POST /api/annual — 年运/Tajika') + print(f' POST /api/annual/package — 年度数据包 v1(同 /api/annual + mode=package)') print(f' POST /api/muhurta — 择日') print(f' POST /api/panchanga_range — Panchanga日期范围') print(f' POST /api/declared_window_chart — 声明出生窗口探针比较') diff --git a/tests/test_annual_package.py b/tests/test_annual_package.py new file mode 100644 index 00000000..27f12e40 --- /dev/null +++ b/tests/test_annual_package.py @@ -0,0 +1,450 @@ +"""Annual data package v1 (#152 ruling 2026-11-01; phase 1 of #153 / #154). + +Birth data: the chart-insight golden profile (1990-06-15 12:00 Beijing, +Raman ayanamsa, mean node). Not a real person. +""" + +from __future__ import annotations + +import functools +import json +import os +import sys +from datetime import UTC, datetime, timedelta + +import pytest + +SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts") +if SCRIPTS not in sys.path: + sys.path.insert(0, SCRIPTS) + +import annual_package # noqa: E402 +from jyotish_api_server import BadRequest, JyotishAPIHandler # noqa: E402 + +VOCAB = {"computed", "blocked", "not_applicable"} +YEAR_CHART_BLOCKS = { + "chart", "muntha", "year_lord", "annual_strength", "sahams", "tajika_yogas", + "tripataki", "mudda_dasha", "patyayini", "monthly_charts", +} + + +def _handler() -> JyotishAPIHandler: + return JyotishAPIHandler.__new__(JyotishAPIHandler) + + +def _body(**overrides): + body = { + "mode": "package", + "year": 1990, "month": 6, "day": 15, "hour": 12, "minute": 0, "second": 0, + "lat": 39.9042, "lon": 116.4074, "tz": 8, "timezone_id": "Asia/Shanghai", + "ayanamsa": "raman", "node_mode": "mean", + "report_range": {"kind": "calendar_year", "year": 2026}, + "observation_date": "2026-11-01", + } + body.update(overrides) + return body + + +@functools.cache +def _cached(key: str) -> dict: + return _handler()._compute_annual(json.loads(key)) + + +def _package(**overrides) -> dict: + """Built once per distinct request; callers must not mutate the result.""" + return _cached(json.dumps(_body(**overrides), sort_keys=True))["package"] + + +def _utc(boundary: dict) -> datetime: + return datetime.strptime(boundary["utc"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC) + + +def _assert_instant(boundary: dict) -> None: + assert set(boundary) == {"utc", "local", "utc_offset", "timezone_id", "jd_ut"} + local = datetime.fromisoformat(boundary["local"]) + assert local.utcoffset() is not None + assert local.astimezone(UTC) == _utc(boundary) + + +# ── shape ────────────────────────────────────────────────────────────────── + +def test_package_shape_and_version() -> None: + result = _cached(json.dumps(_body(), sort_keys=True)) + assert result["success"] is True + assert result["mode"] == "package" + assert result["annual_package_version"] == 1 + package = result["package"] + assert package["schema"] == "jyotish.annual_package.v1" + assert list(package) == [ + "schema", "annual_package_version", "parameters", "report_range", "year_charts", + "natal_periods", "slow_transits", "independent_annual_views", "computation_boundaries", + "not_included", "block_index", "blocked_count", + ] + json.dumps(package) # plain JSON, no datetime / circular references + for chart in package["year_charts"]: + assert chart["status"] in VOCAB + assert set(chart["blocks"]) == YEAR_CHART_BLOCKS + for name, item in chart["blocks"].items(): + assert item["status"] in VOCAB, name + assert item["producer"], name + if item["status"] != "computed": + assert item["reason"], name + assert set(package["natal_periods"]) == {"vimshottari", "narayana"} + assert package["independent_annual_views"]["tithi_pravesha"]["status"] in VOCAB + assert package["block_index"], "every block is listed" + assert all(row["status"] in VOCAB for row in package["block_index"]) + assert package["blocked_count"] == sum(1 for row in package["block_index"] if row["status"] == "blocked") + not_included = {row["item"] for row in package["not_included"]} + assert {"varsha_narayana", "candidate_windows", "real_case_calibration"} <= not_included + + +def test_no_interpretation_text_in_package() -> None: + assert '"interpretation"' not in json.dumps(_package(), ensure_ascii=False) + + +def test_core_tajika_blocks_are_computed_for_a_normal_chart() -> None: + chart = _package()["year_charts"][-1] + blocks = chart["blocks"] + for name in YEAR_CHART_BLOCKS: + assert blocks[name]["status"] == "computed", (name, blocks[name].get("reason")) + asc = blocks["chart"]["ascendant"] + assert blocks["chart"]["ascendant_lord"]["planet"] == annual_package.SIGN_LORDS[asc["sign"]] + muntha = blocks["muntha"]["data"] + assert muntha["muntha_lord"] == annual_package.SIGN_LORDS[muntha["muntha_sign"]] + assert muntha["muntha_lord_placement"]["planet"] == muntha["muntha_lord"] + assert 1 <= muntha["muntha_house_from_annual_ascendant"] <= 12 + assert blocks["year_lord"]["data"]["year_lord"] in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn") + assert "not natal Shadbala" in blocks["annual_strength"]["scope"] + assert set(blocks["annual_strength"]["data"]) == {"harsha_bala", "panchavargiya_bala"} + assert blocks["mudda_dasha"]["independent_of_year_lord"] is True + + +def test_every_boundary_is_an_exact_instant_with_timezone() -> None: + package = _package() + _assert_instant(package["report_range"]["start"]) + _assert_instant(package["report_range"]["end"]) + for chart in package["year_charts"]: + _assert_instant(chart["validity"]["start"]) + _assert_instant(chart["validity"]["end"]) + for row in chart["blocks"]["mudda_dasha"]["periods"] + chart["blocks"]["patyayini"]["periods"]: + _assert_instant(row["start"]) + _assert_instant(row["end"]) + for row in chart["blocks"]["monthly_charts"]["rows"]: + _assert_instant(row["start"]) + _assert_instant(row["end"]) + for row in package["natal_periods"]["vimshottari"]["rows"]: + _assert_instant(row["start"]) + for event in package["slow_transits"]["events"]: + _assert_instant(event["instant"]) + assert package["report_range"]["start"]["local"] == "2026-01-01T00:00:00+08:00" + assert package["report_range"]["start"]["timezone_id"] == "Asia/Shanghai" + + +# ── parameters reach the producers ───────────────────────────────────────── + +def _return_utc(package: dict, year: int) -> datetime: + chart = next(item for item in package["year_charts"] if item["target_year"] == year) + return _utc(chart["solar_return"]["instant"]) + + +def test_birth_second_reaches_return_natal_and_dasha() -> None: + base, later = _package(), _package(second=59) + assert later["parameters"]["birth"]["second"] == 59 + assert later["parameters"]["birth"]["instant"]["local"] == "1990-06-15T12:00:59+08:00" + shift = (_return_utc(later, 2026) - _return_utc(base, 2026)).total_seconds() + assert 40 <= shift <= 80 # the natal Sun moves with the birth clock + moon0 = base["parameters"]["natal_chart"]["longitudes"]["Moon"] + moon1 = later["parameters"]["natal_chart"]["longitudes"]["Moon"] + assert moon1 > moon0 + pd0 = base["natal_periods"]["vimshottari"]["rows"][-1]["end"]["utc"] + pd1 = later["natal_periods"]["vimshottari"]["rows"][-1]["end"]["utc"] + assert pd0 != pd1 + + +def test_node_mode_reaches_annual_chart_monthly_charts_and_transits() -> None: + mean, true = _package(), _package(node_mode="true") + assert true["parameters"]["node_mode"] == "true" + chart_mean = mean["year_charts"][-1]["blocks"]["chart"] + chart_true = true["year_charts"][-1]["blocks"]["chart"] + assert chart_true["node_mode_of_chart"] == "true" + assert chart_mean["planets"]["Rahu"]["longitude"] != chart_true["planets"]["Rahu"]["longitude"] + # Nodes only: the return instant and the seven planets do not move. + assert chart_mean["planets"]["Jupiter"]["longitude"] == chart_true["planets"]["Jupiter"]["longitude"] + month_mean = mean["year_charts"][-1]["blocks"]["monthly_charts"]["rows"][3]["planet_longitudes"]["Rahu"] + month_true = true["year_charts"][-1]["blocks"]["monthly_charts"]["rows"][3]["planet_longitudes"]["Rahu"] + assert month_mean != month_true + rahu_mean = mean["slow_transits"]["positions_at_range_start"]["Rahu"]["longitude"] + rahu_true = true["slow_transits"]["positions_at_range_start"]["Rahu"]["longitude"] + assert rahu_mean != rahu_true + assert true["slow_transits"]["node_mode"] == "true" + + +def test_ayanamsa_reaches_natal_chart() -> None: + raman, lahiri = _package(), _package(ayanamsa="lahiri") + assert lahiri["parameters"]["ayanamsa"] == "lahiri" + lagna_raman = raman["parameters"]["natal_chart"]["longitudes"]["Lagna"] + lagna_lahiri = lahiri["parameters"]["natal_chart"]["longitudes"]["Lagna"] + assert lagna_raman - lagna_lahiri == pytest.approx(1.45, abs=0.2) + + +def test_return_location_keeps_the_field_and_offers_birth_place_only() -> None: + location = _package()["parameters"]["return_location"] + assert location["mode"] == "birth_place" + assert location["options_v1"] == ["birth_place"] + with pytest.raises(BadRequest): + _handler()._compute_annual(_body(return_location={"mode": "current_location"})) + + +# ── year chart validity ──────────────────────────────────────────────────── + +def test_year_chart_validity_runs_from_this_return_to_the_next() -> None: + charts = _package()["year_charts"] + assert [item["target_year"] for item in charts] == [2025, 2026] + for chart in charts: + validity = chart["validity"] + assert validity["start"] == chart["solar_return"]["instant"] + assert validity["end_source"] == "next_solar_return" + assert validity["end_exclusive"] is True + days = (_utc(validity["end"]) - _utc(validity["start"])).total_seconds() / 86400 + assert 365.2 < days < 365.3 + assert validity["duration_days"] == pytest.approx(days, abs=1e-4) + rows = chart["blocks"]["monthly_charts"]["rows"] + assert len(rows) == 12 + assert rows[0]["start"]["utc"] == validity["start"]["utc"] + assert rows[-1]["end"] == validity["end"] + assert rows[-1]["end_source"] == "next_solar_return" + assert all(a["end"] == b["start"] for a, b in zip(rows, rows[1:], strict=False)) + assert charts[0]["validity"]["end"] == charts[1]["validity"]["start"] + assert charts[0]["blocks"]["monthly_charts"]["step"] == "solar_30_degrees" + + +# ── report range ─────────────────────────────────────────────────────────── + +def test_calendar_year_crossing_the_birthday_uses_both_year_charts_and_clips() -> None: + package = _package() + report_range = package["report_range"] + assert report_range["status"] == "computed" + assert report_range["year_chart_years"] == [2025, 2026] + assert report_range["crosses_birthday"] is True + birthday = report_range["birthday_boundaries"][0] + old, new = package["year_charts"] + assert birthday == new["validity"]["start"] + assert old["in_report_range"] == {"start": report_range["start"], "end": birthday} + assert new["in_report_range"] == {"start": birthday, "end": report_range["end"]} + range_start, range_end = _utc(report_range["start"]), _utc(report_range["end"]) + for chart in (old, new): + for row in chart["blocks"]["mudda_dasha"]["periods"]: + start, end = _utc(row["start"]), _utc(row["end"]) + clipped = row["in_report_range"] + if end <= range_start or start >= range_end: + assert clipped is None + else: + assert _utc(clipped["start"]) == max(start, range_start) + assert _utc(clipped["end"]) == min(end, range_end) + + +def test_birthday_year_is_one_chart_validity() -> None: + package = _package(report_range={"kind": "birthday_year", "year": 2026}) + report_range = package["report_range"] + assert report_range["year_chart_years"] == [2026] + chart = package["year_charts"][0] + assert report_range["start"] == chart["validity"]["start"] + assert report_range["end"] == chart["validity"]["end"] + assert report_range["crosses_birthday"] is False + + +def test_next_twelve_months_defaults_to_the_observation_date() -> None: + package = _package(report_range={"kind": "next_12_months"}) + report_range = package["report_range"] + assert report_range["requested"]["start_date"] == "2026-11-01" + assert report_range["start"]["local"] == "2026-11-01T00:00:00+08:00" + assert report_range["end"]["local"] == "2027-11-01T00:00:00+08:00" + assert report_range["year_chart_years"] == [2026, 2027] + assert report_range["observation"]["position"] == "inside_range" + assert report_range["observation"]["lookback_start"]["local"] == "2026-08-01T00:00:00+08:00" + + +def test_bad_range_inputs_are_bad_requests() -> None: + for bad in ({"kind": "lunar_year"}, {"kind": "calendar_year", "year": "abc"}, + {"kind": "calendar_year", "start_date": "2026-01-01"}, "2026"): + with pytest.raises(BadRequest): + _handler()._compute_annual(_body(report_range=bad)) + + +# ── each period system keeps its own year ────────────────────────────────── + +def test_patyayini_keeps_365_25_days_and_is_clipped_not_stretched() -> None: + package = _package() + range_end = _utc(package["report_range"]["end"]) + chart = package["year_charts"][-1] + patyayini = chart["blocks"]["patyayini"] + year = patyayini["year_length"] + assert year["days"] == 365.25 + assert year["basis"] == "fixed_365_25_days" + assert year["start"] == chart["validity"]["start"] + assert year["end"]["jd_ut"] - year["start"]["jd_ut"] == pytest.approx(365.25, abs=1e-6) + # Not stretched to the next return (the solar return year is about 365.256 days). + assert year["end"]["jd_ut"] < chart["validity"]["end"]["jd_ut"] + periods = patyayini["periods"] + assert len(periods) == 8 # Lagna + seven planets + assert periods[0]["start"] == year["start"] + assert abs((_utc(periods[-1]["end"]) - _utc(year["end"])).total_seconds()) <= 1 + straddling = [row for row in periods if _utc(row["start"]) < range_end < _utc(row["end"])] + assert len(straddling) == 1 + assert straddling[0]["in_report_range"]["end"] == package["report_range"]["end"] + assert all(row["in_report_range"] is None for row in periods if _utc(row["start"]) >= range_end) + assert len(patyayini["inputs"]) == 8 + assert {row["point"] for row in patyayini["inputs"]} == {"Lagna", "Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"} + + +def test_mudda_keeps_its_own_year_from_the_return() -> None: + chart = _package()["year_charts"][-1] + mudda = chart["blocks"]["mudda_dasha"] + assert mudda["year_length"]["days"] == 365.25 + assert mudda["year_length"]["start"] == chart["validity"]["start"] + assert mudda["year_length"]["end"]["jd_ut"] - mudda["year_length"]["start"]["jd_ut"] == pytest.approx(365.25, abs=1e-6) + assert mudda["periods"][0]["start"] == chart["validity"]["start"] + assert mudda["starting_lord"] == mudda["periods"][0]["lord"] + + +def test_natal_periods_overlap_the_range() -> None: + package = _package() + range_start, range_end = _utc(package["report_range"]["start"]), _utc(package["report_range"]["end"]) + vim = package["natal_periods"]["vimshottari"] + assert vim["status"] == "computed" + assert vim["year_length"]["days"] == 365.25 + assert {row["level"] for row in vim["rows"]} == {"MD", "AD", "PD"} + for row in vim["rows"]: + assert _utc(row["start"]) < range_end and _utc(row["end"]) > range_start + pds = [row for row in vim["rows"] if row["level"] == "PD"] + assert _utc(pds[0]["start"]) <= range_start and _utc(pds[-1]["end"]) >= range_end + narayana = package["natal_periods"]["narayana"] + assert narayana["age_axis"]["year_days"] == 365.25 + assert narayana["legacy"]["used_by_product_timeline"] is True + assert narayana["rath"]["used_by_product_timeline"] is False + for profile in ("legacy", "rath"): + if narayana[profile]["status"] == "computed": + assert {row["level"] for row in narayana[profile]["rows"]} == {"MD", "AD"} + + +def test_slow_transits_events_are_exact_and_inside_the_range() -> None: + package = _package() + transits = package["slow_transits"] + assert transits["status"] == "computed" + assert transits["bodies"] == ["Jupiter", "Saturn", "Rahu", "Ketu"] + range_start, range_end = _utc(package["report_range"]["start"]), _utc(package["report_range"]["end"]) + jds = [event["instant"]["jd_ut"] for event in transits["events"]] + assert jds == sorted(jds) + for event in transits["events"]: + assert range_start <= _utc(event["instant"]) < range_end + assert event["kind"] in ("ingress", "station") + if event["kind"] == "ingress": + assert event["from_sign"] != event["to_sign"] + assert 1 <= event["to_house_from_lagna"] <= 12 + assert 1 <= event["to_house_from_moon"] <= 12 + for loop in transits["retrograde_loops"]: + assert loop["body"] in ("Jupiter", "Saturn") + assert _utc(loop["station_retrograde"]["instant"]) < _utc(loop["station_direct"]["instant"]) + for contact in transits["natal_point_contacts"]: + assert contact["count"] == len(contact["crossings"]) + + +# ── blocked blocks do not fail the response ──────────────────────────────── + +def _fresh(**overrides) -> dict: + return _handler()._compute_annual(_body(**overrides)) + + +def test_blocked_patyayini_and_rath_do_not_fail_the_response(monkeypatch) -> None: + import narayana_rath + from annual_native import patyayini + + def broken(**_kwargs): + raise ValueError("forced failure") + + def broken_rath(*_args, **_kwargs): + raise RuntimeError("forced rath failure") + + monkeypatch.setattr(patyayini, "build_native_patyayini", broken) + monkeypatch.setattr(narayana_rath, "calc_narayana_rath", broken_rath) + result = _fresh(report_range={"kind": "birthday_year", "year": 2026}) + assert result["success"] is True + package = result["package"] + blocks = package["year_charts"][0]["blocks"] + assert blocks["patyayini"]["status"] == "blocked" + assert "forced failure" in blocks["patyayini"]["reason"] + assert blocks["mudda_dasha"]["status"] == "computed" + assert package["natal_periods"]["narayana"]["rath"]["status"] == "blocked" + assert package["natal_periods"]["narayana"]["legacy"]["status"] == "computed" + paths = {row["path"] for row in package["block_index"] if row["status"] == "blocked"} + assert "year_charts[0].blocks.patyayini" in paths + assert package["blocked_count"] >= 2 + + +def test_blocked_slow_transits_do_not_fail_the_response(monkeypatch) -> None: + import jyotish_engine + + def broken(*_args, **_kwargs): + raise RuntimeError("ephemeris unavailable") + + monkeypatch.setattr(jyotish_engine, "_calc_sidereal_planets_for_jd", broken) + result = _fresh(report_range={"kind": "birthday_year", "year": 2026}) + assert result["success"] is True + transits = result["package"]["slow_transits"] + assert transits["status"] == "blocked" + assert "ephemeris unavailable" in transits["reason"] + assert result["package"]["year_charts"][0]["status"] == "computed" + + +# ── old /api/annual response unchanged ───────────────────────────────────── + +def test_default_annual_response_keeps_its_shape() -> None: + body = _body() + body.pop("mode") + result = _handler()._compute_annual({**body, "target_year": 2026}) + assert result["success"] is True + assert result["endpoint"] == "annual" + assert "package" not in result + assert result["report"]["target_year"] == 2026 + assert "muntha" in result["report"] + + +def test_solar_return_node_mode_default_stays_mean() -> None: + import solar_return + + args = (1990, 6, 15, 12, 0, 39.9042, 116.4074, 8.0, 2026) + default = solar_return.calc_solar_return_chart(*args, ayanamsa_name="raman") + true = solar_return.calc_solar_return_chart(*args, ayanamsa_name="raman", node_mode="true") + assert default["node_mode"] == "mean" + assert true["node_mode"] == "true" + assert default["planet_lons"]["Rahu"] != true["planet_lons"]["Rahu"] + assert default["solar_return"]["jd_ut"] == true["solar_return"]["jd_ut"] + assert default["planet_lons"]["Saturn"] == true["planet_lons"]["Saturn"] + + +# ── pure helpers ─────────────────────────────────────────────────────────── + +def test_instant_uses_the_zone_offset_at_that_moment() -> None: + zone = annual_package._zone("America/New_York", -5) + summer = annual_package.instant(datetime(2026, 7, 1, 12, tzinfo=UTC), zone) + winter = annual_package.instant(datetime(2026, 1, 1, 12, tzinfo=UTC), zone) + assert summer["utc_offset"] == "-04:00" + assert winter["utc_offset"] == "-05:00" + fixed = annual_package.instant(datetime(2026, 7, 1, 12, tzinfo=UTC), annual_package._zone(None, 5.5)) + assert fixed["local"] == "2026-07-01T17:30:00+05:30" + assert fixed["timezone_id"] is None + + +def test_elapsed_mapper_counts_days_across_daylight_saving() -> None: + anchor_utc = datetime(2026, 3, 1, 12, tzinfo=UTC) + convert = annual_package._elapsed_mapper(datetime(2026, 3, 1, 7), anchor_utc) + assert convert("2026-04-01 07:00:00") == anchor_utc + timedelta(days=31) + assert convert(None) is None + + +def test_add_months_clamps_to_month_end() -> None: + from datetime import date + + assert annual_package._add_months(date(2028, 2, 29), 12) == date(2029, 2, 28) + assert annual_package._add_months(date(2026, 11, 1), -3) == date(2026, 8, 1) + assert annual_package._add_months(date(2026, 1, 31), 1) == date(2026, 2, 28)