Files
Jyotisha/scripts/ashtakavarga.py
T
Jesse_ChenandClaude Opus 5.5 ea78804b78 feat(ashtakavarga): standard Sodhita chain on reports and cards, raw values beside (T7, BUG-1209)
Astrologer ruling 乙8. New scripts/ashtakavarga_shodhana.py: per BAV row Trikona then
Ekadhipatya (the two existing ashtakavarga functions, previously only called by
tests), Sodhita SAV = sum of reduced rows; occupied signs = seven grahas. Removed
calc_sodhita_av (Sun/Mars/Saturn contribution removal labelled 'BPHS标准').
Wired into /api/ashtakavarga (result.sodhita, house_scores[*].sav_sodhita) and the
full reading. Reader: SAV table 'Sodhita SAV | Raw SAV', house table gains
Sodhita column, Sodhita BAV table before raw BAV; reference edition section
rewritten. Card: sav_sodhita before sav_score plus sav_columns label. Personal
report facts: score = Sodhita, rawScore/savRawTotal beside (optional schema
fields). Fact tables: Sodhita column and Sodhita BAV subtable. Transit scoring
unchanged (raw bindus).

Golden: report-density-fictional-engine.json leaf worksheets.strengths_and_scores
.ashtakavarga recaptured from the real engine (scripts/research/refresh_density_
fixture_ashtakavarga.py); bav/sav identical, every other leaf byte-equal.
Changed assertions (three columns in PROGRESS): BUG-1200 house-table tests,
report-fact-tables ashtakavarga columns/subtables, run_all t1/t57.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-03 12:41:26 +08:00

