diff --git a/CHANGELOG.md b/CHANGELOG.md index 536e0055..e467beaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-10-08 — 特殊上升点在没有 PyJHora 的环境里也能算出(未上线) + +- 生产镜像装不了 PyJHora,Bhava、Hora、Ghati、Vighati、Sree、Indu、Pranapada、Varnada 这八个点以前算不出来。现在用 Swiss Ephemeris 在本进程里算(BUG-1273)。 +- 生时校正仍用原来的 Hora、Ghati 公式。校正算法身份不升,Skill 版本不变。 + ## 2026-10-07 — 分盘、Yogini 和特殊上升按原书重算(未上线) - D5、D6、D8、D11 改按 PVR《Integrated Approach》6.2 的分段起算。学业、健康、财富这几张分盘上的星座会和以前不同(BUG-1260)。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index cb063165..dc748c9a 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -16986,9 +16986,9 @@ ## BUG-1265 | 特殊上升点用同一套公式,Hora 与 Ghati 永远相同 -- 状态:resolved +- 状态:resolved(生产镜像缺 PyJHora 的回归由 BUG-1273 关闭;`4b9c0198` 在无 PyJHora 的镜像上并未算出这八个点) - 首次发现:2026-10-07 -- 最近更新:2026-10-07 +- 最近更新:2026-10-08 - 影响面:全盘报告的特殊上升点;`scripts/special_lagnas.py` - 用户现象:公开盘上 Hora 与 Ghati 落在同一星座同一度数,报告里没有 Sree、Indu、Pranapada、Varnada - 触发条件:全盘计算特殊上升点 @@ -17000,6 +17000,7 @@ - 复发自:无 - 修复版本:本分支 `4b9c0198` - 验收补记(Claude,2026-10-08):生产与 staging 镜像只装 `requirements.txt`,不含 PyJHora(AGPL,只能作外部对照)。屏蔽 `jhora` 导入后,乔布斯盘 `Hora_Lagna`、`Ghati_Lagna`、`Sree_Lagna`、`Indu_Lagna`、`Pranapada_Lagna` 全部 `blocked`(`pyjhora_unavailable`)。即 staging `2df6ee72` 上这些点不显示。sync6 验收机装了 PyJHora,没看出。修复单 `docs/tasks/TASK-special-lagnas-native-20261008.md`(原生实现,PyJHora 只作冻结对照答案)。状态应视为 regressed,待修复单关闭。 +- 回归关闭(2026-10-08,BUG-1273):同一条「屏蔽 `jhora` 再算全盘」的路径上,八个点(含 Vighati、Varnada)都是 `computed`,`source` 为 `native_special_lagna`。历史修复段仍指 `4b9c0198` 的 PyJHora 调用,生产不可用不是那一版能单独解决的。这版代码尚未换成线上镜像。 ## BUG-1269 | 导入引擎时的岁差引导盖掉调用方已选的模式 @@ -17113,3 +17114,19 @@ - 相关记录:BUG-1254(数据库槽位排队)、BUG-266(runner 资源回收)、BUG-1260、BUG-1268。 - 复发自:无。 - 修复版本:无(运行机侧)。 + +## BUG-1273 | 生产镜像没有 PyJHora,八个特殊上升点算不出来 + +- 状态:resolved(代码已在本分支;未上线,部署以 `/api/health` 的 `deployment.gitCommit` 为准) +- 首次发现:2026-10-08 +- 最近更新:2026-10-08 +- 影响面:全盘、报告和对话卡里的 Bhava、Hora、Ghati、Vighati、Sree、Indu、Pranapada、Varnada。生时校正打分不走这条路径 +- 用户现象:staging `2df6ee72` 上这些点不显示。生产环境因此暂时没有这八个点 +- 触发条件:镜像只装 `requirements.txt`(没有 PyJHora)时计算特殊上升点 +- 根因:BUG-1265 的修复在有经纬度时调用 PyJHora。PyJHora 在 `requirements-reference-engines.txt`,许可证是 AGPL,不能进生产镜像。验收机装了 PyJHora,门禁 `.venv` 和生产镜像都没有。复发类型:验收环境与生产不一致 +- 修复:八个点改由 Swiss Ephemeris 在本进程计算,日出用出生地经纬度和请求的岁差。`source` 为 `native_special_lagna`。PyJHora 4.8.7 只用来离线冻结对照答案。运行时的 `calculate_pyjhora_special_lagnas` 已删除。生时校正仍调用原来的 Hora / Ghati / Bhava 公式,不升算法身份。Tajika 的进程内 PyJHora 打分和校正三引擎对照里的 PyJHora 列,分别挪到文件名含 pyjhora 的参考模块,默认全盘不调用 +- 验证:`tests/test_special_lagnas_birth_instant.py`。三张公开或虚构盘、Lahiri 与 Raman,八点与冻结的 PyJHora 4.8.7 相差不超过 0.01°(实测最大绝对差 4.9e-5°);Hora 与 Ghati 度数不同。屏蔽 `jhora` 导入后跑 `cmd_full_reading`,八点都是 `computed`,`source` 不含 `pyjhora`。缺坐标时旧的 Bhava / Hora / Ghati 公式仍在,另外五点为 blocked。`tests/test_runtime_jhora_import_scan.py` 扫描全盘、报告、对话卡入口的导入图。本机有 PyJHora,验收用的是屏蔽导入,不是一个未安装 PyJHora 的虚拟环境。Linux 全量 pytest 与快速门整段未在本机跑完 +- 防复发:上述度数对照,以及快速门里的导入扫描(`scripts/run_quality_gate.py` 的 quick 列表) +- 相关记录:BUG-1265 +- 复发自:BUG-1265 +- 修复版本:本分支 `codex/special-lagnas-native-20261008` diff --git a/docs/tasks/PROGRESS-special-lagnas-native-20261008.md b/docs/tasks/PROGRESS-special-lagnas-native-20261008.md new file mode 100644 index 00000000..4a2a1363 --- /dev/null +++ b/docs/tasks/PROGRESS-special-lagnas-native-20261008.md @@ -0,0 +1,37 @@ +# PROGRESS · 特殊上升点改为本仓原生计算(2026-10-08) + +执行分支 `codex/special-lagnas-native-20261008`。工作树从当时的 `origin/staging` `fe833b35` 拉出(任务书写的基线是 `c04786f7`,其后 staging 又有纯文档提交)。状态:待验收。未上线。 + +## 做了什么 + +- 八个特殊上升点在有经纬度时改由 Swiss Ephemeris 计算,`source` 为 `native_special_lagna`。日出用出生地经纬度和请求的岁差,算完恢复原来的岁差。 +- 公式按本仓已引用的出处:P.V.R. Narasimha Rao《Vedic Astrology: An Integrated Approach》§5.2(Bhava 0.25°/分、Hora 0.5°/分、Ghati 1.25°/分、Vighati 15°/分),Sree 为月亮在星宿内剩余度数乘 27 再加上升,Indu 与 Varnada 用 B.V. Raman。没有把「一宫每一 vighati」写成 75°/分。`references/` 里没有 BPHS 第 4 章原文,八点都有上述具名出处,没有写 blocked。 +- 运行时删除 `calculate_pyjhora_special_lagnas`。PyJHora 4.8.7 只离线生成 `tests/fixtures/special_lagnas_pyjhora_487.json`。生成命令:`python scripts/research/freeze_special_lagnas_pyjhora_fixture.py`。 +- 缺坐标时仍用原来的 Bhava / Hora / Ghati 公式。生时校正继续调用这三个函数,算法身份不升。 +- Tajika 的进程内 PyJHora 打分、校正三引擎对照的 PyJHora 列,分别挪到 `scripts/pyjhora_panchavargiya_reference.py` 和 `scripts/pyjhora_rectification_d1_reference.py`。默认全盘不调用它们。 +- 快速门增加 `tests/test_runtime_jhora_import_scan.py`。 + +## 对照 + +三张公开或虚构盘 × Lahiri、Raman × 八个点,相对冻结的 PyJHora 4.8.7,最大绝对差 4.9e-5°。Hora 与 Ghati 度数不同。公开盘 Lahiri 的 Bhava / Hora / Ghati 仍是狮子 17.12°、水瓶 22.58°、处女 8.94°。 + +## 本机验证 + +| 检查 | 结果 | +| --- | --- | +| ruff(本轮改过的 Python 文件) | 通过 | +| py_compile(本轮改过的模块) | 通过 | +| `test_runtime_jhora_import_scan.py`、`test_special_lagnas_birth_instant.py`(不含全盘那条)、`test_rectification_three_engine_packet.py` | 18 通过 | +| 屏蔽 `jhora` 后的 `cmd_full_reading`,加上三引擎包测试 | 5 通过;八点 `computed`,`source` 为 `native_special_lagna` | +| 隐私扫描 `tests/test_repo_privacy_markers.py` | 通过 | + +未跑、不能写成通过:没有 PyJHora 的门禁虚拟环境(本机 Anaconda 装了 PyJHora 4.8.7,测试是屏蔽导入);Linux 全量 pytest 相对 62 条基线;快速门整段(含 npm test 与 turbopack 构建);部署后的 `/api/health`、`/login`、未登录 `/api/account`。 + +## 开工预检 + +`python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` 在本工作树跑完。远端可见性已核对,外部引擎适配器诊断通过,Python 运行时通过。碎片扫描在 90 秒超时,聚焦测试在 45 秒超时。不把这次退出当成通过,也没有改断言或补 `.workbuddy` 镜像。 + +## 偏离 + +- 导入扫描会走到 `jyotish_api_server` 再走到校正三引擎包。那里的 `import_module("jhora...")` 挪进 pyjhora 文件名的参考模块,包函数 `_pyjhora_d1` 仍在原处,现有测试照旧替换它。 +- 全盘年运段仍会按需导入 `annual_pyjhora_replay`(文件名含 pyjhora,扫描不进入)。导入失败时该段记 blocked,不挡八个特殊上升点。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index ac7106d5..c5bd9d0c 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -315,7 +315,7 @@ | `TASK-rectification-probe-supply-research-20260913.md` | — | 研究单:六题后引擎在剩余候选上再出带年月题的四种放宽规则,20 例公开 AA 数据离线量收益,有收益才立实现单 | 已合入(离线测量,不改线上) | `b063c668` | | `TASK-rectification-refresh-r3-r4-20260913.md` | `PROGRESS-rectification-refresh-r3-r4-20260913.md` | 刷新阶段 R3(MIN_BOUNDARY_DAYS 45→30)+ R4(pratyantar 与 D9/D10 上升 Narayana);首轮出题不变。即使放宽仍有 0 题例子,定向补事另线保留(BUG-664/665) | 已实现 `ab1ade59`,已部署;2026-09-13 验收通过(实现单由执行方自拟,无产品决策记录段;收益口径见研究文档) | `codex/rectification-refresh-r3-r4-20260913` | | `TASK-upstream-sync6-20261007.md` | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) | **上游同步第六轮(`23be1807`)**:10-05 五个计算修正一行未合——D5/D6/D8/D11 映射(乔布斯盘四张分盘星座全不同)、Vimsopaka 权重、Yogini 顺序与出生余额、Panchadhikari 宫位参照、Narayana 并列、特殊上升、出生秒数、Neecha Bhanga 两条件、首段大运子运、岁差被覆盖;逐项对照原书核实;最先做 | 已验收(门禁补修 `2df6ee72`,已部署 staging) | [PROGRESS-upstream-sync6-20261007.md](PROGRESS-upstream-sync6-20261007.md) | -| `TASK-special-lagnas-native-20261008.md` | — | **特殊上升点原生计算(线上缺陷)**:sync6 U6 运行时调 PyJHora,生产镜像没有 PyJHora → staging 上 Hora/Ghati/Sree/Indu/Pranapada 全 blocked;原生实现、PyJHora 只作冻结对照、快速门禁止运行时 import jhora;最先做 | 待领取 | — | +| `TASK-special-lagnas-native-20261008.md` | `PROGRESS-special-lagnas-native-20261008.md` | **特殊上升点原生计算(线上缺陷)**:sync6 U6 运行时调 PyJHora,生产镜像没有 PyJHora → staging 上 Hora/Ghati/Sree/Indu/Pranapada 全 blocked;原生实现、PyJHora 只作冻结对照、快速门禁止运行时 import jhora;最先做 | 待验收 | `codex/special-lagnas-native-20261008`(未上线;部署以 `/api/health` 为准) | | `TASK-sync6-gate-red-20261007.md` | — | **sync6 后门禁仍红**:run 3213 快速门已过,「Frontend and database tests」开跑 20 秒后日志中断、15 分钟后判失败,后续步骤 failure 0 秒;疑运行机掉线;先 Re-run,红则本机带 Docker 跑 `CI=1 npm test` 对照 `0ed45a4b` | 已完成(Re-run 通过,BUG-1271,部署 `2df6ee72`) | — | | `TASK-rectification-tie-break-entry-fix-20260913.md` | `PROGRESS-rectification-tie-break-entry-fix-20260913.md` | 修复单:参考题入口只读 `window_scan` 标志位 → 两道答完后仍显示、再点弹「现在没有可答的参考题」;并按产品拍板改成点一次连出 D9+D10(BUG-666~668,Skill 10.0.26) | 已合入(无独立验收记录) | `99127601`(BUG-666~668 resolved;已部署 staging,run 2589) | diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 77d7f3ae..13e269ad 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -15664,7 +15664,7 @@ def cmd_full_reading(args): from special_lagnas import SpecialLagnasCalculator sl_calc = SpecialLagnasCalculator() birth_dt = _birth_datetime_from_args(args) - # 6:00 只在缺坐标时给公式回退。有坐标时 PyJHora 按出生时刻覆盖 Bhava / Hora / Ghati。 + # 6:00 只在缺坐标时给公式回退。有坐标时 Swiss Ephemeris 按出生时刻覆盖八个特殊上升点。 sunrise_dt = datetime(args.year, args.month, args.day, 6, 0) import io from contextlib import redirect_stdout diff --git a/scripts/native_special_lagnas.py b/scripts/native_special_lagnas.py new file mode 100644 index 00000000..2567c247 --- /dev/null +++ b/scripts/native_special_lagnas.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""Birth special lagnas from Swiss Ephemeris. No PyJHora import. + +Numeric rates are the ones named by P.V.R. Narasimha Rao, *Vedic Astrology: +An Integrated Approach*, section 5.2, and by the B.V. Raman methods below. +``references/`` does not contain the BPHS chapter 4 verse text, so a point is +not given a rate that those named sources do not state. + +- Bhava: section 5.2 step (2) divides the sunrise-to-birth minutes by 4 + (0.25 degree per minute) and adds that to the sidereal Sun at sunrise. +- Hora: 0.5 degree per minute, one rasi per hour, from that same sunrise Sun. +- Ghati: 1.25 degrees per minute, one rasi per 24-minute ghati. +- Vighati: 15 degrees per minute. A literal "one rasi per vighati" (24 seconds) + would be 75 degrees per minute; section 5.2's special-ascendant family, as + recorded for this rate, uses 15. That is the frozen comparison rate. +- Sree: ascendant plus the Moon's leftover degrees inside its nakshatra, + times 27 (classical Sri Lagna). +- Indu: B.V. Raman. Kala factors of the 9th lords from Lagna and from the Moon + are 30, 16, 6, 8, 10, 12, 1 for Sun through Saturn. The degree is the Moon's + degree inside its own sign. +- Pranapada: ishta ghatis from sunrise (previous sunrise when birth is earlier), + times 4 signs, plus the birth Sun, plus 8 signs for a fixed Sun sign, 4 for + a dual Sun sign, and 0 for a movable Sun sign. The clock is truncated to + whole seconds before the ghati count, matching the frozen comparison. +- Varnada: B.V. Raman method 1. Odd signs are counted forward from Aries, even + signs backward from Pisces. The returned degree is the ascendant's degree + inside its own sign, placed in the Varnada sign. + +Sunrise is the centre of the solar disc, Hindu rising, no refraction +(Swiss Ephemeris). Sidereal mode is the caller's ayanamsa and is restored. +""" + +from __future__ import annotations + +from contextlib import nullcontext +from datetime import datetime + +SIGNS = [ + "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", +] +SOURCE = "native_special_lagna" +POINT_NAMES = ( + ("Bhava_Lagna", "Bhava Lagna"), + ("Hora_Lagna", "Hora Lagna"), + ("Ghati_Lagna", "Ghati Lagna"), + ("ViGhati_Lagna", "Vighati Lagna"), + ("Sree_Lagna", "Sree Lagna"), + ("Indu_Lagna", "Indu Lagna"), + ("Pranapada_Lagna", "Pranapada Lagna"), + ("Varnada_Lagna", "Varnada Lagna"), +) +# Sun..Saturn lords of Aries..Pisces. Planet index 0 is the Sun. +HOUSE_LORDS = (2, 5, 3, 1, 0, 3, 5, 2, 4, 6, 6, 4) +INDU_KALAS = (30, 16, 6, 8, 10, 12, 1) +ODD_SIGNS = frozenset({0, 2, 4, 6, 8, 10}) +FIXED_SIGNS = frozenset({1, 4, 7, 10}) +DUAL_SIGNS = frozenset({2, 5, 8, 11}) +ONE_ARC_SECOND = 1.0 / 3600.0 +RATES = { + "Bhava_Lagna": 0.25, + "Hora_Lagna": 0.5, + "Ghati_Lagna": 1.25, + "ViGhati_Lagna": 15.0, +} + + +def native_special_lagna_rows( + birth_time: datetime, + lat: float | None, + lon: float | None, + tz_offset: float | None, + asc_degree: float, + ayanamsa: str | None = None, +) -> dict[str, dict]: + """Eight birth points. Missing coordinates block the rows and invent nothing.""" + if lat is None or lon is None or tz_offset is None: + return _blocked("birth_coordinates_or_timezone_missing") + try: + import swisseph as swe + except Exception as exc: + return _blocked(f"swisseph_unavailable:{exc}") + try: + from ayanamsa_utils import temporary_ayanamsa + except ImportError: # package import + from scripts.ayanamsa_utils import temporary_ayanamsa + + context = temporary_ayanamsa(ayanamsa) if ayanamsa else nullcontext() + try: + with context: + rows = _compute(swe, birth_time, float(lat), float(lon), float(tz_offset), float(asc_degree)) + except Exception as exc: + reason = exc.__class__.__name__ + if exc.__class__.__name__ == "UnsupportedAyanamsaError": + reason = f"native_ayanamsa_unsupported:{ayanamsa}" + else: + reason = f"native_special_lagna_failed:{exc}" + return _blocked(reason) + if ayanamsa is not None: + for row in rows.values(): + row["ayanamsa"] = ayanamsa + return rows + + +def _blocked(reason: str) -> dict[str, dict]: + return { + key: {"full_name": full_name, "status": "blocked", "source": SOURCE, "reason": reason} + for key, full_name in POINT_NAMES + } + + +def _compute(swe, birth_time: datetime, lat: float, lon: float, tz_offset: float, asc_degree: float) -> dict[str, dict]: + planet_flags = ( + swe.FLG_SWIEPH | swe.FLG_SIDEREAL | swe.FLG_TRUEPOS + | swe.FLG_NOGDEFL | swe.FLG_NONUT | swe.FLG_SPEED + ) + rise_flags = swe.BIT_HINDU_RISING | swe.BIT_NO_REFRACTION | swe.BIT_DISC_CENTER | swe.CALC_RISE + asc_flags = swe.FLG_SWIEPH | swe.FLG_SIDEREAL | swe.BIT_HINDU_RISING | swe.FLG_TRUEPOS | swe.FLG_SPEED + local_hour = ( + birth_time.hour + + birth_time.minute / 60.0 + + birth_time.second / 3600.0 + + birth_time.microsecond / 3_600_000_000.0 + ) + jd_local = swe.julday(birth_time.year, birth_time.month, birth_time.day, local_hour) + birth_hours = swe.revjul(jd_local, swe.GREG_CAL)[3] + sun_at_sunrise, sunrise_hours = _sun_at_sunrise(swe, jd_local, lat, lon, tz_offset, planet_flags, rise_flags) + minutes = (birth_hours - sunrise_hours) * 60.0 + jd_birth_ut = jd_local - tz_offset / 24.0 + sun_birth = _sidereal(swe, jd_birth_ut, swe.SUN, planet_flags) + moon_birth = _sidereal(swe, jd_birth_ut, swe.MOON, planet_flags) + asc_sign, asc_in_sign = _ascendant(swe, jd_local, lat, lon, tz_offset, asc_flags) + asc_long = (asc_sign * 30.0 + asc_in_sign) % 360.0 + + longitudes = { + key: (sun_at_sunrise + minutes * rate) % 360.0 + for key, rate in RATES.items() + } + longitudes["Sree_Lagna"] = (asc_long + (moon_birth % (360.0 / 27.0)) * 27.0) % 360.0 + longitudes["Indu_Lagna"] = _indu_longitude(asc_sign, moon_birth) + longitudes["Pranapada_Lagna"] = _pranapada_longitude( + swe, jd_local, birth_hours, lat, lon, tz_offset, sun_birth, planet_flags, rise_flags, + ) + varnada_sign = _varnada_sign(asc_sign, _sign_index(longitudes["Hora_Lagna"])) + rows = {} + for key, full_name in POINT_NAMES: + if key == "Varnada_Lagna": + longitude = varnada_sign * 30.0 + (asc_in_sign % 30.0) + elif key == "Indu_Lagna": + longitude = longitudes[key] + else: + longitude = longitudes[key] + rows[key] = _payload(full_name, longitude, asc_degree) + return rows + + +def _sun_at_sunrise(swe, jd_local: float, lat: float, lon: float, tz_offset: float, planet_flags: int, rise_flags: int): + year, month, day, _hour = swe.revjul(jd_local, swe.GREG_CAL) + jd_midnight = swe.julday(int(year), int(month), int(day), 0.0) + _status, times = swe.rise_trans( + jd_midnight - tz_offset / 24.0, + swe.SUN, + rise_flags, + (lon, lat, 0.0), + 0.0, + 0.0, + planet_flags, + ) + rise_jd_ut = times[0] + rise_local = (rise_jd_ut - jd_midnight) * 24.0 + tz_offset + hour, minute, second = _clock_hms(rise_local) + truncated = swe.julday(int(year), int(month), int(day), hour + minute / 60.0 + second / 3600.0) + # Chart helpers treat the sunrise JD as local and subtract the timezone. + sun = _sidereal(swe, truncated, swe.SUN, planet_flags) + return sun, rise_local + + +def _pranapada_longitude(swe, jd_local, birth_hours, lat, lon, tz_offset, sun_birth, planet_flags, rise_flags): + sunrise_hours = _sun_at_sunrise(swe, jd_local, lat, lon, tz_offset, planet_flags, rise_flags)[1] + if birth_hours < sunrise_hours: + sunrise_hours = _sun_at_sunrise(swe, jd_local - 1.0, lat, lon, tz_offset, planet_flags, rise_flags)[1] + elapsed = 24.0 + birth_hours - sunrise_hours + else: + elapsed = birth_hours - sunrise_hours + hours, minutes, seconds = _clock_hms(elapsed) + tharparai = int(hours) * 9000 + int(minutes) * 150 + int(seconds) + birth_signs = ((tharparai / 3600.0) * 4.0) % 12.0 + sun_sign = _sign_index(sun_birth) + if sun_sign in FIXED_SIGNS: + extra = 240.0 + elif sun_sign in DUAL_SIGNS: + extra = 120.0 + else: + extra = 0.0 + return (birth_signs * 30.0 + sun_birth + extra) % 360.0 + + +def _indu_longitude(asc_sign: int, moon_long: float) -> float: + moon_sign = _sign_index(moon_long) + ninth = HOUSE_LORDS[(asc_sign + 8) % 12] + ninth_from_moon = HOUSE_LORDS[(moon_sign + 8) % 12] + kalas = (INDU_KALAS[ninth] + INDU_KALAS[ninth_from_moon]) % 12 + if kalas == 0: + kalas = 12 + indu_sign = (moon_sign + kalas - 1) % 12 + return indu_sign * 30.0 + (moon_long % 30.0) + + +def _varnada_sign(lagna: int, hora_sign: int) -> int: + lagna_odd = lagna in ODD_SIGNS + hora_sign = hora_sign % 12 + hora_odd = hora_sign in ODD_SIGNS + count1 = _count_rasis(0, lagna, 1) if lagna_odd else _count_rasis(11, lagna, -1) + count2 = _count_rasis(0, hora_sign, 1) if hora_odd else _count_rasis(11, hora_sign, -1) + same_parity = (count1 + count2) % 12 + opposite_parity = (max(count1, count2) - min(count1, count2)) % 12 + count = same_parity if hora_odd == lagna_odd else opposite_parity + counted = _count_rasis(1, count, 1) if lagna_odd else _count_rasis(12, count, -1) + return (counted - 1) % 12 + + +def _count_rasis(start: int, end: int, direction: int, total: int = 12) -> int: + return ((total + direction * (end - start)) % total) + 1 + + +def _ascendant(swe, jd_local: float, lat: float, lon: float, tz_offset: float, flags: int): + jd_ut = jd_local - tz_offset / 24.0 + _cusps, ascmc = swe.houses_ex(jd_ut, lat, lon, b"P", flags) + longitude = ascmc[0] % 360.0 + sign = int(longitude // 30) % 12 + return sign, longitude - sign * 30.0 + + +def _sidereal(swe, jd_ut: float, planet: int, flags: int) -> float: + position, _speed = swe.calc_ut(jd_ut, planet, flags) + return position[0] % 360.0 + + +def _sign_index(longitude: float) -> int: + sign, _degree = _dasavarga(longitude) + return sign + + +def _dasavarga(longitude: float) -> tuple[int, float]: + one_sign = 360.0 + fraction = (longitude / one_sign) % 1.0 + sign = int(fraction * 12.0) + degree = (longitude - sign * 30.0) % 30.0 + if int(degree + ONE_ARC_SECOND) == 30: + degree = 0.0 + sign = (sign + 1) % 12 + return sign, degree + + +def _clock_hms(hours: float) -> tuple[int, int, int]: + """Whole hours, minutes, and rounded seconds, matching the frozen oracle clock.""" + day_part = int(hours) + minutes_float = (hours - day_part) * 60.0 + minute = int(minutes_float) + second = round((minutes_float - minute) * 60.0) + if day_part > 23: + day_part = day_part % 24 + elif day_part < 0: + day_part = abs(day_part) % 24 + minute = abs(minute) + second = abs(second) + if second == 60: + minute += 1 + second = 0 + if minute == 60: + day_part += 1 + minute = 0 + return int(day_part), int(minute), int(second) + + +def _payload(full_name: str, longitude: float, asc_degree: float) -> dict: + longitude = longitude % 360.0 + sign, degree = _dasavarga(longitude) + full = sign * 30.0 + degree + return { + "full_name": full_name, + "degree": round(full, 4), + "longitude": round(full, 4), + "sign": SIGNS[sign], + "sign_degree": round(degree, 4), + "house": ((sign - int(asc_degree // 30)) % 12) + 1, + "method": "swiss_ephemeris_special_lagna", + "source": SOURCE, + "status": "computed", + } diff --git a/scripts/pyjhora_panchavargiya_reference.py b/scripts/pyjhora_panchavargiya_reference.py new file mode 100644 index 00000000..ade81b1f --- /dev/null +++ b/scripts/pyjhora_panchavargiya_reference.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Optional in-process Panchavargiya lookup for the year-lord research switch. + +Not on the default full-reading, report, or consultation path. Tajika calls +this only when a score was requested with an annual Julian day. The product +year-lord path leaves that day empty unless +``JYOTISH_YEAR_LORD_PYJHORA_REFERENCE=1``. +""" + +from __future__ import annotations + + +def external_panchavargiya_score( + planet: str, + *, + annual_jd: float, + lat: float, + lon: float, + tz: float, + planet_index: int | None, +) -> dict | None: + del planet + from jhora.horoscope.chart import strength as j_strength + from jhora.panchanga import drik as j_drik + + place = j_drik.Place("annual_year_lord_probe", float(lat), float(lon), float(tz)) + # Annual-chart APIs take the local civil JD. ``annual_jd`` arrives as UT. + scores = j_strength.pancha_vargeeya_bala(float(annual_jd) + float(tz) / 24.0, place) + if planet_index is None: + return None + score = float(scores[planet_index]) + return { + "score": score, + "engine": "pyjhora", + "components": {"pyjhora_pancha_vargeeya_bala": score}, + } diff --git a/scripts/pyjhora_rectification_d1_reference.py b/scripts/pyjhora_rectification_d1_reference.py new file mode 100644 index 00000000..7bbda700 --- /dev/null +++ b/scripts/pyjhora_rectification_d1_reference.py @@ -0,0 +1,35 @@ +"""Optional PyJHora D1 signs for the rectification parity packet. + +The product packet calls this only for the PyJHora column. A missing install +raises, and the packet records that column as blocked. +""" + +from __future__ import annotations + +import importlib +from typing import Any + +SIGNS = ( + "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", +) + + +def pyjhora_d1(case: dict[str, Any]) -> dict[str, str]: + utils = importlib.import_module("jhora.utils") + charts = importlib.import_module("jhora.horoscope.chart.charts") + drik = importlib.import_module("jhora.panchanga.drik") + jd = utils.julian_day_number( + (case["year"], case["month"], case["day"]), + (case["hour"], case["minute"], case.get("second", 0)), + ) + drik.set_ayanamsa_mode("LAHIRI", jd=jd) + place = drik.Place("request-level", case["lat"], case["lon"], case["tz"]) + index_to_planet = { + 0: "Sun", 1: "Moon", 2: "Mars", 3: "Mercury", 4: "Jupiter", 5: "Venus", 6: "Saturn", + } + return { + index_to_planet[body]: SIGNS[int(position[0])] + for body, position in charts.rasi_chart(jd, place) + if body in index_to_planet + } diff --git a/scripts/rectification_three_engine_packet.py b/scripts/rectification_three_engine_packet.py index c5d0a8fa..da266d31 100644 --- a/scripts/rectification_three_engine_packet.py +++ b/scripts/rectification_three_engine_packet.py @@ -27,7 +27,6 @@ ROOT = Path(__file__).resolve().parents[1] JYOTISHGANIT_ROOT = ROOT / "references" / "open_source_sources" / "jyotishganit" JYOTISHGANIT_DATA_DIR = ROOT / ".cache" / "jyotishganit" PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn") -SIGNS = ("Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces") def canonical_case_input(case: dict[str, Any]) -> dict[str, Any]: @@ -46,14 +45,12 @@ def _local_d1(case: dict[str, Any]) -> dict[str, str]: def _pyjhora_d1(case: dict[str, Any]) -> dict[str, str]: - utils = importlib.import_module("jhora.utils") - charts = importlib.import_module("jhora.horoscope.chart.charts") - drik = importlib.import_module("jhora.panchanga.drik") - jd = utils.julian_day_number((case["year"], case["month"], case["day"]), (case["hour"], case["minute"], case.get("second", 0))) - drik.set_ayanamsa_mode("LAHIRI", jd=jd) - place = drik.Place("request-level", case["lat"], case["lon"], case["tz"]) - index_to_planet = {0: "Sun", 1: "Moon", 2: "Mars", 3: "Mercury", 4: "Jupiter", 5: "Venus", 6: "Saturn"} - return {index_to_planet[body]: SIGNS[int(position[0])] for body, position in charts.rasi_chart(jd, place) if body in index_to_planet} + """PyJHora D1 signs. The jhora import stays in the reference module.""" + try: + reference = importlib.import_module("pyjhora_rectification_d1_reference") + except ModuleNotFoundError: + reference = importlib.import_module("scripts.pyjhora_rectification_d1_reference") + return reference.pyjhora_d1(case) def _ensure_jyotishganit_data_dir() -> str: diff --git a/scripts/research/freeze_special_lagnas_pyjhora_fixture.py b/scripts/research/freeze_special_lagnas_pyjhora_fixture.py new file mode 100644 index 00000000..b8cf63ff --- /dev/null +++ b/scripts/research/freeze_special_lagnas_pyjhora_fixture.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Freeze PyJHora 4.8.7 special-lagna longitudes. Offline only. + +Run from the repo root, with a Python that has PyJHora 4.8.7 installed: + + python scripts/research/freeze_special_lagnas_pyjhora_fixture.py + +The runtime calculator does not import this module. Tests read the JSON. +""" + +from __future__ import annotations + +import importlib.metadata +import io +import json +import sys +from contextlib import redirect_stdout +from datetime import datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path[:0] = [str(ROOT), str(ROOT / "scripts")] + +import swisseph as swe # noqa: E402 +from jhora.horoscope.chart import charts # noqa: E402 +from jhora.panchanga import drik # noqa: E402 +from jhora.panchanga.drik import Place # noqa: E402 + +OUT = ROOT / "tests" / "fixtures" / "special_lagnas_pyjhora_487.json" +CASES = { + "steve_jobs_1955_aa": { + "source": "references/public_oracle_cases.json id steve_jobs_1955_aa", + "birth": dict(year=1955, month=2, day=24, hour=19, minute=15, second=0, lat=37.7749, lon=-122.4194, tz=-8), + }, + "barack_obama_1961_aa": { + "source": "references/public_oracle_cases.json id barack_obama_1961_aa", + "birth": dict(year=1961, month=8, day=4, hour=19, minute=24, second=0, lat=21.3069, lon=-157.8583, tz=-10), + }, + "fictional_reader_main": { + "source": "tests/test_report_reader_main.py FICTIONAL", + "birth": dict(year=1991, month=4, day=7, hour=9, minute=15, second=0, lat=30.5728, lon=104.0668, tz=8.0), + }, +} +MODES = {"lahiri": "LAHIRI", "raman": "RAMAN"} +FUNCS = ( + ("Bhava_Lagna", "bhava_lagna"), + ("Hora_Lagna", "hora_lagna"), + ("Ghati_Lagna", "ghati_lagna"), + ("ViGhati_Lagna", "vighati_lagna"), + ("Sree_Lagna", "sree_lagna"), + ("Indu_Lagna", "indu_lagna"), + ("Pranapada_Lagna", "pranapada_lagna"), +) +SIGNS = [ + "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", +] + + +def _point(raw) -> dict: + sign = int(raw[0]) % 12 + degree = float(raw[1]) % 30.0 + return { + "sign": SIGNS[sign], + "sign_index": sign, + "sign_degree": degree, + "longitude": sign * 30.0 + degree, + } + + +def _case(birth: dict, mode: str) -> dict: + local_hour = birth["hour"] + birth["minute"] / 60.0 + birth["second"] / 3600.0 + with redirect_stdout(io.StringIO()): + drik.set_ayanamsa_mode(mode) + jd = swe.julday(birth["year"], birth["month"], birth["day"], local_hour) + place = Place("birth_place", birth["lat"], birth["lon"], birth["tz"]) + points = {key: _point(getattr(drik, name)(jd, place)) for key, name in FUNCS} + raw = charts.varnada_lagna( + (birth["year"], birth["month"], birth["day"]), + (birth["hour"], birth["minute"], birth["second"]), + place, + varnada_method=1, + ) + points["Varnada_Lagna"] = _point(raw) + return points + + +def main() -> None: + version = importlib.metadata.version("PyJHora") + payload = { + "pyjhora_version": version, + "generator": "python scripts/research/freeze_special_lagnas_pyjhora_fixture.py", + "generated_on": datetime.now().strftime("%Y-%m-%d"), + "cases": {}, + } + for case_id, spec in CASES.items(): + payload["cases"][case_id] = { + "source": spec["source"], + "birth": spec["birth"], + "ayanamsa": {name: _case(spec["birth"], mode) for name, mode in MODES.items()}, + } + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(OUT) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_quality_gate.py b/scripts/run_quality_gate.py index 106e6e71..272540d0 100644 --- a/scripts/run_quality_gate.py +++ b/scripts/run_quality_gate.py @@ -128,6 +128,8 @@ CORE_PYTEST_TARGETS = [ "tests/test_vedastro_external_technique_evidence.py", # Self-hosted heading font slices: 6500-character coverage, no overlap, 120 KB cap. "tests/test_serif_font_slices.py", + # Chart, report, and consult entries must not import PyJHora (BUG-1273). + "tests/test_runtime_jhora_import_scan.py", ] RUNTIME_TRUTH_PYTEST_TARGETS = [ diff --git a/scripts/special_lagnas.py b/scripts/special_lagnas.py index 0661963e..e8c2f550 100644 --- a/scripts/special_lagnas.py +++ b/scripts/special_lagnas.py @@ -14,10 +14,6 @@ Special Lagnas Calculator - 特殊上升点计算器 from typing import Dict, Tuple from datetime import datetime, timedelta -import io -from contextlib import redirect_stdout - - def jaimini_special_lagna_view(points): """Project canonical birth points without recalculating approximate aliases.""" aliases = { @@ -51,21 +47,6 @@ def bhava_house_rows(bhava): ] -def _pyjhora_ayanamsa_mode(name): - key = str(name or '').strip().lower().replace('-', '_').replace(' ', '_') - return { - 'lahiri': 'LAHIRI', - 'raman': 'RAMAN', - 'kp': 'KP', - 'krishnamurti': 'KP', - 'fagan_bradley': 'FAGAN', - 'fagan': 'FAGAN', - 'true_citra': 'TRUE_CITRA', - 'true_chitra': 'TRUE_CITRA', - 'true_pushya': 'TRUE_PUSHYA', - }.get(key) - - class SpecialLagnasCalculator: """特殊上升点计算器""" @@ -108,7 +89,13 @@ class SpecialLagnasCalculator: # 由 full reading 用它覆盖这两个占位。 result["Arudha_Lagna"] = {"note": "Computed by jaimini.calc_arudha_padas"} result["Upapada_Lagna"] = {"note": "Computed by jaimini.calc_arudha_padas"} - pyjhora = self.calculate_pyjhora_special_lagnas( + # Rectification keeps calculate_hora_lagna / calculate_ghati_lagna above. + # With coordinates, the eight birth points come from Swiss Ephemeris. + try: + from native_special_lagnas import native_special_lagna_rows + except ImportError: + from scripts.native_special_lagnas import native_special_lagna_rows + native = native_special_lagna_rows( birth_time=birth_time, lat=lat, lon=lon, @@ -118,169 +105,11 @@ class SpecialLagnasCalculator: ) if lat is None or lon is None or tz_offset is None: for key in ("ViGhati_Lagna", "Sree_Lagna", "Indu_Lagna", "Pranapada_Lagna", "Varnada_Lagna"): - result[key] = pyjhora[key] + result[key] = native[key] else: - result.update(pyjhora) + result.update(native) return result - def _format_special_lagna_payload(self, label: str, raw, asc_degree: float, method: str) -> Dict: - if isinstance(raw, dict): - return raw - if isinstance(raw, (list, tuple)) and len(raw) >= 2: - sign_idx = int(raw[0]) % 12 - degree = float(raw[1]) % 30 - longitude = sign_idx * 30 + degree - else: - longitude = float(raw) % 360 - sign_idx = int(longitude // 30) % 12 - degree = longitude % 30 - return { - "full_name": label, - "degree": round(longitude, 4), - "longitude": round(longitude, 4), - "sign": self.SIGNS[sign_idx], - "sign_degree": round(degree, 4), - "house": ((sign_idx - int(asc_degree // 30)) % 12) + 1, - "method": method, - "source": "pyjhora_drik", - "status": "computed", - } - - def calculate_pyjhora_special_lagnas( - self, - birth_time: datetime, - lat: float | None, - lon: float | None, - tz_offset: float | None, - asc_degree: float, - ayanamsa: str | None = None, - ) -> Dict[str, Dict]: - names = ( - ("Bhava_Lagna", "Bhava Lagna"), - ("Hora_Lagna", "Hora Lagna"), - ("Ghati_Lagna", "Ghati Lagna"), - ("ViGhati_Lagna", "Vighati Lagna"), - ("Sree_Lagna", "Sree Lagna"), - ("Indu_Lagna", "Indu Lagna"), - ("Pranapada_Lagna", "Pranapada Lagna"), - ("Varnada_Lagna", "Varnada Lagna"), - ) - blocked = { - key: {"full_name": full_name, "status": "blocked", "source": "pyjhora_drik"} - for key, full_name in names - } - if lat is None or lon is None or tz_offset is None: - for row in blocked.values(): - row["reason"] = "birth_coordinates_or_timezone_missing" - return blocked - producer = ( - ("Bhava_Lagna", "bhava_lagna", "Bhava Lagna"), - ("Hora_Lagna", "hora_lagna", "Hora Lagna"), - ("Ghati_Lagna", "ghati_lagna", "Ghati Lagna"), - ("ViGhati_Lagna", "vighati_lagna", "Vighati Lagna"), - ("Sree_Lagna", "sree_lagna", "Sree Lagna"), - ("Indu_Lagna", "indu_lagna", "Indu Lagna"), - ("Pranapada_Lagna", "pranapada_lagna", "Pranapada Lagna"), - ) - try: - with redirect_stdout(io.StringIO()): - import swisseph as swe - from jhora import const as jhora_const - from jhora.panchanga import drik - from jhora.panchanga.drik import Place - except Exception as exc: - for row in blocked.values(): - row["reason"] = f"pyjhora_unavailable:{exc}" - return blocked - - mode = None - if ayanamsa is not None: - mode = _pyjhora_ayanamsa_mode(ayanamsa) - if mode is None: - for row in blocked.values(): - row["reason"] = f"pyjhora_ayanamsa_unsupported:{ayanamsa}" - return blocked - - previous_pyjhora = getattr(jhora_const, "_DEFAULT_AYANAMSA_MODE", None) - previous_swe = None - apply_ayanamsa = None - try: - from ayanamsa_utils import ACTIVE_AYANAMSA_NAME, apply_ayanamsa as _apply - previous_swe = ACTIVE_AYANAMSA_NAME - apply_ayanamsa = _apply - except Exception: - apply_ayanamsa = None - - results = {} - try: - with redirect_stdout(io.StringIO()): - local_hour = ( - birth_time.hour - + birth_time.minute / 60.0 - + birth_time.second / 3600.0 - + birth_time.microsecond / 3_600_000_000.0 - ) - jd_local = swe.julday(birth_time.year, birth_time.month, birth_time.day, local_hour) - place = Place("birth_place", lat, lon, tz_offset) - if mode is not None: - drik.set_ayanamsa_mode(mode) - for output_key, func_name, full_name in producer: - func = getattr(drik, func_name, None) - if func is None: - results[output_key] = { - "full_name": full_name, - "status": "blocked", - "source": "pyjhora_drik", - "reason": f"pyjhora_missing_function:{func_name}", - } - continue - try: - raw = func(jd_local, place) - results[output_key] = self._format_special_lagna_payload( - full_name, raw, asc_degree, method=f"PyJHora drik.{func_name}", - ) - except Exception as exc: - results[output_key] = { - "full_name": full_name, - "status": "blocked", - "source": "pyjhora_drik", - "reason": str(exc), - } - try: - from jhora.horoscope.chart import charts - if not hasattr(charts, "varnada_lagna"): - raise AttributeError("pyjhora_missing_function:varnada_lagna") - raw = charts.varnada_lagna( - (birth_time.year, birth_time.month, birth_time.day), - (birth_time.hour, birth_time.minute, birth_time.second), - place, - varnada_method=1, - ) - results["Varnada_Lagna"] = self._format_special_lagna_payload( - "Varnada Lagna", raw, asc_degree, "PyJHora varnada method 1", - ) - except Exception as exc: - reason = str(exc) or "pyjhora_missing_function:varnada_lagna" - results["Varnada_Lagna"] = { - "full_name": "Varnada Lagna", - "status": "blocked", - "source": "pyjhora_drik", - "reason": reason, - } - if ayanamsa is not None: - for row in results.values(): - row["ayanamsa"] = ayanamsa - finally: - with redirect_stdout(io.StringIO()): - if previous_pyjhora: - try: - drik.set_ayanamsa_mode(previous_pyjhora) - except Exception: - jhora_const._DEFAULT_AYANAMSA_MODE = previous_pyjhora - if apply_ayanamsa is not None and previous_swe: - apply_ayanamsa(previous_swe) - return results - def calculate_bhava_lagna(self, asc_degree: float, sun_degree: float, moon_degree: float) -> Dict: """ diff --git a/scripts/tajika.py b/scripts/tajika.py index 22edf300..906b3495 100644 --- a/scripts/tajika.py +++ b/scripts/tajika.py @@ -15,6 +15,7 @@ Tajika/Varshaphala年运盘模块 v1.0 from typing import Dict, List, Optional from datetime import datetime, timedelta from decimal import Decimal, ROUND_HALF_UP +import importlib import math import json import os @@ -1403,21 +1404,20 @@ def _external_pyjhora_panchavargiya_score( if annual_jd is None or lat is None or lon is None or tz is None: return None try: - from jhora.panchanga import drik as j_drik - from jhora.horoscope.chart import strength as j_strength - place = j_drik.Place('annual_year_lord_probe', float(lat), float(lon), float(tz)) - # PyJHora annual-chart APIs use the local civil JD for a Place whose - # timezone is already supplied. ``annual_jd`` reaches us as UT. - scores = j_strength.pancha_vargeeya_bala(float(annual_jd) + float(tz) / 24.0, place) - planet_idx = PLANET_TO_INDEX.get(planet) - if planet_idx is None: - return None - score = scores[planet_idx] - return { - 'score': float(score), - 'engine': 'pyjhora', - 'components': {'pyjhora_pancha_vargeeya_bala': float(score)}, - } + # The in-process reference engine lives in a PyJHora adapter module. + # This file does not import it unless a caller actually asks for a score. + try: + reference = importlib.import_module("pyjhora_panchavargiya_reference") + except ModuleNotFoundError: + reference = importlib.import_module("scripts.pyjhora_panchavargiya_reference") + return reference.external_panchavargiya_score( + planet, + annual_jd=float(annual_jd), + lat=float(lat), + lon=float(lon), + tz=float(tz), + planet_index=PLANET_TO_INDEX.get(planet), + ) except Exception: return _external_pyjhora_panchavargiya_score_sidecar(planet, annual_jd, lat, lon, tz, ayanamsa_name) diff --git a/tests/fixtures/special_lagnas_pyjhora_487.json b/tests/fixtures/special_lagnas_pyjhora_487.json new file mode 100644 index 00000000..df48c655 --- /dev/null +++ b/tests/fixtures/special_lagnas_pyjhora_487.json @@ -0,0 +1,355 @@ +{ + "pyjhora_version": "4.8.7", + "generator": "python scripts/research/freeze_special_lagnas_pyjhora_fixture.py", + "generated_on": "2026-10-08", + "cases": { + "steve_jobs_1955_aa": { + "source": "references/public_oracle_cases.json id steve_jobs_1955_aa", + "birth": { + "year": 1955, + "month": 2, + "day": 24, + "hour": 19, + "minute": 15, + "second": 0, + "lat": 37.7749, + "lon": -122.4194, + "tz": -8 + }, + "ayanamsa": { + "lahiri": { + "Bhava_Lagna": { + "sign": "Leo", + "sign_index": 4, + "sign_degree": 17.119787409502464, + "longitude": 137.11978740950246 + }, + "Hora_Lagna": { + "sign": "Aquarius", + "sign_index": 10, + "sign_degree": 22.57519513328691, + "longitude": 322.5751951332869 + }, + "Ghati_Lagna": { + "sign": "Virgo", + "sign_index": 5, + "sign_degree": 8.941418304640138, + "longitude": 158.94141830464014 + }, + "ViGhati_Lagna": { + "sign": "Capricorn", + "sign_index": 9, + "sign_degree": 8.988843112785617, + "longitude": 278.9888431127856 + }, + "Sree_Lagna": { + "sign": "Cancer", + "sign_index": 3, + "sign_degree": 0.8709657052586977, + "longitude": 90.8709657052587 + }, + "Indu_Lagna": { + "sign": "Aquarius", + "sign_index": 10, + "sign_degree": 14.511681812210952, + "longitude": 314.51168181221095 + }, + "Pranapada_Lagna": { + "sign": "Capricorn", + "sign_index": 9, + "sign_degree": 29.151528565147373, + "longitude": 299.1515285651474 + }, + "Varnada_Lagna": { + "sign": "Cancer", + "sign_index": 3, + "sign_degree": 29.05555677556339, + "longitude": 119.05555677556339 + } + }, + "raman": { + "Bhava_Lagna": { + "sign": "Leo", + "sign_index": 4, + "sign_degree": 18.56608878347214, + "longitude": 138.56608878347214 + }, + "Hora_Lagna": { + "sign": "Aquarius", + "sign_index": 10, + "sign_degree": 24.021496507256643, + "longitude": 324.02149650725664 + }, + "Ghati_Lagna": { + "sign": "Virgo", + "sign_index": 5, + "sign_degree": 10.387719678609983, + "longitude": 160.38771967860998 + }, + "ViGhati_Lagna": { + "sign": "Capricorn", + "sign_index": 9, + "sign_degree": 10.435144486755235, + "longitude": 280.43514448675523 + }, + "Sree_Lagna": { + "sign": "Leo", + "sign_index": 4, + "sign_degree": 11.36740417631404, + "longitude": 131.36740417631404 + }, + "Indu_Lagna": { + "sign": "Leo", + "sign_index": 4, + "sign_degree": 15.957983186177216, + "longitude": 135.95798318617722 + }, + "Pranapada_Lagna": { + "sign": "Aquarius", + "sign_index": 10, + "sign_degree": 0.5978299391135806, + "longitude": 300.5978299391136 + }, + "Varnada_Lagna": { + "sign": "Sagittarius", + "sign_index": 8, + "sign_degree": 0.5018581495296246, + "longitude": 240.50185814952962 + } + } + } + }, + "barack_obama_1961_aa": { + "source": "references/public_oracle_cases.json id barack_obama_1961_aa", + "birth": { + "year": 1961, + "month": 8, + "day": 4, + "hour": 19, + "minute": 24, + "second": 0, + "lat": 21.3069, + "lon": -157.8583, + "tz": -10 + }, + "ayanamsa": { + "lahiri": { + "Bhava_Lagna": { + "sign": "Aquarius", + "sign_index": 10, + "sign_degree": 6.8803782487445915, + "longitude": 306.8803782487446 + }, + "Hora_Lagna": { + "sign": "Leo", + "sign_index": 4, + "sign_degree": 25.45288250839053, + "longitude": 145.45288250839053 + }, + "Ghati_Lagna": { + "sign": "Aries", + "sign_index": 0, + "sign_degree": 21.170395287328347, + "longitude": 21.170395287328347 + }, + "ViGhati_Lagna": { + "sign": "Leo", + "sign_index": 4, + "sign_degree": 22.658129567855212, + "longitude": 142.6581295678552 + }, + "Sree_Lagna": { + "sign": "Capricorn", + "sign_index": 9, + "sign_degree": 25.798783888142964, + "longitude": 295.79878388814296 + }, + "Indu_Lagna": { + "sign": "Capricorn", + "sign_index": 9, + "sign_degree": 10.039455365709898, + "longitude": 280.0394553657099 + }, + "Pranapada_Lagna": { + "sign": "Cancer", + "sign_index": 3, + "sign_degree": 29.801856125186973, + "longitude": 119.80185612518697 + }, + "Varnada_Lagna": { + "sign": "Aquarius", + "sign_index": 10, + "sign_degree": 24.73348901397577, + "longitude": 324.73348901397577 + } + }, + "raman": { + "Bhava_Lagna": { + "sign": "Aquarius", + "sign_index": 10, + "sign_degree": 8.326679613187252, + "longitude": 308.32667961318725 + }, + "Hora_Lagna": { + "sign": "Leo", + "sign_index": 4, + "sign_degree": 26.89918387283319, + "longitude": 146.8991838728332 + }, + "Ghati_Lagna": { + "sign": "Aries", + "sign_index": 0, + "sign_degree": 22.616696651771008, + "longitude": 22.616696651771008 + }, + "ViGhati_Lagna": { + "sign": "Leo", + "sign_index": 4, + "sign_degree": 24.104430932296964, + "longitude": 144.10443093229696 + }, + "Sree_Lagna": { + "sign": "Pisces", + "sign_index": 11, + "sign_degree": 6.29522209242748, + "longitude": 336.2952220924275 + }, + "Indu_Lagna": { + "sign": "Capricorn", + "sign_index": 9, + "sign_degree": 11.48575673014863, + "longitude": 281.48575673014864 + }, + "Pranapada_Lagna": { + "sign": "Leo", + "sign_index": 4, + "sign_degree": 1.2481574896256973, + "longitude": 121.2481574896257 + }, + "Varnada_Lagna": { + "sign": "Aquarius", + "sign_index": 10, + "sign_degree": 26.17979037841451, + "longitude": 326.1797903784145 + } + } + } + }, + "fictional_reader_main": { + "source": "tests/test_report_reader_main.py FICTIONAL", + "birth": { + "year": 1991, + "month": 4, + "day": 7, + "hour": 9, + "minute": 15, + "second": 0, + "lat": 30.5728, + "lon": 104.0668, + "tz": 8.0 + }, + "ayanamsa": { + "lahiri": { + "Bhava_Lagna": { + "sign": "Aries", + "sign_index": 0, + "sign_degree": 29.32196728818468, + "longitude": 29.32196728818468 + }, + "Hora_Lagna": { + "sign": "Gemini", + "sign_index": 2, + "sign_degree": 5.418736449707069, + "longitude": 65.41873644970707 + }, + "Ghati_Lagna": { + "sign": "Virgo", + "sign_index": 5, + "sign_degree": 23.709043934274177, + "longitude": 173.70904393427418 + }, + "ViGhati_Lagna": { + "sign": "Pisces", + "sign_index": 11, + "sign_degree": 29.031347818005543, + "longitude": 359.03134781800554 + }, + "Sree_Lagna": { + "sign": "Scorpio", + "sign_index": 7, + "sign_degree": 22.29743005005116, + "longitude": 232.29743005005116 + }, + "Indu_Lagna": { + "sign": "Gemini", + "sign_index": 2, + "sign_degree": 20.498014260250045, + "longitude": 80.49801426025005 + }, + "Pranapada_Lagna": { + "sign": "Cancer", + "sign_index": 3, + "sign_degree": 23.762593993954397, + "longitude": 113.7625939939544 + }, + "Varnada_Lagna": { + "sign": "Leo", + "sign_index": 4, + "sign_degree": 8.851045023300273, + "longitude": 128.8510450233003 + } + }, + "raman": { + "Bhava_Lagna": { + "sign": "Taurus", + "sign_index": 1, + "sign_degree": 0.7682686123160352, + "longitude": 30.768268612316035 + }, + "Hora_Lagna": { + "sign": "Gemini", + "sign_index": 2, + "sign_degree": 6.865037773838424, + "longitude": 66.86503777383842 + }, + "Ghati_Lagna": { + "sign": "Virgo", + "sign_index": 5, + "sign_degree": 25.155345258405532, + "longitude": 175.15534525840553 + }, + "ViGhati_Lagna": { + "sign": "Aries", + "sign_index": 0, + "sign_degree": 0.4776491421371247, + "longitude": 0.4776491421371247 + }, + "Sree_Lagna": { + "sign": "Capricorn", + "sign_index": 9, + "sign_degree": 2.793867125751376, + "longitude": 272.7938671257514 + }, + "Indu_Lagna": { + "sign": "Gemini", + "sign_index": 2, + "sign_degree": 21.944315584382196, + "longitude": 81.9443155843822 + }, + "Pranapada_Lagna": { + "sign": "Cancer", + "sign_index": 3, + "sign_degree": 25.208895318086547, + "longitude": 115.20889531808655 + }, + "Varnada_Lagna": { + "sign": "Leo", + "sign_index": 4, + "sign_degree": 10.297346347432423, + "longitude": 130.29734634743244 + } + } + } + } + } +} diff --git a/tests/test_runtime_jhora_import_scan.py b/tests/test_runtime_jhora_import_scan.py new file mode 100644 index 00000000..9c42c5a7 --- /dev/null +++ b/tests/test_runtime_jhora_import_scan.py @@ -0,0 +1,214 @@ +"""BUG-1273: chart, report, and consult entries must not import PyJHora. + +The walk starts at cmd_full_reading, the report builder, and the consult-card +entries. It follows AST imports and constant import_module strings. Adapter +files whose names contain pyjhora, and scripts/research, stay off the walk. +A string that merely mentions ``from jhora`` is not an import. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +from scripts.run_quality_gate import CORE_PYTEST_TARGETS + +ROOT = Path(__file__).resolve().parents[1] +ENTRIES = ( + "scripts/jyotish_engine.py", + "scripts/professional_report_reference.py", + "scripts/consultation_workflow_service.py", + "scripts/consultation_native_layers.py", +) +SELF = "tests/test_runtime_jhora_import_scan.py" + + +def _is_jhora_module(module: str) -> bool: + return module == "jhora" or module.startswith("jhora.") + + +def _skip_module_name(module: str) -> bool: + leaf = module.rsplit(".", 1)[-1] + if "pyjhora" in leaf or leaf.startswith("jhora"): + return True + return module == "research" or module.startswith(("research.", "scripts.research")) + + +def _resolve_script(root: Path, module: str) -> Path | None: + parts = module.split(".") + if parts[0] == "scripts": + parts = parts[1:] + if not parts: + return None + direct = root.joinpath("scripts", *parts) + python_file = direct.with_suffix(".py") + if python_file.is_file(): + return python_file + init = direct / "__init__.py" + if init.is_file(): + return init + return None + + +def _import_module_argument(node: ast.Call) -> str | None: + func = node.func + if isinstance(func, ast.Name): + name = func.id + elif isinstance(func, ast.Attribute): + name = func.attr + else: + return None + if name != "import_module" or not node.args: + return None + arg = node.args[0] + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + return arg.value + return None + + +def _enqueue_relative(path: Path, node: ast.ImportFrom, enqueue) -> None: + anchor = path.parent + for _ in range(node.level - 1): + anchor = anchor.parent + candidates = [] + if node.module: + candidates.append(anchor.joinpath(*node.module.split("."))) + else: + candidates.extend(anchor / alias.name for alias in node.names) + for candidate in candidates: + python_file = candidate.with_suffix(".py") + if python_file.is_file(): + enqueue(python_file) + elif (candidate / "__init__.py").is_file(): + enqueue(candidate / "__init__.py") + + +def product_jhora_import_hits(root: Path, entries: tuple[str, ...]) -> tuple[list[str], list[str]]: + """Return ``path:line:module`` hits and the product files the walk opened.""" + scripts = (root / "scripts").resolve() + root_resolved = root.resolve() + hits: list[str] = [] + visited: list[str] = [] + seen: set[Path] = set() + queue: list[Path] = [] + + def rel(path: Path) -> str: + return path.resolve().relative_to(root_resolved).as_posix() + + def enqueue(path: Path) -> None: + resolved = path.resolve() + if resolved in seen: + return + try: + script_rel = resolved.relative_to(scripts).as_posix() + except ValueError: + return + if script_rel == "research" or script_rel.startswith("research/"): + return + stem = resolved.stem + if "pyjhora" in stem or stem.startswith("jhora"): + return + seen.add(resolved) + queue.append(resolved) + + def consider(module: str, lineno: int, source_rel: str) -> None: + if _is_jhora_module(module): + hits.append(f"{source_rel}:{lineno}:{module}") + return + if _skip_module_name(module): + return + found = _resolve_script(root, module) + if found is not None: + enqueue(found) + + for entry in entries: + enqueue(root / entry) + + while queue: + path = queue.pop() + source_rel = rel(path) + visited.append(source_rel) + try: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=source_rel) + except (OSError, SyntaxError) as exc: + hits.append(f"{source_rel}:0:{exc.__class__.__name__}") + continue + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + consider(alias.name, node.lineno, source_rel) + elif isinstance(node, ast.ImportFrom): + if node.level: + _enqueue_relative(path, node, enqueue) + continue + if node.module: + consider(node.module, node.lineno, source_rel) + elif isinstance(node, ast.Call): + module = _import_module_argument(node) + if module: + consider(module, node.lineno, source_rel) + return hits, visited + + +def test_product_entries_do_not_import_jhora(): + hits, visited = product_jhora_import_hits(ROOT, ENTRIES) + assert hits == [] + for required in ( + "scripts/jyotish_engine.py", + "scripts/professional_report_reference.py", + "scripts/consultation_workflow_service.py", + "scripts/consultation_native_layers.py", + "scripts/special_lagnas.py", + "scripts/native_special_lagnas.py", + "scripts/tajika.py", + "scripts/pl9_reader_export.py", + ): + assert required in visited + assert all("pyjhora" not in path and "/research/" not in path for path in visited) + + +def test_scanner_is_on_the_quick_gate(): + assert SELF in CORE_PYTEST_TARGETS + + +def test_scanner_flags_jhora_and_skips_adapters_research_and_strings(tmp_path): + scripts = tmp_path / "scripts" + research = scripts / "research" + research.mkdir(parents=True) + (scripts / "entry.py").write_text( + "import helper\n" + "import pyjhora_adapter\n" + "from jhora.panchanga import drik\n" + "import importlib\n" + "importlib.import_module('scripts.clean')\n", + encoding="utf-8", + ) + (scripts / "helper.py").write_text( + 'code = "from jhora.panchanga import drik"\n', + encoding="utf-8", + ) + (scripts / "pyjhora_adapter.py").write_text("import jhora\n", encoding="utf-8") + (scripts / "clean.py").write_text("value = 1\n", encoding="utf-8") + (research / "probe.py").write_text("import jhora\n", encoding="utf-8") + (scripts / "caller.py").write_text( + "import importlib\nimportlib.import_module('jhora.panchanga.drik')\n", + encoding="utf-8", + ) + + hits, visited = product_jhora_import_hits(tmp_path, ("scripts/entry.py", "scripts/caller.py")) + assert "scripts/entry.py:3:jhora.panchanga" in hits + assert "scripts/caller.py:2:jhora.panchanga.drik" in hits + assert not any("helper.py" in hit for hit in hits) + assert "scripts/helper.py" in visited + assert "scripts/clean.py" in visited + assert all("pyjhora_adapter" not in path and "research/" not in path for path in visited) + + +def test_relative_import_of_jhora_string_is_not_required(tmp_path): + scripts = tmp_path / "scripts" + scripts.mkdir() + (scripts / "entry.py").write_text("from . import sibling\n", encoding="utf-8") + (scripts / "sibling.py").write_text("ready = True\n", encoding="utf-8") + hits, visited = product_jhora_import_hits(tmp_path, ("scripts/entry.py",)) + assert hits == [] + assert "scripts/sibling.py" in visited diff --git a/tests/test_special_lagnas_birth_instant.py b/tests/test_special_lagnas_birth_instant.py index b95bcb03..45c7e03a 100644 --- a/tests/test_special_lagnas_birth_instant.py +++ b/tests/test_special_lagnas_birth_instant.py @@ -1,71 +1,171 @@ -"""BUG-1265: special lagnas come from the birth instant, not the identical local formulas.""" +"""BUG-1273: eight birth special lagnas are native, and rectification keeps the old formulas. + +The frozen PyJHora 4.8.7 answers live in tests/fixtures/special_lagnas_pyjhora_487.json. +These tests do not import jhora. The offline generator is +scripts/research/freeze_special_lagnas_pyjhora_fixture.py. +""" from __future__ import annotations +import builtins +import importlib import json +import sys from datetime import datetime from pathlib import Path import pytest -from scripts.ayanamsa_utils import ACTIVE_AYANAMSA_NAME, apply_ayanamsa +import scripts.ayanamsa_utils as ayanamsa_utils +from scripts.ayanamsa_utils import apply_ayanamsa from scripts.special_lagnas import SpecialLagnasCalculator, bhava_house_rows ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "tests" / "fixtures" / "special_lagnas_pyjhora_487.json" +EIGHT = ( + "Bhava_Lagna", + "Hora_Lagna", + "Ghati_Lagna", + "ViGhati_Lagna", + "Sree_Lagna", + "Indu_Lagna", + "Pranapada_Lagna", + "Varnada_Lagna", +) +CASE_IDS = ("steve_jobs_1955_aa", "barack_obama_1961_aa", "fictional_reader_main") -def _jobs_birth(): - payload = json.loads((ROOT / "references" / "public_oracle_cases.json").read_text(encoding="utf-8")) - birth = next(row["birth"] for row in payload["cases"] if row["id"] == "steve_jobs_1955_aa") - return birth +def _fixture(): + payload = json.loads(FIXTURE.read_text(encoding="utf-8")) + assert payload["pyjhora_version"] == "4.8.7" + return payload -def test_public_jobs_hora_and_ghati_differ_and_match_birth_instant(): - birth = _jobs_birth() - apply_ayanamsa("raman") - before = ACTIVE_AYANAMSA_NAME - moment = datetime(birth["year"], birth["month"], birth["day"], birth["hour"], birth["minute"], birth["second"]) - rows = SpecialLagnasCalculator().calculate_all_lagnas( - 0.0, 0.0, 0.0, moment, moment.replace(hour=6), - lat=birth["lat"], lon=birth["lon"], tz_offset=birth["tz"], ayanamsa="lahiri", +def _block_jhora(monkeypatch): + """Fail closed if the runtime path imports PyJHora, including a cached package.""" + real_import = builtins.__import__ + + def guarded(name, globals=None, locals=None, fromlist=(), level=0): + if str(name).split(".", 1)[0] == "jhora": + raise ImportError("jhora blocked") + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", guarded) + real_import_module = importlib.import_module + + def guarded_module(name, package=None): + if str(name).split(".", 1)[0] == "jhora": + raise ImportError("jhora blocked") + return real_import_module(name, package) + + monkeypatch.setattr(importlib, "import_module", guarded_module) + for key in list(sys.modules): + if key == "jhora" or key.startswith("jhora."): + monkeypatch.delitem(sys.modules, key, raising=False) + + +def _rows(birth, ayanamsa): + moment = datetime( + birth["year"], birth["month"], birth["day"], + birth["hour"], birth["minute"], birth["second"], ) + return SpecialLagnasCalculator().calculate_all_lagnas( + 0.0, 0.0, 0.0, moment, moment.replace(hour=6), + lat=birth["lat"], lon=birth["lon"], tz_offset=birth["tz"], ayanamsa=ayanamsa, + ) + + +@pytest.mark.parametrize("case_id", CASE_IDS) +@pytest.mark.parametrize("ayanamsa", ["lahiri", "raman"]) +def test_native_matches_frozen_pyjhora_within_one_hundredth(case_id, ayanamsa, monkeypatch): + _block_jhora(monkeypatch) + spec = _fixture()["cases"][case_id] + before = ayanamsa_utils.ACTIVE_AYANAMSA_NAME + rows = _rows(spec["birth"], ayanamsa) + expected = spec["ayanamsa"][ayanamsa] + for key in EIGHT: + row = rows[key] + assert row["status"] == "computed" + assert row["source"] == "native_special_lagna" + assert "pyjhora" not in row["source"] + assert "pyjhora" not in json.dumps(row).lower() + assert row["sign"] == expected[key]["sign"] + assert row["sign_degree"] == pytest.approx(expected[key]["sign_degree"], abs=0.01) + assert row["degree"] == pytest.approx(expected[key]["longitude"], abs=0.01) + assert row["ayanamsa"] == ayanamsa + assert abs(rows["Hora_Lagna"]["degree"] - rows["Ghati_Lagna"]["degree"]) > 0.01 + assert before == ayanamsa_utils.ACTIVE_AYANAMSA_NAME + + +def test_jobs_lahiri_signs_stay_on_the_birth_instant(monkeypatch): + _block_jhora(monkeypatch) + apply_ayanamsa("raman") + before = ayanamsa_utils.ACTIVE_AYANAMSA_NAME + birth = _fixture()["cases"]["steve_jobs_1955_aa"]["birth"] + rows = _rows(birth, "lahiri") expected = { "Bhava_Lagna": ("Leo", 17.12), "Hora_Lagna": ("Aquarius", 22.58), "Ghati_Lagna": ("Virgo", 8.94), } for key, (sign, degree) in expected.items(): - assert rows[key]["status"] == "computed" assert rows[key]["sign"] == sign assert rows[key]["sign_degree"] == pytest.approx(degree, abs=0.01) - assert rows[key]["source"] == "pyjhora_drik" - assert rows[key]["ayanamsa"] == "lahiri" assert rows["Hora_Lagna"]["sign"] != rows["Ghati_Lagna"]["sign"] - for key in ("Sree_Lagna", "Indu_Lagna", "Pranapada_Lagna", "Varnada_Lagna"): - assert rows[key]["status"] == "computed" - assert rows[key]["sign"] - assert ACTIVE_AYANAMSA_NAME == before + assert before == ayanamsa_utils.ACTIVE_AYANAMSA_NAME -def test_missing_coordinates_block_pyjhora_points_without_inventing_them(): - moment = datetime(1955, 2, 24, 19, 15, 0) - rows = SpecialLagnasCalculator().calculate_all_lagnas(10.0, 20.0, 30.0, moment, moment.replace(hour=6)) - assert rows["Sree_Lagna"]["status"] == "blocked" - assert rows["Sree_Lagna"]["reason"] == "birth_coordinates_or_timezone_missing" - assert rows["Hora_Lagna"]["formula"] +def test_rectification_hora_and_ghati_formulas_stay_on_the_old_identity(): + birth = datetime(2000, 1, 1, 19, 15, 0) + sunrise = birth.replace(hour=6, minute=0, second=0) + calculator = SpecialLagnasCalculator() + hora = calculator.calculate_hora_lagna(10.0, 0.0, birth, sunrise) + ghati = calculator.calculate_ghati_lagna(10.0, birth, sunrise) + assert hora["formula"] == "Asc + (Hora数 × 15°)" + assert ghati["formula"] == "Asc + (Ghati数 × 6°)" + assert hora["degree"] == pytest.approx(208.75, abs=1e-4) + assert ghati["degree"] == pytest.approx(208.75, abs=1e-4) + assert not hasattr(calculator, "calculate_pyjhora_special_lagnas") -def test_missing_pyjhora_function_is_blocked(monkeypatch): - import jhora.panchanga.drik as drik - - monkeypatch.delattr(drik, "pranapada_lagna", raising=False) - birth = _jobs_birth() - moment = datetime(birth["year"], birth["month"], birth["day"], birth["hour"], birth["minute"], birth["second"]) - rows = SpecialLagnasCalculator().calculate_pyjhora_special_lagnas( - moment, birth["lat"], birth["lon"], birth["tz"], 0.0, ayanamsa="lahiri", +def test_missing_coordinates_keep_old_three_and_block_the_other_five(): + moment = datetime(2000, 1, 1, 19, 15, 0) + sunrise = moment.replace(hour=6, minute=0, second=0) + rows = SpecialLagnasCalculator().calculate_all_lagnas( + 10.0, 20.0, 30.0, moment, sunrise, ) - assert rows["Pranapada_Lagna"]["status"] == "blocked" - assert "pranapada_lagna" in rows["Pranapada_Lagna"]["reason"] + assert rows["Bhava_Lagna"]["formula"] == "Asc + (Sun - Moon)" + assert rows["Hora_Lagna"]["formula"] == "Asc + (Hora数 × 15°)" + assert rows["Hora_Lagna"]["degree"] == pytest.approx(208.75, abs=1e-4) + assert rows["Ghati_Lagna"]["formula"] == "Asc + (Ghati数 × 6°)" + for key in ("ViGhati_Lagna", "Sree_Lagna", "Indu_Lagna", "Pranapada_Lagna", "Varnada_Lagna"): + assert rows[key]["status"] == "blocked" + assert rows[key]["reason"] == "birth_coordinates_or_timezone_missing" + assert rows[key]["source"] == "native_special_lagna" + assert "pyjhora" not in rows[key]["source"] + + +def test_full_reading_eight_points_with_jhora_blocked(monkeypatch): + from scripts.jyotish_engine import cmd_full_reading + from scripts.professional_report_reference import _export_args + + _block_jhora(monkeypatch) + for key in list(sys.modules): + if key in { + "special_lagnas", + "scripts.special_lagnas", + "native_special_lagnas", + "scripts.native_special_lagnas", + } or key == "jhora" or key.startswith("jhora."): + monkeypatch.delitem(sys.modules, key, raising=False) + birth = _fixture()["cases"]["steve_jobs_1955_aa"]["birth"] + report = cmd_full_reading(_export_args({**birth, "ayanamsa": "lahiri", "node_mode": "mean"})) + points = report["modules"]["special_lagnas"] + for key in EIGHT: + row = points[key] + assert row["status"] == "computed", key + assert row["source"] == "native_special_lagna" + assert "pyjhora" not in json.dumps(row).lower() def test_bhava_house_rows_drop_summary_rows():