diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index c0bf5152..d7076935 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -7127,7 +7127,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM sun_lon = (planets.get('Sun') or {}).get('lon') for y in pancha_mahapurusha_rows(planets, sun_degree=sun_lon): yogas.append({'name': y['name'], 'planets': y['planets'], 'category': 'PMC', - 'reductions': y['reductions']}) + 'reductions': y['reductions'], + 'd9_dignity_modifier': y.get('d9_dignity_modifier')}) except Exception as e: import logging logging.warning(f"[api_server] pancha_mahapurusha detection failed: {e}") diff --git a/scripts/main_yoga_detectors.py b/scripts/main_yoga_detectors.py index 0c6be3de..a9ae9590 100644 --- a/scripts/main_yoga_detectors.py +++ b/scripts/main_yoga_detectors.py @@ -15,8 +15,9 @@ English reader edition). only the cancellation conditions that hold are listed, never called Raja Yoga. - Pancha Mahapurusha (BUG-1207, 甲5): `pancha_mahapurusha.detect_pancha_mahapurusha`; kendras from the Lagna only, retrograde does not cancel, combustion / malefic - conjunction or aspect only mark the yoga `reduced` (部分减弱), D9 debilitation - still cancels it (current behaviour; not in the ruling, asked next round). + conjunction or aspect only mark the yoga `reduced` (部分减弱); D9 debilitation + only weakens it (round-4 ruling 2026-10-03 Q1, BUG-1213): the yoga stays formed + with `d9_dignity_modifier: "weakened"` and the reason 「D9 落陷,兑现减弱」. - Kemadruma (BUG-1206, 甲7): `yoga_expansion.detect_kemadruma`; the 2nd / 12th from the Moon count only Mars, Mercury, Jupiter, Venus, Saturn, and there is no kendra cancellation (planets in kendras, including only Rahu / Ketu there, do @@ -185,6 +186,7 @@ def pancha_mahapurusha_rows(planets: Dict[str, Any], sun_degree: Optional[float] 'combination_en': combination_en, 'reductions': reductions, 'reductions_en': reductions_en, + 'd9_dignity_modifier': item.get('d9_dignity_modifier'), 'effects': ['卓越才能', '领域领军', '人格魅力'], 'effects_en': ['Outstanding abilities', "Leader in one's field", 'Personal charisma'], 'strength': '部分减弱' if reductions else '强', diff --git a/scripts/pancha_mahapurusha.py b/scripts/pancha_mahapurusha.py index 8ec08d49..ba831ec6 100644 --- a/scripts/pancha_mahapurusha.py +++ b/scripts/pancha_mahapurusha.py @@ -12,7 +12,9 @@ Pancha Mahapurusha Yoga(五王瑜伽)完整检测模块 v1.0 - Shasha Yoga (Saturn) 破格口径按占星师定稿 2026-10-03 甲5(BUG-1207):逆行不破格;燃烧、与凶星 -同宫或被照只记「部分减弱」;D9 落陷判不成立(现状,待问)。本模块是五大人格的 +同宫或被照只记「部分减弱」。D9 落陷按占星师第四轮裁定(2026-10-03 问 1,BUG-1213) +只算减弱:格局仍成立(`status: formed`),加 `d9_dignity_modifier: "weakened"`;只有 +D1 条件(命宫角宫 + 入庙/入旺)不成立时才是 `not_formed`。本模块是五大人格的 唯一主检测器,`yoga_rules.json` 同名规则与 `yogas_doshas.calc_pancha_mahapurusha_yoga` 为 reference_only。 """ @@ -108,6 +110,17 @@ def _malefic_aspectors(planet_sign: str, all_planets: Dict, planet: str = None) return casters +def navamsa_sign_of(lon: float) -> str: + """D9 sign of a sidereal longitude (Parashari navamsa, 3°20' each).""" + return SIGNS[int((float(lon) % 360.0) * 9.0 / 30.0) % 12] + + +def _debilitation_sign(planet: str) -> str: + if planet not in EXALTATION: + return '' + return SIGNS[(SIGNS.index(EXALTATION[planet][0]) + 6) % 12] + + def _longitude(pd: Dict) -> Optional[float]: """Sidereal longitude of a row; `degree` may be a longitude or a degree in sign.""" for key in ('lon', 'degree_raw', 'longitude'): @@ -127,26 +140,31 @@ PLANET_ZH = {'Sun': '太阳', 'Mars': '火星', 'Mercury': '水星', 'Jupiter': 'Saturn': '土星', 'Rahu': '罗睺', 'Ketu': '计都'} -def detect_pancha_mahapurusha(planets: Dict, sun_degree: float = None) -> List[Dict]: +def detect_pancha_mahapurusha(planets: Dict, sun_degree: float = None, + include_not_formed: bool = False) -> List[Dict]: """ 检测所有Pancha Mahapurusha Yoga(主检测器)。 - 占星师定稿 2026-10-03 甲5(BUG-1207): + 占星师定稿 2026-10-03 甲5(BUG-1207)与第四轮问 1(BUG-1213): - 只从命宫(Lagna)角宫计算:行星在上升起第 1/4/7/10 宫且入庙或入旺; + 这一条不满足才是 `not_formed`; - 逆行不破格; - 燃烧不破格,只记「部分减弱」(`strength: reduced`); - 与凶星同宫或被凶星照只降低强度,不消灭格局; - - D9 落陷判不成立(`is_valid: False`)保留现状:只在传入 `navamsa_sign` 时检查, - 产品路径不传;定稿未提及,待问占星师。 + - D9 落陷只算减弱:`status` 仍是 `formed`,加 `d9_dignity_modifier: "weakened"`, + 原因列「D9 落陷,兑现减弱」。D9 星座取调用方给的 `navamsa_sign`,没给时由 + 黄道经度推出,所以各路径(咨询、报告、`/api/pancha_mahapurusha`)都检查 D9。 Args: planets: {planet: {'sign': str, 'house': int, 'lon'|'degree': float, 'navamsa_sign'?: str}} `house` 从上升数;经度取 `lon` / `degree_raw` / `longitude`,否则 `degree` (≥30 视为经度,否则按星座内度数)。 sun_degree: 太阳黄道经度(燃烧检测);缺省时取 planets['Sun'] 的经度。 + include_not_formed: 为 True 时,D1 条件不成立的五星也返回一行 + (`status: not_formed`、`is_valid: False`)。 Returns: - 检测到的PMC Yoga列表(含 strength: full / reduced / cancelled) + 检测到的PMC Yoga列表(含 status: formed / not_formed,strength: full / reduced) """ yogas = [] sun_deg = sun_degree @@ -161,10 +179,23 @@ def detect_pancha_mahapurusha(planets: Dict, sun_degree: float = None) -> List[D sign = pd.get('sign', '') house = pd.get('house', 0) - # 基本条件:在Kendra + own/exalted - if house not in KENDRA_HOUSES: - continue - if not _is_exalted_or_own(planet, sign): + # 基本条件:在Kendra + own/exalted(D1)。不满足即 not_formed。 + if house not in KENDRA_HOUSES or not _is_exalted_or_own(planet, sign): + if include_not_formed: + yogas.append({ + 'name': MAHAPURUSHA_NAMES[planet], + 'planet': planet, + 'sign': sign, + 'house': house, + 'status': 'not_formed', + 'is_valid': False, + 'strength': 'not_formed', + 'strength_cn': '不成立', + 'reason': 'd1_kendra_own_or_exalted_not_met', + 'reductions': [], + 'reductions_en': [], + 'cancellations': [], + }) continue yoga_name = MAHAPURUSHA_NAMES[planet] @@ -173,7 +204,6 @@ def detect_pancha_mahapurusha(planets: Dict, sun_degree: float = None) -> List[D cancellations = [] reductions = [] reductions_en = [] - is_valid = True # 1. 燃烧:部分减弱(容许度表与 jyotish_engine.COMBUSTION_ORBS 一致, # tests/test_combustion_orb_tables_agree.py) @@ -197,27 +227,28 @@ def detect_pancha_mahapurusha(planets: Dict, sun_degree: float = None) -> List[D reductions.append(f"受凶星照:{'、'.join(PLANET_ZH.get(m, m) for m in aspectors)}") reductions_en.append(f"aspected by malefic(s): {', '.join(aspectors)}") - # 4. Navamsa弱势(D9中落陷):不成立。现状保留——只在调用方给出 - # `navamsa_sign` 时检查;产品路径(chart.yogas、规则引擎、报告)都不传, - # 所以这一条目前不生效。定稿未提及,记为下轮问题,不在本单启用。 - navamsa_sign = pd.get('navamsa_sign', '') - if navamsa_sign in OWN_SIGNS.get(planet, set()) or (planet in EXALTATION and EXALTATION[planet][0] == navamsa_sign): - pass # D9 also strong → yoga reinforced - elif navamsa_sign: - deb_sign_idx = (SIGNS.index(EXALTATION[planet][0]) + 6) % 12 if planet in EXALTATION else -1 - if deb_sign_idx >= 0 and navamsa_sign == SIGNS[deb_sign_idx]: - cancellations.append(f"{planet}在Navamsa落陷 — Yoga根基不稳") - is_valid = False + # 4. D9 落陷:只算减弱(第四轮问 1,BUG-1213),格局仍成立。 + navamsa_sign = pd.get('navamsa_sign') or '' + if not navamsa_sign and planet_deg is not None: + navamsa_sign = navamsa_sign_of(planet_deg) + d9_modifier = None + if navamsa_sign and navamsa_sign == _debilitation_sign(planet): + d9_modifier = 'weakened' + reductions.append('D9 落陷,兑现减弱') + reductions_en.append('debilitated in D9: delivery weakened') - strength = 'cancelled' if not is_valid else ('reduced' if reductions else 'full') + strength = 'reduced' if reductions else 'full' yogas.append({ 'name': yoga_name, 'planet': planet, 'sign': sign, 'house': house, - 'is_valid': is_valid, + 'status': 'formed', + 'is_valid': True, 'strength': strength, - 'strength_cn': {'full': '完整', 'reduced': '部分减弱', 'cancelled': '不成立'}[strength], + 'strength_cn': {'full': '完整', 'reduced': '部分减弱'}[strength], + 'd9_sign': navamsa_sign or None, + 'd9_dignity_modifier': d9_modifier, 'effect': effect, 'reductions': reductions, 'reductions_en': reductions_en, @@ -239,13 +270,11 @@ def assess_pmc_strength(planets: Dict, sun_degree: float = None) -> Dict: invalid = [y for y in yogas if not y['is_valid']] reduced = [y['name'] for y in valid if y['strength'] == 'reduced'] + d9_weakened = [y['name'] for y in valid if y.get('d9_dignity_modifier') == 'weakened'] if len(valid) >= 2: assessment = "多重大王瑜伽 — 命主在不同领域均有杰出天赋" elif len(valid) == 1: assessment = f"{valid[0]['name']} — 命主在特定领域有卓越潜力" - elif len(invalid) > 0: - st = [y['planet'] for y in invalid] - assessment = f"瑜伽潜质存在但因{', '.join(st)}在 D9 落陷而不成立" else: assessment = "无Pancha Mahapurusha Yoga" @@ -254,6 +283,7 @@ def assess_pmc_strength(planets: Dict, sun_degree: float = None) -> Dict: 'yogas': yogas, 'valid_count': len(valid), 'reduced': reduced, + 'd9_weakened': d9_weakened, 'invalid_count': len(invalid), 'assessment': assessment, } diff --git a/tests/test_pancha_mahapurusha_main_detector.py b/tests/test_pancha_mahapurusha_main_detector.py index a82174f9..53a8994c 100644 --- a/tests/test_pancha_mahapurusha_main_detector.py +++ b/tests/test_pancha_mahapurusha_main_detector.py @@ -2,7 +2,7 @@ `scripts/pancha_mahapurusha.py` is the only detector: kendras from the Lagna, retrograde does not cancel, combustion and malefic conjunction / aspect only -reduce the yoga, D9 debilitation still cancels it (current behaviour). The +reduce the yoga, D9 debilitation only weakens it (round-4 ruling Q1, BUG-1213). The data-driven `mahapurusha_*`, `*_own`, `moolatrikona_*`, `hamsa_trikona` and the BVR Maalavya duplicate are `reference_only`, and `yogas_doshas` keeps its old aggregate for reference only, so a yoga the main detector rejects cannot come @@ -69,12 +69,42 @@ def test_malefic_aspect_reduces() -> None: assert _one({"Jupiter": _row("Cancer", 5.0), "Ketu": _row("Capricorn", 10.0)}, "Hamsa Yoga")["strength"] == "full" -def test_d9_debilitation_still_cancels_when_the_caller_gives_d9() -> None: - # Current behaviour kept (ruling silent): checked only when `navamsa_sign` - # is supplied; product paths do not supply it. - hamsa = _one({"Jupiter": _row("Cancer", 21.0, navamsa_sign="Capricorn")}, "Hamsa Yoga") - assert hamsa["is_valid"] is False and hamsa["strength"] == "cancelled" - assert _one({"Jupiter": _row("Cancer", 21.0)}, "Hamsa Yoga")["is_valid"] is True +def test_d9_debilitation_only_weakens() -> None: + # 原值:传 navamsa_sign 落陷 → is_valid False、strength cancelled(不传则不检查) + # 新值:formed 不变,d9_dignity_modifier = weakened,原因「D9 落陷,兑现减弱」; + # 不传 navamsa_sign 时由经度推出 D9(巨蟹 21° 的 D9 是摩羯) + # 原因:占星师第四轮裁定 2026-10-03 问 1(BUG-1213) + for row in (_row("Cancer", 21.0, navamsa_sign="Capricorn"), _row("Cancer", 21.0)): + hamsa = _one({"Jupiter": row}, "Hamsa Yoga") + assert (hamsa["status"], hamsa["is_valid"], hamsa["strength"]) == ("formed", True, "reduced") + assert hamsa["d9_dignity_modifier"] == "weakened" and hamsa["d9_sign"] == "Capricorn" + assert hamsa["reductions"] == ["D9 落陷,兑现减弱"] + strong = _one({"Jupiter": _row("Cancer", 5.0)}, "Hamsa Yoga") + assert strong["d9_dignity_modifier"] is None and strong["strength"] == "full" + + +def test_d1_condition_not_met_is_not_formed() -> None: + # Jupiter exalted but in the 9th from a Pisces Lagna (trikona, not kendra). + rows = detect_pancha_mahapurusha({"Jupiter": _row("Cancer", 5.0, asc="Pisces")}, include_not_formed=True) + hamsa = next(item for item in rows if item["planet"] == "Jupiter") + assert (hamsa["status"], hamsa["is_valid"]) == ("not_formed", False) + # Kendra but neither own nor exalted. + rows = detect_pancha_mahapurusha({"Jupiter": _row("Leo", 5.0, asc="Leo")}, include_not_formed=True) + assert next(item for item in rows if item["planet"] == "Jupiter")["status"] == "not_formed" + assert detect_pancha_mahapurusha({"Jupiter": _row("Leo", 5.0, asc="Leo")}) == [] + + +def test_d9_weakened_yoga_reaches_the_card_and_the_report_rows_alike() -> None: + planets = {"Jupiter": _row("Cancer", 21.0), "Moon": _row("Taurus", 3.0)} + rows = pancha_mahapurusha_rows(planets) + assert rows[0]["d9_dignity_modifier"] == "weakened" + assert "D9 落陷,兑现减弱" in rows[0]["combination"] + engine_rows = [row for row in YogaEngine(str(RULES)).detect(planets, "Cancer") if row.get("category") == "mahapurusha"] + assert engine_rows == rows + chart_rows = JyotishAPIHandler._detect_yogas(None, planets, SIGNS.index("Cancer")) + entry = _compact_yoga_entry(next(row for row in chart_rows if row.get("category") == "PMC")) + layer = build_traditional_yogas({"raman_support": {"yogas": {"status": "executed", "hits": [], "ungoverned_detected": [entry]}}}) + assert layer["names"] == ["Hamsa Yoga(Jupiter;部分减弱:D9 落陷,兑现减弱)"] def test_reference_only_rules_never_run() -> None: @@ -85,14 +115,17 @@ def test_reference_only_rules_never_run() -> None: def test_a_rejected_yoga_does_not_come_back_by_another_path() -> None: - # Jupiter exalted in the Lagna (Cancer) but debilitated in D9; also in a - # trikona, which the retired hamsa_trikona rule used to accept. - planets = {"Jupiter": _row("Cancer", 21.0, navamsa_sign="Capricorn"), "Moon": _row("Taurus", 3.0), "Sun": _row("Aries", 10.0), - "Mars": _row("Gemini", 1.0), "Mercury": _row("Pisces", 2.0), "Venus": _row("Aries", 2.0), - "Saturn": _row("Leo", 2.0), "Rahu": _row("Virgo", 2.0), "Ketu": _row("Pisces", 2.0)} - engine_rows = YogaEngine(str(RULES)).detect(planets, "Cancer") + # 原值:木星在上升巨蟹入旺但 D9 落陷(被判不成立)作为「被拒格局」 + # 新值:木星入旺巨蟹但在双鱼上升的第 9 宫(三方宫,非角宫),D1 条件不成立 + # 原因:第四轮问 1(BUG-1213)D9 落陷不再拒绝格局;被拒样例改为 D1 不成立, + # 仍是已退役的 hamsa_trikona 规则曾接受的三方宫 + asc = "Pisces" + planets = {"Jupiter": _row("Cancer", 5.0, asc=asc), "Moon": _row("Taurus", 3.0, asc=asc), "Sun": _row("Aries", 10.0, asc=asc), + "Mars": _row("Gemini", 1.0, asc=asc), "Mercury": _row("Aquarius", 2.0, asc=asc), "Venus": _row("Aries", 2.0, asc=asc), + "Saturn": _row("Leo", 2.0, asc=asc), "Rahu": _row("Virgo", 2.0, asc=asc), "Ketu": _row("Pisces", 2.0, asc=asc)} + engine_rows = YogaEngine(str(RULES)).detect(planets, asc) assert not [row for row in engine_rows if "Hamsa" in row["name"] or "汉萨" in row.get("name_cn", "")] - chart_rows = JyotishAPIHandler._detect_yogas(None, planets, SIGNS.index("Cancer")) + chart_rows = JyotishAPIHandler._detect_yogas(None, planets, SIGNS.index(asc)) assert not [row for row in chart_rows if "Hamsa" in row["name"]] merged = merge_detected_yogas( [entry for entry in map(_compact_yoga_entry, chart_rows) if entry], @@ -100,13 +133,17 @@ def test_a_rejected_yoga_does_not_come_back_by_another_path() -> None: ) layer = build_traditional_yogas({"raman_support": {"yogas": {"status": "executed", "hits": [], "ungoverned_detected": merged}}}) assert not [name for name in layer["names"] if "Hamsa" in name] - aggregate = calc_all_yogas_doshas(planets, {}, "Cancer", "Moon", "Taurus", 11, 12, 10, "Gemini") + aggregate = calc_all_yogas_doshas(planets, {}, asc, "Moon", "Taurus", 11, 12, 10, "Gemini") assert aggregate["pancha_mahapurusha"]["status"] == "reference_only" assert aggregate["pancha_mahapurusha"]["yogas"] == [] def test_reduced_yoga_reaches_the_card_with_its_reasons() -> None: - planets = {"Jupiter": _row("Cancer", 5.0), "Saturn": _row("Capricorn", 10.0)} + # 原值:土星摩羯 10°(D9 白羊,落陷;旧规则只在给 navamsa_sign 时检查) + # 新值:土星摩羯 2°(D9 摩羯,本宫),断言不变 + # 原因:BUG-1213 起 D9 由经度推出,摩羯 10° 的萨萨会多一条「D9 落陷」减弱; + # 本用例只测凶星照的原因上卡,换成 D9 不落陷的度数 + planets = {"Jupiter": _row("Cancer", 5.0), "Saturn": _row("Capricorn", 2.0)} all_rows = pancha_mahapurusha_rows(planets) engine_rows = [row for row in YogaEngine(str(RULES)).detect(planets, "Cancer") if row.get("category") == "mahapurusha"] assert engine_rows == all_rows