fix(special-lagnas): compute eight birth points without PyJHora
Independent Staging Quality Gate / validate (push) Successful in 19m4s
Independent Staging Quality Gate / publish (push) Successful in 3m51s

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:
jesse-ux
2026-10-08 15:01:44 +08:00
parent fe833b3576
commit 795b8845c7
16 changed files with 1270 additions and 245 deletions
+5
View File
@@ -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
View File
@@ -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,不挡八个特殊上升点。
+1 -1
View File
@@ -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) |
+1 -1
View File
@@ -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
+290
View File
@@ -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
}
+6 -9
View File
@@ -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()
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
}
}
}
}
+214
View File
@@ -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
+137 -37
View File
@@ -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():