From 4b9c019814760bf54359542b36bd7b368d8ff015 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Wed, 7 Oct 2026 16:59:28 +0800 Subject: [PATCH] fix(special-lagnas): compute birth-instant points via PyJHora (BUG-1265, upstream 1918ffaf) --- docs/BUG_HISTORY.md | 16 ++ scripts/jyotish_engine.py | 30 ++- scripts/special_lagnas.py | 231 ++++++++++++++++++++- tests/test_special_lagnas_birth_instant.py | 79 +++++++ 4 files changed, 342 insertions(+), 14 deletions(-) create mode 100644 tests/test_special_lagnas_birth_instant.py diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 5846153a..8524ab9d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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 +- 复发自:无 +- 修复版本:本分支待发布 diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 7e8ca5af..fd4594d4 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -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「从源宫数」不同。 diff --git a/scripts/special_lagnas.py b/scripts/special_lagnas.py index 97eb3b7e..0661963e 100644 --- a/scripts/special_lagnas.py +++ b/scripts/special_lagnas.py @@ -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: """ diff --git a/tests/test_special_lagnas_birth_instant.py b/tests/test_special_lagnas_birth_instant.py new file mode 100644 index 00000000..b95bcb03 --- /dev/null +++ b/tests/test_special_lagnas_birth_instant.py @@ -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]