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:
@@ -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 大运」两张卡和深读「命性格局」「排盘底册」里的表都搬到这里,不再重复。
|
||||
|
||||
@@ -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 的子运行,每行有三个时刻)。
|
||||
@@ -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);
|
||||
});
|
||||
+4060
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 — 声明出生窗口探针比较')
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user