fix(special-lagnas): compute birth-instant points via PyJHora (BUG-1265, upstream 1918ffaf)

This commit is contained in:
jesse-ux
2026-10-07 16:59:28 +08:00
parent 778cb6389c
commit 4b9c019814
4 changed files with 342 additions and 14 deletions
+16
View File
@@ -16982,3 +16982,19 @@
- 相关记录:BUG-1214
- 复发自:BUG-1214 移植时带入的字段错位;选法结果未改,故不记为选法复发
- 修复版本:本分支待发布
## BUG-1265 | 特殊上升点用同一套公式,Hora 与 Ghati 永远相同
- 状态:resolved
- 首次发现:2026-10-07
- 最近更新:2026-10-07
- 影响面:全盘报告的特殊上升点;`scripts/special_lagnas.py`
- 用户现象:公开盘上 Hora 与 Ghati 落在同一星座同一度数,报告里没有 Sree、Indu、Pranapada、Varnada
- 触发条件:全盘计算特殊上升点
- 根因:Bhava / Hora / Ghati 用「上升加时间差」的本地公式,Hora 的每小时 15 度与 Ghati 的每 24 分钟 6 度代数相同;日出还固定写成当地 6:00。PyJHora 按出生时刻和真实日出分开计算。上游 `1918ffaf` 已把这组点交给 PyJHora,并补 Varnada、Pranapada,同时把岁差传进生产者
- 修复:坐标和时区齐全时,Bhava、Hora、Ghati、Vighati、Sree、Indu、Pranapada 走 PyJHora drik,Varnada 走 method 1。岁差传入生产者,算完恢复进程里原来的岁差。PyJHora 没有对应函数或岁差对不上时,该点返回 blocked 和原因,不编度数。报告「Standard Lagnas」表增加 Pranapada 与 Varnada 两行。生时校正仍调用原来的 Hora / Ghati 公式,不改打分
- 验证:`tests/test_special_lagnas_birth_instant.py`。公开 Jobs 盘 Lahiri:Bhava 狮子 17.12°、Hora 水瓶 22.58°、Ghati 处女 8.94°,Hora 与 Ghati 不同
- 防复发:上述度数断言,以及缺函数时 blocked
- 相关记录:BUG-1190
- 复发自:无
- 修复版本:本分支待发布
+20 -10
View File
@@ -4751,7 +4751,8 @@ def render_pl9_markdown(packet: dict) -> str:
order = (
'Bhava_Lagna', 'Hora_Lagna', 'Ghati_Lagna', 'ViGhati_Lagna',
'Sree_Lagna', 'Indu_Lagna', 'Arudha_Lagna', 'Upapada_Lagna', 'A10_Karma_Pada',
'Sree_Lagna', 'Indu_Lagna', 'Pranapada_Lagna', 'Varnada_Lagna',
'Arudha_Lagna', 'Upapada_Lagna', 'A10_Karma_Pada',
)
out = [
'#### Standard Lagnas(独立计算口径)',
@@ -5214,7 +5215,9 @@ def render_pl9_markdown(packet: dict) -> str:
('Ghati Lagna', 'Ghati_Lagna', '行动能力与社会角色的辅助坐标'),
('ViGhati Lagna', 'ViGhati_Lagna', '行动节奏的辅助坐标'),
('Sree Lagna', 'Sree_Lagna', '资源承接的辅助坐标'),
('Indu Lagna', 'Indu_Lagna', '原始点位可见,但尚未接入稳定财富 producer'),
('Indu Lagna', 'Indu_Lagna', '财富承接的辅助坐标'),
('Pranapada Lagna', 'Pranapada_Lagna', '呼吸点的辅助坐标'),
('Varnada Lagna', 'Varnada_Lagna', '种姓上升的辅助坐标'),
('Arudha Lagna', 'Arudha_Lagna', '公众形象与社会投射的辅助坐标'),
('Upapada Lagna', 'Upapada_Lagna', '关系制度与社会表现的辅助坐标'),
('A10 / Karma Pada', 'A10_Karma_Pada', '职业可见度与结果层的辅助坐标'),
@@ -15563,15 +15566,22 @@ def cmd_full_reading(args):
from special_lagnas import SpecialLagnasCalculator
sl_calc = SpecialLagnasCalculator()
birth_dt = _birth_datetime_from_args(args)
# 简化处理:sunrise 近似为 6:00 当地时间
# 6:00 只在缺坐标时给公式回退。有坐标时 PyJHora 按出生时刻覆盖 Bhava / Hora / Ghati。
sunrise_dt = datetime(args.year, args.month, args.day, 6, 0)
sl_result = sl_calc.calculate_all_lagnas(
asc_degree=asc_deg,
sun_degree=planet_lons.get('Sun', 0),
moon_degree=planet_lons.get('Moon', 0),
birth_time=birth_dt,
sunrise_time=sunrise_dt
)
import io
from contextlib import redirect_stdout
with redirect_stdout(io.StringIO()):
sl_result = sl_calc.calculate_all_lagnas(
asc_degree=asc_deg,
sun_degree=planet_lons.get('Sun', 0),
moon_degree=planet_lons.get('Moon', 0),
birth_time=birth_dt,
sunrise_time=sunrise_dt,
lat=getattr(args, 'lat', None),
lon=getattr(args, 'lon', None),
tz_offset=getattr(args, 'tz', None),
ayanamsa=_current_ayanamsa_name(args),
)
# 补充 Arudha Lagna、A10/Karma Pada 和 Upapada Lagna。
# AL / UL 只有一个来源 jaimini.calc_arudha_padas(BUG-1190,TASK-upstream-sync4 T6):
# special_lagnas 的同名方法 1/7 宫例外「从落点数」,与 jaimini「从源宫数」不同。
+227 -4
View File
@@ -14,6 +14,57 @@ 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 = {
'HL': 'Hora_Lagna',
'GL': 'Ghati_Lagna',
'ViL': 'ViGhati_Lagna',
'PP': 'Pranapada_Lagna',
'VL': 'Varnada_Lagna',
}
return {
alias: dict(points.get(key) or {'status': 'blocked', 'reason': 'birth_point_unavailable'})
for alias, key in aliases.items()
}
def bhava_house_rows(bhava):
"""Return only numbered house records; summaries are not additional houses."""
rows = bhava.get('houses') if isinstance(bhava, dict) else None
if isinstance(rows, list):
return [
dict(row) for row in rows
if isinstance(row, dict) and str(row.get('house')).isdigit() and 1 <= int(row['house']) <= 12
]
mapping = rows if isinstance(rows, dict) else bhava
if not isinstance(mapping, dict):
return []
return [
dict(row, house=int(key))
for key, row in mapping.items()
if str(key).isdigit() and 1 <= int(key) <= 12 and isinstance(row, dict)
]
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:
"""特殊上升点计算器"""
@@ -24,9 +75,11 @@ class SpecialLagnasCalculator:
def __init__(self):
pass
def calculate_all_lagnas(self, asc_degree: float, sun_degree: float,
def calculate_all_lagnas(self, asc_degree: float, sun_degree: float,
moon_degree: float, birth_time: datetime,
sunrise_time: datetime) -> Dict[str, Dict]:
sunrise_time: datetime, lat: float | None = None,
lon: float | None = None, tz_offset: float | None = None,
ayanamsa: str | None = None) -> Dict[str, Dict]:
"""
计算所有特殊上升点
@@ -55,9 +108,179 @@ 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(
birth_time=birth_time,
lat=lat,
lon=lon,
tz_offset=tz_offset,
asc_degree=asc_degree,
ayanamsa=ayanamsa,
)
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]
else:
result.update(pyjhora)
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:
"""
@@ -0,0 +1,79 @@
"""BUG-1265: special lagnas come from the birth instant, not the identical local formulas."""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
import pytest
from scripts.ayanamsa_utils import ACTIVE_AYANAMSA_NAME, apply_ayanamsa
from scripts.special_lagnas import SpecialLagnasCalculator, bhava_house_rows
ROOT = Path(__file__).resolve().parents[1]
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 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",
)
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
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_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",
)
assert rows["Pranapada_Lagna"]["status"] == "blocked"
assert "pranapada_lagna" in rows["Pranapada_Lagna"]["reason"]
def test_bhava_house_rows_drop_summary_rows():
rows = bhava_house_rows({
"houses": [
{"house": 1, "sign": "Aries"},
{"house": "total", "sign": "summary"},
{"house": 12, "sign": "Pisces"},
]
})
assert [row["house"] for row in rows] == [1, 12]