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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
This commit is contained in:
2026-11-01 17:07:54 +08:00
co-authored by Claude Opus 5.5
parent 52f85a793f
commit 340f0f2b85
8 changed files with 6319 additions and 0 deletions
+11
View File
@@ -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 大运」两张卡和深读「命性格局」「排盘底册」里的表都搬到这里,不再重复。
+98
View File
@@ -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 的子运行,每行有三个时刻)。
+299
View File
@@ -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<typeof annualBlockStatusSchema>;
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<typeof annualInstantSchema>;
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<typeof annualPackageSchema>;
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<T> =
| { 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<AnnualPeriodView[]>; rath: AnnualBlockView<AnnualPeriodView[]> };
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<T>(block: Block | undefined, value: (computed: Block) => T): AnnualBlockView<T> {
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<typeof periodRowSchema>[] | 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<string, unknown> {
return (block.data && typeof block.data === "object" ? block.data : {}) as Record<string, unknown>;
}
function text(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function yearChart(chart: z.infer<typeof yearChartSchema>): 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<typeof natalBlockSchema> | 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" })),
};
}
@@ -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<T>(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);
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+57
View File
@@ -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 — 声明出生窗口探针比较')
+450
View File
@@ -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)