diff --git a/references/yoga_rules.json b/references/yoga_rules.json index a66874c0..a0694c14 100644 --- a/references/yoga_rules.json +++ b/references/yoga_rules.json @@ -194,7 +194,9 @@ "strength_en": "strong", "enabled": true, "source_reference": "BVR-22: Mars in own/exaltation in Kendra", - "bvr_chapter": "Chapter 3" + "bvr_chapter": "Chapter 3", + "reference_only": true, + "reference_only_reason": "Astrologer rulings 2026-10-03 (甲5, BUG-1207): Pancha Mahapurusha has one main detector, scripts/pancha_mahapurusha.py (kendras from the Lagna only; retrograde does not cancel; combustion, malefic conjunction or aspect only reduce strength), emitted via scripts/main_yoga_detectors.py. This rule kept for reference only (hamsa_trikona: Jupiter in 1/5/9 is not classical)." }, { "id": "mahapurusha_mars_own", @@ -240,7 +242,9 @@ "enabled": true, "dedup_key": "Ruchaka Yoga", "source_reference": "BVR-22: Mars in own/exaltation in Kendra", - "bvr_chapter": "Chapter 3" + "bvr_chapter": "Chapter 3", + "reference_only": true, + "reference_only_reason": "Astrologer rulings 2026-10-03 (甲5, BUG-1207): Pancha Mahapurusha has one main detector, scripts/pancha_mahapurusha.py (kendras from the Lagna only; retrograde does not cancel; combustion, malefic conjunction or aspect only reduce strength), emitted via scripts/main_yoga_detectors.py. This rule kept for reference only (hamsa_trikona: Jupiter in 1/5/9 is not classical)." }, { "id": "mahapurusha_mercury", @@ -285,7 +289,9 @@ "strength_en": "strong", "enabled": true, "source_reference": "BVR-23: Mercury in own/exaltation in Kendra", - "bvr_chapter": "Chapter 3" + "bvr_chapter": "Chapter 3", + "reference_only": true, + "reference_only_reason": "Astrologer rulings 2026-10-03 (甲5, BUG-1207): Pancha Mahapurusha has one main detector, scripts/pancha_mahapurusha.py (kendras from the Lagna only; retrograde does not cancel; combustion, malefic conjunction or aspect only reduce strength), emitted via scripts/main_yoga_detectors.py. This rule kept for reference only (hamsa_trikona: Jupiter in 1/5/9 is not classical)." }, { "id": "mahapurusha_mercury_own", @@ -331,7 +337,9 @@ "enabled": true, "dedup_key": "Bhadra Yoga", "source_reference": "BVR-23: Mercury in own/exaltation in Kendra", - "bvr_chapter": "Chapter 3" + "bvr_chapter": "Chapter 3", + "reference_only": true, + "reference_only_reason": "Astrologer rulings 2026-10-03 (甲5, BUG-1207): Pancha Mahapurusha has one main detector, scripts/pancha_mahapurusha.py (kendras from the Lagna only; retrograde does not cancel; combustion, malefic conjunction or aspect only reduce strength), emitted via scripts/main_yoga_detectors.py. This rule kept for reference only (hamsa_trikona: Jupiter in 1/5/9 is not classical)." }, { "id": "mahapurusha_jupiter", @@ -376,7 +384,9 @@ "strength_en": "strong", "enabled": true, "source_reference": "BVR-19: Jupiter in own/exaltation in Kendra", - "bvr_chapter": "Chapter 3" + "bvr_chapter": "Chapter 3", + "reference_only": true, + "reference_only_reason": "Astrologer rulings 2026-10-03 (甲5, BUG-1207): Pancha Mahapurusha has one main detector, scripts/pancha_mahapurusha.py (kendras from the Lagna only; retrograde does not cancel; combustion, malefic conjunction or aspect only reduce strength), emitted via scripts/main_yoga_detectors.py. This rule kept for reference only (hamsa_trikona: Jupiter in 1/5/9 is not classical)." }, { "id": "mahapurusha_jupiter_own", @@ -422,7 +432,9 @@ "enabled": true, "dedup_key": "Hamsa Yoga", "source_reference": "BVR-19: Jupiter in own/exaltation in Kendra", - "bvr_chapter": "Chapter 3" + "bvr_chapter": "Chapter 3", + "reference_only": true, + "reference_only_reason": "Astrologer rulings 2026-10-03 (甲5, BUG-1207): Pancha Mahapurusha has one main detector, scripts/pancha_mahapurusha.py (kendras from the Lagna only; retrograde does not cancel; combustion, malefic conjunction or aspect only reduce strength), emitted via scripts/main_yoga_detectors.py. This rule kept for reference only (hamsa_trikona: Jupiter in 1/5/9 is not classical)." }, { "id": "mahapurusha_venus", @@ -467,7 +479,9 @@ "strength_en": "strong", "enabled": true, "source_reference": "BVR-20: Venus in own/exaltation in Kendra", - "bvr_chapter": "Chapter 3" + "bvr_chapter": "Chapter 3", + "reference_only": true, + "reference_only_reason": "Astrologer rulings 2026-10-03 (甲5, BUG-1207): Pancha Mahapurusha has one main detector, scripts/pancha_mahapurusha.py (kendras from the Lagna only; retrograde does not cancel; combustion, malefic conjunction or aspect only reduce strength), emitted via scripts/main_yoga_detectors.py. This rule kept for reference only (hamsa_trikona: Jupiter in 1/5/9 is not classical)." }, { "id": "mahapurusha_venus_own", @@ -513,7 +527,9 @@ "enabled": true, "dedup_key": "Malavya Yoga", "source_reference": "BVR-20: Venus in own/exaltation in Kendra", - "bvr_chapter": "Chapter 3" + "bvr_chapter": "Chapter 3", + "reference_only": true, + "reference_only_reason": "Astrologer rulings 2026-10-03 (甲5, BUG-1207): Pancha Mahapurusha has one main detector, scripts/pancha_mahapurusha.py (kendras from the Lagna only; retrograde does not cancel; combustion, malefic conjunction or aspect only reduce strength), emitted via scripts/main_yoga_detectors.py. This rule kept for reference only (hamsa_trikona: Jupiter in 1/5/9 is not classical)." }, { "id": "mahapurusha_saturn", @@ -558,7 +574,9 @@ "strength_en": "strong", "enabled": true, "source_reference": "BVR-21: Saturn in own/exaltation in Kendra", - "bvr_chapter": "Chapter 3" + "bvr_chapter": "Chapter 3", + "reference_only": true, + "reference_only_reason": "Astrologer rulings 2026-10-03 (甲5, BUG-1207): Pancha Mahapurusha has one main detector, scripts/pancha_mahapurusha.py (kendras from the Lagna only; retrograde does not cancel; combustion, malefic conjunction or aspect only reduce strength), emitted via scripts/main_yoga_detectors.py. This rule kept for reference only (hamsa_trikona: Jupiter in 1/5/9 is not classical)." }, { "id": "mahapurusha_saturn_own", @@ -604,7 +622,9 @@ "enabled": true, "dedup_key": "Sasa Yoga", "source_reference": "BVR-21: Saturn in own/exaltation in Kendra", - "bvr_chapter": "Chapter 3" + "bvr_chapter": "Chapter 3", + "reference_only": true, + "reference_only_reason": "Astrologer rulings 2026-10-03 (甲5, BUG-1207): Pancha Mahapurusha has one main detector, scripts/pancha_mahapurusha.py (kendras from the Lagna only; retrograde does not cancel; combustion, malefic conjunction or aspect only reduce strength), emitted via scripts/main_yoga_detectors.py. This rule kept for reference only (hamsa_trikona: Jupiter in 1/5/9 is not classical)." }, { "id": "hamsa_trikona", @@ -649,7 +669,9 @@ "enabled": true, "dedup_key": "Hamsa Yoga", "source_reference": "BVR-19: Jupiter in own/exaltation in Kendra", - "bvr_chapter": "Chapter 3" + "bvr_chapter": "Chapter 3", + "reference_only": true, + "reference_only_reason": "Astrologer rulings 2026-10-03 (甲5, BUG-1207): Pancha Mahapurusha has one main detector, scripts/pancha_mahapurusha.py (kendras from the Lagna only; retrograde does not cancel; combustion, malefic conjunction or aspect only reduce strength), emitted via scripts/main_yoga_detectors.py. This rule kept for reference only (hamsa_trikona: Jupiter in 1/5/9 is not classical)." }, { "id": "moolatrikona_mars", @@ -694,7 +716,9 @@ "strength_en": "strong", "enabled": true, "source_reference": "BVR-22: Mars in own/exaltation in Kendra", - "bvr_chapter": "Chapter 3" + "bvr_chapter": "Chapter 3", + "reference_only": true, + "reference_only_reason": "Astrologer rulings 2026-10-03 (甲5, BUG-1207): Pancha Mahapurusha has one main detector, scripts/pancha_mahapurusha.py (kendras from the Lagna only; retrograde does not cancel; combustion, malefic conjunction or aspect only reduce strength), emitted via scripts/main_yoga_detectors.py. This rule kept for reference only (hamsa_trikona: Jupiter in 1/5/9 is not classical)." }, { "id": "moolatrikona_mercury", @@ -739,7 +763,9 @@ "strength_en": "strong", "enabled": true, "source_reference": "BVR-23: Mercury in own/exaltation in Kendra", - "bvr_chapter": "Chapter 3" + "bvr_chapter": "Chapter 3", + "reference_only": true, + "reference_only_reason": "Astrologer rulings 2026-10-03 (甲5, BUG-1207): Pancha Mahapurusha has one main detector, scripts/pancha_mahapurusha.py (kendras from the Lagna only; retrograde does not cancel; combustion, malefic conjunction or aspect only reduce strength), emitted via scripts/main_yoga_detectors.py. This rule kept for reference only (hamsa_trikona: Jupiter in 1/5/9 is not classical)." }, { "id": "moolatrikona_jupiter", @@ -784,7 +810,9 @@ "strength_en": "strong", "enabled": true, "source_reference": "BVR-19: Jupiter in own/exaltation in Kendra", - "bvr_chapter": "Chapter 3" + "bvr_chapter": "Chapter 3", + "reference_only": true, + "reference_only_reason": "Astrologer rulings 2026-10-03 (甲5, BUG-1207): Pancha Mahapurusha has one main detector, scripts/pancha_mahapurusha.py (kendras from the Lagna only; retrograde does not cancel; combustion, malefic conjunction or aspect only reduce strength), emitted via scripts/main_yoga_detectors.py. This rule kept for reference only (hamsa_trikona: Jupiter in 1/5/9 is not classical)." }, { "id": "moolatrikona_venus", @@ -829,7 +857,9 @@ "strength_en": "strong", "enabled": true, "source_reference": "BVR-20: Venus in own/exaltation in Kendra", - "bvr_chapter": "Chapter 3" + "bvr_chapter": "Chapter 3", + "reference_only": true, + "reference_only_reason": "Astrologer rulings 2026-10-03 (甲5, BUG-1207): Pancha Mahapurusha has one main detector, scripts/pancha_mahapurusha.py (kendras from the Lagna only; retrograde does not cancel; combustion, malefic conjunction or aspect only reduce strength), emitted via scripts/main_yoga_detectors.py. This rule kept for reference only (hamsa_trikona: Jupiter in 1/5/9 is not classical)." }, { "id": "moolatrikona_saturn", @@ -874,7 +904,9 @@ "strength_en": "strong", "enabled": true, "source_reference": "BVR-21: Saturn in own/exaltation in Kendra", - "bvr_chapter": "Chapter 3" + "bvr_chapter": "Chapter 3", + "reference_only": true, + "reference_only_reason": "Astrologer rulings 2026-10-03 (甲5, BUG-1207): Pancha Mahapurusha has one main detector, scripts/pancha_mahapurusha.py (kendras from the Lagna only; retrograde does not cancel; combustion, malefic conjunction or aspect only reduce strength), emitted via scripts/main_yoga_detectors.py. This rule kept for reference only (hamsa_trikona: Jupiter in 1/5/9 is not classical)." }, { "id": "gajakesari", @@ -13784,7 +13816,9 @@ "strength": "strong", "enabled": true, "draft": false, - "evidence_note": "Source: B.V. Raman, BVR: BVR-20" + "evidence_note": "Source: B.V. Raman, BVR: BVR-20", + "reference_only": true, + "reference_only_reason": "Astrologer rulings 2026-10-03 (甲5, BUG-1207): Pancha Mahapurusha has one main detector, scripts/pancha_mahapurusha.py (kendras from the Lagna only; retrograde does not cancel; combustion, malefic conjunction or aspect only reduce strength), emitted via scripts/main_yoga_detectors.py. This BVR duplicate is kept for reference only." }, { "id": "bvr_maathru_saapa_sutakshaya_yoga", diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index beddf560..1e0af92e 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -7118,11 +7118,13 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM yogas = [] KENDRA = {1,4,7,10} try: - from pancha_mahapurusha import detect_pancha_mahapurusha - pmc = detect_pancha_mahapurusha(planets) - for y in pmc: - if y['is_valid']: - yogas.append({'name': y['name'], 'planets': [y['planet']], 'category': 'PMC'}) + # BUG-1207: the one Pancha Mahapurusha detector, with the Sun's + # longitude for combustion; reduced yogas stay formed. + from main_yoga_detectors import pancha_mahapurusha_rows + 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']}) except Exception as e: import logging logging.warning(f"[api_server] pancha_mahapurusha detection failed: {e}") diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 5bc4afb8..4d273a24 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -12536,7 +12536,12 @@ def cmd_yoga(args): "house": pd["house"], "degree": pd.get("degree_in_sign", pd.get("degree")), } + # Main yoga detectors (BUG-1207) read the longitude and the + # retrograde flag (combustion orb), as the consultation path does. + if pd.get("retrograde") is not None: + planets[pname]["retrograde"] = bool(pd.get("retrograde")) if pd.get("degree_raw") is not None: + planets[pname]["lon"] = pd.get("degree_raw") planet_lons_for_varga[pname] = pd.get("degree_raw") try: from varga import calc_all_vargas diff --git a/scripts/main_yoga_detectors.py b/scripts/main_yoga_detectors.py index 4153fae8..0c6be3de 100644 --- a/scripts/main_yoga_detectors.py +++ b/scripts/main_yoga_detectors.py @@ -13,6 +13,10 @@ English reader edition). - Neecha Bhanga (BUG-1205, 乙14): `yogas_doshas.calc_nicha_bhanga_raj_yoga`; 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). - 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 @@ -29,9 +33,11 @@ from __future__ import annotations from typing import Any, Dict, List, Optional try: # scripts/ on sys.path + from pancha_mahapurusha import _longitude, detect_pancha_mahapurusha from yoga_expansion import detect_kemadruma from yogas_doshas import calc_nicha_bhanga_raj_yoga except ImportError: # package import (scripts.main_yoga_detectors) + from scripts.pancha_mahapurusha import _longitude, detect_pancha_mahapurusha from scripts.yoga_expansion import detect_kemadruma from scripts.yogas_doshas import calc_nicha_bhanga_raj_yoga @@ -55,13 +61,11 @@ NEECHA_BHANGA_NOTE_EN = 'Conditions only; no stand-alone conclusion' def _lon(row: Any) -> Optional[float]: - if not isinstance(row, dict): - return None - for key in ('lon', 'degree_raw', 'longitude'): - value = row.get(key) - if isinstance(value, (int, float)): - return float(value) % 360.0 - return None + """Sidereal longitude; same reading as the Pancha Mahapurusha detector + (`lon` / `degree_raw` / `longitude`, else `degree` as a longitude when + >= 30 or as a degree in `sign`), so the report rows (degree in sign) and + the API rows (longitudes) give the same D9 and the same verdict.""" + return _longitude(row) if isinstance(row, dict) else None def navamsa_sign(lon: float) -> str: @@ -144,6 +148,54 @@ def neecha_bhanga_rows(planets: Dict[str, Any]) -> List[Dict[str, Any]]: return rows +# Names as the rule registry and the card closed list spell them. +MAHAPURUSHA_ROWS = { + 'Mars': ('Ruchaka Yoga', '鲁查卡格局'), + 'Mercury': ('Bhadra Yoga', '巴德拉格局'), + 'Jupiter': ('Hamsa Yoga', '汉萨格局'), + 'Venus': ('Malavya Yoga', '马拉维亚格局'), + 'Saturn': ('Sasa Yoga', '萨萨格局'), +} +EXALTATION_SIGN = {'Mars': 'Capricorn', 'Mercury': 'Virgo', 'Jupiter': 'Cancer', 'Venus': 'Pisces', 'Saturn': 'Libra'} + + +def pancha_mahapurusha_rows(planets: Dict[str, Any], sun_degree: Optional[float] = None) -> List[Dict[str, Any]]: + """Formed Pancha Mahapurusha yogas (full or reduced) from the main detector.""" + rows: List[Dict[str, Any]] = [] + for item in detect_pancha_mahapurusha(planets or {}, sun_degree): + if not item.get('is_valid'): + continue + planet = item['planet'] + name, name_cn = MAHAPURUSHA_ROWS[planet] + sign = item.get('sign') + exalted = EXALTATION_SIGN.get(planet) == sign + reductions = list(item.get('reductions') or []) + reductions_en = list(item.get('reductions_en') or []) + combination = f"{_planet_zh(planet)}在第{item.get('house')}宫{_sign_zh(sign)}({'入旺' if exalted else '入庙'})" + combination_en = f"{planet} in house {item.get('house')}, {sign} ({'exalted' if exalted else 'own sign'})" + if reductions: + combination += ';部分减弱:' + ';'.join(reductions) + combination_en += '; partly weakened: ' + '; '.join(reductions_en) + rows.append({ + 'name': name, + 'name_cn': name_cn, + 'category': 'mahapurusha', + 'planets': [planet], + 'combination': combination, + 'combination_en': combination_en, + 'reductions': reductions, + 'reductions_en': reductions_en, + 'effects': ['卓越才能', '领域领军', '人格魅力'], + 'effects_en': ['Outstanding abilities', "Leader in one's field", 'Personal charisma'], + 'strength': '部分减弱' if reductions else '强', + 'strength_en': 'partly weakened' if reductions else 'strong', + 'source': 'BPHS', + 'rule_id': f'main_detector:pancha_mahapurusha:{planet.lower()}', + 'detector': 'pancha_mahapurusha.detect_pancha_mahapurusha', + }) + return rows + + def kemadruma_rows(planets: Dict[str, Any]) -> List[Dict[str, Any]]: """Kemadruma from `yoga_expansion.detect_kemadruma` (no kendra cancellation).""" result = detect_kemadruma(planets or {}) @@ -170,4 +222,4 @@ def kemadruma_rows(planets: Dict[str, Any]) -> List[Dict[str, Any]]: def main_detector_rows(planets: Dict[str, Any], ascendant: Optional[str] = None) -> List[Dict[str, Any]]: """Every main-detector row for one D1 chart, in a fixed order.""" - return kemadruma_rows(planets) + neecha_bhanga_rows(planets) + return pancha_mahapurusha_rows(planets) + kemadruma_rows(planets) + neecha_bhanga_rows(planets) diff --git a/scripts/pancha_mahapurusha.py b/scripts/pancha_mahapurusha.py index 14aa8a7f..8ec08d49 100644 --- a/scripts/pancha_mahapurusha.py +++ b/scripts/pancha_mahapurusha.py @@ -11,7 +11,10 @@ Pancha Mahapurusha Yoga(五王瑜伽)完整检测模块 v1.0 - Malavya Yoga (Venus) - Shasha Yoga (Saturn) -含完整的燃烧(Combustion)/逆行(Retrograde)/受克(Affliction)失效条件检测 +破格口径按占星师定稿 2026-10-03 甲5(BUG-1207):逆行不破格;燃烧、与凶星 +同宫或被照只记「部分减弱」;D9 落陷判不成立(现状,待问)。本模块是五大人格的 +唯一主检测器,`yoga_rules.json` 同名规则与 `yogas_doshas.calc_pancha_mahapurusha_yoga` +为 reference_only。 """ from typing import Dict, List, Optional @@ -73,7 +76,7 @@ def _is_combust(planet: str, planet_degree: float, sun_degree: float, retrograde def _has_malefic_aspect(planet_sign: str, all_planets: Dict, planet: str = None) -> List[str]: - """检查是否有凶星合相/相位该行星""" + """Natural malefics in the same sign as the yoga planet (the planet itself excluded, BUG-1180).""" afflicting = [] for pn, pd in all_planets.items(): if pn == planet or pn not in NATURAL_MALEFICS: @@ -84,21 +87,73 @@ def _has_malefic_aspect(planet_sign: str, all_planets: Dict, planet: str = None) return afflicting +# Parashari whole-sign graha drishti, as aspects.calc_house_aspects: every graha +# the 7th; Mars also 4 / 8, Saturn 3 / 10; Rahu / Ketu cast none. +MALEFIC_DRISHTI = {'Sun': (7,), 'Mars': (4, 7, 8), 'Saturn': (3, 7, 10)} + + +def _malefic_aspectors(planet_sign: str, all_planets: Dict, planet: str = None) -> List[str]: + """Natural malefics whose graha drishti falls on the yoga planet's sign.""" + if planet_sign not in SIGNS: + return [] + target = SIGNS.index(planet_sign) + casters = [] + for pn, offsets in MALEFIC_DRISHTI.items(): + pd = all_planets.get(pn) + if pn == planet or not isinstance(pd, dict) or pd.get('sign') not in SIGNS: + continue + origin = SIGNS.index(pd['sign']) + if any((origin + offset - 1) % 12 == target for offset in offsets): + casters.append(pn) + return casters + + +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'): + value = pd.get(key) + if isinstance(value, (int, float)): + return float(value) % 360.0 + degree = pd.get('degree') + if not isinstance(degree, (int, float)): + return None + if degree >= 30 or pd.get('sign') not in SIGNS: + return float(degree) % 360.0 + return SIGNS.index(pd['sign']) * 30.0 + float(degree) + + +MAHAPURUSHA_ORDER = ('Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn') +PLANET_ZH = {'Sun': '太阳', 'Mars': '火星', 'Mercury': '水星', 'Jupiter': '木星', 'Venus': '金星', + 'Saturn': '土星', 'Rahu': '罗睺', 'Ketu': '计都'} + + def detect_pancha_mahapurusha(planets: Dict, sun_degree: float = None) -> List[Dict]: """ - 检测所有Pancha Mahapurusha Yoga,含失效条件。 + 检测所有Pancha Mahapurusha Yoga(主检测器)。 + + 占星师定稿 2026-10-03 甲5(BUG-1207): + - 只从命宫(Lagna)角宫计算:行星在上升起第 1/4/7/10 宫且入庙或入旺; + - 逆行不破格; + - 燃烧不破格,只记「部分减弱」(`strength: reduced`); + - 与凶星同宫或被凶星照只降低强度,不消灭格局; + - D9 落陷判不成立(`is_valid: False`)保留现状:只在传入 `navamsa_sign` 时检查, + 产品路径不传;定稿未提及,待问占星师。 Args: - planets: {planet: {'sign': str, 'house': int, 'degree': float, ...}} - sun_degree: 太阳黄道经度(用于燃烧检测) + planets: {planet: {'sign': str, 'house': int, 'lon'|'degree': float, 'navamsa_sign'?: str}} + `house` 从上升数;经度取 `lon` / `degree_raw` / `longitude`,否则 `degree` + (≥30 视为经度,否则按星座内度数)。 + sun_degree: 太阳黄道经度(燃烧检测);缺省时取 planets['Sun'] 的经度。 Returns: - 检测到的PMC Yoga列表 + 检测到的PMC Yoga列表(含 strength: full / reduced / cancelled) """ yogas = [] sun_deg = sun_degree + if sun_deg is None and isinstance(planets.get('Sun'), dict): + sun_deg = _longitude(planets['Sun']) - for planet in MAHAPURUSHA_PLANETS: + for planet in MAHAPURUSHA_ORDER: pd = planets.get(planet) if not pd: continue @@ -115,49 +170,57 @@ def detect_pancha_mahapurusha(planets: Dict, sun_degree: float = None) -> List[D yoga_name = MAHAPURUSHA_NAMES[planet] effect = MAHAPURUSHA_EFFECTS.get(yoga_name.replace(" Yoga", ""), "") - # 检查失效条件 cancellations = [] + reductions = [] + reductions_en = [] is_valid = True - # 1. 燃烧检测 - if sun_deg is not None: - planet_deg = pd.get('degree', 0) - if _is_combust(planet, planet_deg, sun_deg, bool(pd.get('retrograde'))): - profile = COMBUSTION_ORB.get(planet, {'direct': 10}) - threshold = profile.get('retrograde' if pd.get('retrograde') else 'direct', profile.get('direct', 10)) - cancellations.append(f"{planet}被太阳燃烧(orb < {threshold}°) — Yoga效力大减") - is_valid = False + # 1. 燃烧:部分减弱(容许度表与 jyotish_engine.COMBUSTION_ORBS 一致, + # tests/test_combustion_orb_tables_agree.py) + planet_deg = _longitude(pd) + retro = bool(pd.get('retrograde')) + if sun_deg is not None and planet_deg is not None and _is_combust(planet, planet_deg, sun_deg, retro): + profile = COMBUSTION_ORB.get(planet, {'direct': 10}) + threshold = profile.get('retrograde' if retro else 'direct', profile.get('direct', 10)) + reductions.append(f"{PLANET_ZH[planet]}燃烧(距太阳 {threshold}° 以内)") + reductions_en.append(f"{planet} combust (within {threshold}° of the Sun)") - # 2. 逆行检测 - if pd.get('retrograde', False): - cancellations.append(f"{planet}逆行 — Yoga效力减弱") - is_valid = False + # 2. 逆行:不破格,不减弱(定稿甲5) - # 3. 凶星受克 + # 3. 凶星同宫 / 凶星照:只降低强度 malefics = _has_malefic_aspect(sign, planets, planet) if malefics: - cancellations.append(f"{planet}受凶星合相: {', '.join(malefics)} — Yoga部分失效") - is_valid = False + reductions.append(f"与凶星同宫:{'、'.join(PLANET_ZH.get(m, m) for m in malefics)}") + reductions_en.append(f"conjunct malefic(s): {', '.join(malefics)}") + aspectors = _malefic_aspectors(sign, planets, planet) + if aspectors: + 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中落陷) + # 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: - # Check if debilitated in D9 - deb_sign_idx = (SIGNS.index(EXALTATION[planet][0]) + 6) % 12 if planet in EXALTATION and planet != 'Rahu' and planet != 'Ketu' else -1 + 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 + strength = 'cancelled' if not is_valid else ('reduced' if reductions else 'full') yogas.append({ 'name': yoga_name, 'planet': planet, 'sign': sign, 'house': house, 'is_valid': is_valid, - 'strength': '完整' if is_valid else '削弱', + 'strength': strength, + 'strength_cn': {'full': '完整', 'reduced': '部分减弱', 'cancelled': '不成立'}[strength], 'effect': effect, + 'reductions': reductions, + 'reductions_en': reductions_en, 'cancellations': cancellations, }) @@ -175,13 +238,14 @@ def assess_pmc_strength(planets: Dict, sun_degree: float = None) -> Dict: valid = [y for y in yogas if y['is_valid']] invalid = [y for y in yogas if not y['is_valid']] + reduced = [y['name'] for y in valid if y['strength'] == 'reduced'] 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)}受克而削弱" + assessment = f"瑜伽潜质存在但因{', '.join(st)}在 D9 落陷而不成立" else: assessment = "无Pancha Mahapurusha Yoga" @@ -189,6 +253,7 @@ def assess_pmc_strength(planets: Dict, sun_degree: float = None) -> Dict: 'method': 'Pancha Mahapurusha Yoga 完整检测 v1.0', 'yogas': yogas, 'valid_count': len(valid), + 'reduced': reduced, 'invalid_count': len(invalid), 'assessment': assessment, } diff --git a/scripts/yogas_doshas.py b/scripts/yogas_doshas.py index beee6104..2c38376b 100644 --- a/scripts/yogas_doshas.py +++ b/scripts/yogas_doshas.py @@ -827,7 +827,18 @@ def calc_all_yogas_doshas(planets_data: Dict, houses: Dict, # Yogas results['raj_yogas'] = calc_raj_yogas(planets_data, houses) results['dhana_yogas'] = calc_dhana_yogas(planets_data, houses) - results['pancha_mahapurusha'] = calc_pancha_mahapurusha_yoga(planets_data) + # BUG-1207 (甲5): Pancha Mahapurusha has one main detector + # (scripts/pancha_mahapurusha.py, via main_yoga_detectors). This older + # aggregate checks no affliction at all; its list is kept for reference only + # and is not counted as a formed yoga. + reference = calc_pancha_mahapurusha_yoga(planets_data) + results['pancha_mahapurusha'] = { + 'status': 'reference_only', + 'main_detector': 'scripts/pancha_mahapurusha.py', + 'yogas': [], + 'reference_yogas': reference['yogas'], + 'summary': 'reference_only:五大人格以 scripts/pancha_mahapurusha.py 主检测器为准', + } results['nicha_bhanga_raj'] = calc_nicha_bhanga_raj_yoga(planets_data, houses) # Doshas diff --git a/tests/run_all.py b/tests/run_all.py index fc2ec511..0a432762 100644 --- a/tests/run_all.py +++ b/tests/run_all.py @@ -139,19 +139,21 @@ def t30(): assert len(r) >= 1 # === v6.7.5 新增功能测试 === -@test("PMC combustion cancellation") +# BUG-1207 (astrologer ruling 甲5): combustion only reduces the yoga and +# retrograde does not affect it. Was: both asserted is_valid == False. +@test("PMC combustion reduces, does not cancel") def t31(): from pancha_mahapurusha import detect_pancha_mahapurusha - p = {'Mars': {'sign': 'Capricorn', 'house': 4, 'degree': 50}} - r = detect_pancha_mahapurusha(p, sun_degree=53) - assert r[0]['is_valid'] == False, "Mars too close to Sun should cancel PMC" + p = {'Mars': {'sign': 'Capricorn', 'house': 4, 'degree': 280}} + r = detect_pancha_mahapurusha(p, sun_degree=283) + assert r[0]['is_valid'] == True and r[0]['strength'] == 'reduced', "combust Mars keeps PMC, reduced" -@test("PMC retrograde cancellation") +@test("PMC retrograde does not cancel") def t32(): from pancha_mahapurusha import detect_pancha_mahapurusha p = {'Jupiter': {'sign': 'Cancer', 'house': 1, 'degree': 95, 'retrograde': True}} r = detect_pancha_mahapurusha(p) - assert r[0]['is_valid'] == False + assert r[0]['is_valid'] == True and r[0]['strength'] == 'full' @test("Bhava Bala computation") def t33(): @@ -320,7 +322,11 @@ def t53(): def t54(): from yoga_engine import YogaEngine engine = YogaEngine('references/yoga_rules.json') - assert len(engine.rules) >= 400, f"Should have 400+ rules, got {len(engine.rules)}" + # Astrologer rulings 2026-10-03 (BUG-1205..1207): 23 rules became reference_only + # (kept, not evaluated; their yogas come from one main detector each). + # Was: len(engine.rules) >= 400. + total = len(engine.rules) + len(engine.reference_only_rule_ids) + assert total >= 400, f"Should have 400+ rules, got {total}" @test("Yoga engine solar lunar detection") def t55(): diff --git a/tests/test_pancha_mahapurusha_main_detector.py b/tests/test_pancha_mahapurusha_main_detector.py new file mode 100644 index 00000000..4f65c2c2 --- /dev/null +++ b/tests/test_pancha_mahapurusha_main_detector.py @@ -0,0 +1,172 @@ +"""Pancha Mahapurusha: one main detector, ruling 甲5 (BUG-1207). + +`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 +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 +back on the card, the report or `chart.yogas` by another path. Placements are +fictional except the parametrised public AA figures. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from scripts.consultation_native_layers import build_traditional_yogas, merge_detected_yogas +from scripts.jyotish_api_server import JyotishAPIHandler, _compact_yoga_entry +from scripts.jyotish_engine import compute_chart_data +from scripts.main_yoga_detectors import pancha_mahapurusha_rows +from scripts.pancha_mahapurusha import detect_pancha_mahapurusha +from scripts.yoga_engine import YogaEngine +from scripts.yogas_doshas import calc_all_yogas_doshas + +ROOT = Path(__file__).resolve().parents[1] +RULES = ROOT / "references" / "yoga_rules.json" +SIGNS = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"] +REFERENCE_IDS = { + *(f"mahapurusha_{planet}{suffix}" for planet in ("mars", "mercury", "jupiter", "venus", "saturn") for suffix in ("", "_own")), + *(f"moolatrikona_{planet}" for planet in ("mars", "mercury", "jupiter", "venus", "saturn")), + "hamsa_trikona", "bvr_maalavya_yoga", +} + + +def _row(sign: str, lon_in_sign: float, asc: str = "Cancer", **extra) -> dict: + return {"sign": sign, "house": (SIGNS.index(sign) - SIGNS.index(asc)) % 12 + 1, + "lon": SIGNS.index(sign) * 30 + lon_in_sign, "degree": lon_in_sign, **extra} + + +def _one(planets: dict, name: str) -> dict: + return next(item for item in detect_pancha_mahapurusha(planets) if item["name"] == name) + + +def test_retrograde_does_not_cancel() -> None: + hamsa = _one({"Jupiter": _row("Cancer", 5.0, retrograde=True)}, "Hamsa Yoga") + assert (hamsa["is_valid"], hamsa["strength"], hamsa["reductions"]) == (True, "full", []) + + +def test_combustion_reduces_using_the_suns_longitude_from_the_chart() -> None: + # API rows carry `degree` in sign; the longitude decides combustion. + planets = {"Jupiter": _row("Cancer", 5.0), "Sun": _row("Cancer", 10.0)} + hamsa = _one(planets, "Hamsa Yoga") + assert hamsa["is_valid"] is True and hamsa["strength"] == "reduced" + assert hamsa["reductions"][0].startswith("木星燃烧") + far = _one({"Jupiter": _row("Cancer", 5.0), "Sun": _row("Leo", 20.0)}, "Hamsa Yoga") + assert far["strength"] == "full" + + +def test_malefic_aspect_reduces() -> None: + # Saturn in Capricorn casts its 7th on Cancer. + hamsa = _one({"Jupiter": _row("Cancer", 5.0), "Saturn": _row("Capricorn", 10.0)}, "Hamsa Yoga") + assert hamsa["strength"] == "reduced" + assert hamsa["reductions"] == ["受凶星照:土星"] + # Rahu / Ketu cast no aspect (repository drishti rule); Ketu opposite is no reduction. + 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_reference_only_rules_never_run() -> None: + rules = {rule["id"]: rule for rule in json.loads(RULES.read_text(encoding="utf-8"))["rules"]} + for rule_id in REFERENCE_IDS: + assert rules[rule_id]["reference_only"] is True and "BUG-1207" in rules[rule_id]["reference_only_reason"], rule_id + assert not REFERENCE_IDS & {rule["id"] for rule in YogaEngine(str(RULES)).rules} + + +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") + 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")) + 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], + [entry for entry in map(_compact_yoga_entry, engine_rows) if entry], + ) + 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") + 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)} + 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 + # Saturn in Capricorn is the 7th from a Cancer Lagna: Sasa, full. + assert [(row["name"], row["strength"]) for row in all_rows] == [("Hamsa Yoga", "部分减弱"), ("Sasa Yoga", "强")] + rows = all_rows[:1] + entry = _compact_yoga_entry(rows[0]) + layer = build_traditional_yogas({"raman_support": {"yogas": {"status": "executed", "hits": [], "ungoverned_detected": [entry]}}}) + assert layer["names"] == ["Hamsa Yoga(Jupiter;部分减弱:受凶星照:土星)"] + + +FIGURES = ( + ("minute_rectification_development_v1.json", "steve_jobs_1955_development"), + ("minute_rectification_holdout_v4.json", "barack_obama_1961_aa_v4_holdout"), + ("minute_rectification_holdout_v4.json", "elizabeth_taylor_1932_aa_v4_holdout"), + ("minute_rectification_holdout_v5.json", "marilyn_monroe_1926_aa_v5_holdout"), + ("minute_rectification_holdout_v5.json", "judy_garland_1922_aa_v5_holdout"), + ("minute_rectification_holdout_v5.json", "edith_piaf_1915_aa_v5_holdout"), + ("minute_rectification_holdout_v5.json", "frida_kahlo_1907_aa_v5_holdout"), + ("minute_rectification_holdout_v5.json", "george_w_bush_1946_aa_v5_holdout"), + ("minute_rectification_holdout_v5.json", "zinedine_zidane_1972_aa_v5_holdout"), +) + + +@pytest.mark.parametrize("library,case_id", FIGURES) +def test_public_figures_engine_and_chart_paths_agree_with_the_main_detector(library: str, case_id: str) -> None: + cases = json.loads((ROOT / "references/real_case_calibration" / library).read_text(encoding="utf-8"))["cases"] + birth = next(case for case in cases if case["case_id"] == case_id)["birth"] + year, month, day = (int(part) for part in birth["date"].split("-")) + hour, minute = (int(part) for part in birth["time"].split(":")) + chart, *_ = compute_chart_data(year, month, day, hour, minute, birth["latitude"], birth["longitude"], + birth["timezone_offset"], ayanamsa_name="raman") + asc = chart["ascendant"]["sign"] + # Report path shape (degree in sign + lon) and API path shape (degree = lon). + report_planets = {name: {"sign": row["sign"], "house": row["house"], "degree": row["degree_in_sign"], + "lon": row["degree_raw"], "retrograde": row["retrograde"]} + for name, row in chart["planets"].items()} + api_planets = {name: {**row, "degree": row["lon"]} for name, row in report_planets.items()} + expected = {item["planet"]: item["strength"] for item in detect_pancha_mahapurusha(api_planets) if item["is_valid"]} + engine = {row["planets"][0]: row for row in YogaEngine(str(RULES)).detect(report_planets, asc) if row.get("category") == "mahapurusha"} + assert {planet: ("reduced" if row["reductions"] else "full") for planet, row in engine.items()} == expected + chart_rows = JyotishAPIHandler._detect_yogas(None, api_planets, SIGNS.index(asc)) + assert {row["planets"][0] for row in chart_rows if row.get("category") == "PMC"} == set(expected) + + +def test_report_yoga_rows_carry_retrograde_and_longitude() -> None: + """cmd_yoga (report path) hands the detectors the same longitude and + retrograde flag as the consultation path, so combustion orbs agree.""" + from scripts.jyotish_engine import cmd_yoga + + cases = json.loads((ROOT / "references/real_case_calibration/minute_rectification_holdout_v5.json").read_text(encoding="utf-8"))["cases"] + birth = next(case for case in cases if case["case_id"] == "judy_garland_1922_aa_v5_holdout")["birth"] + year, month, day = (int(part) for part in birth["date"].split("-")) + hour, minute = (int(part) for part in birth["time"].split(":")) + args = type("Args", (), {"planets": None, "year": year, "month": month, "day": day, "hour": hour, "minute": minute, + "lat": birth["latitude"], "lon": birth["longitude"], "tz": birth["timezone_offset"], + "node_mode": "mean", "second": 0, "ascendant": "Aries"})() + report = [row for row in cmd_yoga(args)["yogas"] if row.get("category") == "mahapurusha"] + chart, *_ = compute_chart_data(year, month, day, hour, minute, birth["latitude"], birth["longitude"], birth["timezone_offset"]) + api = {name: {"sign": row["sign"], "house": row["house"], "lon": row["degree_raw"], "degree": row["degree_raw"], + "retrograde": row["retrograde"]} for name, row in chart["planets"].items()} + assert [(row["name"], row["reductions"]) for row in report] == [ + (row["name"], row["reductions"]) for row in pancha_mahapurusha_rows(api)] diff --git a/tests/test_pancha_mahapurusha_self_conjunction.py b/tests/test_pancha_mahapurusha_self_conjunction.py index 58dd35ef..cf920818 100644 --- a/tests/test_pancha_mahapurusha_self_conjunction.py +++ b/tests/test_pancha_mahapurusha_self_conjunction.py @@ -39,8 +39,11 @@ def test_another_malefic_in_the_same_sign_still_cancels_and_is_named() -> None: "Saturn": {"sign": "Aries", "house": 1, "degree": 12.0}, } ruchaka = _yoga(detect_pancha_mahapurusha(planets), "Ruchaka Yoga") - assert ruchaka["is_valid"] is False - assert any("Saturn" in reason and "Mars," not in reason for reason in ruchaka["cancellations"]) + # BUG-1207 (ruling 甲5): a malefic in the same sign only reduces the yoga. + # Was: is_valid False with the reason in `cancellations`. + assert ruchaka["is_valid"] is True + assert ruchaka["strength"] == "reduced" + assert any("土星" in reason and "火星" not in reason for reason in ruchaka["reductions"]) assert _has_malefic_aspect("Aries", planets, "Mars") == ["Saturn"] @@ -50,5 +53,7 @@ def test_benefic_yoga_planet_unchanged() -> None: "Mars": {"sign": "Cancer", "house": 4, "degree": 100.0}, } hamsa = _yoga(detect_pancha_mahapurusha(planets), "Hamsa Yoga") - assert hamsa["is_valid"] is False + # BUG-1207 (ruling 甲5): reduced, not cancelled (was is_valid False). + assert hamsa["is_valid"] is True + assert hamsa["strength"] == "reduced" assert _has_malefic_aspect("Cancer", planets, "Jupiter") == ["Mars"]