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
This commit is contained in:
Jesse_Chen
2026-10-03 12:41:26 +08:00
co-authored by Claude Opus 5.5
parent 5fdf2f7498
commit ea78804b78
16 changed files with 1031 additions and 288 deletions
+4 -95
View File
@@ -491,101 +491,10 @@ def calc_yoga_pinda(bav_results: Dict, planets: Dict, asc_sign_idx: int) -> Dict
}
def calc_sodhita_av(bav_results: Dict, planets: Dict, asc_sign_idx: int) -> Dict:
"""
计算 Sodhita Ashtakavarga — 净化后的八分行星力量表
Sodhita 算法(BPHS标准):
从每颗行星的每个宫位BAV中,减去Saturn、Mars和Sun的贡献(若结果为负取0)。
这反映了"减去凶星影响后的纯净力量"。
Args:
bav_results: calc_ashtakavarga返回的bav结果
planets: 行星数据 dict
asc_sign_idx: 上升星座索引
Returns:
{
'sodhita_bav': {planet: [12个sodhita值]},
'sodhita_sav': [12个sodhita SAV值],
'reduction_summary': {...}
}
"""
# 1. 获取PAV展开表(需要知道每个贡献源的单独贡献)
pav_data = calc_prastara_av(planets, asc_sign_idx)
pav = pav_data['pav']
# 2. 定义需要减去的"凶星"贡献源
malefic_sources = ['Saturn', 'Mars', 'Sun']
# 3. 计算Sodhita BAV
sodhita_bav = {}
total_reduction = {}
for planet in SEVEN_PLANETS:
if planet not in pav:
continue
# 原始BAV(从所有8个来源)
original_bav = [0] * 12
for source in ALL_SOURCES:
if source in pav.get(planet, {}):
for i in range(12):
original_bav[i] += pav[planet][source][i]
# 减去凶星贡献
reduction = [0] * 12
sodhita = [0] * 12
for i in range(12):
malefic_sum = 0
for source in malefic_sources:
if source in pav.get(planet, {}):
malefic_sum += pav[planet][source][i]
reduction[i] = malefic_sum
sodhita[i] = max(0, original_bav[i] - malefic_sum)
sodhita_bav[planet] = sodhita
total_reduction[planet] = sum(reduction)
# 4. 计算Sodhita SAV
sodhita_sav = [0] * 12
for planet in SEVEN_PLANETS:
if planet in sodhita_bav:
for i in range(12):
sodhita_sav[i] += sodhita_bav[planet][i]
# 5. 评估
sodhita_assessment = []
for i in range(12):
score = sodhita_sav[i]
if score >= 25:
level = "极吉"
elif score >= 22:
level = "吉利"
elif score >= 19:
level = "中等"
else:
level = "挑战"
sodhita_assessment.append({
'sign': SIGNS[i],
'score': score,
'level': level,
})
return {
'method': 'Sodhita Ashtakavarga (BPHS标准)',
'version': '1.0',
'sodhita_bav': sodhita_bav,
'sodhita_sav': {
'scores': {SIGNS[i]: sodhita_sav[i] for i in range(12)},
'assessment': sodhita_assessment,
'total': sum(sodhita_sav),
},
'reduction_summary': total_reduction,
'validation_note': 'Sodhita AV ≤ 原始BAV(逐宫位)',
}
# 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 = (
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Standard Ashtakavarga reduction chain for display (ruling 乙8, BUG-1209).
BAV of each of the seven planets -> Trikona Shodhana -> Ekadhipatya Shodhana ->
Sodhita BAV; Sodhita SAV is the sum of the seven reduced BAV rows. The two
reductions are `ashtakavarga.calc_trikona_shodhana` (the minimum of each trine
removed from its three signs, i.e. PVR rules 1-3) and
`ashtakavarga.calc_ekadhipatya_shodhana` (the five dual-lordship sign pairs,
PVR rules 1-4), applied to every BAV row as BPHS ch. 67 orders them.
Occupied signs for Ekadhipatya: signs holding one of the seven grahas (Rahu,
Ketu and the Lagna are not counted). PyJHora 4.8.7 counts any non-empty chart
cell (nodes, Lagna marker) and reduces only each planet's own sign pair in its
own row; both differences are recorded as a question for the astrologer.
Display only: transit (gochara) scoring keeps the raw BAV bindus (decision
record of TASK-astrologer-rulings-batch1-20261003). Raw values are kept beside
the reduced ones.
"""
from __future__ import annotations
from typing import Any, Dict, List
try: # scripts/ on sys.path
from ashtakavarga import SEVEN_PLANETS, SIGNS, calc_ekadhipatya_shodhana, calc_trikona_shodhana
except ImportError: # package import
from scripts.ashtakavarga import SEVEN_PLANETS, SIGNS, calc_ekadhipatya_shodhana, calc_trikona_shodhana
METHOD = 'Trikona Shodhana -> Ekadhipatya Shodhana (BPHS ch. 67), per BAV row; Sodhita SAV = sum of reduced BAV'
METHOD_ZH = '三方净化 → 单宫主净化(BPHS 第 67 章,逐行 BAV);净化后 SAV = 七星净化后 BAV 之和'
def occupied_signs(planets: Dict[str, Any]) -> set[int]:
signs = set()
for name in SEVEN_PLANETS:
sign = (planets.get(name) or {}).get('sign') if isinstance(planets.get(name), dict) else None
if sign in SIGNS:
signs.add(SIGNS.index(sign))
return signs
def sodhita_chain(bav_results: Dict[str, Any], planets: Dict[str, Any]) -> Dict[str, Any]:
"""Reduced BAV rows and Sodhita SAV from `calc_ashtakavarga(...)['bav']`."""
raw: Dict[str, List[int]] = {}
for name in SEVEN_PLANETS:
bindus = (bav_results.get(name) or {}).get('bindus')
if isinstance(bindus, list) and len(bindus) == 12:
raw[name] = [int(value) for value in bindus]
if len(raw) != len(SEVEN_PLANETS):
return {'status': 'blocked', 'reason': 'bav_rows_missing', 'method': METHOD}
trikona = {name: calc_trikona_shodhana(row)['reduced_scores'] for name, row in raw.items()}
occupied = occupied_signs(planets)
ekadhipatya = calc_ekadhipatya_shodhana(trikona, occupied)
reduced = ekadhipatya['rows']
sav = [sum(reduced[name][index] for name in SEVEN_PLANETS) for index in range(12)]
raw_sav = [sum(raw[name][index] for name in SEVEN_PLANETS) for index in range(12)]
return {
'status': 'executed',
'method': METHOD,
'method_zh': METHOD_ZH,
'source': 'scripts/ashtakavarga_shodhana.py (ashtakavarga.calc_trikona_shodhana + calc_ekadhipatya_shodhana)',
'occupied_signs': [SIGNS[index] for index in sorted(occupied)],
'raw_bav': raw,
'trikona_bav': trikona,
'sodhita_bav': reduced,
'ekadhipatya_audit': ekadhipatya['audit'],
'sodhita_sav': {
'scores': {SIGNS[index]: sav[index] for index in range(12)},
'assessment': [{'sign': SIGNS[index], 'score': sav[index]} for index in range(12)],
'total': sum(sav),
},
'raw_sav': {'scores': {SIGNS[index]: raw_sav[index] for index in range(12)}, 'total': sum(raw_sav)},
'transit_scoring': 'raw BAV bindus (unchanged)',
}
def attach_house_values(house_scores: Dict[str, Any], chain: Dict[str, Any]) -> None:
"""Add `sav_sodhita` next to the raw `sav_score` of each house row."""
if chain.get('status') != 'executed' or not isinstance(house_scores, dict):
return
scores = chain['sodhita_sav']['scores']
for row in house_scores.values():
if isinstance(row, dict) and row.get('sign') in scores:
row['sav_sodhita'] = scores[row['sign']]
+4 -2
View File
@@ -7917,7 +7917,9 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
ashtakavarga = _load_local_module('ashtakavarga')
result = ashtakavarga.calc_ashtakavarga(planets, asc_sign_idx)
pav = ashtakavarga.calc_prastara_av(planets, asc_sign_idx)
sodhita = ashtakavarga.calc_sodhita_av(result.get('bav', {}), planets, asc_sign_idx)
# BUG-1209 (乙8): standard Trikona -> Ekadhipatya chain; raw values kept.
sodhita = _load_local_module('ashtakavarga_shodhana').sodhita_chain(result.get('bav', {}), planets)
_load_local_module('ashtakavarga_shodhana').attach_house_values(result.get('house_scores'), sodhita)
yoga_pinda = result.get('yoga_pinda') or ashtakavarga.calc_yoga_pinda(result.get('bav', {}), planets, asc_sign_idx)
result['pav'] = pav
result['sodhita'] = sodhita
@@ -8085,7 +8087,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
'available': [
{'key': 'sav_bav', 'label': 'SAV/BAV', 'status': 'active', 'source': 'scripts/ashtakavarga.py'},
{'key': 'prastara_av', 'label': 'Prastara AV / PAV', 'status': 'active', 'source': 'scripts/ashtakavarga.py'},
{'key': 'sodhita_av', 'label': 'Sodhita AV', 'status': 'active', 'source': 'scripts/ashtakavarga.py'},
{'key': 'sodhita_av', 'label': 'Sodhita AV (Trikona + Ekadhipatya)', 'status': 'active', 'source': 'scripts/ashtakavarga_shodhana.py'},
{'key': 'yoga_pinda', 'label': 'Yoga Pinda', 'status': 'active', 'source': 'scripts/ashtakavarga.py'},
],
'boundary': '当前按本地 BAV 贡献规则生成 SAV、PAV、Sodhita 与 Yoga Pinda;后续可继续加入 Sarvashtakavarga 规则版本对比。',
+12 -9
View File
@@ -7513,23 +7513,24 @@ def render_pl9_markdown(packet: dict) -> str:
if sodhita_rows:
out.extend([
'',
'#### Sodhita Ashtakavarga(本地减分结果)',
'#### Sodhita Ashtakavarga(三方净化 + 单宫主净化)',
'',
'本表复用既有本地 Sodhita producer,按该 producer 的 Sun / Mars / Saturn 贡献扣减生成。它不等同于 PL9 第 54 页的 Trikona / Ekadhipatya reduction 前后矩阵;逐阶段规则、矩阵与外部数值 parity 未闭环,所有行保持 parameter_sensitive。',
'逐行 BAV 先做三方净化(Trikona Shodhana),再做单宫主净化(Ekadhipatya Shodhana),净化后 SAV 为七星净化后 BAV 之和;原始 SAV 并列。外部数值 parity 未闭环,所有行保持 parameter_sensitive。',
'',
'| 项目 | 数值 | 状态 |',
'|------|------|------|',
f"| 方法 | {_md_cell(sodhita.get('method'))} | parameter_sensitive |",
f"| 总分 | {_md_cell(sodhita_sav.get('total'))} | parameter_sensitive |",
f"| 方法 | {_md_cell(sodhita.get('method_zh') or sodhita.get('method'))} | parameter_sensitive |",
f"| 净化后总分 | {_md_cell(sodhita_sav.get('total'))} | parameter_sensitive |",
'',
'| 星座 | Sodhita SAV | 本地等级 | 状态 |',
'|------|-------------|----------|------|',
'| 星座 | 净化后 SAV | 原始 SAV | 状态 |',
'|------|------------|----------|------|',
])
raw_scores = (sodhita.get('raw_sav') or {}).get('scores') or {}
for row in sodhita_rows:
if isinstance(row, dict):
out.append(
f"| {_md_cell(_humanize_reader_token(row.get('sign')))} | {_md_cell(row.get('score'))} | "
f"{_md_cell(row.get('level'))} | parameter_sensitive |"
f"{_md_cell(raw_scores.get(row.get('sign')))} | parameter_sensitive |"
)
pav = ashtakavarga.get('pav') if isinstance(ashtakavarga.get('pav'), dict) else {}
pav_summary = pav.get('pav_summary') if isinstance(pav.get('pav_summary'), dict) else {}
@@ -16490,9 +16491,11 @@ def cmd_full_reading(args):
# ── Step 11: Ashtakavarga八分法 ──
asht_data = None
try:
from ashtakavarga import calc_ashtakavarga, calc_prastara_av, calc_sodhita_av
from ashtakavarga import calc_ashtakavarga, calc_prastara_av
from ashtakavarga_shodhana import attach_house_values, sodhita_chain # BUG-1209
asht_data = calc_ashtakavarga(planets, asc_idx)
asht_data['sodhita'] = calc_sodhita_av(asht_data.get('bav', {}), planets, asc_idx)
asht_data['sodhita'] = sodhita_chain(asht_data.get('bav', {}), planets)
attach_house_values(asht_data.get('house_scores'), asht_data['sodhita'])
asht_data['pav'] = calc_prastara_av(planets, asc_idx)
report['modules']['ashtakavarga'] = asht_data
except Exception as e:
+38 -6
View File
@@ -1310,19 +1310,34 @@ def render_pl9_parity_markdown(packet: dict) -> str:
ashtakavarga = _dict(strengths.get('ashtakavarga'))
sav = _dict(ashtakavarga.get('sav'))
sav_scores = _dict(sav.get('scores'))
# BUG-1209 (乙8): the main value is the Sodhita SAV (Trikona then
# Ekadhipatya per BAV row); the raw value stands beside it.
sodhita = _dict(ashtakavarga.get('sodhita'))
sodhita_ok = sodhita.get('status') == 'executed'
sodhita_scores = _dict(_dict(sodhita.get('sodhita_sav')).get('scores')) if sodhita_ok else {}
sodhita_bav = _dict(sodhita.get('sodhita_bav')) if sodhita_ok else {}
english = _pl9_report_language(packet) == 'en'
if sav_scores:
lines.extend([
'### p51-p60 Ashtakavarga SAV / BAV / Pinda',
'',
f"- 方法:{_cell(ashtakavarga.get('method'))}",
f"- BAV 校验:{_cell('all_valid' if ashtakavarga.get('all_bav_valid') else ashtakavarga.get('all_bav_valid'))}",
])
if sodhita_ok:
lines.append(
f"- Reduction: {_cell(sodhita.get('method'))}" if english
else f"- 净化:{_cell(sodhita.get('method_zh') or sodhita.get('method'))};原始值并列"
)
lines.extend([
'',
'| Sign | SAV score |',
'|------|-----------|',
'| Sign | Sodhita SAV | Raw SAV |' if sodhita_ok else '| Sign | Raw SAV |',
'|------|-------------|---------|' if sodhita_ok else '|------|---------|',
])
for sign in globals().get('SIGNS', []):
if sign in sav_scores:
lines.append(f"| {_cell(_token(sign))} | {_cell(sav_scores.get(sign))} |")
main = f"{_cell(sodhita_scores.get(sign))} | " if sodhita_ok else ''
lines.append(f"| {_cell(_token(sign))} | {main}{_cell(sav_scores.get(sign))} |")
lines.append('')
full_house_scores = ashtakavarga.get('house_scores_full')
if isinstance(full_house_scores, dict):
@@ -1335,8 +1350,8 @@ def render_pl9_parity_markdown(packet: dict) -> str:
lines.extend([
'### p53 Ashtakavarga 完整宫位分数',
'',
'| House | Sign | SAV | Lagna BAV | SAV + Lagna |',
'|-------|------|-----|-----------|-------------|',
'| House | Sign | Sodhita SAV | Raw SAV | Lagna BAV | Raw SAV + Lagna |',
'|-------|------|-------------|---------|-----------|-----------------|',
])
# `house_scores_full[*].sav_score` is the with-Lagna total (386
# scale), never plain SAV (BUG-1200). Split it from the engine's
@@ -1361,15 +1376,32 @@ def render_pl9_parity_markdown(packet: dict) -> str:
sav_value, lagna_value = parts if split else (None, None)
lines.append(
f"| {_cell(row.get('house'))} | {_cell(_token(row.get('sign')))} | "
f"{_cell(sodhita_scores.get(row.get('sign')))} | "
f"{_cell(sav_value)} | {_cell(lagna_value)} | {_cell(row.get('sav_score'))} |"
)
if split:
sodhita_total = sum(sodhita_scores.values()) if len(sodhita_scores) == 12 else '-'
lines.append(
f"| 合计 | - | {sum(parts[0] for parts in splits)} | "
f"| 合计 | - | {sodhita_total} | {sum(parts[0] for parts in splits)} | "
f"{sum(parts[1] for parts in splits)} | {sum(row['sav_score'] for row in full_rows)} |"
)
lines.append('')
bav = _dict(ashtakavarga.get('bav'))
if bav and sodhita_bav:
signs = globals().get('SIGNS', [])
lines.extend([
('Sodhita BAV (after Trikona and Ekadhipatya reduction):' if english else '净化后 BAV(三方净化 + 单宫主净化):'),
'',
'| Planet | Sodhita total | Aries | Taurus | Gemini | Cancer | Leo | Virgo | Libra | Scorpio | Sagittarius | Capricorn | Aquarius | Pisces |',
'|--------|---------------|-------|--------|--------|--------|-----|-------|-------|---------|-------------|-----------|----------|--------|',
])
for planet in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'):
reduced = _list(sodhita_bav.get(planet))
if len(reduced) != 12:
continue
cells = ' | '.join(_cell(value) for value in reduced)
lines.append(f"| {_cell(_token(planet))} | {_cell(sum(reduced))} | {cells} |")
lines.extend(['', 'Raw BAV:' if english else '原始 BAV:', ''])
if bav:
signs = globals().get('SIGNS', [])
lines.extend([
@@ -0,0 +1,46 @@
"""Refresh one leaf of the report density fixture from the real engine (BUG-1209).
`frontend/tests/fixtures/report-density-fictional-engine.json` is a 2026-09-23
real-engine snapshot without a capture script. The Ashtakavarga reduction moved
to the standard Trikona -> Ekadhipatya chain, so only
`worksheets.strengths_and_scores.ashtakavarga` is recaptured here, from the
fixture's own recorded input (fictional, 2000-01-01 12:00 UT, 0/0); every other
leaf stays byte-identical.
PYTHONHASHSEED=0 python3 scripts/research/refresh_density_fixture_ashtakavarga.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path[:0] = [str(ROOT), str(ROOT / "scripts")]
from scripts.jyotish_engine import build_professional_report_reference_packet, cmd_full_reading # noqa: E402
from scripts.professional_report_reference import _export_args # noqa: E402
FIXTURE = ROOT / "frontend/tests/fixtures/report-density-fictional-engine.json"
def main() -> int:
text = FIXTURE.read_text(encoding="utf-8")
fixture = json.loads(text)
case = dict(fixture["fixtureProvenance"]["input"])
case.setdefault("age", int(case["target_year"]) - int(case["year"]))
packet = build_professional_report_reference_packet(cmd_full_reading(type("Args", (), dict(case))()), _export_args(dict(case)), [])
fixture["worksheets"]["strengths_and_scores"]["ashtakavarga"] = packet["worksheets"]["strengths_and_scores"]["ashtakavarga"]
fixture["fixtureProvenance"]["refreshed"] = {
"leaf": "worksheets.strengths_and_scores.ashtakavarga",
"reason": "BUG-1209 standard Sodhita chain (TASK-astrologer-rulings-batch1-20261003 T7)",
"generator": "scripts/research/refresh_density_fixture_ashtakavarga.py",
}
indent = 2 if text.startswith("{\n ") else None
FIXTURE.write_text(json.dumps(fixture, ensure_ascii=False, indent=indent) + ("\n" if text.endswith("\n") else ""), encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())