#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Solar Return (太阳返照盘 / Varshaphala) 计算模块 Jyotish Vedic Astrology Skill — v6.0.18 功能: 1. find_solar_return_ut() — 计算太阳返照精确 UT 时刻 2. calc_solar_return_chart() — 生成完整的太阳返照盘(调用 engine 的 compute_chart_data) 3. solar_return_full_report() — 太阳返照盘完整报告(整合 Muntha / Year Lord / Tajika Yogas) 依赖: - swisseph(高精度天文计算,可选) - 若 swisseph 不可用,使用近似算法(误差 ±1 天以内) """ from functools import lru_cache from typing import Dict, Optional, Tuple, List from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo import math import logging import sys import os from ayanamsa_utils import sidereal_flags # ── swisseph 可用性检测 ───────────────────────────────────────────── try: import swisseph as swe HAS_SWE = True except ImportError: HAS_SWE = False logger = logging.getLogger(__name__) def _gregorian_calendar_flag() -> int: """Return the Gregorian calendar flag across swisseph builds.""" return int(getattr(swe, 'GREG_FLAG', 1)) if HAS_SWE else 1 # ── 常量 ────────────────────────────────────────────────────────── SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo', 'Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces'] SIGN_LORDS = {'Aries':'Mars','Taurus':'Venus','Gemini':'Mercury','Cancer':'Moon', 'Leo':'Sun','Virgo':'Mercury','Libra':'Venus','Scorpio':'Mars', 'Sagittarius':'Jupiter','Capricorn':'Saturn','Aquarius':'Saturn','Pisces':'Jupiter'} # Sun sign lookup table (approximate, valid ~1950-2100, Lahiri ayanamsa) # Maps (month, day_range) → sidereal sign index _SUN_SIGN_LOOKUP = { 1: [(1, 14, 9), (15, 31, 10)], # Jan: Sagittarius → Capricorn 2: [(1, 12, 10), (13, 29, 11)], # Feb: Capricorn → Aquarius 3: [(1, 14, 11), (15, 31, 0)], # Mar: Aquarius → Pisces 4: [(1, 13, 0), (14, 30, 1)], # Apr: Pisces → Aries 5: [(1, 14, 1), (15, 31, 2)], # May: Aries → Taurus 6: [(1, 15, 2), (16, 30, 3)], # Jun: Taurus → Gemini 7: [(1, 16, 3), (17, 31, 4)], # Jul: Gemini → Cancer 8: [(1, 16, 4), (17, 31, 5)], # Aug: Cancer → Leo 9: [(1, 16, 5), (17, 30, 6)], # Sep: Leo → Virgo 10: [(1, 17, 6), (18, 31, 7)], # Oct: Virgo → Libra 11: [(1, 16, 7), (17, 30, 8)], # Nov: Libra → Scorpio 12: [(1, 15, 8), (16, 31, 9)], # Dec: Scorpio → Sagittarius } def _estimate_sun_sign(birth_month: int, birth_day: int) -> int: """从出生月日估算出生太阳星座(查表法,用于无 swisseph 时的近似计算)。 精度 ±1 星座,对 Muntha 计算足够(Muntha 只关心12年周期)。 返回 sign index 0-11。""" entries = _SUN_SIGN_LOOKUP.get(birth_month, [(1, 31, 0)]) for day_start, day_end, sign_idx in entries: if day_start <= birth_day <= day_end: return sign_idx return 0 # fallback def _muntha_from_natal_ascendant(natal_asc_sign_idx: int, completed_years: int) -> Dict: """Advance Muntha from the natal ascendant, one sign per completed year.""" muntha_sign_idx = (int(natal_asc_sign_idx) + int(completed_years)) % 12 return { 'status': 'used', 'muntha_sign_idx': muntha_sign_idx, 'muntha_sign': SIGNS[muntha_sign_idx], 'muntha_lord': SIGN_LORDS[SIGNS[muntha_sign_idx]], 'natal_asc_sign_idx': int(natal_asc_sign_idx), 'natal_asc_sign': SIGNS[int(natal_asc_sign_idx)], 'age': int(completed_years), 'formula': ( f'(natal ascendant {SIGNS[int(natal_asc_sign_idx)]} + ' f'completed years {int(completed_years)}) mod 12 = {SIGNS[muntha_sign_idx]}' ), } # ========================================================================= # 工具函数 # ========================================================================= def _jd_to_datetime(jd_ut: float) -> datetime: """Julian Day (UT) → datetime (UTC)""" # 使用 swisseph 的辅助函数,或手动转换 if HAS_SWE: # swe.revjul 返回 (year, month, day, hour, minute, second) rev = swe.revjul(jd_ut, _gregorian_calendar_flag()) if len(rev) == 4: y, m, d, hour_decimal = rev h = int(hour_decimal) minute_decimal = (hour_decimal - h) * 60.0 mn = int(minute_decimal) s = int(round((minute_decimal - mn) * 60.0)) if s >= 60: s -= 60 mn += 1 if mn >= 60: mn -= 60 h += 1 else: y, m, d, h, mn, s = rev return datetime(y, m, d, int(h), int(mn), int(s)) else: # 近似:JD 2440587.5 = 1970-01-01 00:00:00 UT jd_unix = jd_ut - 2440587.5 ts = int(jd_unix * 86400) return datetime(1970, 1, 1) + timedelta(seconds=ts) def _datetime_to_jd_ut(dt: datetime) -> float: """datetime (UTC) → Julian Day (UT)""" if HAS_SWE: return swe.julday(dt.year, dt.month, dt.day, dt.hour + dt.minute/60.0 + dt.second/3600.0, _gregorian_calendar_flag()) else: # 近似 unix_sec = int((dt - datetime(1970, 1, 1)).total_seconds()) return 2440587.5 + unix_sec / 86400.0 def _get_sun_lon_jd(jd_ut: float, ayanamsa_name: str = 'lahiri') -> Optional[float]: """计算给定 JD (UT) 的太阳恒星黄经(默认 Raman)""" if not HAS_SWE: return None try: res = swe.calc_ut(jd_ut, swe.SUN, sidereal_flags(swe, ayanamsa_name)) return float(res[0][0] % 360.0) except Exception as exc: logger.warning("solar_return._get_sun_lon_jd degraded: %s", type(exc).__name__) logger.debug("solar_return._get_sun_lon_jd degraded", exc_info=True) return None # ========================================================================= # 核心:计算太阳返照精确时刻 # ========================================================================= def find_solar_return_ut( birth_jd_ut: float, birth_sun_lon: float, target_year: int, lat: float = 0.0, lon: float = 0.0, tz: float = 0.0, max_iter: int = 30, tol_deg: float = 0.0003, # ~1 arcsec ayanamsa_name: str = 'raman', ) -> Dict: """ 计算太阳返照(Solar Return)精确 UT 时刻。 参数: birth_jd_ut: 出生时刻 JD (UT) birth_sun_lon: 出生太阳恒星黄经 (0-360) target_year: 目标年份(计算该年的太阳返照) lat, lon, tz: 出生地点(仅用于近似计算,精确计算不需要) max_iter: 最大迭代次数 tol_deg: 收敛精度(度) 返回: dict: { 'jd_ut': float, # 太阳返照时刻 JD (UT) 'dt_ut': datetime, # 太阳返照 UTC 时间 'dt_local': datetime, # 太阳返照本地时间(近似) 'sun_lon': float, # 返照时太阳经度(应≈birth_sun_lon) 'error_deg': float, # 太阳经度误差(度) 'iterations': int, # 实际迭代次数 'method': str, # 'swisseph' or 'approximation' 'note': str, } """ if HAS_SWE: return _find_solar_return_swe(birth_jd_ut, birth_sun_lon, target_year, max_iter, tol_deg, ayanamsa_name) else: return _find_solar_return_approx(birth_jd_ut, birth_sun_lon, target_year, tz) def _find_solar_return_swe( birth_jd_ut: float, birth_sun_lon: float, target_year: int, max_iter: int, tol_deg: float, ayanamsa_name: str = 'raman', ) -> Dict: """使用 swisseph 精确计算太阳返照时刻(Newton 迭代法)""" # 近似起始点:出生日期在目标年份的同一天 birth_dt = _jd_to_datetime(birth_jd_ut) try: approx_dt = datetime(target_year, birth_dt.month, birth_dt.day, 12, 0, 0) except ValueError: # 出生是 2月29日 等特殊情况 approx_dt = datetime(target_year, birth_dt.month, 1, 12, 0, 0) jd_guess = _datetime_to_jd_ut(approx_dt) prev_diff = None for i in range(max_iter): sun_lon = _get_sun_lon_jd(jd_guess, ayanamsa_name=ayanamsa_name) if sun_lon is None: return {'error': 'swisseph calc_ut failed', 'method': 'swisseph_failed'} diff = (sun_lon - birth_sun_lon + 180.0) % 360.0 - 180.0 # -180..180 if abs(diff) < tol_deg: # 收敛 dt_ut = _jd_to_datetime(jd_guess) return { 'jd_ut': jd_guess, 'dt_ut': dt_ut, 'dt_local': dt_ut, # 调用方另行加 tz 'sun_lon': sun_lon, 'error_deg': abs(diff), 'iterations': i + 1, 'method': 'swisseph_newton', 'note': f'Newton迭代 {i+1} 步收敛,误差 {abs(diff)*3600:.1f}"', } # Newton 步:sun 速度 ~1°/天,步长 = -diff(天) # 更精确:用瞬时速度(下一小时的速度) jd_next = jd_guess + 1.0 / 24.0 sun_lon_next = _get_sun_lon_jd(jd_next, ayanamsa_name=ayanamsa_name) if sun_lon_next is not None: speed = (sun_lon_next - sun_lon) * 24.0 # °/day if abs(speed) > 0.01: step = -diff / speed # days else: step = -diff # fallback else: step = -diff jd_guess += step prev_diff = diff # 未收敛:返回当前最佳估计 dt_ut = _jd_to_datetime(jd_guess) sun_lon = _get_sun_lon_jd(jd_guess, ayanamsa_name=ayanamsa_name) return { 'jd_ut': jd_guess, 'dt_ut': dt_ut, 'dt_local': dt_ut, 'sun_lon': sun_lon, 'error_deg': abs((sun_lon - birth_sun_lon + 180) % 360 - 180) if sun_lon else None, 'iterations': max_iter, 'method': 'swisseph_newton_not_converged', 'note': f'Newton迭代 {max_iter} 步未完全收敛,误差较大,请检查', 'warning': True, } def _find_solar_return_approx( birth_jd_ut: float, birth_sun_lon: float, target_year: int, tz: float, timezone_id: str | None = None, ) -> Dict: """ 近似计算太阳返照时间(不使用 swisseph)。 误差:±1 天以内(太阳黄经误差 < 1°)。 """ birth_dt = _jd_to_datetime(birth_jd_ut) try: approx_dt_utc = datetime(target_year, birth_dt.month, birth_dt.day, 12, 0, 0) except ValueError: approx_dt_utc = datetime(target_year, birth_dt.month, 1, 12, 0, 0) dt_local = _localize_return_datetime(approx_dt_utc, tz, timezone_id).replace(tzinfo=None) return { 'jd_ut': _datetime_to_jd_ut(approx_dt_utc), 'dt_ut': approx_dt_utc, 'dt_local': dt_local, 'sun_lon': None, # 未知(没有 swisseph) 'error_deg': None, 'iterations': 0, 'method': 'approximation_no_swe', 'note': '近似算法(无swisseph),太阳返照时间误差约±1天,太阳经度未知;建议安装swisseph以获精确结果', 'warning': True, } # ========================================================================= # 生成完整太阳返照盘 # ========================================================================= def _localize_return_datetime(dt_ut, utc_offset, timezone_id=None): """Convert the return instant using the zone's offset at that instant. With an IANA ``timezone_id`` the offset is taken from the zone rules at the return instant (DST-aware). Without one, the fixed ``utc_offset`` is used. Upstream yinduzhanxing 41d1c740. """ zone = ZoneInfo(timezone_id) if timezone_id else timezone(timedelta(hours=float(utc_offset))) return dt_ut.replace(tzinfo=timezone.utc).astimezone(zone) def calc_solar_return_chart( birth_year: int, birth_month: int, birth_day: int, birth_hour: int, birth_minute: int, birth_lat: float, birth_lon: float, birth_tz: float, target_year: int, ayanamsa_name: str = 'raman', local_timezone_id: str | None = None, return_lat: float | None = None, return_lon: float | None = None, return_tz: float | None = None, birth_second: int = 0, node_mode: str = 'mean', ) -> Dict: """ 计算太阳返照盘(Varshaphala)。 工作流程: 1. 计算精确太阳返照时刻(find_solar_return_ut) 2. 以返照时刻调用 compute_chart_data() 生成星盘 3. 返回返照盘数据(planet_lons, houses, ascendant 等) 参数: birth_*: 出生信息 target_year: 目标年份(计算该年太阳返照) node_mode: 年盘罗睺 / 计都口径(mean / true),默认 mean(BUG-1501) 返回: dict: { 'solar_return': {...}, # find_solar_return_ut 的结果 'chart': {...}, # compute_chart_data 的返回值 'planet_lons': dict, # 返照盘行星经度 'houses': dict, # 返照盘宫位 'ascendant': dict, # 返照盘上升 'note': str, } """ # Step 1: 获取出生太阳经度(需要先计算出生盘) # 但 compute_chart_data 需要 swisseph,这里假设调用方已算好 birth_sun_lon # → 改为由调用方传入 birth_sun_lon,或从出生盘数据里取 # 这里我们先做:调用 engine 的 compute_chart_data 算出生盘 try: if (return_lat is None) != (return_lon is None): raise ValueError('return_location_incomplete') if return_lat is not None and return_tz is None and not local_timezone_id: raise ValueError('return_location_timezone_missing') annual_lat = float(birth_lat if return_lat is None else return_lat) annual_lon = float(birth_lon if return_lon is None else return_lon) annual_tz = float(birth_tz if return_tz is None else return_tz) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from jyotish_engine import compute_chart_data, HAS_SWE as ENGINE_HAS_SWE if not ENGINE_HAS_SWE: return { 'error': 'swisseph未安装,无法计算星盘', 'hint': '安装swisseph: pip install swisseph', 'solar_return_approx': _find_solar_return_approx( _datetime_to_jd_ut(datetime(birth_year, birth_month, birth_day, birth_hour, birth_minute, birth_second)), 0, # birth_sun_lon unknown target_year, annual_tz, timezone_id=local_timezone_id, ), } # 算出出生盘的太阳经度 birth_chart, birth_asc_idx, birth_jd, _ = compute_chart_data( birth_year, birth_month, birth_day, birth_hour, birth_minute, birth_lat, birth_lon, birth_tz, node_mode, second=birth_second, ayanamsa_name=ayanamsa_name, ) if birth_chart is None: return {'error': '出生盘计算失败(swisseph问题)'} sun_data = birth_chart.get('planets', {}).get('Sun', {}) birth_sun_lon = sun_data.get('degree_raw', sun_data.get('degree', 0)) # Natal Moon for Mudda Dasha (BUG-1216: first lord from the natal # Moon nakshatra, astrologer round-5 Q7). moon_data = birth_chart.get('planets', {}).get('Moon', {}) birth_moon_lon = moon_data.get('degree_raw', moon_data.get('degree')) if isinstance(moon_data, dict) else None birth_jd_ut = birth_jd # BUG-1220: the target longitude must come from the same frame the # solver iterates in (`_get_sun_lon_jd`, swe sidereal flags). The chart's # Sun differs by up to ~17" (about 7 minutes of return time). Upstream # yinduzhanxing 7cc6425d. solver_frame_sun = _get_sun_lon_jd(birth_jd_ut, ayanamsa_name=ayanamsa_name) target_sun_frame = 'swe_sidereal_flags' if solver_frame_sun is None: target_sun_frame = 'birth_chart_degree_raw' else: birth_sun_lon = solver_frame_sun # Step 2: 计算太阳返照精确时刻 sr_result = find_solar_return_ut( birth_jd_ut, birth_sun_lon, target_year, birth_lat, birth_lon, birth_tz, ayanamsa_name=ayanamsa_name, ) if 'error' in sr_result: return {'error': sr_result['error'], 'solar_return': sr_result} sr_result['target_sun_frame'] = target_sun_frame sr_jd_ut = sr_result['jd_ut'] sr_dt_ut = sr_result['dt_ut'] # 返照时刻本地化:按返照瞬间的时区偏移(IANA 区支持夏令时),而不是 # 把 UT 直接当本地时间(旧行为 dt_local == dt_ut)。 local_dt = _localize_return_datetime(sr_dt_ut, annual_tz, local_timezone_id) annual_tz = local_dt.utcoffset().total_seconds() / 3600 sr_result['dt_local'] = local_dt.replace(tzinfo=None) sr_result['annual_location'] = {'latitude': annual_lat, 'longitude': annual_lon, 'utc_offset': annual_tz, 'timezone_id': local_timezone_id} # Step 3: 以返照时刻计算星盘 # Keep the solver's seconds (BUG-1220, upstream 46ed9399): truncating # to the minute moves the annual Ascendant by up to ~0.25 deg. sr_chart, sr_asc_idx, sr_jd, sr_ayanamsa = compute_chart_data( sr_dt_ut.year, sr_dt_ut.month, sr_dt_ut.day, sr_dt_ut.hour, sr_dt_ut.minute, annual_lat, annual_lon, 0, # UT 时间,时区=0 node_mode, # BUG-1501: was always 'mean' second=sr_dt_ut.second, ayanamsa_name=ayanamsa_name, ) if sr_chart is None: return {'error': '返照盘计算失败', 'solar_return': sr_result} # 提取行星经度 planet_lons = {} for pn, pd in sr_chart.get('planets', {}).items(): if isinstance(pd, dict) and 'degree' in pd: planet_lons[pn] = pd['degree'] houses = sr_chart.get('houses', {}) ascendant = sr_chart.get('ascendant', {}) return { 'solar_return': sr_result, 'chart': sr_chart, 'planet_lons': planet_lons, 'houses': houses, 'ascendant': ascendant, 'asc_sign_idx': sr_asc_idx, 'birth_asc_sign_idx': birth_asc_idx, 'birth_moon_longitude': birth_moon_lon, # 占星师第三轮审查 2026-11-07:下一次真实返照用同一目标经度与求解器。 'birth_jd_ut': birth_jd_ut, 'target_sun_longitude': birth_sun_lon, 'target_year': target_year, 'birth_year': birth_year, 'annual_location': {'latitude': annual_lat, 'longitude': annual_lon, 'utc_offset': annual_tz, 'timezone_id': local_timezone_id}, 'age': target_year - birth_year, 'node_mode': node_mode, 'note': f'太阳返照盘 {target_year} 年(年龄 {target_year-birth_year} 岁)', } except Exception as e: return {'error': f'calc_solar_return_chart异常: {e}'} # ========================================================================= # 完整报告:太阳返照盘 + Muntha + Year Lord + Tajika Yogas # ========================================================================= def solar_return_full_report( birth_year: int, birth_month: int, birth_day: int, birth_hour: int, birth_minute: int, birth_lat: float, birth_lon: float, birth_tz: float, target_year: int, ayanamsa_name: str = 'raman', local_timezone_id: str | None = None, return_lat: float | None = None, return_lon: float | None = None, return_tz: float | None = None, birth_second: int = 0, node_mode: str = 'mean', ) -> Dict: """ 太阳返照盘完整报告(Varshaphala 年运分析)。 整合: - 太阳返照盘计算 - Muntha(年度主星) - Year Lord(年守护星) - Tajika Yogas(Ithasala/Easarapha 等) - Sahams(特殊点) - Tri-Pataka(三旗评估) - Mudda Dasha(年内大运) v6.0.19 修复:swisseph 不可用时不再提前退出, 而是用查表法估算太阳星座,继续计算 Muntha 和 Year Lord。 """ result = {'target_year': target_year} age = target_year - birth_year degraded = False # True when swisseph unavailable # 1. 计算太阳返照盘 sr = calc_solar_return_chart( birth_year, birth_month, birth_day, birth_hour, birth_minute, birth_lat, birth_lon, birth_tz, target_year, ayanamsa_name=ayanamsa_name, local_timezone_id=local_timezone_id, return_lat=return_lat, return_lon=return_lon, return_tz=return_tz, birth_second=birth_second, node_mode=node_mode, ) if 'error' in sr: degraded = True result['error'] = sr['error'] # Extract approximate solar return data if available sr_approx = sr.get('solar_return_approx', {}) result['solar_return'] = sr_approx if sr_approx else sr # Estimate sun sign from birth date for Muntha calculation approx_sun_sign = _estimate_sun_sign(birth_month, birth_day) planet_lons = {'Sun': approx_sun_sign * 30 + 15} # mid-sign for computations asc_sign_idx = None result['sr_chart_info'] = { 'asc_sign': '未知(无swisseph)', 'asc_sign_idx': None, 'age': age, 'note': 'swisseph未安装,Muntha使用近似太阳星座(查表法,±1星座)', } else: result['solar_return'] = sr['solar_return'] result['annual_location'] = dict(sr.get('annual_location') or {}) result['node_mode'] = sr.get('node_mode', node_mode) result['sr_chart_info'] = { 'asc_sign': SIGNS[sr['asc_sign_idx']], 'asc_sign_idx': sr['asc_sign_idx'], 'age': sr['age'], } planet_lons = sr['planet_lons'] try: from annual_native.annual_houses import build_annual_whole_sign_houses from annual_native.tripataki_chakra import build_tripataki_chakra except ImportError: from scripts.annual_native.annual_houses import build_annual_whole_sign_houses from scripts.annual_native.tripataki_chakra import build_tripataki_chakra annual_chart = sr.get('chart') if isinstance(sr.get('chart'), dict) else {} result['annual_houses'] = build_annual_whole_sign_houses(annual_chart) raw_longitudes = {} for name, data in (annual_chart.get('planets') or {}).items(): if isinstance(data, dict) and isinstance(data.get('degree_raw'), (int, float)) and not isinstance(data.get('degree_raw'), bool): raw_longitudes[name] = data['degree_raw'] ascendant = annual_chart.get('ascendant') if isinstance(annual_chart.get('ascendant'), dict) else {} result['tripataki_chakra'] = build_tripataki_chakra(raw_longitudes, ascendant.get('degree_raw')) sr_planets = sr.get('chart', {}).get('planets', {}) tajika_planets = { pn: { 'longitude': pd.get('degree_raw', pd.get('degree')), 'speed': pd.get('speed'), } for pn, pd in sr_planets.items() if pn in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn') and isinstance(pd, dict) and pd.get('degree_raw', pd.get('degree')) is not None and pd.get('speed') is not None } asc_sign_idx = sr['asc_sign_idx'] # 2. Muntha advances from the natal ascendant, not the solar sign. try: natal_asc = sr.get('birth_asc_sign_idx') if not degraded else None if natal_asc is None: result['muntha'] = { 'status': 'blocked', 'reason': 'natal_ascendant_required_for_muntha', } else: result['muntha'] = _muntha_from_natal_ascendant(natal_asc, age) result['muntha']['interpretation'] = _interpret_muntha_sign(result['muntha']['muntha_sign_idx']) except Exception as e: result['muntha'] = {'error': str(e)} # 3. Year Lord:Panchadhikari 选法(占星师第四轮问 3,BUG-1214)。 # 与 cmd_tajika 共用 tajika_year_lord.select_annual_year_lord;年盘不全时 # 返回 blocked 与原因,不用 Muntha 主星顶替(BUG-1223)。 try: muntha_data = result.get('muntha', {}) muntha_sign_val = muntha_data.get('muntha_sign_idx') if isinstance(muntha_data, dict) else None from tajika_year_lord import blocked_year_lord, select_annual_year_lord if not isinstance(muntha_sign_val, int) or isinstance(muntha_sign_val, bool): result['year_lord'] = blocked_year_lord('muntha_sign_required_for_year_lord') else: result['year_lord'] = select_annual_year_lord( sr, natal_asc_sign_idx=sr['birth_asc_sign_idx'], completed_years=age, ayanamsa_name=ayanamsa_name, birth_lat=birth_lat, birth_lon=birth_lon, birth_tz=birth_tz, ) except Exception as e: result['year_lord'] = {'error': str(e)} # 4. Tajika Yogas(需完整星盘,降级模式跳过) if degraded: result['tajika_yogas'] = {'error': 'swisseph未安装,无法计算Tajika Yogas(需完整星盘)'} else: try: from tajika import calc_tajika_yogas yogas = calc_tajika_yogas(tajika_planets, chart_type='varsha') result['tajika_yogas'] = yogas except Exception as e: result['tajika_yogas'] = {'error': str(e)} # 5. Sahams(需完整星盘,降级模式跳过) # The annual API returns this report directly, unlike the enriched annual # pack. Expose existing structural evidence separately from the base # kernel's deliberately narrower support list. No event verdict is added. if degraded: result['tajika_named_yoga_structures'] = { 'status': 'blocked', 'reason': 'annual_motion_inputs_unavailable', 'rows': [], 'claim_boundary': 'no_event_verdict', } else: try: try: from tajika_named_yoga_authority import build_tajika_named_yoga_authority except ImportError: from scripts.tajika_named_yoga_authority import build_tajika_named_yoga_authority result['tajika_named_yoga_structures'] = build_tajika_named_yoga_authority(tajika_planets) except Exception as exc: result['tajika_named_yoga_structures'] = { 'status': 'blocked', 'reason': str(exc), 'rows': [], 'claim_boundary': 'no_event_verdict', } if degraded: result['sahams'] = {'error': 'swisseph未安装,无法计算Sahams(需完整星盘)'} else: try: from tajika import calc_all_sahams asc_lon = sr['ascendant'].get('lon', sr['ascendant'].get('degree', 0)) sr_dt_local = sr['solar_return'].get('dt_local') if sr_dt_local is None: raise ValueError('solar_return_local_datetime_required_for_saham_daynight') sahams = calc_all_sahams( planet_lons, asc_lon, sr_dt_local, chart_type='varsha', lat=sr.get('annual_location', {}).get('latitude', birth_lat), lon=sr.get('annual_location', {}).get('longitude', birth_lon), tz=sr.get('annual_location', {}).get('utc_offset', birth_tz), ) result['sahams'] = sahams except Exception as e: result['sahams'] = {'error': str(e)} # 6. Tri-Pataka(需完整星盘 + year_lord + muntha,降级模式跳过) if degraded: result['tri_pataka'] = {'error': 'swisseph未安装,无法计算Tri-Pataka(需完整星盘)'} else: try: from tajika import calc_tri_pataka yl_data = result.get('year_lord', {}) yl = yl_data.get('year_lord') if isinstance(yl_data, dict) else None muntha_data = result.get('muntha', {}) ms = muntha_data.get('muntha_sign_idx', 0) if yl: result['tri_pataka'] = calc_tri_pataka(planet_lons, yl, ms) else: # BUG-1223: no year lord -> no Tri-Pataka (no stand-in planet). result['tri_pataka'] = {'status': 'blocked', 'reason': 'year_lord_blocked'} except Exception as e: result['tri_pataka'] = {'error': str(e)} # 7. Mudda Dasha:出生月宿 + 已满年数起运,与年主无关(占星师第五轮问 7, # BUG-1216)。与 cmd_tajika 共用 tajika_mudda.annual_mudda_dasha。降级模式跳过。 if degraded: result['mudda_dasha'] = {'error': 'swisseph未安装,无法计算Mudda Dasha(需完整星盘)'} else: try: from tajika_mudda import annual_mudda_dasha # BUG-1722: the report covers this actual return -> the next actual return; # the Mudda timeline itself stays whole (raw_periods). result['mudda_dasha'] = annual_mudda_dasha( annual_start=sr['solar_return'].get('dt_local'), completed_years=age, natal_moon_longitude=sr.get('birth_moon_longitude'), coverage_end=next_return_local(sr, target_year, ayanamsa_name), # 第五轮裁定 2: actual-elapsed-time balance as a separate comparison. birth_jd_ut=sr.get('birth_jd_ut'), ayanamsa_name=ayanamsa_name, ) except Exception as e: result['mudda_dasha'] = {'error': str(e)} # 8. Harsha / Panchavargiya Bala(年度强度层) try: from tajika import calc_tajika_strength_layers yl_data = result.get('year_lord', {}) yl = yl_data.get('year_lord', '') # 产品 2026-11-07 与占星师上游年运同步:Harsha 第四项按年度开始于白天 / 夜间, # 与年主选法同一判定(上游 solar_return 同样取 year_lord.is_daytime)。 is_daytime = yl_data.get('is_daytime') if isinstance(yl_data, dict) else None if degraded: asc_lon = float((asc_sign_idx or 0) * 30) else: asc = sr.get('ascendant', {}) asc_lon = asc.get('lon', asc.get('degree', float((asc_sign_idx or 0) * 30))) result['tajika_strength'] = calc_tajika_strength_layers( planet_lons, asc_lon=asc_lon, year_lord=yl, is_daytime=is_daytime if isinstance(is_daytime, bool) else None, ) except Exception as e: result['tajika_strength'] = {'error': str(e)} # 8b. 年界三种区间分开(占星师第三轮审查 2026-11-07):真实返照区间(本次 → 下一次 # 真实返照)、大运算法年长(Mudda 365.25 天)、展示截断窗口;Mudda 段连续性检查。 if not degraded: try: result['annual_interval'] = _annual_interval(sr, result.get('mudda_dasha'), target_year, ayanamsa_name) except Exception as e: # noqa: BLE001 - reported on the field, the report continues result['annual_interval'] = {'status': 'blocked', 'reason': f'{type(e).__name__}: {e}'} # 9. 综合总结 result['summary'] = _make_sr_summary(result) # Keep the longitudes already calculated above so the report pack can print # them. This does not recompute the return. Do not attach `chart` here: a # `chart` key switches annual_tajika_pack._tajika_yogas_field onto the named- # yoga authority surface and empties the chat annual card's Tajika aspects. # The report reads that surface from `tajika_named_yogas` instead. if not degraded: result['planet_lons'] = { name: float(value) for name, value in planet_lons.items() if isinstance(value, (int, float)) and not isinstance(value, bool) } chart = sr.get('chart') if isinstance(sr, dict) else None if isinstance(chart, dict): result['sr_chart'] = chart return result def _next_return(sr: Dict, target_year: int, ayanamsa_name: str) -> Dict: """The next actual solar return (same target longitude and solver).""" birth_jd, target_lon = sr.get('birth_jd_ut'), sr.get('target_sun_longitude') if birth_jd is None or target_lon is None: return {'error': 'birth_jd_or_target_longitude_missing'} return dict(_next_return_cached(float(birth_jd), float(target_lon), int(target_year) + 1, ayanamsa_name)) @lru_cache(maxsize=64) def _next_return_cached(birth_jd: float, target_lon: float, year: int, ayanamsa_name: str) -> Dict: return find_solar_return_ut(birth_jd, target_lon, year, ayanamsa_name=ayanamsa_name) def next_return_local(sr: Dict, target_year: int, ayanamsa_name: str) -> Optional[datetime]: """Next actual return as naive local wall time in the offset of *this* return instant. Same clock as ``solar_return.dt_local`` and the Mudda rows (BUG-1722); None when unknown. """ try: this = (sr or {}).get('solar_return') or {} location = sr.get('annual_location') or {} nxt = _next_return(sr, target_year, ayanamsa_name) if not isinstance(this.get('dt_ut'), datetime) or 'error' in nxt or not isinstance(nxt.get('dt_ut'), datetime): return None offset, zone_id = float(location.get('utc_offset') or 0.0), location.get('timezone_id') local_offset = _localize_return_datetime(this['dt_ut'], offset, zone_id).utcoffset() return nxt['dt_ut'].replace(tzinfo=None) + local_offset except Exception: # noqa: BLE001 - coverage falls back to the nominal year, labelled return None def _annual_interval(sr: Dict, mudda: Optional[Dict], target_year: int, ayanamsa_name: str) -> Dict: """This return → next actual return, the dasha year length, and the shown window. Mudda keeps its own 365.25-day year (BUG-1216); the real interval between two sidereal solar returns is about 365.256 days and varies, so the two ends differ by minutes. Both are exposed; neither is stretched to the other. """ try: from chart_dasha.timeline_boundaries import BOUNDARY_RULE, BOUNDARY_RULE_TEXT_ZH, check_continuity except ImportError: # package import from scripts.chart_dasha.timeline_boundaries import BOUNDARY_RULE, BOUNDARY_RULE_TEXT_ZH, check_continuity this = sr.get('solar_return') or {} location = sr.get('annual_location') or {} birth_jd, target_lon = sr.get('birth_jd_ut'), sr.get('target_sun_longitude') if not isinstance(this.get('dt_ut'), datetime) or birth_jd is None or target_lon is None: return {'status': 'blocked', 'reason': 'solar_return_instant_missing'} nxt = _next_return(sr, target_year, ayanamsa_name) if 'error' in nxt or not isinstance(nxt.get('dt_ut'), datetime): return {'status': 'blocked', 'reason': f"next_solar_return_unavailable:{nxt.get('error')}"} offset, zone_id = float(location.get('utc_offset') or 0.0), location.get('timezone_id') start = this['dt_ut'].replace(tzinfo=timezone.utc) end = nxt['dt_ut'].replace(tzinfo=timezone.utc) def stamp(moment: datetime, jd_ut: Optional[float] = None) -> Dict: local = _localize_return_datetime(moment.replace(tzinfo=None), offset, zone_id) jd = jd_ut if isinstance(jd_ut, (int, float)) else _datetime_to_jd_ut(moment.replace(tzinfo=None)) return {'utc': moment.isoformat(), 'local': local.isoformat(), 'timezone_id': zone_id, 'jd_ut': round(float(jd), 6)} out: Dict = { 'status': 'computed', 'boundary_rule': BOUNDARY_RULE, 'boundary_rule_text': BOUNDARY_RULE_TEXT_ZH, 'solar_return_interval': { 'start': stamp(start, this.get('jd_ut')), 'end': stamp(end, nxt.get('jd_ut')), 'duration_days': round(float(nxt['jd_ut']) - float(this['jd_ut']), 6) if isinstance(this.get('jd_ut'), (int, float)) and isinstance(nxt.get('jd_ut'), (int, float)) else round((end - start).total_seconds() / 86400.0, 6), 'source': 'find_solar_return_ut(target_year) → find_solar_return_ut(target_year + 1), same target longitude and solver', 'next_return_residual_degrees': nxt.get('error_deg'), }, } if isinstance(mudda, dict) and mudda.get('annual_start') and mudda.get('annual_end'): # Mudda dates are naive local wall time = SR local + elapsed days, i.e. # in the UTC offset at the return instant. local_offset = timezone(_localize_return_datetime(this['dt_ut'], offset, zone_id).utcoffset()) dasha_end = datetime.fromisoformat(str(mudda['annual_end'])).replace(tzinfo=local_offset) days = mudda.get('annual_duration_days') out['dasha_year'] = { 'system': 'mudda_dasha', 'days': days, 'basis': mudda.get('year_length_basis'), 'start': stamp(start, this.get('jd_ut')), 'end': stamp(dasha_end.astimezone(timezone.utc)), 'end_minus_next_return_seconds': round((dasha_end - end).total_seconds(), 1), 'note': 'Mudda keeps its own year (365.25 days); it is not stretched to the next solar return', } periods = mudda.get('periods') or [] coverage = mudda.get('coverage_interval') or {} raw = mudda.get('raw_periods') or [] # BUG-1722: 原值 truncated_at = dasha_year_end(比下一次真实返照早 5–15 分钟,留空档); # 新值:视图裁在报告覆盖区间(本次 → 下一次真实返照),原始十段完整保留在 raw_periods。 out['displayed_window'] = { 'start': periods[0]['start'] if periods else None, 'end': periods[-1]['end'] if periods else None, 'truncated_at': 'report_coverage_interval' if coverage.get('basis') == 'actual_solar_return_to_next_actual_solar_return' else 'dasha_year_end', 'coverage_basis': coverage.get('basis'), 'time_basis': f"naive local wall time in the return-instant offset {local_offset}", 'note': 'A view only: Mudda rows clipped to the report coverage interval; the full raw timeline stays in raw_periods', } if raw and raw[-1].get('end'): raw_end = datetime.fromisoformat(str(raw[-1]['end'])).replace(tzinfo=local_offset) out['raw_timeline'] = { 'field': 'raw_periods', 'groups': len(raw), 'end': stamp(raw_end.astimezone(timezone.utc)), 'end_minus_next_return_seconds': round((raw_end - end).total_seconds(), 1), 'note': 'Upstream keeps ten groups; the last one runs past the next return on purpose', } out['mudda_continuity'] = check_continuity( periods, label='mudda_dasha.periods', expected_start=mudda.get('annual_start'), expected_end=coverage.get('end') or mudda.get('annual_end')) gap = out['dasha_year']['end_minus_next_return_seconds'] out['dasha_year_vs_return'] = ( 'equal' if abs(gap) <= 1 else f"Mudda year ends {abs(gap):.0f} s {'after' if gap > 0 else 'before'} the next actual solar return" ) return out def _interpret_muntha_sign(sign_idx: int) -> str: """Muntha 在12星座的解读""" interp = { 0: "年度主题:新开始、冒险、独立行动。适合启动项目。", 1: "年度主题:财务稳定、物质积累。投资、储蓄、巩固基础。", 2: "年度主题:学习、沟通、短途旅行。信息处理量大,社交活跃。", 3: "年度主题:家庭、情感、安全感。家庭事务重要,内心探索。", 4: "年度主题:创造力、自我表达、子女。创作项目,娱乐活动。", 5: "年度主题:健康、服务、日常工作。细节管理,身心调整。", 6: "年度主题:关系、合作、伴侣。婚姻/合伙事务活跃。", 7: "年度主题:深入转型、危机处理。可能涉及共享财务、心理探索。", 8: "年度主题:高等学习、长途旅行、哲学。出国、深造、信念探索。", 9: "年度主题:事业成就、社会地位。职业晋升,长期规划结果。", 10: "年度主题:社交网络、创新、团体。朋友活跃,科技相关。", 11: "年度主题:灵性、慈悲服务、潜意识。修心养性,幕后工作。", } return interp.get(sign_idx, '') def _make_sr_summary(result: Dict) -> str: """生成太阳返照盘综合总结""" lines = ["【太阳返照盘综合总结】"] target = result.get('target_year', '?') age = result.get('sr_chart_info', {}).get('age', '?') lines.append(f" 目标年份:{target} 年(年龄 {age} 岁)") sr = result.get('solar_return', {}) if 'dt_ut' in sr: lines.append(f" 太阳返照时刻(UT):{sr['dt_ut']}") if sr.get('method', '').startswith('approx'): lines.append(" ⚠ 注意:太阳返照时间为近似值,建议安装swisseph获精确时刻") muntha = result.get('muntha', {}) if 'muntha_sign' in muntha: lines.append(f" Muntha:{muntha['muntha_sign']}(守护星 {muntha['muntha_lord']})") lines.append(f" → {muntha.get('interpretation','')[:60]}") yl = result.get('year_lord', {}) if yl.get('year_lord'): lines.append(f" Year Lord:{yl['year_lord']}") elif yl.get('selection_basis_text'): lines.append(f" Year Lord:{yl['selection_basis_text']}") tp = result.get('tri_pataka', {}) if 'verdict' in tp: lines.append(f" Tri-Pataka:{tp['verdict']} — {tp.get('interpretation','')[:50]}") yogas = result.get('tajika_yogas', {}) if 'summary' in yogas: lines.append(f" Tajika Yogas:{yogas['summary'][:100]}") return '\n'.join(lines) # ========================================================================= # CLI 测试入口 # ========================================================================= if __name__ == '__main__': print("Solar Return (太阳返照盘) 模块") print(f" swisseph可用: {HAS_SWE}") print() # Generic public smoke fixture, calculating the 2026 solar return. test_birth_year, test_birth_month, test_birth_day = 1990, 4, 17 test_birth_hour, test_birth_minute = 14, 45 test_lat, test_lon, test_tz = 36.4667, 114.2, 8.0 test_target_year = 2026 print(f"测试:出生 {test_birth_year}-{test_birth_month:02d}-{test_birth_day:02d} " f"{test_birth_hour:02d}:{test_birth_minute:02d} (tz+{test_tz})") print(f" 目标年份:{test_target_year}") print() result = solar_return_full_report( test_birth_year, test_birth_month, test_birth_day, test_birth_hour, test_birth_minute, test_lat, test_lon, test_tz, test_target_year ) print("结果:") for k, v in result.items(): if k in ('tajika_yogas', 'sahams', 'mudda_dasha'): print(f" {k}: (见详细输出)") elif k == 'sr_chart_info': print(f" {k}: {v}") elif k == 'solar_return': sr = v print(f" solar_return:") print(f" method: {sr.get('method', '?')}") print(f" dt_ut: {sr.get('dt_ut', '?')}") print(f" sun_lon: {sr.get('sun_lon', '?')}") print(f" error_deg: {sr.get('error_deg', '?')}") elif k == 'summary': print(f" summary:\n{v}") else: print(f" {k}: {v}")