Enhance Jyotish precision techniques and tests

This commit is contained in:
732642856
2026-06-13 15:06:11 +08:00
parent ee82af5ecb
commit 3f355de1db
37 changed files with 7162 additions and 718 deletions
+492 -116
View File
@@ -1,138 +1,514 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Bhava Chalit 宫位系统 v1.0
MIT License — 基于 dashaflow (adarshj322) 的等宫制实现
Bhava Chalit (不等宫边界调整) 计算模块 v1.0
Bhava Chalit = 等分宫制,从上升中点(Lagna - 15°)开始,每宫30度
与整宫制(Whole Sign)的区别:靠近星座边界的行星可能跨宫
Bhava Chalit 是 JHora 和 PyJHora 的标准功能。它根据实际宫位边界
(非等宫30°)调整行星的宫位归属。
核心概念:
- Bhava Madhya: 宫位中点(宫头/cusp
- Bhava Sandhi: 宫位边界(相邻两宫头的中点)
- 行星根据落在哪两个 Sandhi 边界之间来确定 Bhava 宫位
- Bhava 宫位可能与 Rashi(星座/整宫)宫位不同
支持的宫位制:
- equal: 等宫制(每宫30°,从上升点起算)
- whole_sign: 整宫制(星座=宫位)
- sripati: SripatiPorphyry 变体,吠陀标准)
- porphyry: Porphyry(四象限三等分)
- placidus: Placidus(时间等分,西方最常用)
- koch: Koch(时间等分,西方流行)
"""
from typing import Dict, List, Tuple
SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo',
'Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces']
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 calculate_bhava_chalit(asc_lon: float, raw_planets: Dict) -> Dict:
"""
Bhava Chalit 计算(等宫制从上升中点划分)
规则:
- 第1宫中点(Bhava Madhya) = Lagna 经度
- 第1宫起点 = Lagna - 15°
- 每宫跨度 = 30°
- 行星的Bhava宫位可能与其Rashi(整宫)宫位不同
Args:
asc_lon: 上升点黄道经度 (0-360)
raw_planets: {planet_name: {"lon": float, ...}}
每颗行星需要lon(黄道经度)和sign_idx(星座索引)
Returns:
dict: {planet_name: {"bhava_house": int, "rashi_house": int, "shifted": bool}}
"""
cusp_start = (asc_lon - 15.0) % 360.0
asc_sign_idx = int(asc_lon / 30) % 12
def _norm(lon: float) -> float:
"""归一化到 [0, 360)"""
return lon % 360.0
result = {}
for name, rp in raw_planets.items():
planet_lon = rp.get("lon", 0)
# 整宫制
sign_idx = rp.get("sign_idx", int(planet_lon / 30) % 12)
rashi_house = ((sign_idx - asc_sign_idx) % 12) + 1
# Bhava 宫位(等宫制)
diff = (planet_lon - cusp_start) % 360.0
bhava_house = int(diff / 30.0) + 1
if bhava_house > 12:
bhava_house = 12
result[name] = {
"bhava_house": bhava_house,
"rashi_house": rashi_house,
"shifted": bhava_house != rashi_house,
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,
}
return result
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,
}
def get_bhava_cusps(asc_lon: float) -> List[float]:
"""
获取所有12宫的Bhava Cusp(宫头)经度
Returns:
12个float,第1宫到第12宫的Cusp经度
"""
cusp_start = (asc_lon - 15.0) % 360.0
return [(cusp_start + i * 30.0) % 360.0 for i in range(12)]
# ======================================================================
# CLI 入口
# ======================================================================
def cmd_bhava_chalit(args):
"""bhava-chalit 子命令处理函数。"""
import json
import sys
import os
def get_bhava_madhyas(asc_lon: float) -> List[float]:
"""
获取所有12宫的Bhava Madhya(宫中点)经度
Returns:
12个float,第1宫到第12宫的中点经度
"""
return [(asc_lon + i * 30.0) % 360.0 for i in range(12)]
# 延迟导入, 避免循环依赖
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 未安装, 无法计算"}
def get_bhava_ranges(asc_lon: float) -> List[Tuple[float, float]]:
"""
获取每个宫位的起止范围
Returns:
12个tuple (start, end),第1宫到第12宫的经度范围
"""
cusps = get_bhava_cusps(asc_lon)
ranges = []
for i in range(12):
start = cusps[i]
end = cusps[(i + 1) % 12]
if end <= start:
end += 360
ranges.append((start, end))
return ranges
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": "星盘计算失败"}
def planet_in_which_bhava(planet_lon: float, asc_lon: float) -> int:
"""
判断行星经度落在哪个Bhava宫(1-12)
"""
cusps = get_bhava_cusps(asc_lon)
for i in range(12):
start = cusps[i]
end = cusps[(i + 1) % 12]
if end <= start:
end += 360
plon = planet_lon if planet_lon >= start else planet_lon + 360
if start <= plon < end:
return i + 1
return 12
# 提取行星黄经
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
def cross_house_check(asc_lon: float, planet_name: str, planet_lon: float,
planet_sign_idx: int) -> Dict:
"""
跨宫检查:判断行星是否因Bhava Chalit而换宫
Returns:
{"planet": str, "rashi_house": int, "bhava_house": int,
"shifted": bool, "delta_degrees": float}
"""
asc_sign_idx = int(asc_lon / 30) % 12
rashi_house = ((planet_sign_idx - asc_sign_idx) % 12) + 1
bhava_house = planet_in_which_bhava(planet_lon, asc_lon)
delta = abs(bhava_house - rashi_house)
if delta > 6:
delta = 12 - delta
return {
"planet": planet_name,
"rashi_house": rashi_house,
"bhava_house": bhava_house,
"shifted": bhava_house != rashi_house,
"delta_houses": delta,
}
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)