#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Bhava Chalit (不等宫边界调整) 计算模块 v1.0 Bhava Chalit 是 JHora 和 PyJHora 的标准功能。它根据实际宫位边界 (非等宫30°)调整行星的宫位归属。 核心概念: - Bhava Madhya: 宫位中点(宫头/cusp) - Bhava Sandhi: 宫位边界(相邻两宫头的中点) - 行星根据落在哪两个 Sandhi 边界之间来确定 Bhava 宫位 - Bhava 宫位可能与 Rashi(星座/整宫)宫位不同 支持的宫位制: - equal: 等宫制(每宫30°,从上升点起算) - whole_sign: 整宫制(星座=宫位) - sripati: Sripati(Porphyry 变体,吠陀标准) - porphyry: Porphyry(四象限三等分) - placidus: Placidus(时间等分,西方最常用) - koch: Koch(时间等分,西方流行) """ from typing import Dict, List, Optional SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces'] SIGNS_CN = {'Aries': '白羊座', 'Taurus': '金牛座', 'Gemini': '双子座', 'Cancer': '巨蟹座', 'Leo': '狮子座', 'Virgo': '处女座', 'Libra': '天秤座', 'Scorpio': '天蝎座', 'Sagittarius': '射手座', 'Capricorn': '摩羯座', 'Aquarius': '水瓶座', 'Pisces': '双鱼座'} def _norm(lon: float) -> float: """归一化到 [0, 360)""" return lon % 360.0 def _sign_idx(lon: float) -> int: """黄经对应的星座索引 (0-11)""" return int(_norm(lon) / 30) % 12 def _angular_dist(a: float, b: float) -> float: """从 a 到 b 的正向角距离 [0, 360)""" return _norm(b - a) class BhavaChalitCalculator: """Bhava Chalit (不等宫边界调整) 计算器。""" HOUSE_SYSTEMS = { 'equal': 'Equal house (30° each)', 'placidus': 'Placidus (time-based, most common Western)', 'porphyry': 'Porphyry (quadrant trisection)', 'sripati': 'Sripati (Vedic standard, Porphyry variant)', 'whole_sign': 'Whole Sign (Rashi = House)', 'koch': 'Koch (time-based, popular in Western)', } # swisseph house system codes _SWE_HSYS = { 'placidus': b'P', 'koch': b'K', 'porphyry': b'O', 'sripati': b'R', # Sripati uses Regiomontanus approximation in swe } def __init__(self): self._has_swe = False try: import swisseph as swe self._has_swe = True self._swe = swe except ImportError: pass # ------------------------------------------------------------------ # 核心算法: 宫头计算 # ------------------------------------------------------------------ def calculate_cusps(self, asc_lon: float, mc_lon: float, house_system: str = 'sripati', jd: float = None, lat: float = None, lon: float = None) -> List[float]: """计算12个宫头(Bhava Madhya)。 Parameters ---------- asc_lon : float 上升点黄经 (sidereal) mc_lon : float 天顶黄经 (sidereal), 仅 sripati/porphyry 需要 house_system : str 宫位制 jd : float 儒略日, swisseph 宫位制需要 lat : float 纬度, swisseph 宫位制需要 lon : float 经度, swisseph 宫位制需要 Returns ------- list[float] 12个宫头黄经, 索引0=第1宫, 索引1=第2宫, ... """ hs = house_system.lower() if hs not in self.HOUSE_SYSTEMS: raise ValueError(f"不支持的宫位制: {house_system}。" f"可选: {list(self.HOUSE_SYSTEMS.keys())}") if hs == 'equal': return self._cusps_equal(asc_lon) elif hs == 'whole_sign': return self._cusps_whole_sign(asc_lon) elif hs == 'sripati': return self._cusps_sripati(asc_lon, mc_lon) elif hs == 'porphyry': return self._cusps_porphyry(asc_lon, mc_lon) elif hs in ('placidus', 'koch'): return self._cusps_swe(asc_lon, hs, jd, lat, lon) else: return self._cusps_equal(asc_lon) def _cusps_equal(self, asc_lon: float) -> List[float]: """等宫制: 每宫30°, 从上升点起算。""" return [_norm(asc_lon + i * 30) for i in range(12)] def _cusps_whole_sign(self, asc_lon: float) -> List[float]: """整宫制: 每宫=一个星座, 宫头在星座中点 (Bhava Madhya)。 在 Jyotish 中, 宫头 (cusp) 是 Bhava Madhya (宫位中点)。 Whole Sign 下, 中点在 15° of each sign。 Sandhi (边界) 在星座0°, 确保不会出现 Rashi/Bhava 偏移。 """ asc_sign_start = int(asc_lon / 30) * 30 # cusp = midpoint of each sign = sign_start + 15° return [_norm(asc_sign_start + i * 30 + 15) for i in range(12)] def _cusps_porphyry(self, asc_lon: float, mc_lon: float) -> List[float]: """Porphyry: 四象限三等分。 四个象限: Q1: Asc → MC (顺时针, 即 MC 在 Asc 之前/之上) Q2: MC → Desc (7宫 = Asc+180°) Q3: Desc → IC (4宫 = MC+180°) Q4: IC → Asc 每个象限三等分 → 每象限3个宫。 """ desc_lon = _norm(asc_lon + 180) ic_lon = _norm(mc_lon + 180) cusps = [0.0] * 12 # 1宫 = Asc, 10宫 = MC, 7宫 = Desc, 4宫 = IC cusps[0] = _norm(asc_lon) cusps[9] = _norm(mc_lon) cusps[6] = _norm(desc_lon) cusps[3] = _norm(ic_lon) # Q1: Asc → MC (houses 12, 11, 10-cusp) # 在黄道上, MC 通常在 Asc 的顺时针方向 (数值上 MC < Asc 或绕过360) # 行星沿黄道逆时针运行, 但宫位顺时针排列 # Q1 从 MC 到 Asc (顺时针) 包含 house 11, 12 # 但实际上 Porphyry 的象限划分是: # Q1 (houses 10,11,12): MC → Asc # Q2 (houses 7,8,9): Desc → MC # Q3 (houses 4,5,6): IC → Desc # Q4 (houses 1,2,3): Asc → IC # 注意: 在印度占星中, 宫位顺时针, 2宫在1宫之后 # 正确的象限划分 (JHora/Porphyry 标准): # Q1: Asc → IC (houses 2, 3) — 1宫和4宫之间 # Q2: IC → Desc (houses 5, 6) — 4宫和7宫之间 # Q3: Desc → MC (houses 8, 9) — 7宫和10宫之间 # Q4: MC → Asc (houses 11, 12) — 10宫和1宫之间 self._trisect_quadrant(cusps, 0, 3, 1, 2) # Asc → IC: houses 2,3 self._trisect_quadrant(cusps, 3, 6, 4, 5) # IC → Desc: houses 5,6 self._trisect_quadrant(cusps, 6, 9, 7, 8) # Desc → MC: houses 8,9 self._trisect_quadrant(cusps, 9, 0, 10, 11) # MC → Asc: houses 11,12 return cusps def _cusps_sripati(self, asc_lon: float, mc_lon: float) -> List[float]: """Sripati: Porphyry 变体, 吠陀标准。 与 Porphyry 相同的四象限三等分法。 Sripati 的特点是: 先用 Porphyry 算出宫头, 然后每个宫的中点 (midpoint between cusps) 才是真正的 Bhava Madhya。 但在 JHora 的实现中, Sripati 宫头就是 Porphyry 宫头, 差异仅在于 Sandhi (边界) 的计算方式。 这里采用与 JHora 一致的 Sripati 算法: 即 Porphyry 宫头 + Sandhi 在相邻 Porphyry 宫头中点。 """ return self._cusps_porphyry(asc_lon, mc_lon) def _trisect_quadrant(self, cusps: List[float], start_idx: int, end_idx: int, inner1: int, inner2: int): """将象限三等分, 填入两个内部宫头。 从 cusps[start_idx] 到 cusps[end_idx], 顺时针方向。 """ start_lon = cusps[start_idx] end_lon = cusps[end_idx] arc = _angular_dist(start_lon, end_lon) third = arc / 3.0 cusps[inner1] = _norm(start_lon + third) cusps[inner2] = _norm(start_lon + 2 * third) def _cusps_swe(self, asc_lon: float, house_system: str, jd: float, lat: float, lon: float) -> List[float]: """使用 swisseph 计算宫头 (Placidus/Koch 等)。 swisseph houses/houses_ex 返回12个值 (0-indexed): cusps[0]=H1, cusps[1]=H2, ..., cusps[11]=H12 这些是 tropical 度数, 需要减去 ayanamsa 转为 sidereal。 """ if not self._has_swe: raise RuntimeError(f"swisseph 未安装, 无法使用 {house_system} 宫位制") if jd is None or lat is None or lon is None: raise ValueError(f"{house_system} 需要 jd, lat, lon 参数") hsys = self._SWE_HSYS.get(house_system, b'P') cusps_swe, ascmc = self._swe.houses(jd, lat, lon, hsys) ayanamsa = self._swe.get_ayanamsa(jd) result = [] for i in range(12): result.append(_norm(cusps_swe[i] - ayanamsa)) return result # ------------------------------------------------------------------ # 核心: Bhava Sandhi (宫位边界) # ------------------------------------------------------------------ def calculate_sandhis(self, cusps: List[float]) -> List[float]: """计算12个 Bhava Sandhi (宫位边界)。 Sandhi[i] = 宫i的起始边界 = 从 cusp[i-1] 到 cusp[i] 的中点 即相邻两宫头的中点 (沿黄道正向)。 Returns ------- list[float] 12个Sandhi, sandhi[0]=第1宫起始边界, ... """ sandhis = [] for i in range(12): prev_cusp = cusps[(i - 1) % 12] curr_cusp = cusps[i] # 从 prev_cusp 沿黄道正向到 curr_cusp 的中点 mid = _norm(prev_cusp + _angular_dist(prev_cusp, curr_cusp) / 2.0) sandhis.append(mid) return sandhis # ------------------------------------------------------------------ # 核心: 行星 Bhava 归属 # ------------------------------------------------------------------ def _planet_bhava(self, planet_lon: float, sandhis: List[float]) -> int: """确定行星落在哪个 Bhava。 行星落在 sandhi[i] 和 sandhi[(i+1)%12] 之间 → 第(i+1)宫。 Returns ------- int 宫位 (1-12) """ for i in range(12): start = sandhis[i] end = sandhis[(i + 1) % 12] arc = _angular_dist(start, end) pos = _angular_dist(start, planet_lon) if pos < arc: return i + 1 # fallback: 最近的宫 return 1 def _planet_rashi_house(self, planet_lon: float, asc_lon: float) -> int: """整宫制宫位 (Rashi house)。""" p_si = _sign_idx(planet_lon) a_si = _sign_idx(asc_lon) return ((p_si - a_si) % 12) + 1 # ------------------------------------------------------------------ # 公共 API # ------------------------------------------------------------------ def calculate_bhava_boundaries(self, asc_lon: float, mc_lon: float, house_system: str = 'sripati', jd: float = None, lat: float = None, lon: float = None) -> Dict: """计算完整的宫位边界信息。 Returns ------- dict with keys: house_system, cusps, sandhis, houses_detail """ cusps = self.calculate_cusps(asc_lon, mc_lon, house_system, jd, lat, lon) sandhis = self.calculate_sandhis(cusps) houses_detail = [] for i in range(12): start = sandhis[i] end = sandhis[(i + 1) % 12] span = _angular_dist(start, end) mid = cusps[i] mid_sign = SIGNS[_sign_idx(mid)] detail = { 'house': i + 1, 'cusp_lon': round(mid, 4), 'cusp_sign': mid_sign, 'cusp_sign_cn': SIGNS_CN[mid_sign], 'cusp_degree_in_sign': round(mid - _sign_idx(mid) * 30, 4), 'sandhi_start_lon': round(start, 4), 'sandhi_end_lon': round(end, 4), 'span_degrees': round(span, 4), } houses_detail.append(detail) return { 'house_system': house_system, 'house_system_desc': self.HOUSE_SYSTEMS.get(house_system, ''), 'ascendant_lon': round(asc_lon, 4), 'mc_lon': round(mc_lon, 4), 'cusps': [round(c, 4) for c in cusps], 'sandhis': [round(s, 4) for s in sandhis], 'houses': houses_detail, } def get_bhava_chalit_chart(self, planet_lons: Dict[str, float], asc_lon: float, mc_lon: float, house_system: str = 'sripati', jd: float = None, lat: float = None, lon: float = None) -> Dict: """根据 Bhava 边界重新分配行星宫位。 Parameters ---------- planet_lons : dict {行星名: sidereal黄经} asc_lon : float 上升点黄经 mc_lon : float 天顶黄经 house_system: str 宫位制 jd, lat, lon swisseph 宫位制所需参数 Returns ------- dict with keys: house_system, boundaries, planets """ cusps = self.calculate_cusps(asc_lon, mc_lon, house_system, jd, lat, lon) sandhis = self.calculate_sandhis(cusps) planets = {} for pname, plon in planet_lons.items(): bhava = self._planet_bhava(plon, sandhis) rashi = self._planet_rashi_house(plon, asc_lon) si = _sign_idx(plon) deg_in_sign = plon - si * 30 planets[pname] = { 'longitude': round(plon, 4), 'sign': SIGNS[si], 'sign_cn': SIGNS_CN[SIGNS[si]], 'degree_in_sign': round(deg_in_sign, 4), 'rashi_house': rashi, 'bhava_house': bhava, 'shifted': bhava != rashi, 'shift_direction': 'forward' if bhava > rashi or (bhava == 1 and rashi == 12) else 'backward' if bhava != rashi else 'none', } # 修正 shift_direction: 考虑环绕 if bhava != rashi: diff = ((bhava - rashi) % 12) if diff <= 6: planets[pname]['shift_direction'] = 'forward' else: planets[pname]['shift_direction'] = 'backward' return { 'house_system': house_system, 'house_system_desc': self.HOUSE_SYSTEMS.get(house_system, ''), 'ascendant_lon': round(asc_lon, 4), 'mc_lon': round(mc_lon, 4), 'planets': planets, 'shifted_planets': [p for p, d in planets.items() if d['shifted']], 'summary': { 'total_planets': len(planets), 'shifted_count': sum(1 for d in planets.values() if d['shifted']), 'shifted_names': [p for p, d in planets.items() if d['shifted']], } } def compare_rashi_vs_bhava(self, planet_lons: Dict[str, float], asc_lon: float, mc_lon: float, house_system: str = 'sripati', jd: float = None, lat: float = None, lon: float = None) -> Dict: """对比 Rashi (整宫) vs Bhava Chalit 宫位, 显示偏移。 Returns ------- dict with keys: house_system, rashi_chart, bhava_chart, shifts, boundaries """ # Rashi chart (whole sign) rashi_chart = {} asc_si = _sign_idx(asc_lon) for pname, plon in planet_lons.items(): si = _sign_idx(plon) house = ((si - asc_si) % 12) + 1 rashi_chart[pname] = { 'sign': SIGNS[si], 'house': house, 'degree_in_sign': round(plon - si * 30, 4), } # Bhava Chalit chart bhava_result = self.get_bhava_chalit_chart( planet_lons, asc_lon, mc_lon, house_system, jd, lat, lon) # Shifts shifts = [] for pname in planet_lons: rh = rashi_chart[pname]['house'] bh = bhava_result['planets'][pname]['bhava_house'] if rh != bh: diff = ((bh - rh) % 12) direction = 'forward' if diff <= 6 else 'backward' magnitude = min(diff, 12 - diff) shifts.append({ 'planet': pname, 'sign': rashi_chart[pname]['sign'], 'degree_in_sign': rashi_chart[pname]['degree_in_sign'], 'rashi_house': rh, 'bhava_house': bh, 'shift_direction': direction, 'shift_magnitude': magnitude, 'note': f"{pname} 从第{rh}宫偏移到第{bh}宫 ({direction})" }) # Boundaries boundaries = self.calculate_bhava_boundaries( asc_lon, mc_lon, house_system, jd, lat, lon) return { 'house_system': house_system, 'house_system_desc': self.HOUSE_SYSTEMS.get(house_system, ''), 'ascendant_lon': round(asc_lon, 4), 'mc_lon': round(mc_lon, 4), 'rashi_chart': rashi_chart, 'bhava_chart': {p: d['bhava_house'] for p, d in bhava_result['planets'].items()}, 'shifts': shifts, 'shifted_count': len(shifts), 'boundaries': boundaries, } # ====================================================================== # CLI 入口 # ====================================================================== def cmd_bhava_chalit(args): """bhava-chalit 子命令处理函数。""" import json import sys import os # 延迟导入, 避免循环依赖 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from jyotish_engine import compute_chart_data, HAS_SWE, output_json if not HAS_SWE: return {"error": "swisseph 未安装, 无法计算"} 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')) if chart is None: return {"error": "星盘计算失败"} # 提取行星黄经 planet_lons = {} for pname, pdata in chart.get('planets', {}).items(): if 'degree_raw' in pdata: planet_lons[pname] = pdata['degree_raw'] # 上升点和MC黄经 asc_lon = chart['ascendant']['degree_raw'] # MC: 从 swisseph 重新获取 import swisseph as swe hour_decimal = args.hour + args.minute / 60.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 mc_lon = (mc_tropical - ayanamsa) % 360 house_system = getattr(args, 'house_system', 'sripati') calc = BhavaChalitCalculator() mode = getattr(args, 'mode', 'compare') if mode == 'boundaries': return 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( planet_lons, asc_lon, mc_lon, house_system, jd_val, args.lat, args.lon) else: # compare return calc.compare_rashi_vs_bhava( planet_lons, asc_lon, mc_lon, house_system, jd_val, args.lat, args.lon)