From 6feb1a4aaae856ba7b2578c0d2655fcf6bf55c39 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 3 Oct 2026 12:45:58 +0800 Subject: [PATCH] feat(bhava-bala): reports use the formal three components; local +2/-1.5 score hidden (T4, BUG-1210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Astrologer ruling 甲13. shadbala.calc_bhava_bala renamed calc_local_bhava_score and removed from calc_shadbala's result (it fed the report's Bhava Bala table). New bhava_bala.bhava_bala_from_chart (Bhavadhipati = lord's Shadbala virupas, Bhava Dig, Bhava Drishti; equal houses as /api/bhava_bala; total and total_rupas; the module's invented level bands not shown) drives modules.bhava_bala in the full reading. Reader table: Bhavadhipati | Dig | Drishti | Total (Virupa) | Total (Rupa); reference edition table and wealth/summary lines read the formal totals. Other users of the local score: none outside shadbala.py (remedies and health narrative read Shadbala rupas only). Changed assertions (three columns in PROGRESS): test_shadbala_complete local-score tests renamed; test_report_longform_gaps2 fixture rows and heading. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE --- scripts/bhava_bala.py | 41 +++++++++++++++++++ scripts/jyotish_engine.py | 40 +++++++++--------- scripts/pl9_reader_export.py | 12 +++--- scripts/shadbala.py | 18 +++++---- tests/test_bhava_bala_formal.py | 63 +++++++++++++++++++++++++++++ tests/test_report_longform_gaps2.py | 13 ++++-- tests/test_shadbala_complete.py | 14 +++---- 7 files changed, 160 insertions(+), 41 deletions(-) create mode 100644 tests/test_bhava_bala_formal.py diff --git a/scripts/bhava_bala.py b/scripts/bhava_bala.py index 387f990d..77aa0ce9 100644 --- a/scripts/bhava_bala.py +++ b/scripts/bhava_bala.py @@ -244,3 +244,44 @@ def calc_bhava_bala(house_signs: List[str], house_degrees: List[float], 'strongest_house': max(houses, key=lambda h: h['total'])['house'], 'weakest_house': min(houses, key=lambda h: h['total'])['house'], } + + +def bhava_bala_from_chart(planets: Dict, asc_sign: str, asc_degree: float, shadbala_planets: Dict) -> Dict: + """Formal three-component Bhava Bala for one D1 chart (BUG-1210, ruling 甲13). + + `planets` rows carry `sign` and a degree (`degree_in_sign`, `degree` or a + longitude); `shadbala_planets` is `shadbala.calc_shadbala(...)['planets']`, + whose `total_virupas` is each lord's Shadbala (Bhavadhipati Bala). Houses are + equal houses from the Lagna degree, as `/api/bhava_bala` uses. Totals are in + Virupas; `total_rupas` = total / 60. The `level` bands of `calc_bhava_bala` + are not a classical scale and are not displayed. + """ + if asc_sign not in SIGNS: + return {} + asc_idx = SIGNS.index(asc_sign) + house_signs = [SIGNS[(asc_idx + i) % 12] for i in range(12)] + house_degrees = [float(asc_degree) % 30 for _ in range(12)] + positions = {} + for name, row in (planets or {}).items(): + if not isinstance(row, dict) or row.get('sign') not in SIGNS: + continue + degree = row.get('degree_in_sign') + if degree is None: + degree = row.get('degree', 0) + positions[name] = {'sign': row['sign'], 'degree': float(degree or 0) % 30} + virupas = {} + for name, row in (shadbala_planets or {}).items(): + value = row.get('total_virupas') if isinstance(row, dict) else None + if isinstance(value, (int, float)): + virupas[name] = float(value) + if len(virupas) < 7: + return {} + result = calc_bhava_bala(house_signs, house_degrees, asc_sign, float(asc_degree) % 30, positions, virupas) + for house in result['houses']: + house['lord'] = SIGN_LORDS.get(house['sign']) + house['total_rupas'] = round(house['total'] / 60.0, 2) + house.pop('level', None) + result['method'] = 'Bhava Bala 三分量:Bhavadhipati(宫主 Shadbala)+ Bhava Dig + Bhava Drishti(jyotishganit MIT 算法,等宫)' + result['unit'] = 'virupa' + result['source'] = 'scripts/bhava_bala.py' + return result diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 0978c7df..75b438df 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -5250,11 +5250,10 @@ def render_pl9_markdown(packet: dict) -> str: row = next((item for item in houses if isinstance(item, dict) and item.get('house') == house_no), None) if not row: continue - score = row.get('score') - strength = row.get('strength') or '-' - focus.append(f"{house_no}宫={score}({strength})") + # BUG-1210: formal three components, total in Virupa / Rupa. + focus.append(f"{house_no}宫={row.get('total')} Virupa({row.get('total_rupas')} Rupa)") if focus: - rows.append(('Bhava Bala 主题宫位', ';'.join(focus), 'simplified_bhava_bala_lord_occupant_aspect', '仅为本地三分量相对分;不是外部同口径 Rupas 表。')) + rows.append(('Bhava Bala 主题宫位', ';'.join(focus), 'bhava_bala_three_components', 'Bhavadhipati + Bhava Dig + Bhava Drishti 三分量(等宫);外部同口径数值对照未闭环。')) ashtakavarga = strength_sheet.get('ashtakavarga') if isinstance(strength_sheet.get('ashtakavarga'), dict) else {} assessment = (ashtakavarga.get('sav') or {}).get('assessment') if isinstance(ashtakavarga.get('sav'), dict) else [] @@ -6796,7 +6795,7 @@ def render_pl9_markdown(packet: dict) -> str: if not isinstance(row, dict): continue try: - score = float(row.get('score')) + score = float(row.get('total', row.get('score'))) except (TypeError, ValueError): continue if top_score is None or score > top_score: @@ -7439,7 +7438,7 @@ def render_pl9_markdown(packet: dict) -> str: return [ '### 力量、Ashtakavarga 与功能性吉凶', '', - *_blocked_planned_section('#### Bhava Bala(本地三分量评分)', 'bhava_bala_unavailable'), + *_blocked_planned_section('#### Bhava Bala(三分量)', 'bhava_bala_unavailable'), ] out = [ '### 力量、Ashtakavarga 与功能性吉凶', @@ -7635,28 +7634,27 @@ def render_pl9_markdown(packet: dict) -> str: rows = bhava_bala.get('houses') if isinstance(bhava_bala.get('houses'), list) else [] if rows: out.extend([ - '#### Bhava Bala(本地三分量评分)', + '#### Bhava Bala(三分量)', '', - '该表是本地宫主位置、宫内行星与相位影响的三分量评分;不是 PL9 同口径的 Bhava Bala Rupas 表。PL9 第 31–32 页的字段级数值对照尚未闭环,所有行保持 parameter_sensitive。', + 'Bhavadhipati Bala(宫主 Shadbala)+ Bhava Dig Bala + Bhava Drishti Bala,单位 Virupa(等宫,jyotishganit MIT 算法移植)。PL9 第 31–32 页的字段级数值对照尚未闭环,所有行保持 parameter_sensitive。', '', - '| Bhava | 星座 | 宫主 | Lord | Occupant | Aspect | Local Score | 状态 |', - '|-------|------|------|------|----------|--------|-------------|------|', + '| Bhava | 星座 | 宫主 | Bhavadhipati | Dig | Drishti | 合计 Virupa | 合计 Rupa | 状态 |', + '|-------|------|------|--------------|-----|---------|-------------|-----------|------|', ]) for row in rows: if not isinstance(row, dict): continue - components = row.get('components') if isinstance(row.get('components'), dict) else {} out.append( f"| {_md_cell(row.get('house'))} | {_md_cell(_humanize_reader_token(row.get('sign')))} | " - f"{_md_cell(_humanize_reader_token(row.get('lord')))} | {_md_cell(components.get('lord_position'))} | " - f"{_md_cell(components.get('occupant_influence'))} | {_md_cell(components.get('aspect_influence'))} | " - f"{_md_cell(row.get('score'))} | parameter_sensitive |" + f"{_md_cell(_humanize_reader_token(row.get('lord')))} | {_md_cell(row.get('adhipathi_bala'))} | " + f"{_md_cell(row.get('dig_bala'))} | {_md_cell(row.get('drik_bala'))} | " + f"{_md_cell(row.get('total'))} | {_md_cell(row.get('total_rupas'))} | parameter_sensitive |" ) out.append('') else: - out.extend(_blocked_planned_section('#### Bhava Bala(本地三分量评分)', 'bhava_bala_unavailable')) + out.extend(_blocked_planned_section('#### Bhava Bala(三分量)', 'bhava_bala_unavailable')) else: - out.extend(_blocked_planned_section('#### Bhava Bala(本地三分量评分)', 'bhava_bala_unavailable')) + out.extend(_blocked_planned_section('#### Bhava Bala(三分量)', 'bhava_bala_unavailable')) out.extend([ '#### Vimsopaka 十六分盘力量摘要', '', @@ -16431,8 +16429,14 @@ def cmd_full_reading(args): planet.update(get_kp_lords(float(planet.get('degree_raw', 0.0)))) planet['r_c'] = '' report['modules']['shadbala'] = shadbala_result - if isinstance(shadbala_result, dict) and shadbala_result.get('bhava_bala'): - report['modules']['bhava_bala'] = _normalize_bhava_bala_module(shadbala_result.get('bhava_bala')) + # BUG-1210 (甲13): Bhava Bala is the formal three-component module + # (scripts/bhava_bala.py); the local +2 / -1.5 house score stays out of + # every user-visible surface. + if isinstance(shadbala_result, dict): + from bhava_bala import bhava_bala_from_chart + formal = bhava_bala_from_chart(planets, asc_sign, asc_deg % 30, shadbala_result.get('planets') or {}) + if formal: + report['modules']['bhava_bala'] = formal except Exception as e: report['errors'].append(f"shadbala: {e}") diff --git a/scripts/pl9_reader_export.py b/scripts/pl9_reader_export.py index 882a34c2..59867b25 100644 --- a/scripts/pl9_reader_export.py +++ b/scripts/pl9_reader_export.py @@ -1194,19 +1194,21 @@ def render_pl9_parity_markdown(packet: dict) -> str: if str(key).isdigit() and isinstance(value, dict) ] if bhava_rows: + # BUG-1210 (甲13): the formal three components (scripts/bhava_bala.py); + # the local +2 / -1.5 house score is never shown. lines.extend([ '### p32 Bhava Bala 十二宫力量', '', - '| House | Sign | Lord | Score | 关键因素 |', - '|-------|------|------|-------|----------|', + '| House | Sign | Lord | Bhavadhipati | Dig | Drishti | Total (Virupa) | Total (Rupa) |', + '|-------|------|------|--------------|-----|---------|----------------|--------------|', ]) for row in bhava_rows[:12]: - if not isinstance(row, dict): + if not isinstance(row, dict) or row.get('total') is None: continue - factors = row.get('factors') if isinstance(row.get('factors'), list) else [] lines.append( f"| {_cell(row.get('house') or row.get('number'))} | {_cell(_token(row.get('sign')))} | {_cell(_token(row.get('lord')))} | " - f"{_num(row.get('score'))} | {_cell(_brief(factors, 2))} |" + f"{_num(row.get('adhipathi_bala'))} | {_num(row.get('dig_bala'))} | {_num(row.get('drik_bala'))} | " + f"{_num(row.get('total'))} | {_num(row.get('total_rupas'))} |" ) lines.append('') friendship = _dict(strengths.get('planetary_friendship')) diff --git a/scripts/shadbala.py b/scripts/shadbala.py index c0299073..74bca6be 100644 --- a/scripts/shadbala.py +++ b/scripts/shadbala.py @@ -616,8 +616,9 @@ def calc_shadbala(planets: Dict, asc_sign: str, birth_hour: float, for i, (name, _) in enumerate(ranked): results[name]['rank'] = i + 1 - # Bhava Bala(宫位力量)v6.9.12 新增 - bhava_bala = calc_bhava_bala(planets, asc_sign) + # BUG-1210 (甲13): the local house score (calc_local_bhava_score, formerly + # calc_bhava_bala: benefic +2, malefic -1.5 ...) is no longer part of this + # result; Bhava Bala is the formal three components in scripts/bhava_bala.py. return { 'method': 'Shadbala六重力量(absolute Virupas; precise Sthana/Dig/Kala/Drik; bounded BPHS Chesta)', @@ -629,7 +630,6 @@ def calc_shadbala(planets: Dict, asc_sign: str, birth_hour: float, 'is_night_birth': is_night, 'sun_uttarayana': sun_northern, 'planets': results, - 'bhava_bala': bhava_bala, 'ranking': [name for name, _ in ranked], 'strongest': ranked[0][0] if ranked else None, 'weakest': ranked[-1][0] if ranked else None, @@ -1138,14 +1138,16 @@ def calc_drik_bala(pname: str, sign: str, house: int, # ============================================================================ -# Bhava Bala(宫位力量)v6.9.12 新增 +# 本地宫位评分(不是 Bhava Bala)v6.9.12;BUG-1210 改名 # ============================================================================ -def calc_bhava_bala(planets: Dict, asc_sign: str) -> Dict: +def calc_local_bhava_score(planets: Dict, asc_sign: str) -> Dict: """ - Bhava Bala(宫位力量)—— 评估每个宫位的综合强度。 - - 基于 BPHS 和 Parashara 传统: + 本地宫位评分(原名 calc_bhava_bala)—— 网站自定的加减分,不是 BPHS Bhava Bala。 + + 占星师定稿 2026-10-03 甲13:删除无出处的「吉星 +2、凶星 -1.5」口径,用户可见面 + 一律改用 scripts/bhava_bala.py 的正式三分量(Bhavadhipati / Dig / Drishti)。 + 本函数只留作审计,不进报告、卡片或任何用户可见文字。原说明: - 宫主星力量(该宫主宰行星的 Shadbala 投影) - 宫内行星影响(自然吉凶 + 入庙/落陷) - 相位影响(吉星/凶星对该宫的相位) diff --git a/tests/test_bhava_bala_formal.py b/tests/test_bhava_bala_formal.py new file mode 100644 index 00000000..5acc93a0 --- /dev/null +++ b/tests/test_bhava_bala_formal.py @@ -0,0 +1,63 @@ +"""Bhava Bala is the formal three-component module on every surface (BUG-1210). + +Astrologer rulings 2026-10-03 甲13: the local house score (benefic +2, malefic +-1.5 ...; `shadbala.calc_local_bhava_score`, formerly `calc_bhava_bala`) has no +source and is not shown; reports use `bhava_bala.bhava_bala_from_chart` +(Bhavadhipati + Bhava Dig + Bhava Drishti). Fictional chart. +""" + +from __future__ import annotations + +import copy +import re +from pathlib import Path + +from scripts.bhava_bala import bhava_bala_from_chart, calc_bhava_adhipathi_bala +from scripts.jyotish_engine import cmd_full_reading +from scripts.pl9_reader_export import _pl9_export_markdown_for_edition +from scripts.shadbala import calc_local_bhava_score, calc_shadbala +from tests.test_report_english_edition import CASES, _packet + +ROOT = Path(__file__).resolve().parents[1] + + +def test_local_score_is_renamed_and_not_in_shadbala_result() -> None: + import scripts.shadbala as shadbala + + assert not hasattr(shadbala, "calc_bhava_bala") + planets = {"Sun": {"sign": "Leo", "house": 1, "degree": 10.0, "lon": 130.0}, + "Moon": {"sign": "Cancer", "house": 12, "degree": 5.0, "lon": 95.0}} + assert calc_local_bhava_score(planets, "Leo")[1]["lord"] == "Sun" + result = calc_shadbala(planets, "Leo", 12.0, 130.0, 95.0) + assert "bhava_bala" not in result and "local_bhava_score" not in result + + +def test_full_reading_bhava_bala_is_the_formal_module() -> None: + case = CASES["day"] + reading = cmd_full_reading(type("Args", (), dict(case))()) + module = reading["modules"]["bhava_bala"] + assert module["source"] == "scripts/bhava_bala.py" and module["unit"] == "virupa" + shadbala = reading["modules"]["shadbala"]["planets"] + lords = [row["lord"] for row in module["houses"]] + assert [row["adhipathi_bala"] for row in module["houses"]] == [round(shadbala[lord]["total_virupas"], 2) for lord in lords] + for row in module["houses"]: + assert row["total"] == round(row["adhipathi_bala"] + row["dig_bala"] + row["drik_bala"], 2) or abs( + row["total"] - (row["adhipathi_bala"] + row["dig_bala"] + row["drik_bala"])) < 0.02 + assert row["total_rupas"] == round(row["total"] / 60.0, 2) + assert "factors" not in row and "score" not in row and "level" not in row + + +def test_reader_report_shows_three_components_and_no_plus_minus_scoring() -> None: + packet = _packet(CASES["day"]) + for language in ("zh", "en"): + edition = copy.deepcopy(packet) + edition["report_language"] = language + markdown = _pl9_export_markdown_for_edition(edition, "reader_main") + header = next(line for line in markdown.splitlines() if line.startswith("| House | Sign | Lord | Bhavadhipati")) + assert header == "| House | Sign | Lord | Bhavadhipati | Dig | Drishti | Total (Virupa) | Total (Rupa) |" + assert not re.search(r"\(Benefic\): \+2|\(Malefic\): -1\.5|吉星 \+2|凶星 -1\.5", markdown) + + +def test_adhipathi_is_the_lords_shadbala() -> None: + assert calc_bhava_adhipathi_bala(["Aries", "Taurus"], {"Mars": 400.0, "Venus": 350.0}) == [400.0, 350.0] + assert bhava_bala_from_chart({}, "Aries", 10.0, {}) == {} diff --git a/tests/test_report_longform_gaps2.py b/tests/test_report_longform_gaps2.py index 49e7c3e2..68838f25 100644 --- a/tests/test_report_longform_gaps2.py +++ b/tests/test_report_longform_gaps2.py @@ -168,13 +168,18 @@ def test_auxiliary_dasha_headings_render_from_native_modules() -> None: }, }, "strengths_and_scores": { + # BUG-1210: formal three-component rows (was the local score row + # with lord_position / occupant / aspect components). "bhava_bala": { "houses": [{ "house": 1, "sign": "Aries", "lord": "Mars", - "score": 1, - "components": {"lord_position": 1, "occupant_influence": 0, "aspect_influence": 0}, + "adhipathi_bala": 420.0, + "dig_bala": 30.0, + "drik_bala": 1.5, + "total": 451.5, + "total_rupas": 7.53, }], }, }, @@ -182,7 +187,9 @@ def test_auxiliary_dasha_headings_render_from_native_modules() -> None: assert "#### Yogini Dasha 周期(本地计算)" in markdown assert "#### Narayana Rashi Dasha" in markdown assert "#### Kala Chakra Dasha(本地原始周期,冲突保留)" in markdown - assert "#### Bhava Bala(本地三分量评分)" in markdown + # BUG-1210: was "#### Bhava Bala(本地三分量评分)". + assert "#### Bhava Bala(三分量)" in markdown + assert "| 1 | 白羊座 | 火星 | 420.0 | 30.0 | 1.5 | 451.5 | 7.53 | parameter_sensitive |" in markdown def test_quality_gate_runs_this_file() -> None: diff --git a/tests/test_shadbala_complete.py b/tests/test_shadbala_complete.py index 9569d8d1..7878c2e2 100644 --- a/tests/test_shadbala_complete.py +++ b/tests/test_shadbala_complete.py @@ -12,7 +12,7 @@ if SCRIPTS not in sys.path: from shadbala import ( calc_shadbala, calc_sthana_bala, calc_dig_bala, calc_kala_bala, - calc_chesta_bala, calc_drik_bala, calc_bhava_bala, + calc_chesta_bala, calc_drik_bala, calc_local_bhava_score, NAISARGIKA_BALA, MIN_REQUIRED, DIG_BALA_HOUSE, EXALTATION_DEG, DEBILITATION_DEG, FRIENDSHIP, SHADBALA_CONSTANTS_PATH, ) @@ -286,31 +286,31 @@ class TestShadbalaFull: assert NAISARGIKA_BALA['Saturn'] == 8.57 -# ── Bhava Bala Tests ──────────────────────────────────────────────── +# ── Local house score (was calc_bhava_bala; renamed BUG-1210, not user-visible) ── class TestBhavaBala: def test_12_houses_calculated(self): - result = calc_bhava_bala(_sample_planets(), 'Leo') + result = calc_local_bhava_score(_sample_planets(), "Leo") for house in range(1, 13): assert house in result def test_benefic_in_house_positive(self): # Jupiter in house 5 = benefic - result = calc_bhava_bala(_sample_planets(), 'Leo') + result = calc_local_bhava_score(_sample_planets(), "Leo") assert result[5]['score'] > 0 def test_strength_classification(self): - result = calc_bhava_bala(_sample_planets(), 'Leo') + result = calc_local_bhava_score(_sample_planets(), "Leo") for house, data in result.items(): assert data['strength'] in ('Strong', 'Moderate', 'Weak') def test_lord_info_present(self): - result = calc_bhava_bala(_sample_planets(), 'Leo') + result = calc_local_bhava_score(_sample_planets(), "Leo") for house, data in result.items(): assert 'lord' in data assert 'sign' in data def test_factors_list(self): - result = calc_bhava_bala(_sample_planets(), 'Leo') + result = calc_local_bhava_score(_sample_planets(), "Leo") for house, data in result.items(): assert isinstance(data['factors'], list)