574 lines
20 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 -*-
"""
Ashtakavarga 计算模块 v2.1(八分法)
基于 Brihat Parashara Hora Shastra (BPHS) 完整标准表,并经 PVR/PyJHora 书例校准
核心修正(v2.0 vs v1.0):
- v1.0: BAV_BASE 只有自贡献 + 少量跨源贡献 → SAV=94 ❌
- v2.0: 完整 8×8 贡献矩阵(每颗行星 × 8来源)→ SAV=337 ✅
架构:
- BAV_CONTRIBUTION[planet][source] = 有利宫位列表
- 每颗行星的 BAV = 所有 8 个来源(7行星+Lagna)的贡献之和
- SAV = 7 颗行星 BAV 之和(Lagna 自身 BAV 单独展示,不计入 SAV)
- SAV 总和 = 337(宇宙常数)
PVR/PyJHora 书例校准(v2.1):
- 第六轮 benchmark 使用 PyJHora `pvr_tests.py` 中公开书例仲裁,修正 Moon/Venus 7 个贡献表项。
- 固定不变量保持:7行星 SAV=337,含 Lagna full SAV=386。
"""
from typing import Dict, List, Tuple
SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces']
# ============================================================================
# 完整 BAV 贡献表(BPHS 标准法)
# BAV_CONTRIBUTION[planet][source] = 从 source 数起的有利宫位列表
# 每颗行星的 BAV 总和 = 所有 8 个来源的贡献数之和
# ============================================================================
# --- 太阳 BAV(总48) ---
# 来源: Sun(8) + Moon(4) + Mars(8) + Mercury(7) + Jupiter(4) + Venus(3) + Saturn(8) + Lagna(6) = 48
_SUN_BAV = {
'Sun': [1, 2, 4, 7, 8, 9, 10, 11],
'Moon': [3, 6, 10, 11],
'Mars': [1, 2, 4, 7, 8, 9, 10, 11],
'Mercury': [3, 5, 6, 9, 10, 11, 12],
'Jupiter': [5, 6, 9, 11],
'Venus': [6, 7, 12],
'Saturn': [1, 2, 4, 7, 8, 9, 10, 11],
'Lagna': [3, 4, 6, 10, 11, 12],
}
# --- 月亮 BAV(总49) ---
# 来源: Sun(6) + Moon(7) + Mars(6) + Mercury(8) + Jupiter(7) + Venus(7) + Saturn(4) + Lagna(4) = 49
# v2.1: PVR/PyJHora 书例校准 Moon/Mars/Jupiter/Lagna 贡献项。
_MOON_BAV = {
'Sun': [3, 6, 7, 8, 10, 11],
'Moon': [1, 3, 6, 7, 9, 10, 11],
'Mars': [2, 3, 5, 6, 10, 11],
'Mercury': [1, 3, 4, 5, 7, 8, 10, 11],
'Jupiter': [1, 2, 4, 7, 8, 10, 11],
'Venus': [3, 4, 5, 7, 9, 10, 11],
'Saturn': [3, 5, 6, 11],
'Lagna': [3, 6, 10, 11],
}
# --- 火星 BAV(总39) ---
# 来源: Sun(5) + Moon(3) + Mars(7) + Mercury(4) + Jupiter(4) + Venus(4) + Saturn(7) + Lagna(5) = 39
_MARS_BAV = {
'Sun': [3, 5, 6, 10, 11],
'Moon': [3, 6, 11],
'Mars': [1, 2, 4, 7, 8, 10, 11],
'Mercury': [3, 5, 6, 11],
'Jupiter': [6, 10, 11, 12],
'Venus': [6, 8, 11, 12],
'Saturn': [1, 4, 7, 8, 9, 10, 11],
'Lagna': [1, 3, 6, 10, 11],
}
# --- 水星 BAV(总54) ---
# 来源: Sun(5) + Moon(6) + Mars(8) + Mercury(8) + Jupiter(4) + Venus(8) + Saturn(8) + Lagna(7) = 54
_MERCURY_BAV = {
'Sun': [5, 6, 9, 11, 12],
'Moon': [2, 4, 6, 8, 10, 11],
'Mars': [1, 2, 4, 7, 8, 9, 10, 11],
'Mercury': [1, 3, 5, 6, 9, 10, 11, 12],
'Jupiter': [6, 8, 11, 12],
'Venus': [1, 2, 3, 4, 5, 8, 9, 11],
'Saturn': [1, 2, 4, 7, 8, 9, 10, 11],
'Lagna': [1, 2, 4, 6, 8, 10, 11],
}
# --- 木星 BAV(总56) ---
# 来源: Sun(9) + Moon(5) + Mars(7) + Mercury(8) + Jupiter(8) + Venus(6) + Saturn(4) + Lagna(9) = 56
_JUPITER_BAV = {
'Sun': [1, 2, 3, 4, 7, 8, 9, 10, 11],
'Moon': [2, 5, 7, 9, 11],
'Mars': [1, 2, 4, 7, 8, 10, 11],
'Mercury': [1, 2, 4, 5, 6, 9, 10, 11],
'Jupiter': [1, 2, 3, 4, 7, 8, 10, 11],
'Venus': [2, 5, 6, 9, 10, 11],
'Saturn': [3, 5, 6, 12],
'Lagna': [1, 2, 4, 5, 6, 7, 9, 10, 11],
}
# --- 金星 BAV(总52) ---
# 来源: Sun(3) + Moon(9) + Mars(6) + Mercury(5) + Jupiter(5) + Venus(9) + Saturn(7) + Lagna(8) = 52
# v2.1: PVR/PyJHora 书例校准 Mars/Mercury/Lagna 贡献项。
_VENUS_BAV = {
'Sun': [8, 11, 12],
'Moon': [1, 2, 3, 4, 5, 8, 9, 11, 12],
'Mars': [3, 4, 6, 9, 11, 12],
'Mercury': [3, 5, 6, 9, 11],
'Jupiter': [5, 8, 9, 10, 11],
'Venus': [1, 2, 3, 4, 5, 8, 9, 10, 11],
'Saturn': [3, 4, 5, 8, 9, 10, 11],
'Lagna': [1, 2, 3, 4, 5, 8, 9, 11],
}
# --- 土星 BAV(总39) ---
# 来源: Sun(7) + Moon(3) + Mars(6) + Mercury(6) + Jupiter(4) + Venus(3) + Saturn(4) + Lagna(6) = 39
_SATURN_BAV = {
'Sun': [1, 2, 4, 7, 8, 10, 11],
'Moon': [3, 6, 11],
'Mars': [3, 5, 6, 10, 11, 12],
'Mercury': [6, 8, 9, 10, 11, 12],
'Jupiter': [5, 6, 11, 12],
'Venus': [6, 11, 12],
'Saturn': [3, 5, 6, 11],
'Lagna': [1, 3, 4, 6, 10, 11],
}
# --- Lagna BAV(总49,不计入 SAV 337) ---
# 来源: Sun(6) + Moon(5) + Mars(5) + Mercury(7) + Jupiter(9) + Venus(7) + Saturn(6) + Lagna(4) = 49
_LAGNA_BAV = {
'Sun': [3, 4, 6, 10, 11, 12],
'Moon': [3, 6, 10, 11, 12],
'Mars': [1, 3, 6, 10, 11],
'Mercury': [1, 2, 4, 6, 8, 10, 11],
'Jupiter': [1, 2, 4, 5, 6, 7, 9, 10, 11],
'Venus': [1, 2, 3, 4, 5, 8, 9],
'Saturn': [1, 3, 4, 6, 10, 11],
'Lagna': [3, 6, 10, 11],
}
# 汇总为统一查找表
BAV_CONTRIBUTION = {
'Sun': _SUN_BAV,
'Moon': _MOON_BAV,
'Mars': _MARS_BAV,
'Mercury': _MERCURY_BAV,
'Jupiter': _JUPITER_BAV,
'Venus': _VENUS_BAV,
'Saturn': _SATURN_BAV,
'Lagna': _LAGNA_BAV,
}
# 各行星BAV固定总数(宇宙常数)
BAV_TOTALS = {
'Sun': 48, 'Moon': 49, 'Mars': 39, 'Mercury': 54,
'Jupiter': 56, 'Venus': 52, 'Saturn': 39, 'Lagna': 49
}
# 7颗行星 BAV 总和 = SAV 337
EXPECTED_SAV_TOTAL = 337
# Shodhya Pinda 行星权重
PLANET_WEIGHTS = {
'Sun': 5, 'Moon': 5, 'Mars': 8, 'Mercury': 5,
'Jupiter': 10, 'Venus': 7, 'Saturn': 5
}
# 行星列表
SEVEN_PLANETS = ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn']
ALL_SOURCES = SEVEN_PLANETS + ['Lagna']
def _get_sign_idx(sign_name: str) -> int:
"""星座名称转索引"""
return SIGNS.index(sign_name) if sign_name in SIGNS else 0
def calc_ashtakavarga(planets: Dict, asc_sign_idx: int) -> Dict:
"""
计算完整 Ashtakavarga(v2.0 BPHS标准法)
Args:
planets: 行星数据 dict,每颗行星需要 {sign, degree, ...}
sign 是星座名称
asc_sign_idx: 上升星座在 SIGNS 中的索引 (0-11)
Returns:
完整的 Ashtakavarga 计算结果,含 BAV、SAV、校验
"""
# 1. 确定每个来源(7行星+Lagna)的星座索引
source_sign_idx = {}
for pname in SEVEN_PLANETS:
if pname in planets:
sign = planets[pname].get('sign', '')
if sign in SIGNS:
source_sign_idx[pname] = SIGNS.index(sign)
source_sign_idx['Lagna'] = asc_sign_idx
# 2. 为每颗行星计算 BAV
# BAV[planet][sign_i] = count of sources that contribute bindhu to sign_i
bav_results = {}
sav = [0] * 12 # 12星座的SAV(仅统计7行星贡献)
lagna_sav = [0] * 12 # Lagna单独的贡献
for planet in ALL_SOURCES:
contribution_rules = BAV_CONTRIBUTION[planet]
bav = [0] * 12 # 12星座的 bindhu 数
for source in ALL_SOURCES:
if source not in source_sign_idx:
continue
# 获取该 source 对该 planet BAV 的有利宫位
favorable_houses = contribution_rules.get(source, [])
source_idx = source_sign_idx[source]
# 从 source 所在星座开始,将有利宫位映射到实际星座
for house in favorable_houses:
target_sign = (source_idx + house - 1) % 12
bav[target_sign] += 1
bav_total = sum(bav)
expected = BAV_TOTALS.get(planet, 0)
bav_results[planet] = {
'bindus': bav,
'total': bav_total,
'expected_total': expected,
'valid': bav_total == expected,
'deviation': abs(bav_total - expected),
}
# 累加到 SAV(仅7行星)
if planet in SEVEN_PLANETS:
for i in range(12):
sav[i] += bav[i]
else:
# Lagna 的贡献单独记录
for i in range(12):
lagna_sav[i] += bav[i]
# 3. 校验 SAV 总分
sav_total = sum(sav)
# 4. SAV 评估
sav_assessment = []
for i in range(12):
score = sav[i]
if score >= 30:
level = "极吉"
elif score >= 28:
level = "吉利"
elif score >= 25:
level = "中等"
else:
level = "挑战"
sav_assessment.append({
'sign': SIGNS[i],
'score': score,
'level': level,
})
# 5. 含 Lagna 的完整 SAV(386 = 337 + 49)
full_sav = [sav[i] + lagna_sav[i] for i in range(12)]
# 6. Shodhya/Yoga Pinda 计算
yoga_pinda = calc_yoga_pinda(bav_results, planets, asc_sign_idx)
shodhya = {
pname: {
'rashi_pinda': row['rashi_pinda'],
'graha_pinda': row['graha_pinda'],
'total_pinda': row['yoga_pinda'],
'bindu_at_own_sign': row['bindu_at_own_sign'],
}
for pname, row in yoga_pinda.get('planets', {}).items()
}
# 7. 排名
ranked_sav = sorted(enumerate(sav_assessment), key=lambda x: x[1]['score'], reverse=True)
# 8. BAV 校验报告
bav_validation = []
all_valid = True
for planet in ALL_SOURCES:
r = bav_results[planet]
status = "✅" if r['valid'] else f"❌ 偏差{r['deviation']}"
if not r['valid']:
all_valid = False
bav_validation.append({
'planet': planet,
'actual': r['total'],
'expected': r['expected_total'],
'status': status,
})
return {
'method': 'Ashtakavarga八分法(BPHS/PVR书例校准v2.1)',
'version': '2.1',
'bav': bav_results,
'sav': {
'scores': {SIGNS[i]: sav[i] for i in range(12)},
'full_scores_with_lagna': {SIGNS[i]: full_sav[i] for i in range(12)},
'assessment': sav_assessment,
'total': sav_total,
'expected_total': EXPECTED_SAV_TOTAL,
'valid': sav_total == EXPECTED_SAV_TOTAL,
'deviation': abs(sav_total - EXPECTED_SAV_TOTAL),
'full_total_with_lagna': sum(full_sav),
},
'bav_validation': bav_validation,
'all_bav_valid': all_valid,
'shodhya_pinda': shodhya,
'yoga_pinda': yoga_pinda,
'strongest_signs': [SIGNS[i] for i, _ in ranked_sav[:3]],
'weakest_signs': [SIGNS[i] for i, _ in ranked_sav[-3:]],
'house_scores': _map_to_houses(sav_assessment, asc_sign_idx),
'house_scores_full': _map_to_houses_values(full_sav, asc_sign_idx),
}
def _map_to_houses(sav_assessment: List[Dict], asc_sign_idx: int) -> Dict:
"""将 SAV 分数映射到宫位"""
houses = {}
for house_num in range(1, 13):
sign_idx = (asc_sign_idx + house_num - 1) % 12
score = sav_assessment[sign_idx]['score']
level = sav_assessment[sign_idx]['level']
houses[f"house_{house_num}"] = {
'sign': SIGNS[sign_idx],
'sav_score': score,
'level': level,
}
return houses
def _map_to_houses_values(scores: List[int], asc_sign_idx: int) -> Dict:
"""将含Lagna的完整SAV映射到宫位"""
houses = {}
for house_num in range(1, 13):
sign_idx = (asc_sign_idx + house_num - 1) % 12
houses[f"house_{house_num}"] = {
'sign': SIGNS[sign_idx],
'sav_score': scores[sign_idx],
}
return houses
def calc_prastara_av(planets: Dict, asc_sign_idx: int) -> Dict:
"""
计算 Prastara Ashtakavarga (PAV) — 展开式八分行星力量表
PAV 是一个 7×12×8 的三维矩阵:
- 7 颗行星 (Sun, Moon, Mars, Mercury, Jupiter, Venus, Saturn)
- 12 个宫位/星座
- 8 个贡献源 (7行星 + Lagna)
每格为 0 或 1,表示该贡献源是否对该行星的该宫位贡献 bindu。
Args:
planets: 行星数据 dict,每颗行星需要 {sign, degree, ...}
asc_sign_idx: 上升星座在 SIGNS 中的索引 (0-11)
Returns:
{
'pav': {planet: {source: [12个0/1值]}},
'pav_summary': {planet: {source: total_bindus}},
'validation': {...}
}
"""
# 1. 确定每个来源的星座索引
source_sign_idx = {}
for pname in SEVEN_PLANETS:
if pname in planets:
sign = planets[pname].get('sign', '')
if sign in SIGNS:
source_sign_idx[pname] = SIGNS.index(sign)
source_sign_idx['Lagna'] = asc_sign_idx
# 2. 计算 PAV 三维矩阵
pav = {}
pav_summary = {}
for planet in SEVEN_PLANETS:
contribution_rules = BAV_CONTRIBUTION[planet]
pav[planet] = {}
pav_summary[planet] = {}
for source in ALL_SOURCES:
if source not in source_sign_idx:
continue
# 12个宫位的bindu标记(0或1)
bindu_markers = [0] * 12
favorable_houses = contribution_rules.get(source, [])
source_idx = source_sign_idx[source]
for house in favorable_houses:
target_sign = (source_idx + house - 1) % 12
bindu_markers[target_sign] = 1
pav[planet][source] = bindu_markers
pav_summary[planet][source] = sum(bindu_markers)
# 3. 验证:每行的总和应等于BAV对应值
validation = []
all_valid = True
for planet in SEVEN_PLANETS:
for source in ALL_SOURCES:
if source not in pav_summary.get(planet, {}):
continue
actual = pav_summary[planet][source]
# 从BAV_CONTRIBUTION查期望的贡献数(该source对planet的宫位数)
expected = len(BAV_CONTRIBUTION[planet].get(source, []))
valid = actual == expected
if not valid:
all_valid = False
validation.append({
'planet': planet,
'source': source,
'actual': actual,
'expected': expected,
'valid': valid,
})
return {
'method': 'Prastara Ashtakavarga (PAV)',
'version': '1.0',
'matrix_shape': {'planets': len(SEVEN_PLANETS), 'signs': 12, 'sources': len(ALL_SOURCES)},
'pav': pav,
'pav_summary': pav_summary,
'validation': validation,
'all_valid': all_valid,
}
def calc_yoga_pinda(bav_results: Dict, planets: Dict, asc_sign_idx: int) -> Dict:
"""
计算 Yoga Pinda / Shodhya Pinda 汇总。
本项目沿用既有 v2.1 口径:取行星本星座的 BAV bindu,分别乘以星座权重与行星权重;
两者相加作为 Yoga Pinda。该函数把原本嵌在 calc_ashtakavarga() 内部的结果提升为
一等契约,便于 API、前端与测试直接引用。
"""
source_sign_idx = {}
for pname in SEVEN_PLANETS:
if pname in planets:
sign = planets[pname].get('sign', '')
if sign in SIGNS:
source_sign_idx[pname] = SIGNS.index(sign)
source_sign_idx['Lagna'] = asc_sign_idx
rows = {}
all_valid = True
for pname in SEVEN_PLANETS:
bav_row = bav_results.get(pname, {})
bindus = bav_row.get('bindus', [])
own_sign = source_sign_idx.get(pname)
if own_sign is None or len(bindus) != 12:
all_valid = False
continue
bindu_at_own = bindus[own_sign]
sign_weight = 4
planet_weight = PLANET_WEIGHTS.get(pname, 5)
rashi_pinda = bindu_at_own * sign_weight
graha_pinda = bindu_at_own * planet_weight
rows[pname] = {
'sign': SIGNS[own_sign],
'bindu_at_own_sign': bindu_at_own,
'rashi_weight': sign_weight,
'planet_weight': planet_weight,
'rashi_pinda': rashi_pinda,
'graha_pinda': graha_pinda,
'yoga_pinda': rashi_pinda + graha_pinda,
}
ranked = sorted(rows.items(), key=lambda item: item[1]['yoga_pinda'], reverse=True)
strongest = ranked[0][0] if ranked else None
weakest = ranked[-1][0] if ranked else None
total = sum(item['yoga_pinda'] for item in rows.values())
return {
'method': 'Yoga Pinda',
'version': '1.0',
'planets': rows,
'summary': {
'strongest_planet': strongest,
'weakest_planet': weakest,
'total_yoga_pinda': total,
'average_yoga_pinda': round(total / len(rows), 2) if rows else 0,
},
'all_valid': all_valid and len(rows) == len(SEVEN_PLANETS),
'validation_note': 'Yoga Pinda = Rashi Pinda + Graha Pinda;当前复用既有 v2.1 Shodhya Pinda 权重口径。',
}
# calc_sodhita_av (removed, BUG-1209): it subtracted the Sun, Mars and Saturn
# contributions from every BAV and was labelled "BPHS标准"; that is not the BPHS
# Sodhita. The standard chain (Trikona -> Ekadhipatya per BAV row) is
# scripts/ashtakavarga_shodhana.py, built on the two functions below.
TRIKONA_SIGN_GROUPS = (
(0, 4, 8), # Aries, Leo, Sagittarius
(1, 5, 9), # Taurus, Virgo, Capricorn
(2, 6, 10), # Gemini, Libra, Aquarius
(3, 7, 11), # Cancer, Scorpio, Pisces
)
def calc_trikona_shodhana(sav_scores: List[int]) -> Dict:
"""Apply the classical Trikona Shodhana reduction to a 12-sign SAV row.
The minimum value in each elemental trine is removed from all three signs;
the original row remains in the result for auditability.
"""
if len(sav_scores) != 12:
raise ValueError('SAV scores must contain exactly 12 signs')
original = [int(value) for value in sav_scores]
reduced = original[:]
groups = []
for indexes in TRIKONA_SIGN_GROUPS:
baseline = min(original[index] for index in indexes)
for index in indexes:
reduced[index] -= baseline
groups.append({'sign_indexes': list(indexes), 'baseline_removed': baseline})
return {
'method': 'Trikona Shodhana',
'original_scores': original,
'reduced_scores': reduced,
'trikona_groups': groups,
'status': 'executed',
'claim_boundary': 'Standalone classical reduction; Ekadhipatya Shodhana remains a separate layer.',
}
EKADHIPATYA_SIGN_PAIRS = ((0, 7), (1, 6), (2, 5), (8, 11), (9, 10))
def calc_ekadhipatya_shodhana(
rows: Dict[str, List[int]], occupied_signs: set[int]
) -> Dict:
"""Apply PyJHora/PVR Ekadhipatya Shodhana to post-Trikona rows."""
reduced = {planet: list(values) for planet, values in rows.items()}
audit = []
for planet, values in reduced.items():
if len(values) != 12:
raise ValueError('each BAV row must contain exactly 12 signs')
for first, second in EKADHIPATYA_SIGN_PAIRS:
first_value, second_value = values[first], values[second]
if first_value == 0 or second_value == 0:
continue
first_occupied = first in occupied_signs
second_occupied = second in occupied_signs
before = [first_value, second_value]
if first_occupied and second_occupied:
continue
if first_occupied != second_occupied:
occupied, vacant = (first, second) if first_occupied else (second, first)
values[vacant] = values[occupied] if values[occupied] < values[vacant] else 0
elif first_value == second_value:
values[first] = values[second] = 0
elif first_value > second_value:
values[first] = second_value
else:
values[second] = first_value
audit.append({'planet': planet, 'pair': [first, second], 'before': before,
'after': [values[first], values[second]]})
return {
'method': 'Ekadhipatya Shodhana',
'rows': reduced,
'occupied_signs': sorted(occupied_signs),
'audit': audit,
'status': 'executed',
'claim_boundary': 'Applied after Trikona Shodhana; source profile follows PyJHora/PVR rule ordering.',
}