fix(special-lagnas): compute eight birth points without PyJHora
Production images do not install PyJHora, so those eight points were blocked. Swiss Ephemeris now computes them in-process. Rectification keeps the old Hora and Ghati formulas. BUG-1273.
This commit is contained in:
@@ -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)。
|
||||
|
||||
+19
-2
@@ -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`
|
||||
|
||||
@@ -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,不挡八个特殊上升点。
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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 = [
|
||||
|
||||
+9
-180
@@ -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:
|
||||
"""
|
||||
|
||||
+15
-15
@@ -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)
|
||||
|
||||
|
||||
+355
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user