fix(special-lagnas): compute birth-instant points via PyJHora (BUG-1265, upstream 1918ffaf)
This commit is contained in:
@@ -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
@@ -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
@@ -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]
|
||||
Reference in New Issue
Block a user