fix(yoga): Pancha Mahapurusha has one main detector; combust/afflicted only reduced (T1, BUG-1207)

Astrologer ruling 甲5. scripts/pancha_mahapurusha.py is the only detector: kendras from
the Lagna; retrograde no longer cancels; combustion (shared orb table), malefic
conjunction and malefic graha drishti (Sun 7, Mars 4/7/8, Saturn 3/7/10) mark the
yoga reduced with reasons; D9 debilitation still cancels when navamsa_sign is given
(current behaviour, ruling silent, asked next round). _detect_yogas now passes the
Sun's longitude (combustion was never checked). mahapurusha_*, *_own, moolatrikona_*,
hamsa_trikona (Jupiter 1/5/9, not classical) and bvr_maalavya_yoga are reference_only;
yogas_doshas.calc_pancha_mahapurusha_yoga is kept as reference_yogas only. The rule
engine appends main-detector rows; cmd_yoga rows now carry lon + retrograde so the
report and card get the same combustion verdict. Card label lists 部分减弱 reasons.

Changed assertions (three columns in PROGRESS): self-conjunction tests (malefic in
same sign: invalid -> reduced), run_all t31/t32 (combust/retro: invalid -> reduced /
full), run_all t54 (evaluated rules >= 400 -> evaluated + reference_only >= 400).

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:26:22 +08:00
co-authored by Claude Opus 5.5
parent 6916bbaecc
commit de79390175
9 changed files with 421 additions and 69 deletions
+7 -5
View File
@@ -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}")
+5
View File
@@ -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
+60 -8
View File
@@ -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)
+93 -28
View File
@@ -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,
}
+12 -1
View File
@@ -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