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
+10 -4
View File
@@ -14,7 +14,11 @@ def test(name):
# === 模块导入测试 ===
@test("ashtakavarga import")
def t1(): from ashtakavarga import calc_ashtakavarga, calc_prastara_av, calc_sodhita_av
def t1():
# BUG-1209: calc_sodhita_av (Sun/Mars/Saturn removal, mislabelled BPHS) was removed;
# the standard chain lives in ashtakavarga_shodhana. Was: imported calc_sodhita_av.
from ashtakavarga import calc_ashtakavarga, calc_prastara_av # noqa: F401
from ashtakavarga_shodhana import sodhita_chain # noqa: F401
@test("shadbala import")
def t2(): from shadbala import calc_shadbala
@@ -354,16 +358,18 @@ def t56():
@test("Sodhita less or equal to original")
def t57():
from ashtakavarga import calc_ashtakavarga, calc_sodhita_av
# BUG-1209: standard Trikona -> Ekadhipatya chain (was calc_sodhita_av, <= 8 per cell).
from ashtakavarga import calc_ashtakavarga
from ashtakavarga_shodhana import sodhita_chain
s = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo','Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces']
p = {}
for i,(pn,d) in enumerate([('Sun',15),('Moon',75),('Mars',220),('Mercury',55),('Jupiter',310),('Venus',350),('Saturn',180)]):
p[pn] = {'sign':s[int(d/30)%12],'degree':d}
av = calc_ashtakavarga(p, 0)
sodhita = calc_sodhita_av(av['bav'], p, 0)
sodhita = sodhita_chain(av['bav'], p)
for pn in sodhita['sodhita_bav']:
for i in range(12):
assert sodhita['sodhita_bav'][pn][i] <= 8, f"Sodhita {pn}[{i}] should <= 8"
assert sodhita['sodhita_bav'][pn][i] <= av['bav'][pn]['bindus'][i], f"Sodhita {pn}[{i}] should <= raw"
# ── Dasha system tests ──
@test("Vimshottari remaining years positive")
+129
View File
@@ -0,0 +1,129 @@
"""Standard Ashtakavarga reduction chain for display (BUG-1209, ruling 乙8).
BAV -> Trikona Shodhana -> Ekadhipatya Shodhana per row -> Sodhita SAV (sum of
the reduced rows). Checked against the PVR book chart used by PyJHora
(`jhora.horoscope.chart.ashtakavarga`, Exercise 22 / Chart 7 BAV) for the
Trikona step, rule by rule for Ekadhipatya, and live on a fictional chart.
The removed `calc_sodhita_av` (Sun / Mars / Saturn contributions subtracted,
labelled "BPHS标准") is gone; transit scoring keeps the raw bindus.
"""
from __future__ import annotations
import pytest
import scripts.ashtakavarga as ashtakavarga
from scripts.ashtakavarga import SEVEN_PLANETS, SIGNS, calc_ashtakavarga
from scripts.ashtakavarga_shodhana import attach_house_values, occupied_signs, sodhita_chain
# PVR "Vedic Astrology: An Integrated Approach", Exercise 22 / Chart 7 BAV
# (Sun..Saturn rows, Aries..Pisces), as quoted in PyJHora 4.8.7.
PVR_CHART_7_BAV = [
[4, 2, 3, 4, 6, 5, 5, 3, 2, 6, 6, 2],
[6, 3, 5, 3, 5, 5, 6, 3, 3, 4, 4, 2],
[3, 2, 3, 4, 2, 5, 4, 3, 3, 4, 3, 3],
[4, 6, 4, 3, 4, 7, 4, 5, 6, 3, 5, 3],
[4, 4, 3, 5, 6, 5, 6, 4, 6, 4, 3, 6],
[3, 5, 5, 4, 6, 2, 3, 6, 5, 2, 7, 4],
[3, 2, 2, 3, 5, 6, 3, 4, 1, 3, 6, 1],
]
def _trikona_by_rules(row: list[int]) -> list[int]:
out = row[:]
for start in range(4):
trine = [start, start + 4, start + 8]
values = [row[index] for index in trine]
if 0 in values:
continue
if len(set(values)) == 1:
for index in trine:
out[index] = 0
continue
low = min(values)
for index in trine:
out[index] -= low
return out
def _bav(rows: list[list[int]]) -> dict:
return {name: {"bindus": row} for name, row in zip(SEVEN_PLANETS, rows)}
def test_old_mislabelled_variant_is_removed() -> None:
assert not hasattr(ashtakavarga, "calc_sodhita_av")
assert "BPHS标准" not in (ashtakavarga.__doc__ or "")
def test_trikona_step_on_the_pvr_book_bav() -> None:
chain = sodhita_chain(_bav(PVR_CHART_7_BAV), {})
for name, row in zip(SEVEN_PLANETS, PVR_CHART_7_BAV):
assert chain["trikona_bav"][name] == _trikona_by_rules(row), name
def test_trikona_step_matches_pyjhora_when_installed() -> None:
jhora = pytest.importorskip("jhora.horoscope.chart.ashtakavarga")
expected = jhora._trikona_sodhana([row[:] for row in PVR_CHART_7_BAV] + [[0] * 12])
chain = sodhita_chain(_bav(PVR_CHART_7_BAV), {})
assert [chain["trikona_bav"][name] for name in SEVEN_PLANETS] == [list(row) for row in expected[:7]]
@pytest.mark.parametrize("first,second,occupied,expected", [
(3, 5, set(), [3, 3]), # both vacant, different -> both the lower
(4, 4, set(), [0, 0]), # both vacant, equal -> both zero
(2, 5, {"A"}, [2, 2]), # vacant one higher -> occupied value
(5, 2, {"A"}, [5, 0]), # vacant one lower -> zero
(3, 5, {"A", "B"}, [3, 5]), # both occupied -> unchanged
(0, 5, set(), [0, 5]), # a zero -> unchanged
])
def test_ekadhipatya_rules_on_a_pair(first, second, occupied, expected) -> None:
# Pair Capricorn (9) / Aquarius (10); Mars, Mercury sit there when occupied.
row = [0] * 12
row[9], row[10] = first, second
rows = [row[:] for _ in SEVEN_PLANETS]
planets = {"Sun": {"sign": "Aries"}}
if "A" in occupied:
planets["Mars"] = {"sign": "Capricorn"}
if "B" in occupied:
planets["Mercury"] = {"sign": "Aquarius"}
# A trine with a zero is left alone by Trikona, so only Ekadhipatya acts here.
chain = sodhita_chain(_bav(rows), planets)
assert chain["sodhita_bav"]["Sun"][9:11] == expected
def test_nodes_and_lagna_do_not_occupy() -> None:
planets = {"Rahu": {"sign": "Capricorn"}, "Ketu": {"sign": "Cancer"}, "Moon": {"sign": "Leo"}}
assert occupied_signs(planets) == {SIGNS.index("Leo")}
def test_live_chart_chain_and_house_values() -> None:
planets = {name: {"sign": SIGNS[(index * 5) % 12]} for index, name in enumerate(SEVEN_PLANETS)}
result = calc_ashtakavarga(planets, 3)
chain = sodhita_chain(result["bav"], planets)
assert chain["status"] == "executed"
reduced = chain["sodhita_bav"]
for name in SEVEN_PLANETS:
assert all(0 <= value <= raw for value, raw in zip(reduced[name], result["bav"][name]["bindus"]))
scores = chain["sodhita_sav"]["scores"]
assert [scores[sign] for sign in SIGNS] == [sum(reduced[name][index] for name in SEVEN_PLANETS) for index in range(12)]
assert chain["sodhita_sav"]["total"] == sum(scores.values())
assert chain["raw_sav"]["scores"] == result["sav"]["scores"]
attach_house_values(result["house_scores"], chain)
for row in result["house_scores"].values():
assert row["sav_sodhita"] == scores[row["sign"]]
assert row["sav_score"] == result["sav"]["scores"][row["sign"]]
def test_consultation_layer_carries_the_chain() -> None:
from scripts.capture_report_blocked_repairs_golden import _handler
planets = {name: {"lon": (index * 47.0) % 360} for index, name in enumerate(SEVEN_PLANETS)}
response = _handler()._compute_ashtakavarga({"planets": planets, "ascendant": {"sign_idx": 2}})
result = response["result"]
assert result["sodhita"]["status"] == "executed"
assert all("sav_sodhita" in row for row in result["house_scores"].values())
assert response["rule_variants"]["available"][2]["source"] == "scripts/ashtakavarga_shodhana.py"
def test_missing_rows_block() -> None:
assert sodhita_chain({}, {})["status"] == "blocked"
+17 -9
View File
@@ -93,25 +93,31 @@ def test_declination_table_has_no_kranti_column(editions) -> None:
def test_ashtakavarga_house_table_shows_three_real_columns(editions) -> None:
zh, en, packet = editions
expected_sav = packet["worksheets"]["strengths_and_scores"]["ashtakavarga"]["sav"]["scores"]
# BUG-1209 (乙8): a Sodhita SAV column comes first and the raw columns are
# labelled Raw. Was: ["House", "Sign", "SAV", "Lagna BAV", "SAV + Lagna"]
# with the raw cells at indexes 2..4 (now 3..5).
sodhita = packet["worksheets"]["strengths_and_scores"]["ashtakavarga"]["sodhita"]["sodhita_sav"]["scores"]
for markdown, heading, header, total in (
(zh, "Ashtakavarga 完整宫位分数", ["House", "Sign", "SAV", "上升 BAV", "SAV + 上升"], "合计"),
(en, "Ashtakavarga Full House Scores", ["House", "Sign", "SAV", "Lagna BAV", "SAV + Lagna"], "Total"),
(zh, "Ashtakavarga 完整宫位分数", ["House", "Sign", "Sodhita SAV", "Raw SAV", "上升 BAV", "Raw SAV + 上升"], "合计"),
(en, "Ashtakavarga Full House Scores", ["House", "Sign", "Sodhita SAV", "Raw SAV", "Lagna BAV", "Raw SAV + Lagna"], "Total"),
):
rows = _table_after(markdown, heading)
assert rows[0] == header
body, totals = rows[1:13], rows[13]
for row in body:
sav, lagna, full = (int(cell) for cell in row[2:])
sav, lagna, full = (int(cell) for cell in row[3:])
assert sav + lagna == full
assert totals == [total, "-", str(EXPECTED_SAV_TOTAL), str(BAV_TOTALS["Lagna"]), str(EXPECTED_SAV_TOTAL + BAV_TOTALS["Lagna"])]
assert int(row[2]) <= sav
assert totals == [total, "-", str(sum(sodhita.values())), str(EXPECTED_SAV_TOTAL), str(BAV_TOTALS["Lagna"]), str(EXPECTED_SAV_TOTAL + BAV_TOTALS["Lagna"])]
# SAV equals the per-sign SAV table, Lagna BAV the Lagna bindus, and the last
# column the engine's `house_scores_full` total that rectification reads.
ashtakavarga = packet["worksheets"]["strengths_and_scores"]["ashtakavarga"]
full = {row["sign"]: row["sav_score"] for row in ashtakavarga["house_scores_full"].values()}
for row in _table_after(en, "Ashtakavarga Full House Scores")[1:13]:
assert int(row[2]) == expected_sav[row[1]]
assert int(row[3]) == ashtakavarga["bav"]["Lagna"]["bindus"][SIGNS.index(row[1])]
assert int(row[4]) == full[row[1]]
assert int(row[2]) == sodhita[row[1]]
assert int(row[3]) == expected_sav[row[1]]
assert int(row[4]) == ashtakavarga["bav"]["Lagna"]["bindus"][SIGNS.index(row[1])]
assert int(row[5]) == full[row[1]]
def test_ashtakavarga_house_table_never_labels_the_total_as_sav(editions) -> None:
@@ -120,8 +126,10 @@ def test_ashtakavarga_house_table_never_labels_the_total_as_sav(editions) -> Non
rows = _table_after(_pl9_export_markdown_for_edition(packet, "reader_main"), "Ashtakavarga 完整宫位分数")
assert len(rows) == 13, "no total row without the split"
for row in rows[1:]:
assert row[2:4] == ["-", "-"]
assert row[4].isdigit()
# BUG-1209: raw SAV / Lagna BAV moved from [2:4] to [3:5]; Sodhita at [2].
assert row[2].isdigit()
assert row[3:5] == ["-", "-"]
assert row[5].isdigit()
def test_chart_section_carries_the_divisional_caveat(editions) -> None: