Files
Jyotisha/scripts/varga.py
T
jesse-uxandGrok (agent loop) 5c5f333d4d fix(varga): port the five sarpa ranges and the 22nd drekkana house (BUG-1494, BUG-1495, issue #159)
Upstream 50847e57, ported semantically:

- Sarpa Drekkana now accepts the five classical ranges (Cancer 2/3,
  Scorpio 1/2, Pisces 3) and partitions on the raw longitude instead of
  the rounded display degrees.
- The 22nd Drekkana is the eighth house of D3 (+7), not +21 zodiacal
  ten-degree divisions.

scripts/varga.py is part of the extended frozen identity, so the sealed
holdout and the reported-offset sweep were re-frozen under ERR-110:
20/20 sealed trials and 900/900 sweep trials match the sync8 records on
every compared field, and the metrics, summary and widening geometry are
unchanged.

Tests: the upstream varga/ sarpa files are added under
tests/test_upstream_sync9b_*.py; the stale +21 assertion and the sarpa
definition string are updated with the original/new/reason columns in
PROGRESS.

Co-Authored-By: Grok (agent loop)
2026-10-28 16:25:45 +08:00

369 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
吠陀占星分盘计算模块 v1.0
BPHS Shodasavarga(十六分盘体系)
支持: D2/D3/D4/D5/D6/D7/D8/D9/D10/D11/D12/D16/D20/D24/D27/D30/D40/D45/D60/D81/D108/D144
每个分盘输出精确度数,支持进一步分析。
"""
from typing import Dict, List, Optional
SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo',
'Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces']
SIGNS_CN = {s: f"{cn}" for s, cn in zip(SIGNS,
['白羊座','金牛座','双子座','巨蟹座','狮子座','处女座',
'天秤座','天蝎座','射手座','摩羯座','水瓶座','双鱼座'])}
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'}
EXALT_SIGN = {'Sun':0,'Moon':1,'Mars':9,'Mercury':5,'Jupiter':3,'Venus':11,'Saturn':6}
DEBIL_SIGN = {'Sun':6,'Moon':7,'Mars':3,'Mercury':11,'Jupiter':9,'Venus':5,'Saturn':0}
OWN_SIGNS = {'Sun':[4],'Moon':[3],'Mars':[0,7],'Mercury':[2,5],
'Jupiter':[8,11],'Venus':[1,6],'Saturn':[9,10]}
VARGA_META = {
2:{'name':'Hora','cn':'财富','area':'财富资源'},3:{'name':'Drekkana','cn':'兄弟','area':'兄弟姐妹'},
4:{'name':'Turyamsa','cn':'财产','area':'财产住所'},5:{'name':'Panchamsa','cn':'名声','area':'名声权力'},
6:{'name':'Shashthamsa','cn':'健康','area':'健康敌人'},7:{'name':'Saptamsa','cn':'子女','area':'子女后代'},
8:{'name':'Ashtamsa','cn':'突发','area':'突发转化'},
9:{'name':'Navamsa','cn':'婚姻','area':'婚姻伴侣灵魂'},10:{'name':'Dasamsa','cn':'事业','area':'事业公众形象'},
11:{'name':'Rudramsa','cn':'收益转化','area':'收益朋友愿望'},12:{'name':'Dwadashamsa','cn':'父母','area':'父母祖先'},16:{'name':'Shodasamsa','cn':'享受','area':'车辆物质'},
20:{'name':'Vimsamsa','cn':'修行','area':'精神修行'},24:{'name':'Siddhamsa','cn':'学识','area':'教育学识'},
27:{'name':'Bhamsa','cn':'力量','area':'力量弱点'},30:{'name':'Trimsamsa','cn':'苦难','area':'灾难苦难'},
40:{'name':'Khavedamsa','cn':'运势','area':'吉凶运势'},45:{'name':'Akshavedamsa','cn':'格局','area':'整体格局'},
60:{'name':'Shashtyamsa','cn':'业力','area':'前世业力同盘区分'},
81:{'name':'Navamsa-Navamsa','cn':'D9之D9','area':'配偶灵性精微层'},
108:{'name':'Dwadasamsa-Navamsa','cn':'D12之D9','area':'祖先父母精微层'},
144:{'name':'Dwadasamsa-Dwadasamsa','cn':'D12之D12','area':'父母祖先精微层'}}
VEDASTRO_RAW_VERIFIED_DIVISIONS = frozenset({2, 3, 4, 7, 9, 10, 12, 16, 20, 24, 27, 40, 45, 60})
def _si(lon): return int(lon/30)%12
def _sn(i): return SIGNS[i%12]
def _odd(si): return si%2==0 # Aries(0)=odd
def _modality(si):
if si % 3 == 0:
return 'movable'
if si % 3 == 1:
return 'fixed'
return 'dual'
def _element(si):
return si % 4
def _d30_map(si, pi):
if _odd(si):
if pi<5: return 0
if pi<10: return 10
if pi<18: return 8
if pi<25: return 2
return 6
else:
if pi<5: return 1
if pi<12: return 5
if pi<20: return 9
if pi<25: return 7
return 11
def varga_map(si, pi, div):
"""BPHS分盘映射:星座索引si的第pi份→目标星座索引"""
o = _odd(si)
if div==2: return (4 if o else 3) if pi==0 else (3 if o else 4)
if div==3: return (si+pi*4)%12 # Drekkana: same → +4 → +8, no odd/even distinction
if div==4: return (si+pi*3)%12
# PVR Integrated Approach 6.2.5 / 6.2.6 / 6.2.8. D8 start is movable Aries, fixed Sagittarius, dual Leo.
if div==5: return ((0,10,8,2,6) if o else (1,5,11,9,7))[pi]
if div==6: return ((0 if o else 6)+pi)%12
if div==7: return (si+pi)%12 if o else (si+6+pi)%12
if div==8: return ((0,8,4)[si%3]+pi)%12
if div==9:
# BPHS Navamsa: movable=same, fixed=9th from sign (+8), dual=5th from sign (+4)
if si%3==0: start=si
elif si%3==1: start=(si+8)%12
else: start=(si+4)%12
return (start+pi)%12
if div==10: return (si+pi)%12 if o else (si+8+pi)%12 # D10: even signs count from 9th inclusively => +8 offset
if div==11: return (-si+pi)%12 # PVR Integrated Approach 6.2.11: anti-zodiacal seed from Aries, then zodiacal parts
if div==12: return (si+pi)%12
if div==16: return ((0 if o else 4)+pi)%12 # D16: movable=+0, fixed=+4; dual needs separate (2026-05-03 fix: was +1)
if div==20: return ((0 if o else 8)+pi)%12
if div==24: return ((4 if o else 3)+pi)%12
if div==27: return ((0 if o else 6)+pi)%12
if div==30: return _d30_map(si,pi)
if div==40: return ((0 if o else 6)+pi)%12
if div==45: return ((0 if o else 6)+pi)%12
if div==60: return (si+pi)%12 if o else (si+1+pi)%12
if div==81:
outer_part = pi // 9
inner_part = pi % 9
outer_sign = varga_map(si, outer_part, 9)
return varga_map(outer_sign, inner_part, 9)
if div==108:
outer_part = pi // 12
inner_part = pi % 12
outer_sign = varga_map(si, outer_part, 9)
return varga_map(outer_sign, inner_part, 12)
if div==144:
outer_part = pi // 12
inner_part = pi % 12
return (si + outer_part + inner_part) % 12
raise ValueError(f"不支持的D{div}")
def _d30_map_vedastro(si, pi):
if _odd(si):
if pi < 5: return 7
if pi < 10: return 10
if pi < 18: return 8
if pi < 25: return 2
return 6
else:
if pi < 5: return 1
if pi < 12: return 2
if pi < 20: return 8
if pi < 25: return 9
return 7
def varga_map_vedastro(si, pi, div):
"""VedAstro-compatible varga sign mapping.
This mode is calibrated against VedAstro official AllPlanetData /
AllHouseData outputs. It intentionally lives beside the historical local
mapping so older research workflows can still audit classical variants.
"""
if div == 2:
return (si + pi * 4) % 12
if div == 4:
return (si + pi * 3) % 12
if div == 7:
return (si + pi) % 12
if div == 16:
start = {'movable': 0, 'fixed': 4, 'dual': 8}[_modality(si)]
return (start + pi) % 12
if div == 20:
start = {'movable': 0, 'fixed': 8, 'dual': 4}[_modality(si)]
return (start + pi) % 12
if div == 27:
start = {0: 0, 1: 3, 2: 6, 3: 9}[_element(si)]
return (start + pi) % 12
if div == 30:
return _d30_map_vedastro(si, pi)
if div == 45:
start = {'movable': 0, 'fixed': 4, 'dual': 8}[_modality(si)]
return (start + pi) % 12
if div == 60:
return (si + pi) % 12
return varga_map(si, pi, div)
def calc_varga(lon, div, mode='classical_local'):
"""计算行星在指定分盘的位置(星座+精确度数+尊贵状态)"""
si=_si(lon); d=lon-si*30
if mode == 'vedastro' and div == 2:
ps = 10.0
pi = int(d / ps)
dp = (d - pi * ps) * 3
dp_display = round(dp, 4)
if dp_display >= 30:
dp_display = 0.0
vsi = varga_map_vedastro(si, pi, div)
r={'sign':_sn(vsi),'sign_idx':vsi,'degree_in_sign':dp_display,
'part_index':pi,'lord':SIGN_LORDS.get(_sn(vsi),'')}
return r
ps=30.0/div; pi=int(d/ps)
# For all vargas, degree within divisional sign is scaled to 0-30 degrees.
dp=(d-pi*ps)*div
# Keep the displayed divisional degree inside [0, 30). Values such as
# 29.9999997 can round to 30.0000 at sign boundaries, which breaks
# downstream range invariants while the underlying sign mapping is valid.
dp_display = round(dp, 4)
if dp_display >= 30:
dp_display = 0.0
vsi=varga_map_vedastro(si,pi,div) if mode == 'vedastro' else varga_map(si,pi,div)
r={'sign':_sn(vsi),'sign_idx':vsi,'degree_in_sign':dp_display,
'part_index':pi,'lord':SIGN_LORDS.get(_sn(vsi),'')}
if div==9: r['pada']=pi+1
return r
def normalize_varga_ascendant(value):
"""Return the public ascendant object used by all varga charts."""
source_format = None
if isinstance(value, list):
candidates = [
row for row in value
if isinstance(row, dict)
and any(
str(row.get(field, '')).strip().lower() == 'ascendant'
for field in ('name', 'planet', 'key')
)
]
if len(candidates) != 1:
raise ValueError('ambiguous legacy ascendant list')
value = candidates[0]
source_format = 'legacy_list_normalized'
if not isinstance(value, dict):
raise ValueError('ascendant must be a mapping or identified legacy list')
sign = value.get('sign')
if sign not in SIGNS:
raise ValueError('ascendant sign is required')
sign_idx = value.get('sign_idx', SIGNS.index(sign))
if not isinstance(sign_idx, int) or not 0 <= sign_idx < len(SIGNS):
raise ValueError('ascendant sign_idx is invalid')
degree_in_sign = value.get('degree_in_sign')
if not isinstance(degree_in_sign, (int, float)) or not 0 <= degree_in_sign < 30:
raise ValueError('ascendant degree_in_sign is invalid')
normalized = {
'sign': sign,
'sign_idx': sign_idx,
'degree_in_sign': round(float(degree_in_sign), 4),
'longitude': round(float(value.get('longitude', sign_idx * 30 + degree_in_sign)) % 360, 4),
}
if source_format:
normalized['source_format'] = source_format
return normalized
def calc_64th_navamsa(moon_lon: float) -> Dict:
"""Compute the 64th Navamsa from the Moon's Navamsa position.
Traditional counting is inclusive, so the 64th point is +63 signs from the
Moon's Navamsa anchor.
"""
base = calc_varga(moon_lon, 9)
sign_idx = (base['sign_idx'] + 63) % 12
return {
'base_navamsa_sign_idx': base['sign_idx'],
'base_navamsa_sign': base['sign'],
'offset_from_moon_navamsa': 64,
'sign_idx': sign_idx,
'sign': _sn(sign_idx),
'lord': SIGN_LORDS.get(_sn(sign_idx), ''),
}
def calc_22nd_drekkana(lagna_lon: float) -> Dict:
"""Compute the 22nd Drekkana from the Lagna's Drekkana position.
Crux of Vedic Astrology, printed p.329 (corpus page 340), identifies
this as the eighth house in D3. Counting 21 ten-degree divisions is
not the same as moving 21 signs in the D3 chart.
"""
base = calc_varga(lagna_lon, 3)
sign_idx = (base['sign_idx'] + 7) % 12
return {
'base_drekkana_sign_idx': base['sign_idx'],
'base_drekkana_sign': base['sign'],
'offset_from_lagna_drekkana': 22,
'sign_idx': sign_idx,
'sign': _sn(sign_idx),
'lord': SIGN_LORDS.get(_sn(sign_idx), ''),
}
def calc_bhrigu_bindu(moon_lon: float, rahu_lon: float) -> Dict:
"""Compute Bhrigu Bindu as the midpoint on the Rahu->Moon forward arc."""
moon = moon_lon % 360
rahu = rahu_lon % 360
arc = (moon - rahu) % 360
longitude = (rahu + arc / 2.0) % 360
sign_idx = _si(longitude)
degree_in_sign = round(longitude - sign_idx * 30, 4)
return {
'longitude': round(longitude, 4),
'sign_idx': sign_idx,
'sign': _sn(sign_idx),
'degree_in_sign': degree_in_sign,
'arc_mode': 'forward_rahu_to_moon',
'lord': SIGN_LORDS.get(_sn(sign_idx), ''),
}
# Raman, Hindu Predictive Astrology, Drekkanas chapter, corpus pp.279–283:
# five serpent divisions, including both Cancer-3 and Scorpio-2.
SARPA_DREKKANA_SIGNS = {'Cancer': (2, 3), 'Scorpio': (1, 2), 'Pisces': (3,)}
def calc_sarpa_drekkana(lon: float) -> Dict:
"""Classify Sarpa Drekkana using the classical water-sign sensitive drekkanas."""
lon = lon % 360
base = calc_varga(lon, 3)
sign = _sn(_si(lon))
degree_in_sign = round((lon % 30), 4)
drekkana_number = int((lon % 30) / 10) + 1
expected = SARPA_DREKKANA_SIGNS.get(sign, ())
return {
'longitude': round(lon % 360, 4),
'sign': sign,
'sign_idx': _si(lon),
'degree_in_sign': degree_in_sign,
'drekkana_number': drekkana_number,
'd3_sign': base['sign'],
'd3_sign_idx': base['sign_idx'],
'is_sarpa_drekkana': drekkana_number in expected,
'definition': 'Cancer-2, Cancer-3, Scorpio-1, Scorpio-2, Pisces-3',
}
def dignity(planet, sign_idx):
if planet in EXALT_SIGN and sign_idx==EXALT_SIGN[planet]: return 'Exalted'
if planet in DEBIL_SIGN and sign_idx==DEBIL_SIGN[planet]: return 'Debilitated'
if planet in OWN_SIGNS and sign_idx in OWN_SIGNS[planet]: return 'Own Sign'
return 'Neutral'
def calc_all_vargas(planet_lons, asc_lon, divisions=None, mode='classical_local'):
"""批量计算所有指定分盘"""
if divisions is None:
divisions=[2,3,4,5,6,7,8,9,10,11,12,16,20,24,27,30,40,45,60,81,108,144]
results={}
for div in divisions:
m=VARGA_META.get(div,{})
key=f"D{div}_{m.get('name',f'D{div}')}"
mapping_status = (
'official_raw_sign_verified'
if mode == 'vedastro' and div in VEDASTRO_RAW_VERIFIED_DIVISIONS
else 'legacy_candidate_unverified'
if mode == 'vedastro'
else 'classical_local'
)
vd={'_meta':{'div':div,'name':m.get('name',''),'cn':m.get('cn',''),
'area':m.get('area',''),'part_size':30.0/div,
'mapping_status': mapping_status,
'mapping_source': (
'PVR Integrated Approach sections 6.2.5-6.2.11; D8 movable Aries, fixed Sagittarius, dual Leo'
if div in (5, 6, 8, 11) else mapping_status
)}}
vd['Ascendant']=normalize_varga_ascendant(calc_varga(asc_lon,div,mode=mode))
for pn,lon in planet_lons.items():
vd[pn]=calc_varga(lon,div,mode=mode)
# 尊贵状态
vd['_dignity']={pn:dignity(pn,pd['sign_idx'])
for pn,pd in vd.items() if pn not in ('Ascendant','_meta') and isinstance(pd,dict) and 'sign_idx' in pd}
# D9专项
if div==9:
ai=vd['Ascendant']['sign_idx']
s7=(ai+6)%12
p7=[pn for pn,pd in vd.items() if isinstance(pd,dict) and pd.get('sign_idx')==s7]
vd['_d9_analysis']={
'navamsa_7th_sign':_sn(s7),'navamsa_7th_lord':SIGN_LORDS.get(_sn(s7),''),
'planets_in_d9_7th':p7,
'venus_d9':f"{vd.get('Venus',{}).get('sign','?')} {vd.get('Venus',{}).get('degree_in_sign',0):.2f}°",
'jupiter_d9':f"{vd.get('Jupiter',{}).get('sign','?')} {vd.get('Jupiter',{}).get('degree_in_sign',0):.2f}°",
'venus_dignity':dignity('Venus',vd.get('Venus',{}).get('sign_idx',-1)),
'jupiter_dignity':dignity('Jupiter',vd.get('Jupiter',{}).get('sign_idx',-1)),
}
# D60专项
if div==60:
ai=vd['Ascendant']['sign_idx']
ben,mal=[],[]
for pn,pd in vd.items():
if not isinstance(pd,dict) or 'sign_idx' not in pd: continue
h=((pd['sign_idx']-ai)%12)+1
if h in (1,4,7,10):
if pn in ('Jupiter','Venus','Moon'): ben.append(f"{pn}({h}宫)")
elif pn in ('Saturn','Mars','Sun','Rahu','Ketu'): mal.append(f"{pn}({h}宫)")
vd['_d60_analysis']={'benefics_in_kendras':ben,'malefics_in_kendras':mal,
'karma_hint':'前世善业较重' if len(ben)>len(mal) else '前世有业力课题' if len(mal)>len(ben) else '善恶交织'}
results[key]=vd
return results