diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 8524ab9d..3f5f0f82 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -16998,3 +16998,19 @@ - 相关记录:BUG-1190 - 复发自:无 - 修复版本:本分支待发布 + +## BUG-1269 | 导入引擎时的岁差引导盖掉调用方已选的模式 + +- 状态:resolved +- 首次发现:2026-10-07 +- 最近更新:2026-10-07 +- 影响面:`scripts/ayanamsa_utils.py`、`scripts/jyotish_engine.py`、`scripts/bhava_chalit.py`、`scripts/cmd_nakshatra_adv.py` +- 用户现象:调用方先选定岁差,再导入引擎,实际计算回到产品默认的 Raman。Bhava Chalit 和星宿命令丢掉请求的岁差与出生秒 +- 触发条件:先 `apply_ayanamsa` 再导入 `jyotish_engine`;或调用 bhava-chalit / nakshatra / transit 时传入非默认岁差 +- 根因:引擎导入时无条件 `apply_ayanamsa(DEFAULT_AYANAMSA_NAME)`。本文件作为 `__main__` 跑过之后,Bhava Chalit 和星宿命令再次按模块名导入,引导会再跑一遍。行运行星计算不读请求的岁差。Bhava Chalit 与星宿命令调用 `compute_chart_data` 时不传 `second` 和 `ayanamsa_name`,宫位时刻也不加秒 +- 修复:`ayanamsa_utils` 增加进程闩 `AYANAMSA_APPLIED_IN_PROCESS`。已有人设过岁差时,导入引导不再覆盖;还没人设过时仍用本仓默认 Raman。行运在计算前应用请求的岁差,并在策略里记下 requested 与 applied。Bhava Chalit 与星宿命令把岁差和出生秒传进星盘,Bhava 的宫位时刻带上秒 +- 验证:`tests/test_ayanamsa_requested_applied.py`。六种岁差(lahiri、raman、krishnamurti、fagan_bradley、true_citra、true_pushya)在本命、行运、Bhava Chalit、星宿命令上 requested 等于 applied。子进程先设 lahiri 再导入引擎,名字仍是 lahiri +- 防复发:上述测试 +- 相关记录:BUG-1265 +- 复发自:无 +- 修复版本:本分支待发布 diff --git a/scripts/ayanamsa_utils.py b/scripts/ayanamsa_utils.py index e904ecf5..a7ae6f11 100644 --- a/scripts/ayanamsa_utils.py +++ b/scripts/ayanamsa_utils.py @@ -45,6 +45,10 @@ AYANAMSA_DISPLAY_NAMES = { DEFAULT_AYANAMSA_NAME = 'raman' ACTIVE_AYANAMSA_NAME = DEFAULT_AYANAMSA_NAME +# Flipped the first time any caller applies a mode. Import bootstraps read this +# so they do not replace a mode that was already selected. This module is +# imported once; jyotish_engine can be loaded a second time as its own module. +AYANAMSA_APPLIED_IN_PROCESS = False class UnsupportedAyanamsaError(ValueError): @@ -92,7 +96,7 @@ def current_ayanamsa_name(args: Any = None) -> str: def apply_ayanamsa(name: Any = None, swe_module: Any = None) -> bool: """Set Swiss Ephemeris sidereal mode explicitly.""" - global ACTIVE_AYANAMSA_NAME + global ACTIVE_AYANAMSA_NAME, AYANAMSA_APPLIED_IN_PROCESS module = swe_module or swe if module is None: return False @@ -102,9 +106,15 @@ def apply_ayanamsa(name: Any = None, swe_module: Any = None) -> bool: except Exception: return False ACTIVE_AYANAMSA_NAME = key + AYANAMSA_APPLIED_IN_PROCESS = True return True +def ayanamsa_already_applied() -> bool: + """True once any caller has applied an ayanamsa in this process.""" + return AYANAMSA_APPLIED_IN_PROCESS + + @contextmanager def temporary_ayanamsa(name: Any = None, swe_module: Any = None) -> Iterator[str]: """Apply a sidereal mode and restore the previous process-wide mode.""" diff --git a/scripts/bhava_chalit.py b/scripts/bhava_chalit.py index 89ee9ca4..54e1738e 100644 --- a/scripts/bhava_chalit.py +++ b/scripts/bhava_chalit.py @@ -473,9 +473,12 @@ def cmd_bhava_chalit(args): if not HAS_SWE: return {"error": "swisseph 未安装, 无法计算"} + second = int(getattr(args, 'second', 0) or 0) chart, asc_idx, jd, ayanamsa = compute_chart_data( args.year, args.month, args.day, args.hour, args.minute, - args.lat, args.lon, args.tz, getattr(args, 'node_mode', 'mean')) + args.lat, args.lon, args.tz, getattr(args, 'node_mode', 'mean'), + second=second, + ayanamsa_name=getattr(args, 'ayanamsa', None)) if chart is None: return {"error": "星盘计算失败"} @@ -490,7 +493,7 @@ def cmd_bhava_chalit(args): asc_lon = chart['ascendant']['degree_raw'] # MC: 从 swisseph 重新获取 import swisseph as swe - hour_decimal = args.hour + args.minute / 60.0 - args.tz + hour_decimal = args.hour + args.minute / 60.0 + second / 3600.0 - args.tz jd_val = swe.julday(args.year, args.month, args.day, hour_decimal) cusps_raw, ascmc = swe.houses(jd_val, args.lat, args.lon, b'A') mc_tropical = ascmc[1] # MC @@ -500,15 +503,22 @@ def cmd_bhava_chalit(args): calc = BhavaChalitCalculator() mode = getattr(args, 'mode', 'compare') + applied_ayanamsa = (chart.get('birth_info') or {}).get('ayanamsa_name') + + def _stamp(payload): + if isinstance(payload, dict): + payload['requested_ayanamsa'] = getattr(args, 'ayanamsa', None) + payload['applied_ayanamsa'] = applied_ayanamsa + return payload if mode == 'boundaries': - return calc.calculate_bhava_boundaries( - asc_lon, mc_lon, house_system, jd_val, args.lat, args.lon) + return _stamp(calc.calculate_bhava_boundaries( + asc_lon, mc_lon, house_system, jd_val, args.lat, args.lon)) elif mode == 'chart': - return calc.get_bhava_chalit_chart( + return _stamp(calc.get_bhava_chalit_chart( planet_lons, asc_lon, mc_lon, house_system, - jd_val, args.lat, args.lon) + jd_val, args.lat, args.lon)) else: # compare - return calc.compare_rashi_vs_bhava( + return _stamp(calc.compare_rashi_vs_bhava( planet_lons, asc_lon, mc_lon, house_system, - jd_val, args.lat, args.lon) + jd_val, args.lat, args.lon)) diff --git a/scripts/cmd_nakshatra_adv.py b/scripts/cmd_nakshatra_adv.py index 18dafddd..6a9643fc 100644 --- a/scripts/cmd_nakshatra_adv.py +++ b/scripts/cmd_nakshatra_adv.py @@ -26,7 +26,9 @@ def _compute_chart_from_args(args: Any) -> tuple: chart, asc_idx, jd, ayanamsa = compute_chart_data( args.year, args.month, args.day, args.hour, args.minute, - args.lat, args.lon, args.tz, getattr(args, 'node_mode', 'mean')) + args.lat, args.lon, args.tz, getattr(args, 'node_mode', 'mean'), + second=int(getattr(args, 'second', 0) or 0), + ayanamsa_name=getattr(args, 'ayanamsa', None)) if chart is None: return None, None, None, None, {}, 0, 0, 0 @@ -66,6 +68,14 @@ def cmd_nakshatra_adv(args: Any) -> Dict[str, Any]: if chart is None: return {"error": "swisseph未安装"} + applied_ayanamsa = (chart.get('birth_info') or {}).get('ayanamsa_name') if isinstance(chart, dict) else None + + def _stamp(payload): + if isinstance(payload, dict): + payload['requested_ayanamsa'] = getattr(args, 'ayanamsa', None) + payload['applied_ayanamsa'] = applied_ayanamsa + return payload + try: from nakshatra_advanced import ( find_nakshatra, calc_all_tara_balas, calc_sub_lord, @@ -85,7 +95,7 @@ def cmd_nakshatra_adv(args: Any) -> Dict[str, Any]: detail[pn] = find_nakshatra(lon) result_dict = {'planets': detail} if mode == 'detail': - return result_dict + return _stamp(result_dict) else: result_dict = {} @@ -93,7 +103,7 @@ def cmd_nakshatra_adv(args: Any) -> Dict[str, Any]: if mode in ('all', 'tara'): tara = calc_all_tara_balas(moon_nak_idx, planet_lons) if mode == 'tara': - return {'tara_bala': tara, 'moon_nakshatra_idx': moon_nak_idx} + return _stamp({'tara_bala': tara, 'moon_nakshatra_idx': moon_nak_idx}) result_dict['tara_bala'] = tara # === mode: 'chandra' (v6.0.22 新增) === @@ -107,7 +117,7 @@ def cmd_nakshatra_adv(args: Any) -> Dict[str, Any]: 'chandra': calc_chandra_bala(moon_sign_idx, p_sign), } if mode == 'chandra': - return {'chandra_bala': chandra, 'moon_sign_idx': moon_sign_idx} + return _stamp({'chandra_bala': chandra, 'moon_sign_idx': moon_sign_idx}) result_dict['chandra_bala'] = chandra # === mode: 'combined' (v6.0.22 新增) === @@ -115,14 +125,14 @@ def cmd_nakshatra_adv(args: Any) -> Dict[str, Any]: moon_sign_idx = int(moon_lon / 30) % 12 combined = calc_tara_chandra_combined(moon_nak_idx, moon_sign_idx, planet_lons) if mode == 'combined': - return {'tara_chandra_combined': combined} + return _stamp({'tara_chandra_combined': combined}) result_dict['tara_chandra_combined'] = combined # === mode: 'sublord' === if mode in ('all', 'sublord'): sublords = {pn: calc_sub_lord(lon) for pn, lon in planet_lons.items()} if mode == 'sublord': - return {'sub_lords': sublords} + return _stamp({'sub_lords': sublords}) result_dict['sub_lords'] = sublords # === mode: 'all' / 'full' — 综合报告 === @@ -130,7 +140,7 @@ def cmd_nakshatra_adv(args: Any) -> Dict[str, Any]: full = nakshatra_full_report(chart) result_dict.update(full) - return result_dict + return _stamp(result_dict) def cmd_nakshatra_dasha(args: Any) -> Dict[str, Any]: @@ -196,7 +206,8 @@ def cmd_nakshatra_dasha(args: Any) -> Dict[str, Any]: ty, tm, td = map(int, transit_date_str.split('-')) transit_jd = swe.julday(ty, tm, td, 12.0 - args.tz) transit_planets, transit_ayanamsa = _calc_sidereal_planets_for_jd( - transit_jd, node_mode=getattr(args, 'node_mode', 'mean'), include_ketu=True) + transit_jd, node_mode=getattr(args, 'node_mode', 'mean'), include_ketu=True, + ayanamsa_name=getattr(args, 'ayanamsa', None)) transit_lons = {} for pn, pd in transit_planets.items(): if isinstance(pd, dict) and 'degree' in pd: @@ -217,7 +228,8 @@ def cmd_nakshatra_dasha(args: Any) -> Dict[str, Any]: ty, tm, td = map(int, transit_date_str.split('-')) transit_jd = swe.julday(ty, tm, td, 12.0 - args.tz) transit_planets, _ = _calc_sidereal_planets_for_jd( - transit_jd, node_mode=getattr(args, 'node_mode', 'mean'), include_ketu=True) + transit_jd, node_mode=getattr(args, 'node_mode', 'mean'), include_ketu=True, + ayanamsa_name=getattr(args, 'ayanamsa', None)) transit_lons = {} for pn, pd in transit_planets.items(): if isinstance(pd, dict) and 'degree' in pd: diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index fd4594d4..b9449dee 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -93,6 +93,7 @@ from ayanamsa_utils import ( DEFAULT_AYANAMSA_NAME, UnsupportedAyanamsaError, apply_ayanamsa, + ayanamsa_already_applied, ayanamsa_display_name, current_ayanamsa_name, ) @@ -145,7 +146,10 @@ load_local_env(ROOT_DIR) try: import swisseph as swe - apply_ayanamsa(DEFAULT_AYANAMSA_NAME, swe) # default Raman sidereal mode; callers pass explicit names + # Default stays Raman. Skip when this process already applied a mode, including + # a second import of this file under the name jyotish_engine. + if not ayanamsa_already_applied(): + apply_ayanamsa(DEFAULT_AYANAMSA_NAME, swe) HAS_SWE = True except ImportError: HAS_SWE = False @@ -772,10 +776,15 @@ def _sidereal_calculation_profile(jd, position_mode): return mode, ephemeris_flags, ayanamsa -def _calc_sidereal_planets_for_jd(jd, node_mode='mean', include_ketu=True): - """使用 Swiss Ephemeris 计算指定 Julian Day 的恒星黄道行星位置。""" +def _calc_sidereal_planets_for_jd(jd, node_mode='mean', include_ketu=True, ayanamsa_name=None): + """使用 Swiss Ephemeris 计算指定 Julian Day 的恒星黄道行星位置。 + + ayanamsa_name 显式指定岁差。省略时沿用进程当前模式。 + """ if not HAS_SWE: return {}, None + if ayanamsa_name: + _apply_ayanamsa(ayanamsa_name) ayanamsa = swe.get_ayanamsa(jd) calc_flags = getattr(swe, 'FLG_SWIEPH', 2) data = {} @@ -12867,11 +12876,16 @@ def cmd_db_stats(args): # ============================================================================ # 8. 过境查询 # ============================================================================ -def _transit_ayanamsa_mixed_review_policy(command_name, *, requested_ayanamsa=None, transit_ayanamsa=None): +def _transit_ayanamsa_mixed_review_policy(command_name, *, requested_ayanamsa=None, transit_ayanamsa=None, + applied_ayanamsa=None): + requested_key = str(requested_ayanamsa).strip().lower().replace('-', '_') if requested_ayanamsa else None + applied_key = str(applied_ayanamsa).strip().lower().replace('-', '_') if applied_ayanamsa else None return { 'status': 'mixed_review', 'command': command_name, 'requested_ayanamsa': requested_ayanamsa, + 'applied_ayanamsa': applied_ayanamsa, + 'same_ayanamsa_transit_parity': bool(requested_key and requested_key == applied_key), 'transit_ayanamsa': round(transit_ayanamsa, 4) if isinstance(transit_ayanamsa, (int, float)) else transit_ayanamsa, 'degree_claim_policy': 'sign_house_month_only_until_same_ayanamsa_transit_parity_closes', 'runtime_note': ( @@ -12891,6 +12905,9 @@ def cmd_transit(args): return {"error": "swisseph未安装,无法计算实时Transit"} swe.set_ephe_path('') + requested_ayanamsa = getattr(args, 'ayanamsa', None) + if requested_ayanamsa: + _apply_ayanamsa(requested_ayanamsa) # --- 参数解析 --- t_year = args.year @@ -12912,7 +12929,8 @@ def cmd_transit(args): # --- 计算行星位置 --- node_mode = getattr(args, 'node_mode', 'mean') - transit_data, ayanamsa = _calc_sidereal_planets_for_jd(jd_mid, node_mode=node_mode, include_ketu=True) + transit_data, ayanamsa = _calc_sidereal_planets_for_jd( + jd_mid, node_mode=node_mode, include_ketu=True, ayanamsa_name=requested_ayanamsa) if target_planets: target_set = set(target_planets) transit_data = {p: d for p, d in transit_data.items() if p in target_set} @@ -12971,7 +12989,8 @@ def cmd_transit(args): 'data_layer': 'true_transit_positions', 'transit_ayanamsa_policy': _transit_ayanamsa_mixed_review_policy( 'transit', - requested_ayanamsa=getattr(args, 'ayanamsa', None), + requested_ayanamsa=requested_ayanamsa, + applied_ayanamsa=_current_ayanamsa_name(args), transit_ayanamsa=ayanamsa, ), 'planets': transit_data, diff --git a/tests/test_ayanamsa_requested_applied.py b/tests/test_ayanamsa_requested_applied.py new file mode 100644 index 00000000..eb57420f --- /dev/null +++ b/tests/test_ayanamsa_requested_applied.py @@ -0,0 +1,92 @@ +"""BUG-1269: import bootstrap must not clobber a chosen ayanamsa, and the +natal, transit, bhava-chalit, and nakshatra paths must apply the requested mode. +""" + +from __future__ import annotations + +import subprocess +import sys +from types import SimpleNamespace + +import pytest + +from scripts.ayanamsa_utils import AYANAMSA_MODES +from scripts.bhava_chalit import cmd_bhava_chalit +from scripts.cmd_nakshatra_adv import cmd_nakshatra_adv +from scripts.jyotish_engine import cmd_transit, compute_chart_data + +MODES = ("lahiri", "raman", "krishnamurti", "fagan_bradley", "true_citra", "true_pushya") + + +def test_mode_names_exist(): + for name in MODES: + assert name in AYANAMSA_MODES + + +def _birth(mode: str, second: int = 0): + return SimpleNamespace( + year=1955, month=2, day=24, hour=19, minute=15, second=second, + lat=37.7749, lon=-122.4194, tz=-8.0, + ayanamsa=mode, node_mode="mean", + mode="chart", house_system="sripati", planet=None, + retrograde_calendar=False, + ) + + +@pytest.mark.parametrize("mode", MODES) +def test_requested_ayanamsa_is_applied_on_four_paths(mode: str): + chart, _asc, _jd, _value = compute_chart_data( + 1955, 2, 24, 19, 15, 37.7749, -122.4194, -8.0, "mean", + second=0, ayanamsa_name=mode, + ) + assert chart["birth_info"]["ayanamsa_name"] == mode + + transit = cmd_transit(SimpleNamespace( + year=2026, month=10, day=5, tz=-8.0, ayanamsa=mode, + node_mode="mean", planet=None, retrograde_calendar=False, + )) + policy = transit["transit_ayanamsa_policy"] + assert policy["requested_ayanamsa"] == mode + assert policy["applied_ayanamsa"] == mode + assert policy["same_ayanamsa_transit_parity"] is True + + bhava = cmd_bhava_chalit(_birth(mode)) + assert bhava["requested_ayanamsa"] == mode + assert bhava["applied_ayanamsa"] == mode + + nak_args = _birth(mode) + nak_args.mode = "detail" + nakshatra = cmd_nakshatra_adv(nak_args) + assert nakshatra["requested_ayanamsa"] == mode + assert nakshatra["applied_ayanamsa"] == mode + + +def test_lahiri_and_raman_longitudes_differ(): + lahiri, *_ = compute_chart_data(1955, 2, 24, 19, 15, 37.7749, -122.4194, -8.0, "mean", ayanamsa_name="lahiri") + raman, *_ = compute_chart_data(1955, 2, 24, 19, 15, 37.7749, -122.4194, -8.0, "mean", ayanamsa_name="raman") + assert lahiri["planets"]["Sun"]["degree_raw"] != pytest.approx(raman["planets"]["Sun"]["degree_raw"], abs=0.2) + + lahiri_t = cmd_transit(SimpleNamespace(year=2026, month=10, day=5, tz=-8.0, ayanamsa="lahiri", node_mode="mean", planet=None, retrograde_calendar=False)) + raman_t = cmd_transit(SimpleNamespace(year=2026, month=10, day=5, tz=-8.0, ayanamsa="raman", node_mode="mean", planet=None, retrograde_calendar=False)) + assert lahiri_t["planets"]["Sun"]["degree_raw"] != pytest.approx(raman_t["planets"]["Sun"]["degree_raw"], abs=0.2) + + +def test_bhava_chalit_uses_birth_second(): + zero = cmd_bhava_chalit(_birth("lahiri", 0)) + later = cmd_bhava_chalit(_birth("lahiri", 40)) + assert abs(later["ascendant_lon"] - zero["ascendant_lon"]) > 0.05 + + +def test_import_bootstrap_does_not_clobber_explicit_mode(): + probe = """ +import sys +sys.path.insert(0, "scripts") +import ayanamsa_utils as au +au.apply_ayanamsa("lahiri") +import jyotish_engine +assert au.ACTIVE_AYANAMSA_NAME == "lahiri", au.ACTIVE_AYANAMSA_NAME +print("OK") +""" + proc = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True, cwd=str(__import__("pathlib").Path(__file__).resolve().parents[1])) + assert proc.returncode == 0, proc.stderr[-2000:] + assert proc.stdout.strip().endswith("OK")