From 7c5ea7ae5ad158d1187a498b56575d35c4c84d4b Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 29 Sep 2026 14:58:46 +0800 Subject: [PATCH] feat(annual): localise the solar-return instant, relocated returns, compact PL9 export (BUG-1095..1097) Upstream yinduzhanxing 41d1c740 / 59c9139f, hand-merged; 089858c0 verified not applicable. - solar_return: dt_local was the UT instant verbatim (no caller ever added the offset). Localise with ZoneInfo at the return instant (IANA id) or the fixed offset; expose annual_location; Sahams day/night at the true instant; optional current_location return charts. - cmd_solar_return / annual_tajika_pack / engine: pass birth IANA zone and --solar-return-location-mode / --current-* through; external PyJHora replay blocked when relocated. - output_json keeps the upstream stream= signature; PL9 exports written compact via dumps (measured faster than json.dump streaming), 9.57 -> 4.61 MB. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8 --- CHANGELOG.md | 8 + docs/BUG_HISTORY.md | 44 +++++ .../tasks/PROGRESS-upstream-sync3-20260929.md | 125 +++++++++++++ docs/tasks/README.md | 2 +- scripts/annual_tajika_pack.py | 25 +++ scripts/cmd_solar_return.py | 36 ++++ scripts/jyotish_engine.py | 70 ++++++-- scripts/solar_return.py | 56 +++++- tests/test_solar_return_timezone.py | 168 ++++++++++++++++++ 9 files changed, 516 insertions(+), 18 deletions(-) create mode 100644 docs/tasks/PROGRESS-upstream-sync3-20260929.md create mode 100644 tests/test_solar_return_timezone.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 84e48389..14513953 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 印度占星 Skill 更新日志 +## 2026-09-29 — 年运返照时刻改为真正的本地时间;PL9 导出文件减半(待验收) + +- 专业报告里「Varshapravesha(太阳返照时刻)」此前打印的是世界时(北京盘差 8 小时,跨日时日期也错),现在按出生地时区显示;有夏令时的地区按返照那一刻的实际偏移(BUG-1095)。Sahams 昼夜判定也改用真实返照时刻。 +- 引擎新增返照地点选项(`--solar-return-location-mode current_location` + `--current-*`)与出生地 IANA 时区(`--timezone-id`);网页端暂未传时区 ID,仍按固定偏移。 +- `pl9-export` JSON 改为紧凑写出:9.57 MB → 4.61 MB,内容不变(BUG-1097)。其他命令输出格式不变。 +- 核实上游「年运面板装配丢失」修复对本仓不适用,未抄(BUG-1096)。 +- Skill 版本不 bump(Skill 文本未改)。不改数据库结构。 + ## 2026-09-29 — 生时校正不再一路索要经历,选择题问完就给范围(待验收) - 说够开场那几件经历(至少 3 件、两类事)之后,校正只问点选题,问完直接给「目前范围」;不再逐条追问家里、钱、身体等七类事,也不再「身体这边再问一次」或按某年某月点名补经历。原因:打字补的经历在这之后几乎不能把范围缩小,反而把用户拖到十几二十件(BUG-1084)。自己主动打字补的经历照常记下。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index f8ffcde6..2f4c9efe 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -14686,3 +14686,47 @@ - 相关记录:BUG-560、BUG-1048、BUG-1084 - 复发自:无 - 修复版本:未修 + +## BUG-1095 | 太阳返照本地时间是 UT 原样:读者版「Varshapravesha」差一个时区,Sahams 昼夜按错误时刻判 + +- 状态:resolved(2026-09-29 执行方自验;待 Claude 验收) +- 首次发现 / 最近更新:2026-09-29 / 2026-09-29 +- 影响面:`scripts/solar_return.py`(`find_solar_return_ut` 返回 `dt_local = dt_ut`,注释「调用方另行加 tz」但无调用方加)、`solar_return_full_report` → `calc_all_sahams(tz=birth_tz)` 昼夜判定、`pl9_reader_export.py:1830/2201` 打印 `dt_local`、年运包 `annual_tajika_pack.py` 经 `cmd_solar_return` 同一路径。 +- 用户现象:报告读者版「Varshapravesha:2026-06-15 08:04:52」实为 UT,北京盘应为 16:04:52;跨日时连日期都错(虚构北京盘 2028 年行 06-14 → 06-15)。 +- 触发条件:任何目标年的年运;偏移越大越明显。 +- 根因:返照求解只算 UT,本地化从未实现;任务书写的「固定偏移在夏令时地区差 1 小时」低估了——本仓根本没加偏移。 +- 修复:抄上游 `41d1c740` 的 `_localize_return_datetime`(ZoneInfo 按返照瞬间取偏移,无 IANA 区则固定偏移),`calc_solar_return_chart` 写回 `dt_local` 与 `annual_location`;Sahams 昼夜用 `annual_location`;返照地点可选 `current_location`(引擎 / CLI / 年运包参数;外部 PyJHora 回放异地时 blocked);新增出生地 `--timezone-id` / API `timezone` 传入。 +- 验证:`tests/test_solar_return_timezone.py`(10 条);北京盘改前改后解析比对差异仅 `dt_local`、`annual_location`(新增)、`daynight_evidence.julian_day_ut`;`tajika` 0 差异;`Asia/Shanghai` 与固定 +8 0 差异;纽约盘 IANA 区 21:08:03(−4)vs 固定 20:08:03(−5);伦敦异地返照盘上升 Taurus vs Capricorn。数字见 `docs/tasks/PROGRESS-upstream-sync3-20260929.md`。 +- 防复发:`test_beijing_return_local_time_is_ut_plus_eight` 锁 `dt_local − dt_ut = +8h` 与昼夜判定时刻 = `jd_ut`;不得再返回 UT 当本地时间并寄望调用方补偏移。遗留:网页报告链只传数字偏移(`personal-report-longform-birth.ts:79`),IANA 区未到引擎,夏令时地区用户仍是固定偏移。 +- 相关记录:BUG-608、BUG-1028、BUG-1096、BUG-1097 +- 复发自:无 +- 修复版本:分支 `codex/upstream-sync3-20260929`,未合入 + +## BUG-1096 | 全量报告装配时年运面板键被清空(上游 089858c0 所修)——本仓核实不适用 + +- 状态:closed_not_applicable(2026-09-29 执行方核实) +- 首次发现 / 最近更新:2026-09-29 / 2026-09-29 +- 影响面:`scripts/jyotish_engine.py::build_professional_report_reference_packet` / `_apply_pl9_pack_selection`;`scripts/annual_tajika_pack.py`。 +- 用户现象:无(本仓从未生成这些面板)。 +- 已确认事实:虚构北京盘 `pl9-export --pack full` 的 `annual_tajika_pack` 15 个键里,上游 12 个键中 `profile`、`patyayini_dasha` 有,其余 10 个(`monthly_chart_pack`、`normalized_tables`、`lifetime_annual_chart_pages`、`annual_chart_series_pack`、`pl9_annual_visual_panels`、`pl9_annual_page_map`、`pl9_current_year_results`、`pl9_annual_dasha_results`、`annual_special_points`、`tripataki_chakra`)在整个导出包任何路径都不存在;本仓 `annual_tajika_pack.py` 没有生产者,只有 `pl9_reader_export.py` 防御性读取。`_apply_pl9_pack_selection` 在 `full` 时原样返回 packet,不存在装配丢失。 +- 根因:不适用——上游修的是它自己 09 月新增的面板生产者被装配层清空;本仓没有该生产者。 +- 修复:不抄。上游同批 `full_report_quality_gate.py` +86 行(`80756803`)是逐年 Muntha / Year Lord 一致性与 Ashtakavarga 12 宫映射校验,与面板无关,亦未接。 +- 验证:核实表见 `docs/tasks/PROGRESS-upstream-sync3-20260929.md` §T2。 +- 防复发:若日后引入年运面板生产者,再评估 `_preserve_annual_report_surface`。 +- 相关记录:BUG-1095、BUG-1028 +- 复发自:无 +- 修复版本:未修(不适用) + +## BUG-1097 | PL9 导出 JSON 在内存里再造一份报告大小的缩进字符串,文件体积翻倍 + +- 状态:resolved(2026-09-29 执行方自验;待 Claude 验收) +- 首次发现 / 最近更新:2026-09-29 / 2026-09-29 +- 影响面:`scripts/jyotish_engine.py::output_json` 与 `main()` 的 `pl9-export --output` 落盘。仅 CLI 与落盘路径;生产 API(`jyotish_api_server.py`)自行序列化,不经过此处。 +- 用户现象:`pl9-export` JSON 9.57 MB(缩进),序列化峰值 46.9 MB。 +- 根因:`print(json.dumps(..., indent=2))`;上游 `59c9139f` 改为流式紧凑。 +- 修复:`output_json(data, *, stream=None)` 照抄签名;实现为 PL9 schema 紧凑 `dumps` 一次写出,其他命令仍 `indent=2`。实测上游的 `json.dump` 流式在 CPython 走纯 Python 编码器(0.59 s),比紧凑 `dumps`(0.30 s)慢,故未抄其实现。 +- 验证:单步基准 A 0.410 s / 46.9 MB / 9.57 MB → C 0.303 s / 22.1 MB / 4.61 MB;整条 CLI 交错 A/B 3 次基线 6.92/6.62/6.42 s、RSS 197.9/169.2/169.6 MB → 6.58/6.42/6.38 s、191.3/159.4/159.0 MB;解析后相等;`test_output_json_is_compact_only_for_pl9_exports`。 +- 防复发:非 PL9 命令输出格式不变由同一测试锁定。 +- 相关记录:BUG-1095、ERR-111 +- 复发自:无 +- 修复版本:分支 `codex/upstream-sync3-20260929`,未合入 diff --git a/docs/tasks/PROGRESS-upstream-sync3-20260929.md b/docs/tasks/PROGRESS-upstream-sync3-20260929.md new file mode 100644 index 00000000..cc67ceaf --- /dev/null +++ b/docs/tasks/PROGRESS-upstream-sync3-20260929.md @@ -0,0 +1,125 @@ +# PROGRESS · 上游同步 3:返照时区本地化 / 年运面板核实 / PL9 紧凑写出(2026-09-29) + +任务书:`TASK-upstream-sync3-20260929.md`。分支 `codex/upstream-sync3-20260929`,基线 `origin/staging` = `15be6a06`。上游本机检出 `/workspace/yinduzhanxing` @ `db8cdf4a`。执行方:Claude fork 子代理;只 commit 不 push。 + +## 一句话结论 + +- **T1 做了,且比任务书写的更严重**:本仓返照的 `dt_local` 从来就是 UT 原样(`find_solar_return_ut` 注释「调用方另行加 tz」,但没有任何调用方加过)。北京盘读者版报告里的「Varshapravesha:2026-06-15 08:04:52」实际是 UT,应为 16:04:52;2028 年那行连日期都错(06-14 → 06-15)。所以**硬红线 1「北京盘逐字节相同」无法满足,也不应满足**——差异恰好只有被修正的三处(见 §T1 等价比对表),已把改前改后每个差异路径列出,请验收方按语义判断。 +- **T2 不适用**:12 个键里 10 个在本仓从来没有生产者(`annual_tajika_pack.py` 不产出面板类键,只有 `pl9_reader_export.py` 防御性读取),不是装配时丢失。BUG-1096 记 `closed_not_applicable`。 +- **T3 做了,但没抄上游的实现**:实测 `json.dump` 流式在 CPython 走纯 Python 编码器,比 `dumps` 慢 45%;改为紧凑 `dumps` 一次写出,保留上游 `stream=` 签名。文件 9.57 MB → 4.61 MB、序列化峰值 46.9 → 22.1 MB、0.41 → 0.30 s。 + +## 上游对照与取舍 + +| 上游 SHA | 内容 | 本仓落点 | 取舍 | +| --- | --- | --- | --- | +| `41d1c740` | `_localize_return_datetime`(ZoneInfo 按返照瞬间取偏移)、`annual_location`、返照地点 `current_location` 模式与 CLI 参数、外部 PyJHora 回放在异地时 blocked | `scripts/solar_return.py`(helper、`calc_solar_return_chart` / `solar_return_full_report` 四个新 kwargs、`dt_local` 真正本地化、Sahams 用 `annual_location`);`scripts/cmd_solar_return.py`(新增 `solar_return_location_kwargs`,同时接受年运包命名空间 `return_*` 与 CLI 命名空间 `--solar-return-location-mode` / `--current-*` / `--timezone-id` / API `timezone`);`scripts/annual_tajika_pack.py`(`_args_from_payload` 读 `settings.current_location`,出生 `timezone`/`timezone_id` 是 IANA 时传 `return_timezone_id`;新增 `_iana_timezone_id`);`scripts/jyotish_engine.py`(`_annual_payload_from_args` 写 `current_location` 与 `birth.timezone_id`;`_build_pl9_full_annual_section` 异地时回放 blocked;full-reading Step 4.12 透传;`_add_solar_return_location_args` 挂到 `solar-return` / `full-reading` / `pl9-export`) | 全抄,另加 `--timezone-id`(出生地 IANA 区)——上游只在 `current_location` 模式带 IANA 区,出生地模式仍是固定偏移;本仓把出生地 IANA 区也接上,否则 T1 对默认模式没有意义。`tajika_monthly.py` / `_local_eight_year_overview` / `_annual_special_points` 本仓不存在,对应块不抄 | +| `089858c0` | `_preserve_annual_report_surface`:全量报告装配时把 12 个年运面板键从快照补回 | 无 | **不适用**(§T2 核实表)。同批 `full_report_quality_gate.py` +86 行(`80756803`)内容是逐年 Muntha / Year Lord 一致性与 Ashtakavarga 12 宫映射校验,与面板无关,任务书假设的「面板覆盖检查」不存在,未接 | +| `59c9139f` | `output_json(data, *, stream=None)` 流式写出,PL9 紧凑 | `scripts/jyotish_engine.py::output_json` 与 `main()` 的 pl9 `--output` 落盘 | 签名照抄,实现改为紧凑 `dumps`(§T3 数据) | + +## T1 · 返照时刻本地化(BUG-1095) + +### 事实 + +- 改前 `find_solar_return_ut` 返回 `'dt_local': dt_ut`(`scripts/solar_return.py:221`),`calc_solar_return_chart` 原样透传;`solar_return_full_report` 把它当本地时间交给 `calc_all_sahams(..., tz=birth_tz)` 做昼夜判定(`saham_daynight.determine_daytime` 再减一次 tz → 判定时刻比真实返照早 8 小时)。 +- 读者版 `pl9_reader_export.py:1830/2201` 直接打印 `dt_local`。北京虚构盘(1990-06-15 10:30,39.9042/116.4074,+8,目标 2026)reader_main 渲染: + +| 行 | 改前 | 改后 | +| --- | --- | --- | +| `- Varshapravesha:` | 2026-06-15 08:04:52 | 2026-06-15 16:04:52 | +| 2027 表行 | 2027-06-15 14:07:50 | 2027-06-15 22:07:50 | +| 2028 表行 | 2028-06-14 20:25:46 | 2028-06-15 04:25:46 | + +- 昼夜判定时刻:`sahams.daynight_evidence.julian_day_ut` 2461206.5034(=06-15 00:04 UT)→ 2461206.8367(=真实返照 08:04:52 UT)。这张盘两者恰好都是白天,判定没翻;其他盘会翻。 + +### 等价比对(硬红线 1) + +方法:改前用基线树(`git worktree add --detach 15be6a06`,用完已删)、改后用本分支,同一命令、`PYTHONHASHSEED=0`,JSON 解析后递归比对,剔除 `generated_at` / `elapsed_seconds` / `*_hash` 等易变键。 + +| 命令 | 差异路径数 | 差异内容 | +| --- | --- | --- | +| `solar-return` 北京盘 | 6 | `solar_return.dt_local` 08:04:52 → 16:04:52;`solar_return.annual_location` / 顶层 `annual_location` 新增;`sahams.daynight_evidence.julian_day_ut` 改为真实返照时刻;`sunrise_jd_ut` / `sunset_jd_ut` 第 9 位小数浮动(rise_trans 起点变了) | +| `tajika --age 36` 北京盘 | **0** | — | +| `pl9-export --pack full` 北京盘 | 83 | 上面三项在 `full_report_pack.sections.annual.*`、`personal_report_producer.source_pack_contracts.annual_tajika_pack.*`、`raw_module_index[35].field_names` 的传播;其余全是易变项(VedAstro 快照 `called_at`、`slowest_stages` 顺序、authority / quality-gate 哈希 id——两次改后运行之间同样有 30 处这类差异) | +| `solar-return` 北京盘 固定 +8 vs `--timezone-id Asia/Shanghai` | **0** | 无夏令时地区 IANA 区与固定偏移等价 | + +**结论:北京盘不是逐字节相同,差异恰为修正项本身;这一条需要验收方确认接受。** + +### 夏令时与异地验证(虚构纽约盘 1985-07-04 08:00,40.7128/-74.006,`--tz -5`,目标 2026) + +| 模式 | `dt_ut` | `dt_local` | `annual_location.utc_offset` | 返照盘上升 | +| --- | --- | --- | --- | --- | +| 固定偏移(改后默认) | 2026-07-05 01:08:03 | 2026-07-04 20:08:03 | −5.0 | Capricorn | +| `--timezone-id America/New_York` | 同 | 2026-07-04 **21:08:03** | **−4.0**(EDT) | Capricorn(盘按 UT 起,不变) | +| `--solar-return-location-mode current_location --current-lat 51.5074 --current-lon -0.1278 --current-tz 0 --current-timezone-id Europe/London` | 同 | 2026-07-05 02:08:03 | +1.0(BST) | **Taurus** | + +### 时区 ID 能否传到年运路径(任务书要求核查) + +- 引擎侧已通:CLI `--timezone-id`;API `birth.timezone` / `timezone_name`(`calculation_profile_contract._normalize_timezone` 认这两个键)→ profile `timezone.name` → `_annual_payload_from_args` 写 `birth.timezone_id` → `annual_tajika_pack._args_from_payload` → `return_timezone_id`;full-reading Step 4.12 走 `solar_return_location_kwargs(args)` 读 `timezone_id` / `timezone` / `timezone_name`。 +- **前端缺口(未修,前端不在本单范围)**:`frontend/src/lib/personal-report-longform-birth.ts:79` 只传数字 `tz: profile.timezoneOffset`;账户资料其实存了 IANA 区(`account-profile-patch.ts:55` `timezone_id`,全球出生地必填)。网页报告因此仍走固定偏移:中国用户无影响;夏令时地区用户只有当资料里的偏移恰好等于返照瞬间的偏移时才对。补一行 `timezone: profile.timezone_id` 即可接上,建议另开前端小单。 + +## T2 · 年运面板核实表(BUG-1096,closed_not_applicable) + +北京盘 `pl9-export --pack full --format json`(`selected_report_scope = full`),`worksheets.timing_and_predictive_systems.annual_tajika_pack`: + +| 键 | 状态 | 说明 | +| --- | --- | --- | +| `profile` | 有 | | +| `patyayini_dasha` | 有 | 同时出现在 `external_engine_comparison.pyjhora` 与 `exports.json.field_statuses` | +| `monthly_chart_pack` | 缺 | 全包任何路径都没有;本仓 `annual_tajika_pack.py` 无生产者 | +| `normalized_tables` | 缺 | 同上 | +| `lifetime_annual_chart_pages` | 缺 | 同上 | +| `annual_chart_series_pack` | 缺 | 同上 | +| `pl9_annual_visual_panels` | 缺 | 同上 | +| `pl9_annual_page_map` | 缺 | 同上(本仓源码 0 处引用) | +| `pl9_current_year_results` | 缺 | 同上 | +| `pl9_annual_dasha_results` | 缺 | 同上 | +| `annual_special_points` | 缺 | 同上 | +| `tripataki_chakra` | 缺 | 同上 | + +本仓年运包实际键:`annual_chart, audit, exports, external_engine_comparison, monthly_windows, mudda_dasha, muntha, patyayini_dasha, profile, report_sections, sahams, schema, solar_return, tajika_yogas, year_lord`(15 个)。「缺」不是装配丢失(`_apply_pl9_pack_selection` 在 `full` 时原样返回 packet),而是从未生成;上游的补回逻辑没有可补的来源。不抄代码。 + +## T3 · PL9 紧凑写出(BUG-1097) + +### 序列化单步基准(改后 4.61 MB 语义包,`tracemalloc`,5 次取中位) + +| 变体 | 耗时 | 峰值内存 | 文件 | +| --- | --- | --- | --- | +| A 旧:`dumps(indent=2)` + 写 | 0.410 s | 46.9 MB | 9.57 MB | +| B 上游:`json.dump(stream, compact)` | 0.593 s | 0.1 MB | 4.61 MB | +| C 采用:紧凑 `dumps` 一次写出 | **0.303 s** | 22.1 MB | 4.61 MB | + +B 慢是因为 `json.dump` 只能走 `iterencode` 纯 Python 路径(C 编码器只在 `dumps` 且 `indent=None` 时启用)。本仓生产 API 路径(`jyotish_api_server.py`)不经过 `output_json`,本项只影响 CLI 与 `--output` 落盘,所以选耗时最优的 C,保留 `stream=` 签名。三种变体解析后与源对象相等。 + +### 整条 CLI 交错 A/B(`pl9-export --pack full --format json`,基线树 / 本分支交替各 3 次,`resource.getrusage` 子进程) + +| 轮 | 基线耗时 | 基线 RSS | 改后耗时 | 改后 RSS | +| --- | --- | --- | --- | --- | +| 1 | 6.92 s | 197.9 MB | 6.58 s | 191.3 MB | +| 2 | 6.62 s | 169.2 MB | 6.42 s | 159.4 MB | +| 3 | 6.42 s | 169.6 MB | 6.38 s | 159.0 MB | + +输出文件 9.57 MB → 4.61 MB;非 PL9 命令(`solar-return` / `tajika`)仍 `indent=2`,输出逐字节不变(tajika 0 差异)。 + +## 测试 + +- 新增 `tests/test_solar_return_timezone.py`(10 条):ZoneInfo 夏令 / 标准偏移;北京 `dt_local = dt_ut + 8h` 与昼夜判定时刻回归;`Asia/Shanghai` 等价固定 +8;纽约 IANA 区差 1 小时且盘不变;伦敦异地返照盘上升不同;返照地点不全时报错;两种命名空间的 kwargs;年运包参数映射与缺字段报错;`output_json` 只对 PL9 紧凑且可解析。 +- 任务书点名:`tests/test_tajika.py tests/test_annual_tajika_pack.py tests/test_full_report_quality_gate.py tests/test_professional_report_reference_api.py tests/test_api_server_growth_contract.py tests/test_cli_smoke.py` → 118 passed。 +- 快速门(`run_quality_gate.py --profile quick`,系统 `python3` 3.13.5 + swisseph,`/workspace/Jyotisha/.venv` 在本机不存在): + - 基线 15be6a06 第一次(无 Node):Python 步 1001 passed / 1 skipped,`npm test` exit 127(PATH 无 node)。 + - 基线第二次(`PATH=/exec-daemon` Node 22.14):Python 步 **1 failed** `tests/test_consultation_native_layers.py::test_annual_route_attaches_the_annual_pack_verbatim`(`'blocked' == 'parameter_sensitive'`),门在 Python 步停下、未到 npm。该测试改后单独跑 3/3 通过(exit 0);当时同机另有两个子代理在跑全量测试,判为负载相关的瞬时失败,不是本单改动(基线树、改动前)。 + - 改后(Node 22):见文末「快速门改后」一节。 + +## 未完成 / 缺口 + +- 前端 IANA 区未传(见 T1 末节)。 +- `full_report_quality_gate.py` 未改(上游同批改动与面板无关)。 +- `saham_daynight` 的昼夜判定输入现在是真实返照本地时刻;其他盘的昼夜结论可能因此翻转(这是修正,不是回归),未逐盘统计。 + +## 快速门改后(`PATH=/exec-daemon` Node 22.14,系统 python3 3.13.5) + +- Python 步:**1001 passed / 1 skipped / 3 subtests passed**(149.84 s),与基线第一次逐条一致;基线第二次瞬时失败的 `test_annual_route_attaches_the_annual_pack_verbatim` 本次通过。 +- `npm test` 步:`sh: 1: tsx: not found` → exit 127。原因是本 worktree 没有 `frontend/node_modules`(基线两次同样在这一步 exit 127,逐条一致)。本单不改前端,另用临时软链到主检出的 `frontend/node_modules` 单独跑了一次 `npm test`(见下一节;软链跑完即删,不入库)。 + +## 前端 `npm test`(本单零前端改动;临时软链主检出 `frontend/node_modules`,Node 22.14,跑完即删) + +`# tests 4357 / # pass 4302 / # fail 24 / # cancelled 0 / # skipped 31`,exit 1。24 条失败全是需要 Docker / 本地 PostgreSQL / 部署套件的用例(`local PostgreSQL applies the reviewed business schema…`、`migration runner…`、`staging backups…`、`Better Auth…` 等),与 09-29 已知基线 4302/24 的环境失败清单一致(无 Docker),无新增失败。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index d2396635..c60f840a 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -287,7 +287,7 @@ | `TASK-upstream-sync-fix-20260903.md` | `PROGRESS-upstream-sync-fix-20260903.md` | 非原生主题恢复 `degraded`、finance Yogi/D11/confidence cap、模板注册表引用、VedAstro/MCP finance 路由与 quick CORE 扩列 | 已验收 | `4ee79210`(BUG-515);staging health 已到该 SHA;十主题 e2e 合同全对,Python 红 90→69 无新增 | | `TASK-upstream-sync2-20260909.md` | `PROGRESS-upstream-sync2-20260909.md` | 上游活跃分支 `b9a0ef8f`(未进 main):婚恋三层触发模型(punarphoo / event_class_split 进证据链)+ 十套条件大运接 full reading 与长报告时间系统表;快照锁推进;Skill 6.9.16;不取 shadbala profile / PL9 用户版 / 校正泄漏项 | 已验收(04ad3325):定向 176 通过、quick 门 708/1 env 失败、pl9 时间系统表 5+10 行;1 P1(网页咨询路径 `dasha_info` 无 MD/AD,事件类拆分空转)→ 修复单 `TASK-upstream-sync2-fix-20260909.md` | 分支 `codex/upstream-sync2-20260909`(BUG-608) | | `TASK-upstream-sync2-fix-20260909.md` | `PROGRESS-upstream-sync2-fix-20260909.md` | 修复单:`_thematic_dasha_info` 改读 `dasha_sub_periods.current`、婚恋 activation 句按事件类出、`_render_south_chart` 围栏失败不打断报告、health 版本读 `__version__` | 待验收 | `7d3bb0c5`(BUG-609~611) | -| `TASK-upstream-sync3-20260929.md` | — | **上游同步 3(小单)**:上游 `19195f6e..db8cdf4a` 63 提交只抄三条报告面修复——返照时刻按时区本地化(`41d1c740`,本仓 `solar_return.py:275` 固定偏移)、年运面板装配丢失(`089858c0`,先核实再抄)、PL9 紧凑流式写出(`59c9139f`)。不推进快照锁(上游仍 29 处私人标记);**MEVG 不跟上游放松,AGENTS Part B3 维持**(产品 09-29) | 待领取 | — | +| `TASK-upstream-sync3-20260929.md` | `PROGRESS-upstream-sync3-20260929.md` | **上游同步 3(小单)**:上游 `19195f6e..db8cdf4a` 63 提交只抄三条报告面修复——返照时刻按时区本地化(`41d1c740`,本仓 `solar_return.py:275` 固定偏移)、年运面板装配丢失(`089858c0`,先核实再抄)、PL9 紧凑流式写出(`59c9139f`)。不推进快照锁(上游仍 29 处私人标记);**MEVG 不跟上游放松,AGENTS Part B3 维持**(产品 09-29) | 已实现待验收(执行方 09-29:T1 返照本地化做了且发现 `dt_local` 一直是 UT,北京盘差异=修正项本身,硬红线 1 需验收方判;T2 核实 10/12 键本仓无生产者,不适用;T3 紧凑 `dumps` 9.57→4.61 MB;前端 IANA 区未传是缺口) | `codex/upstream-sync3-20260929`(BUG-1095~1097),未合入 | | `TASK-rectification-occupation-dated-answer-20260911.md` | `PROGRESS-rectification-occupation-dated-answer-20260911.md` | 09-11 真机:职业题答出的年月被 `applyOccupationCollectLedgerNorm` 抹成 unknown 不计分(BUG-649)+ 工具轮缺口句未并入正文(BUG-650) | 已实现 `1fa994ea`,已部署 | `codex/rectification-occupation-dated-answer-20260911` | | `TASK-rectification-probe-pool-exhausted-exit-20260911.md` | `PROGRESS-rectification-probe-pool-exhausted-exit-20260911.md` | 09-11 真机:六道带年月题问完后决策仍判「出区分题」(性格题被当区分题)而持久化无载体 → 「没有拿到下一个问题」(BUG-651/652) | 已实现 `66f63c76` + `f870d3d7`,已部署 | `codex/rectification-probe-pool-exhausted-exit-20260911` | | `TASK-rectification-narrow-before-deliver-20260911.md` | `PROGRESS-rectification-narrow-before-deliver-20260911.md` | 09-11 真机:池空即出卡但范围仍 20 分钟——探针只在引擎跑时按初始簇生成一次、家人题被 0.85 先验丢弃、交付前无定向补事(BUG-653/654,Skill 10.0.24) | 已实现 `6c9a0896`,已部署;验收见修复单 | `codex/rectification-narrow-before-deliver-20260911` | diff --git a/scripts/annual_tajika_pack.py b/scripts/annual_tajika_pack.py index bf1e5a8a..75bc6ec2 100755 --- a/scripts/annual_tajika_pack.py +++ b/scripts/annual_tajika_pack.py @@ -8,6 +8,7 @@ the next conflict-gate layer. from __future__ import annotations from datetime import datetime, timedelta +from zoneinfo import ZoneInfo from types import SimpleNamespace from typing import Any @@ -80,9 +81,29 @@ def build_annual_tajika_pack( return pack +def _iana_timezone_id(value: Any) -> str | None: + """Return ``value`` when it names a loadable IANA zone, else ``None``.""" + if not isinstance(value, str) or "/" not in value: + return None + try: + ZoneInfo(value.strip()) + except Exception: + return None + return value.strip() + + def _args_from_payload(payload: dict[str, Any]) -> SimpleNamespace: birth = dict(payload.get("birth") or {}) settings = dict(payload.get("settings") or {}) + return_location: dict[str, Any] = {} + if settings.get("solar_return_location_mode") == "current_location": + return_location = dict(settings.get("current_location") or {}) + if any(return_location.get(key) is None for key in ("latitude", "longitude", "utc_offset")): + raise ValueError("current_location requires latitude, longitude and utc_offset") + return_timezone_id = ( + _iana_timezone_id(return_location.get("timezone_id")) if return_location + else _iana_timezone_id(birth.get("timezone_id") or birth.get("timezone")) + ) year, month, day = [int(part) for part in str(birth["date"]).split("-")] time_parts = [int(part) for part in str(birth.get("time") or "00:00:00").split(":")] while len(time_parts) < 3: @@ -110,6 +131,10 @@ def _args_from_payload(payload: dict[str, Any]) -> SimpleNamespace: dasha_year_days=settings.get("dasha_year_days", 365.25), solar_return_location_mode=settings.get("solar_return_location_mode", "birth_place"), annual_year_policy=settings.get("annual_year_policy", "solar_return_exact"), + return_lat=return_location.get("latitude"), + return_lon=return_location.get("longitude"), + return_tz=_offset_to_hours(return_location["utc_offset"]) if return_location else None, + return_timezone_id=return_timezone_id, ) diff --git a/scripts/cmd_solar_return.py b/scripts/cmd_solar_return.py index bde98c0e..e38eb714 100644 --- a/scripts/cmd_solar_return.py +++ b/scripts/cmd_solar_return.py @@ -43,9 +43,45 @@ def cmd_solar_return(args: Any) -> Dict[str, Any]: args.lat, args.lon, args.tz, args.target_year, ayanamsa_name=getattr(args, 'ayanamsa', None) or DEFAULT_AYANAMSA_NAME, + **solar_return_location_kwargs(args), ) +def solar_return_location_kwargs(args: Any) -> Dict[str, Any]: + """Return-location kwargs for ``solar_return_full_report``. + + Two arg shapes are accepted: the annual-pack namespace (``return_lat`` / + ``return_lon`` / ``return_tz`` / ``return_timezone_id``, built by + ``annual_tajika_pack._args_from_payload``) and the CLI namespace + (``--solar-return-location-mode`` / ``--current-*`` / ``--timezone-id``). + Upstream yinduzhanxing 41d1c740. + """ + if getattr(args, 'return_lat', None) is not None or getattr(args, 'return_timezone_id', None): + return { + 'local_timezone_id': getattr(args, 'return_timezone_id', None), + 'return_lat': getattr(args, 'return_lat', None), + 'return_lon': getattr(args, 'return_lon', None), + 'return_tz': getattr(args, 'return_tz', None), + } + mode = getattr(args, 'solar_return_location_mode', None) or 'birth_place' + if mode == 'current_location': + if any(getattr(args, key, None) is None for key in ('current_lat', 'current_lon', 'current_tz')): + raise ValueError('current_location requires current_lat, current_lon and current_tz') + return { + 'local_timezone_id': getattr(args, 'current_timezone_id', None), + 'return_lat': float(args.current_lat), + 'return_lon': float(args.current_lon), + 'return_tz': float(args.current_tz), + } + # CLI passes ``--timezone-id``; API payloads reach here as ``timezone`` / + # ``timezone_name`` (calculation_profile_contract keys). Only IANA ids count. + for key in ('timezone_id', 'timezone', 'timezone_name'): + value = getattr(args, key, None) + if isinstance(value, str) and '/' in value: + return {'local_timezone_id': value.strip()} + return {'local_timezone_id': None} + + if __name__ == '__main__': # 独立测试入口 import argparse diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index e4c77701..9cae69fa 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -765,9 +765,22 @@ def _calc_sidereal_planets_for_jd(jd, node_mode='mean', include_ketu=True): return data, ayanamsa -def output_json(data): - """统一JSON输出""" - print(json.dumps(data, ensure_ascii=False, indent=2, default=str)) +def output_json(data, *, stream=None): + """统一JSON输出。 + + PL9 专业导出(``pl9_style_professional_export_v1``)用紧凑分隔符、不缩进, + 其他命令保持 ``indent=2``;``stream`` 签名与上游 yinduzhanxing 59c9139f 一致。 + """ + stream = sys.stdout if stream is None else stream + is_pl9 = isinstance(data, dict) and data.get('schema') == 'pl9_style_professional_export_v1' + # 实测(9.6 MB 报告,CPython 3.13):json.dump 流式走纯 Python 编码器,比 dumps 慢 ~45%; + # 紧凑 dumps 走 C 编码器,字符串体积减半、峰值内存 47 MB → 22 MB、耗时 0.41 s → 0.30 s。 + # 故保留上游的 stream 签名,但 PL9 用紧凑 dumps 一次写出。 + if is_pl9: + stream.write(json.dumps(data, ensure_ascii=False, default=str, separators=(',', ':'))) + else: + stream.write(json.dumps(data, ensure_ascii=False, indent=2, default=str)) + stream.write('\n') def _normalize_pl9_pack_selection(args) -> list[str]: @@ -3015,12 +3028,17 @@ def _build_pl9_full_annual_section(args, profile: dict | None = None) -> dict: longitude=float(location.get('longitude', args.lon)), timezone=float(args.tz), ) - replay = replay_pyjhora_annual( - annual_profile, - birth_julian_day=birth_jd, - place=place, - age=age, - ) + if payload['settings'].get('solar_return_location_mode') == 'current_location': + # The external adapter accepts one place for both natal and annual + # calculations. Do not present a birthplace replay as relocated parity. + replay = {'status': 'blocked', 'reason': 'separate_return_location_not_supported_by_external_adapter'} + else: + replay = replay_pyjhora_annual( + annual_profile, + birth_julian_day=birth_jd, + place=place, + age=age, + ) return build_annual_tajika_pack(payload, pyjhora_replay=replay) except Exception as exc: return {'schema': 'jyotish.annual_tajika_pack.v1', 'status': 'blocked', 'reason': str(exc)} @@ -3053,6 +3071,18 @@ def _annual_payload_from_args(args, profile: dict | None = None) -> dict: 'target_year': int(args.target_year), 'age': getattr(args, 'age', None), } + birth_timezone_id = getattr(args, 'timezone_id', None) or timezone.get('name') + if isinstance(birth_timezone_id, str) and '/' in birth_timezone_id: + payload['birth']['timezone_id'] = birth_timezone_id.strip() + if payload['settings']['solar_return_location_mode'] == 'current_location': + if any(getattr(args, key, None) is None for key in ('current_lat', 'current_lon', 'current_tz')): + raise ValueError('current_location requires current_lat, current_lon and current_tz') + payload['settings']['current_location'] = { + 'latitude': float(args.current_lat), 'longitude': float(args.current_lon), + 'utc_offset': _pl9_utc_offset_from_tz(args.current_tz), + 'timezone_id': getattr(args, 'current_timezone_id', None), + } + return payload def _pl9_utc_offset_from_tz(tz_value) -> str: @@ -3062,6 +3092,21 @@ def _pl9_utc_offset_from_tz(tz_value) -> str: return f"{sign}{total_minutes // 60:02d}:{total_minutes % 60:02d}" +def _solar_return_location_kwargs(args) -> dict: + """Solar-return localisation kwargs (shared with cmd_solar_return; upstream 41d1c740).""" + from cmd_solar_return import solar_return_location_kwargs + return solar_return_location_kwargs(args) + + +def _add_solar_return_location_args(p) -> None: + p.add_argument('--timezone-id', default=None, help='出生地 IANA 时区(如 Asia/Shanghai);用于返照时刻按夏令时规则本地化') + p.add_argument('--solar-return-location-mode', choices=['birth_place', 'current_location'], default='birth_place') + p.add_argument('--current-lat', type=float, default=None) + p.add_argument('--current-lon', type=float, default=None) + p.add_argument('--current-tz', type=float, default=None) + p.add_argument('--current-timezone-id', default=None, help='返照地 IANA 时区(仅 current_location 模式)') + + def _is_domi_pl9_control_case(packet: dict) -> bool: return False @@ -15919,6 +15964,7 @@ def cmd_full_reading(args): args.lat, args.lon, args.tz, args.target_year, ayanamsa_name=_current_ayanamsa_name(args), + **_solar_return_location_kwargs(args), ) report['modules']['solar_return'] = sr_result # 更新 muntha 为正确值(来自太阳返照盘) @@ -18179,6 +18225,7 @@ def main(): p = sub.add_parser('solar-return', help='太阳返照盘 Varshaphala 年运分析') _add_chart_args(p) p.add_argument('--target-year', type=int, required=True, help='目标年份(计算该年太阳返照)') + _add_solar_return_location_args(p) # 21.6 narayana-dasha (v6.0.20新增) p = sub.add_parser('narayana-dasha', help='Narayana Dasha(Rishi Dasha)星座大运分析') @@ -18212,6 +18259,7 @@ def main(): # 22. full-reading (v3.7.1新增) p = sub.add_parser('full-reading', help='全自动综合解盘(出生信息→全链路→完整报告)') _add_chart_args(p) + _add_solar_return_location_args(p) p.add_argument('--age', type=int, default=None, help='当前年龄(不提供则自动计算)') p.add_argument('--today', default=None, help='Dasha/Sandhi参考日期 YYYY-MM-DD(默认今天)') p.add_argument('--transit-date', default=None, help='Transit真实过境参考日期 YYYY-MM-DD(默认跟随--today或今天)') @@ -18227,6 +18275,7 @@ def main(): p = sub.add_parser('pl9-export', help='PL9 风格专业排盘导出(基于 full-reading 聚合专业模块)') _add_chart_args(p) + _add_solar_return_location_args(p) p.add_argument('--age', type=int, default=None, help='当前年龄(不提供则自动计算)') p.add_argument('--today', default=None, help='Dasha/Sandhi参考日期 YYYY-MM-DD(默认今天)') p.add_argument('--transit-date', default=None, help='Transit真实过境参考日期 YYYY-MM-DD(默认跟随--today或今天)') @@ -18410,7 +18459,8 @@ def main(): if output_path: target = Path(output_path) target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(json.dumps(result, ensure_ascii=False, indent=2, default=str) + '\n', encoding='utf-8') + with target.open('w', encoding='utf-8') as stream: + output_json(result, stream=stream) output_json(result) else: output_json(result) diff --git a/scripts/solar_return.py b/scripts/solar_return.py index 63cc77b8..c7159eed 100644 --- a/scripts/solar_return.py +++ b/scripts/solar_return.py @@ -15,7 +15,8 @@ Jyotish Vedic Astrology Skill — v6.0.18 """ from typing import Dict, Optional, Tuple, List -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo import math import sys import os @@ -261,6 +262,7 @@ def _find_solar_return_approx( birth_sun_lon: float, target_year: int, tz: float, + timezone_id: str | None = None, ) -> Dict: """ 近似计算太阳返照时间(不使用 swisseph)。 @@ -272,7 +274,7 @@ def _find_solar_return_approx( except ValueError: approx_dt_utc = datetime(target_year, birth_dt.month, 1, 12, 0, 0) - dt_local = approx_dt_utc + timedelta(hours=tz) + dt_local = _localize_return_datetime(approx_dt_utc, tz, timezone_id).replace(tzinfo=None) return { 'jd_ut': _datetime_to_jd_ut(approx_dt_utc), @@ -291,12 +293,27 @@ def _find_solar_return_approx( # 生成完整太阳返照盘 # ========================================================================= +def _localize_return_datetime(dt_ut, utc_offset, timezone_id=None): + """Convert the return instant using the zone's offset at that instant. + + With an IANA ``timezone_id`` the offset is taken from the zone rules at the + return instant (DST-aware). Without one, the fixed ``utc_offset`` is used. + Upstream yinduzhanxing 41d1c740. + """ + zone = ZoneInfo(timezone_id) if timezone_id else timezone(timedelta(hours=float(utc_offset))) + return dt_ut.replace(tzinfo=timezone.utc).astimezone(zone) + + def calc_solar_return_chart( birth_year: int, birth_month: int, birth_day: int, birth_hour: int, birth_minute: int, birth_lat: float, birth_lon: float, birth_tz: float, target_year: int, ayanamsa_name: str = 'raman', + local_timezone_id: str | None = None, + return_lat: float | None = None, + return_lon: float | None = None, + return_tz: float | None = None, ) -> Dict: """ 计算太阳返照盘(Varshaphala)。 @@ -325,6 +342,13 @@ def calc_solar_return_chart( # → 改为由调用方传入 birth_sun_lon,或从出生盘数据里取 # 这里我们先做:调用 engine 的 compute_chart_data 算出生盘 try: + if (return_lat is None) != (return_lon is None): + raise ValueError('return_location_incomplete') + if return_lat is not None and return_tz is None and not local_timezone_id: + raise ValueError('return_location_timezone_missing') + annual_lat = float(birth_lat if return_lat is None else return_lat) + annual_lon = float(birth_lon if return_lon is None else return_lon) + annual_tz = float(birth_tz if return_tz is None else return_tz) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from jyotish_engine import compute_chart_data, HAS_SWE as ENGINE_HAS_SWE @@ -337,7 +361,8 @@ def calc_solar_return_chart( birth_hour, birth_minute, 0)), 0, # birth_sun_lon unknown target_year, - birth_tz + annual_tz, + timezone_id=local_timezone_id, ), } @@ -368,6 +393,14 @@ def calc_solar_return_chart( sr_jd_ut = sr_result['jd_ut'] sr_dt_ut = sr_result['dt_ut'] + # 返照时刻本地化:按返照瞬间的时区偏移(IANA 区支持夏令时),而不是 + # 把 UT 直接当本地时间(旧行为 dt_local == dt_ut)。 + local_dt = _localize_return_datetime(sr_dt_ut, annual_tz, local_timezone_id) + annual_tz = local_dt.utcoffset().total_seconds() / 3600 + sr_result['dt_local'] = local_dt.replace(tzinfo=None) + sr_result['annual_location'] = {'latitude': annual_lat, 'longitude': annual_lon, + 'utc_offset': annual_tz, 'timezone_id': local_timezone_id} + # Step 3: 以返照时刻计算星盘 # compute_chart_data 接受 year/month/day/hour/minute sr_year, sr_month, sr_day = sr_dt_ut.year, sr_dt_ut.month, sr_dt_ut.day @@ -378,7 +411,7 @@ def calc_solar_return_chart( sr_chart, sr_asc_idx, sr_jd, sr_ayanamsa = compute_chart_data( sr_year, sr_month, sr_day, sr_hour_int, sr_minute_int, - birth_lat, birth_lon, 0, # UT 时间,时区=0 + annual_lat, annual_lon, 0, # UT 时间,时区=0 'mean', ayanamsa_name=ayanamsa_name, ) @@ -404,6 +437,8 @@ def calc_solar_return_chart( 'birth_asc_sign_idx': birth_asc_idx, 'target_year': target_year, 'birth_year': birth_year, + 'annual_location': {'latitude': annual_lat, 'longitude': annual_lon, + 'utc_offset': annual_tz, 'timezone_id': local_timezone_id}, 'age': target_year - birth_year, 'note': f'太阳返照盘 {target_year} 年(年龄 {target_year-birth_year} 岁)', } @@ -422,6 +457,10 @@ def solar_return_full_report( birth_lat: float, birth_lon: float, birth_tz: float, target_year: int, ayanamsa_name: str = 'raman', + local_timezone_id: str | None = None, + return_lat: float | None = None, + return_lon: float | None = None, + return_tz: float | None = None, ) -> Dict: """ 太阳返照盘完整报告(Varshaphala 年运分析)。 @@ -449,6 +488,8 @@ def solar_return_full_report( birth_lat, birth_lon, birth_tz, target_year, ayanamsa_name=ayanamsa_name, + local_timezone_id=local_timezone_id, + return_lat=return_lat, return_lon=return_lon, return_tz=return_tz, ) if 'error' in sr: @@ -469,6 +510,7 @@ def solar_return_full_report( } else: result['solar_return'] = sr['solar_return'] + result['annual_location'] = dict(sr.get('annual_location') or {}) result['sr_chart_info'] = { 'asc_sign': SIGNS[sr['asc_sign_idx']], 'asc_sign_idx': sr['asc_sign_idx'], @@ -549,9 +591,9 @@ def solar_return_full_report( asc_lon, sr_dt_local, chart_type='varsha', - lat=birth_lat, - lon=birth_lon, - tz=birth_tz, + lat=sr.get('annual_location', {}).get('latitude', birth_lat), + lon=sr.get('annual_location', {}).get('longitude', birth_lon), + tz=sr.get('annual_location', {}).get('utc_offset', birth_tz), ) result['sahams'] = sahams except Exception as e: diff --git a/tests/test_solar_return_timezone.py b/tests/test_solar_return_timezone.py new file mode 100644 index 00000000..4661d3aa --- /dev/null +++ b/tests/test_solar_return_timezone.py @@ -0,0 +1,168 @@ +"""Solar-return instant localisation and relocated annual charts (upstream 41d1c740). + +Regression for BUG-1095: ``find_solar_return_ut`` returned ``dt_local == dt_ut`` +("调用方另行加 tz") and no caller ever added the offset, so the Varshapravesha +shown to readers and the Saham day/night instant were the UT clock labelled as +local (8 hours off for a Beijing chart). Fictional charts only. +""" +from __future__ import annotations + +import io +import json +import sys +from datetime import datetime, timedelta +from pathlib import Path +from types import SimpleNamespace + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import solar_return # noqa: E402 +from cmd_solar_return import solar_return_location_kwargs # noqa: E402 +from annual_tajika_pack import _args_from_payload # noqa: E402 + +try: # pragma: no cover - environment probe + import swisseph # noqa: F401 + + HAS_SWE = True +except Exception: # pragma: no cover + HAS_SWE = False + +BEIJING = dict(birth_year=1990, birth_month=6, birth_day=15, birth_hour=10, birth_minute=30, + birth_lat=39.9042, birth_lon=116.4074, birth_tz=8.0, target_year=2026) +NEW_YORK = dict(birth_year=1985, birth_month=7, birth_day=4, birth_hour=8, birth_minute=0, + birth_lat=40.7128, birth_lon=-74.006, birth_tz=-5.0, target_year=2026) + + +def _dt(value) -> datetime: + return value if isinstance(value, datetime) else datetime.fromisoformat(str(value)) + + +def test_localize_return_datetime_uses_zone_offset_at_the_instant() -> None: + instant = datetime(2026, 7, 5, 1, 8, 3) # UT, northern-hemisphere summer + zoned = solar_return._localize_return_datetime(instant, -5.0, "America/New_York") + fixed = solar_return._localize_return_datetime(instant, -5.0, None) + assert zoned.utcoffset() == timedelta(hours=-4) # EDT at that instant + assert fixed.utcoffset() == timedelta(hours=-5) # the supplied fixed offset + assert zoned.replace(tzinfo=None) - fixed.replace(tzinfo=None) == timedelta(hours=1) + + +def test_localize_return_datetime_winter_instant_matches_standard_offset() -> None: + instant = datetime(2026, 1, 15, 12, 0, 0) + zoned = solar_return._localize_return_datetime(instant, -5.0, "America/New_York") + assert zoned.utcoffset() == timedelta(hours=-5) + + +@pytest.mark.skipif(not HAS_SWE, reason="swisseph required") +def test_beijing_return_local_time_is_ut_plus_eight() -> None: + report = solar_return.solar_return_full_report(**BEIJING) + sr = report["solar_return"] + assert "error" not in report + assert _dt(sr["dt_local"]) - _dt(sr["dt_ut"]) == timedelta(hours=8) + assert report["annual_location"] == { + "latitude": BEIJING["birth_lat"], "longitude": BEIJING["birth_lon"], + "utc_offset": 8.0, "timezone_id": None, + } + # Saham day/night must be judged at the true return instant, not UT-as-local. + assert report["sahams"]["daynight_evidence"]["julian_day_ut"] == pytest.approx(sr["jd_ut"], abs=1e-5) + + +@pytest.mark.skipif(not HAS_SWE, reason="swisseph required") +def test_beijing_iana_zone_equals_fixed_offset_where_there_is_no_dst() -> None: + fixed = solar_return.solar_return_full_report(**BEIJING) + zoned = solar_return.solar_return_full_report(**BEIJING, local_timezone_id="Asia/Shanghai") + assert zoned["solar_return"]["dt_local"] == fixed["solar_return"]["dt_local"] + assert zoned["annual_location"]["utc_offset"] == 8.0 + assert zoned["annual_location"]["timezone_id"] == "Asia/Shanghai" + assert zoned["sr_chart_info"] == fixed["sr_chart_info"] + + +@pytest.mark.skipif(not HAS_SWE, reason="swisseph required") +def test_new_york_summer_return_is_one_hour_later_with_iana_zone() -> None: + fixed = solar_return.solar_return_full_report(**NEW_YORK) + zoned = solar_return.solar_return_full_report(**NEW_YORK, local_timezone_id="America/New_York") + assert fixed["solar_return"]["dt_ut"] == zoned["solar_return"]["dt_ut"] + assert _dt(zoned["solar_return"]["dt_local"]) - _dt(fixed["solar_return"]["dt_local"]) == timedelta(hours=1) + assert fixed["annual_location"]["utc_offset"] == -5.0 + assert zoned["annual_location"]["utc_offset"] == -4.0 + assert zoned["sr_chart_info"] == fixed["sr_chart_info"] # the chart itself is cast in UT + + +@pytest.mark.skipif(not HAS_SWE, reason="swisseph required") +def test_relocated_return_casts_the_annual_chart_at_the_current_location() -> None: + home = solar_return.solar_return_full_report(**NEW_YORK) + london = solar_return.solar_return_full_report( + **NEW_YORK, return_lat=51.5074, return_lon=-0.1278, return_tz=0.0, local_timezone_id="Europe/London", + ) + assert london["solar_return"]["dt_ut"] == home["solar_return"]["dt_ut"] + assert london["annual_location"]["latitude"] == 51.5074 + assert london["annual_location"]["utc_offset"] == 1.0 # BST at the July instant + assert london["sr_chart_info"]["asc_sign"] != home["sr_chart_info"]["asc_sign"] + + +@pytest.mark.skipif(not HAS_SWE, reason="swisseph required") +def test_incomplete_return_location_is_reported_not_guessed() -> None: + report = solar_return.calc_solar_return_chart(**NEW_YORK, return_lat=51.5) + assert "return_location_incomplete" in report["error"] + report = solar_return.calc_solar_return_chart(**NEW_YORK, return_lat=51.5, return_lon=-0.1) + assert "return_location_timezone_missing" in report["error"] + + +def test_location_kwargs_accept_cli_and_annual_pack_namespaces() -> None: + cli = SimpleNamespace(solar_return_location_mode="current_location", current_lat=51.5, current_lon=-0.1, + current_tz=0.0, current_timezone_id="Europe/London") + assert solar_return_location_kwargs(cli) == { + "local_timezone_id": "Europe/London", "return_lat": 51.5, "return_lon": -0.1, "return_tz": 0.0, + } + pack = SimpleNamespace(return_lat=51.5, return_lon=-0.1, return_tz=0.0, return_timezone_id=None) + assert solar_return_location_kwargs(pack)["return_lat"] == 51.5 + birth_zone = SimpleNamespace(timezone_id="Asia/Shanghai") + assert solar_return_location_kwargs(birth_zone) == {"local_timezone_id": "Asia/Shanghai"} + assert solar_return_location_kwargs(SimpleNamespace()) == {"local_timezone_id": None} + with pytest.raises(ValueError): + solar_return_location_kwargs(SimpleNamespace(solar_return_location_mode="current_location", current_lat=1.0)) + + +def test_annual_pack_args_carry_the_return_location_and_birth_zone() -> None: + payload = { + "birth": {"date": "1985-07-04", "time": "08:00:00", "latitude": 40.7128, "longitude": -74.006, + "utc_offset": "-05:00", "timezone": "America/New_York"}, + "settings": {}, + "target_year": 2026, + } + args = _args_from_payload(payload) + assert args.return_lat is None and args.return_tz is None + assert args.return_timezone_id == "America/New_York" + + payload["birth"]["timezone"] = "UTC-05:00" + assert _args_from_payload(payload).return_timezone_id is None + + payload["settings"] = { + "solar_return_location_mode": "current_location", + "current_location": {"latitude": 51.5, "longitude": -0.1, "utc_offset": "+00:00", "timezone_id": "Europe/London"}, + } + args = _args_from_payload(payload) + assert (args.return_lat, args.return_lon, args.return_tz, args.return_timezone_id) == (51.5, -0.1, 0.0, "Europe/London") + + payload["settings"]["current_location"] = {"latitude": 51.5} + with pytest.raises(ValueError): + _args_from_payload(payload) + + +def test_output_json_is_compact_only_for_pl9_exports() -> None: + import jyotish_engine + + pl9 = {"schema": "pl9_style_professional_export_v1", "a": [1, 2, {"b": "中文"}]} + other = {"schema": "other", "a": [1, 2]} + buf = io.StringIO() + jyotish_engine.output_json(pl9, stream=buf) + text = buf.getvalue() + assert text == '{"schema":"pl9_style_professional_export_v1","a":[1,2,{"b":"中文"}]}\n' + assert json.loads(text) == pl9 + buf = io.StringIO() + jyotish_engine.output_json(other, stream=buf) + assert buf.getvalue() == json.dumps(other, ensure_ascii=False, indent=2) + "\n"