fix(yoga): Neecha Bhanga lists the conditions that hold, never Raja Yoga (T8, BUG-1205)
Astrologer ruling 乙14. One condition source, yogas_doshas.calc_nicha_bhanga_raj_yoga (conditions A-F, now also structured). New scripts/main_yoga_detectors.py turns it into yoga_engine-shaped rows (zh + en twins) that YogaEngine.detect appends; the four neechabhanga_* rules are reference_only (kept, renamed, not evaluated). Card: traditional_yogas canonical 落陷取消(Neecha Bhanga) with 成立条件; compact yoga entries keep conditions/reductions. Report: category 落陷取消, mixed group, not raja; personal-report alias neecha_bhanga -> special. Changed assertion (test_consultation_yoga_detection_merge): canonical name 'Neecha Bhanga Raja Yoga' -> '落陷取消(Neecha Bhanga)' (ruling 乙14). 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:
co-authored by
Claude Opus 5.5
parent
0cf2249e6b
commit
df21e591e8
@@ -1163,7 +1163,9 @@ function safeDomainLabel(value: unknown): string | null {
|
||||
}
|
||||
|
||||
const YOGA_CATEGORY_ALIASES: Readonly<Record<string, ReportYogaCategory>> = {
|
||||
raja: "raja", raja_yoga: "raja", neecha_bhanga: "raja",
|
||||
raja: "raja", raja_yoga: "raja",
|
||||
// BUG-1205 (乙14): a Neecha Bhanga lists its conditions and is not a raja yoga.
|
||||
neecha_bhanga: "special",
|
||||
dhana: "dhana", dhana_yoga: "dhana", wealth: "dhana",
|
||||
mahapurusha: "mahapurusha", pancha_mahapurusha: "mahapurusha",
|
||||
nabhasa: "nabhasa",
|
||||
|
||||
+23
-15
@@ -922,8 +922,8 @@
|
||||
},
|
||||
{
|
||||
"id": "neechabhanga_basic",
|
||||
"name": "Neechabhanga Raja Yoga",
|
||||
"name_cn": "落陷取消格局",
|
||||
"name": "Neechabhanga (reference: dispositor in kendra)",
|
||||
"name_cn": "落陷取消(参考:定位星在角宫)",
|
||||
"source": "BPHS",
|
||||
"bvr_id": "BVR-24-26",
|
||||
"category": "raja",
|
||||
@@ -949,12 +949,14 @@
|
||||
"strength_en": "strong",
|
||||
"enabled": true,
|
||||
"source_reference": "BVR-24-26: Debilitated planet with canceling condition",
|
||||
"bvr_chapter": "Chapter 4"
|
||||
"bvr_chapter": "Chapter 4",
|
||||
"reference_only": true,
|
||||
"reference_only_reason": "Astrologer rulings 2026-10-03 (乙14, BUG-1205): Neecha Bhanga lists only the cancellation conditions that hold and is never called Raja Yoga; main detector scripts/main_yoga_detectors.py (yogas_doshas.calc_nicha_bhanga_raj_yoga, conditions A-F)."
|
||||
},
|
||||
{
|
||||
"id": "neechabhanga_kendra",
|
||||
"name": "Neechabhanga Raja Yoga (Kendra)",
|
||||
"name_cn": "落陷取消格局(角宫版)",
|
||||
"name": "Neechabhanga (reference: debilitated planet in kendra)",
|
||||
"name_cn": "落陷取消(参考:落陷星在角宫)",
|
||||
"source": "BPHS",
|
||||
"bvr_id": "BVR-24-26",
|
||||
"category": "raja",
|
||||
@@ -977,9 +979,11 @@
|
||||
"strength": "强",
|
||||
"strength_en": "strong",
|
||||
"enabled": true,
|
||||
"dedup_key": "Neechabhanga Raja Yoga",
|
||||
"dedup_key": "Neechabhanga (reference)",
|
||||
"source_reference": "BVR-24-26: Debilitated planet with canceling condition",
|
||||
"bvr_chapter": "Chapter 4"
|
||||
"bvr_chapter": "Chapter 4",
|
||||
"reference_only": true,
|
||||
"reference_only_reason": "Astrologer rulings 2026-10-03 (乙14, BUG-1205): Neecha Bhanga lists only the cancellation conditions that hold and is never called Raja Yoga; main detector scripts/main_yoga_detectors.py (yogas_doshas.calc_nicha_bhanga_raj_yoga, conditions A-F)."
|
||||
},
|
||||
{
|
||||
"id": "dhana_yoga",
|
||||
@@ -4935,8 +4939,8 @@
|
||||
},
|
||||
{
|
||||
"id": "neechabhanga_lord_exalted",
|
||||
"name": "Neechabhanga Raja Yoga (Lord Exalted)",
|
||||
"name_cn": "坏座位消王瑜伽(主星入旺)",
|
||||
"name": "Neechabhanga (reference: dispositor exalted)",
|
||||
"name_cn": "落陷取消(参考:主星入旺)",
|
||||
"source": "BPHS",
|
||||
"bvr_id": "NB-3",
|
||||
"category": "raja",
|
||||
@@ -4955,16 +4959,18 @@
|
||||
"logic": {
|
||||
"type": "custom",
|
||||
"expr": "any(exalted(lord_of_house(h)) or own(lord_of_house(h)) for h in range(1,13) if lord_of_house(h) and lord_of_house(h) in ctx.planets and any(ctx.is_debilitated(p) for p in ctx.planets if p==lord_of_house(h)) and (ctx.house_of(lord_of_house(h)) in [1,4,7,10] or any(ctx.is_exalted(p) for p in ctx.planets if ctx.house_of(p) and any(ctx.house_of(q)==(ctx.house_of(p)-1)%12+1 for q in ctx.planets if q!=p))))",
|
||||
"combo_template": "Neechabhanga Raja Yoga(主星旺/庙)",
|
||||
"combo_template_en": "Neechabhanga Raja Yoga (dispositor exalted or in own sign)"
|
||||
"combo_template": "落陷取消(参考:主星旺/庙)",
|
||||
"combo_template_en": "Neechabhanga reference (dispositor exalted or in own sign)"
|
||||
},
|
||||
"source_reference": "BVR-24-26: Debilitated planet with canceling condition",
|
||||
"bvr_chapter": "Chapter 4"
|
||||
"bvr_chapter": "Chapter 4",
|
||||
"reference_only": true,
|
||||
"reference_only_reason": "Astrologer rulings 2026-10-03 (乙14, BUG-1205): Neecha Bhanga lists only the cancellation conditions that hold and is never called Raja Yoga; main detector scripts/main_yoga_detectors.py (yogas_doshas.calc_nicha_bhanga_raj_yoga, conditions A-F)."
|
||||
},
|
||||
{
|
||||
"id": "neechabhanga_parivartana",
|
||||
"name": "Neechabhanga Raja Yoga (Parivartana)",
|
||||
"name_cn": "坏座位消王瑜伽(互换)",
|
||||
"name": "Neechabhanga (reference: exchange)",
|
||||
"name_cn": "落陷取消(参考:互换)",
|
||||
"source": "BPHS",
|
||||
"bvr_id": "NB-4",
|
||||
"category": "raja",
|
||||
@@ -4998,7 +5004,9 @@
|
||||
}
|
||||
},
|
||||
"source_reference": "BVR-24-26: Debilitated planet with canceling condition",
|
||||
"bvr_chapter": "Chapter 4"
|
||||
"bvr_chapter": "Chapter 4",
|
||||
"reference_only": true,
|
||||
"reference_only_reason": "Astrologer rulings 2026-10-03 (乙14, BUG-1205): Neecha Bhanga lists only the cancellation conditions that hold and is never called Raja Yoga; main detector scripts/main_yoga_detectors.py (yogas_doshas.calc_nicha_bhanga_raj_yoga, conditions A-F)."
|
||||
},
|
||||
{
|
||||
"id": "viparita_6_12",
|
||||
|
||||
@@ -759,7 +759,8 @@ TRADITIONAL_YOGAS = (
|
||||
("Malavya Yoga", ("malavya",)),
|
||||
("Sasa Yoga", ("sasa", "shasha")),
|
||||
("Gajakesari Yoga", ("gajakesari",)),
|
||||
("Neecha Bhanga Raja Yoga", ("neechabhanga", "neechbhanga")),
|
||||
# BUG-1205 (乙14): never called Raja Yoga; the card lists the conditions that hold.
|
||||
("落陷取消(Neecha Bhanga)", ("neechabhanga", "neechbhanga")),
|
||||
# Budha-Aditya: the engine also emits the same Sun-Mercury conjunction as
|
||||
# "Surya-Budha Yoga"; both map to one row.
|
||||
("Budha-Aditya Yoga", ("budhaaditya", "budhaditya", "suryabudha")),
|
||||
@@ -787,6 +788,7 @@ def build_traditional_yogas(modules: dict) -> dict:
|
||||
if sieve.get("status") != "executed":
|
||||
return _blocked("raman_support_yogas_missing")
|
||||
found: dict[str, list[str]] = {}
|
||||
notes: dict[str, list[str]] = {}
|
||||
for entry in list(sieve.get("hits") or []) + list(sieve.get("ungoverned_detected") or []):
|
||||
row = _record(entry)
|
||||
if row.get("hit") is False:
|
||||
@@ -798,8 +800,23 @@ def build_traditional_yogas(modules: dict) -> dict:
|
||||
for planet in row.get("planets") or []:
|
||||
if isinstance(planet, str) and planet not in planets:
|
||||
planets.append(planet)
|
||||
# Main-detector rows: the conditions that hold (Neecha Bhanga, BUG-1205)
|
||||
# and what weakens a formed Pancha Mahapurusha yoga (BUG-1207).
|
||||
bucket = notes.setdefault(canonical, [])
|
||||
for key, prefix in (("conditions", "成立条件:"), ("reductions", "部分减弱:")):
|
||||
items = [str(item) for item in row.get(key) or [] if item]
|
||||
if items:
|
||||
text = prefix + ";".join(items)
|
||||
if text not in bucket:
|
||||
bucket.append(text)
|
||||
|
||||
def _label(canonical: str) -> str:
|
||||
parts = ["、".join(found[canonical])] if found[canonical] else []
|
||||
parts += notes.get(canonical) or []
|
||||
return f"{canonical}({';'.join(parts)})" if parts else canonical
|
||||
|
||||
names = [
|
||||
f"{canonical}({'、'.join(found[canonical])})" if found[canonical] else canonical
|
||||
_label(canonical)
|
||||
for canonical, _prefixes in TRADITIONAL_YOGAS
|
||||
if canonical in found
|
||||
]
|
||||
|
||||
@@ -896,6 +896,13 @@ def _compact_yoga_entry(value) -> dict | None:
|
||||
category = value.get('category') or value.get('cat')
|
||||
if category:
|
||||
compact['category'] = str(category)[:40]
|
||||
# Main-detector rows (scripts/main_yoga_detectors.py) carry the conditions
|
||||
# that hold (Neecha Bhanga) and what weakens a formed yoga (Pancha
|
||||
# Mahapurusha); the card lists them with the name.
|
||||
for key in ('conditions', 'reductions'):
|
||||
items = value.get(key)
|
||||
if isinstance(items, list) and items:
|
||||
compact[key] = [str(item)[:160] for item in items[:6] if item]
|
||||
return compact
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One main detector per classical yoga (astrologer rulings 2026-10-03).
|
||||
|
||||
The same yoga used to be judged by several implementations (local code,
|
||||
`references/yoga_rules.json`, upstream ports), merged by name, so whichever
|
||||
arrived first won. Each yoga below now has exactly one detector; the data-driven
|
||||
rules of the same name are `reference_only` in `yoga_rules.json` and the rule
|
||||
engine (`yoga_engine.YogaEngine.detect`) appends the rows built here instead, so
|
||||
the reader report, the consultation sieve and `chart.yogas` carry one verdict.
|
||||
|
||||
Rows are shaped like `yoga_engine` rows (Chinese fields plus `*_en` twins for the
|
||||
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.
|
||||
|
||||
Inputs are D1 rows `{sign, house, lon?, retrograde?, navamsa_sign?}`. A missing
|
||||
`navamsa_sign` is derived from the sidereal longitude (Parashari navamsa: each
|
||||
3°20' from the movable / fixed / dual starting sign), so every caller that has
|
||||
longitudes gets the same verdict.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
try: # scripts/ on sys.path
|
||||
from yogas_doshas import calc_nicha_bhanga_raj_yoga
|
||||
except ImportError: # package import (scripts.main_yoga_detectors)
|
||||
from scripts.yogas_doshas import calc_nicha_bhanga_raj_yoga
|
||||
|
||||
SIGNS = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
|
||||
'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces']
|
||||
SIGN_ZH = {
|
||||
'Aries': '白羊座', 'Taurus': '金牛座', 'Gemini': '双子座', 'Cancer': '巨蟹座',
|
||||
'Leo': '狮子座', 'Virgo': '处女座', 'Libra': '天秤座', 'Scorpio': '天蝎座',
|
||||
'Sagittarius': '射手座', 'Capricorn': '摩羯座', 'Aquarius': '水瓶座', 'Pisces': '双鱼座',
|
||||
}
|
||||
PLANET_ZH = {
|
||||
'Sun': '太阳', 'Moon': '月亮', 'Mars': '火星', 'Mercury': '水星', 'Jupiter': '木星',
|
||||
'Venus': '金星', 'Saturn': '土星', 'Rahu': '罗睺', 'Ketu': '计都',
|
||||
}
|
||||
|
||||
NEECHA_BHANGA_NAME = 'Neecha Bhanga'
|
||||
NEECHA_BHANGA_NAME_ZH = '落陷取消'
|
||||
NEECHA_BHANGA_CATEGORY = 'neecha_bhanga'
|
||||
NEECHA_BHANGA_NOTE_ZH = '只列成立的取消条件,不单独下结论'
|
||||
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
|
||||
|
||||
|
||||
def navamsa_sign(lon: float) -> str:
|
||||
"""D9 sign of a sidereal longitude (Parashari navamsa)."""
|
||||
return SIGNS[int((lon % 360.0) * 9.0 / 30.0) % 12]
|
||||
|
||||
|
||||
def with_navamsa(planets: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
||||
"""Copy of `planets` with `navamsa_sign` filled from the longitude when missing."""
|
||||
rows: Dict[str, Dict[str, Any]] = {}
|
||||
for name, row in (planets or {}).items():
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
copy = dict(row)
|
||||
lon = _lon(row)
|
||||
if not copy.get('navamsa_sign') and lon is not None:
|
||||
copy['navamsa_sign'] = navamsa_sign(lon)
|
||||
rows[name] = copy
|
||||
return rows
|
||||
|
||||
|
||||
def _planet_zh(name: Any) -> str:
|
||||
return PLANET_ZH.get(str(name), str(name))
|
||||
|
||||
|
||||
def _sign_zh(name: Any) -> str:
|
||||
return SIGN_ZH.get(str(name), str(name))
|
||||
|
||||
|
||||
def _neecha_condition_text(item: Dict[str, Any], debilitated: str) -> tuple[str, str]:
|
||||
code = item.get('code')
|
||||
planet = item.get('planet')
|
||||
house = item.get('house')
|
||||
frame_zh = '上升' if item.get('frame') == 'lagna' else '月亮'
|
||||
frame_en = 'the Lagna' if item.get('frame') == 'lagna' else 'the Moon'
|
||||
if code in ('A', 'B'):
|
||||
return (f'落陷星座主星{_planet_zh(planet)}在{frame_zh}起第{house}宫(角宫)',
|
||||
f'dispositor {planet} in house {house} from {frame_en} (kendra)')
|
||||
if code in ('C', 'D'):
|
||||
return (f'该星入旺星座的主星{_planet_zh(planet)}在{frame_zh}起第{house}宫(角宫)',
|
||||
f'lord of its exaltation sign, {planet}, in house {house} from {frame_en} (kendra)')
|
||||
if code == 'E':
|
||||
return (f'{_planet_zh(debilitated)}与落陷星座主星{_planet_zh(planet)}互换星座',
|
||||
f'{debilitated} and its dispositor {planet} exchange signs')
|
||||
if code == 'F':
|
||||
return (f'{_planet_zh(planet)}在 D9 入庙或入旺({_sign_zh(item.get("sign"))})',
|
||||
f'{planet} own or exalted in D9 ({item.get("sign")})')
|
||||
return (str(item), str(item))
|
||||
|
||||
|
||||
def neecha_bhanga_rows(planets: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Formed Neecha Bhanga rows; conditions from `calc_nicha_bhanga_raj_yoga`."""
|
||||
result = calc_nicha_bhanga_raj_yoga(with_navamsa(planets), {})
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for item in result.get('yogas') or []:
|
||||
planet = item.get('planet')
|
||||
texts = [_neecha_condition_text(cond, planet) for cond in item.get('cancellation_conditions') or []]
|
||||
if not texts:
|
||||
continue
|
||||
zh = [text[0] for text in texts]
|
||||
en = [text[1] for text in texts]
|
||||
sign = item.get('debilitated_sign')
|
||||
rows.append({
|
||||
'name': NEECHA_BHANGA_NAME,
|
||||
'name_cn': NEECHA_BHANGA_NAME_ZH,
|
||||
'category': NEECHA_BHANGA_CATEGORY,
|
||||
'planets': [planet],
|
||||
'combination': f'{_planet_zh(planet)}落陷于{_sign_zh(sign)};成立条件:' + ';'.join(zh),
|
||||
'combination_en': f'{planet} debilitated in {sign}; conditions met: ' + '; '.join(en),
|
||||
'conditions': zh,
|
||||
'conditions_en': en,
|
||||
'effects': [NEECHA_BHANGA_NOTE_ZH],
|
||||
'effects_en': [NEECHA_BHANGA_NOTE_EN],
|
||||
'strength': f'成立条件 {len(zh)} 条',
|
||||
'strength_en': f'{len(zh)} condition(s) met',
|
||||
'source': 'BPHS',
|
||||
'rule_id': 'main_detector:neecha_bhanga',
|
||||
'detector': 'yogas_doshas.calc_nicha_bhanga_raj_yoga',
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
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 neecha_bhanga_rows(planets)
|
||||
@@ -223,6 +223,7 @@ def _strip_pl9_user_engineering_markers(markdown: str) -> str:
|
||||
"common": "常见",
|
||||
"occasional": "偶见",
|
||||
"raja": "王权/事业",
|
||||
"neecha_bhanga": "落陷取消",
|
||||
"dhana": "财富",
|
||||
"putra": "子女/创造",
|
||||
"ayur": "寿命/健康",
|
||||
@@ -2500,7 +2501,8 @@ def render_pl9_parity_markdown(packet: dict) -> str:
|
||||
name = str(row.get('name') or row.get('name_cn') or row.get('type') or '').lower()
|
||||
if category in {'durbhaga', 'ari'} or 'daridra' in name or 'arishta' in name:
|
||||
return 'Applicable Malefic Yogas'
|
||||
if category in {'conjunction', 'special', 'kalatra', 'solar_yoga', 'lunar_yoga'} or 'dosha' in name:
|
||||
# A Neecha Bhanga only lists the conditions that hold (BUG-1205).
|
||||
if category in {'conjunction', 'special', 'kalatra', 'solar_yoga', 'lunar_yoga', 'neecha_bhanga'} or 'dosha' in name:
|
||||
return 'Applicable Mixed Yogas'
|
||||
return 'Applicable Benefic Yogas'
|
||||
|
||||
|
||||
+15
-1
@@ -16,6 +16,11 @@ import json
|
||||
import os
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
try: # scripts/ on sys.path
|
||||
from main_yoga_detectors import main_detector_rows
|
||||
except ImportError: # package import (scripts.yoga_engine)
|
||||
from scripts.main_yoga_detectors import main_detector_rows
|
||||
|
||||
# ============================================================================
|
||||
# 基础常量(自包含,避免循环导入)
|
||||
# ============================================================================
|
||||
@@ -717,7 +722,14 @@ class YogaEngine:
|
||||
data = json.load(f)
|
||||
self.schema_version = data.get("schema_version", "1.0")
|
||||
self.rules = [r for r in data.get("rules", []) if r.get("enabled", True)
|
||||
and r.get("id") not in RETIRED_RULE_IDS]
|
||||
and r.get("id") not in RETIRED_RULE_IDS
|
||||
and not r.get("reference_only")]
|
||||
# `reference_only` rules stay in the registry with their source and
|
||||
# logic but are never evaluated: the yoga they name has one main
|
||||
# detector (scripts/main_yoga_detectors.py; astrologer rulings
|
||||
# 2026-10-03), whose rows `detect` appends below.
|
||||
self.reference_only_rule_ids = sorted(
|
||||
r.get("id") for r in data.get("rules", []) if r.get("reference_only"))
|
||||
|
||||
def detect(self, planets: Dict[str, Dict], ascendant: str, context: Optional[Dict] = None) -> List[Dict]:
|
||||
"""
|
||||
@@ -774,6 +786,8 @@ class YogaEngine:
|
||||
|
||||
# ── 附加算法级 Yoga(不在 rules JSON 中) ──
|
||||
results += self._detect_solar_lunar_yogas(ctx)
|
||||
# ── 主检测器(同名 JSON 规则为 reference_only) ──
|
||||
results += main_detector_rows(ctx.planets, ctx.ascendant)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
+18
-5
@@ -400,7 +400,10 @@ def calc_pancha_mahapurusha_yoga(planets_data: Dict) -> Dict:
|
||||
|
||||
def calc_nicha_bhanga_raj_yoga(planets_data: Dict, houses: Dict) -> Dict:
|
||||
"""
|
||||
计算 Neecha Bhanga Raj Yoga(落陷解除王者瑜伽)v7.0
|
||||
计算 Neecha Bhanga(落陷取消)条件清单 v7.1
|
||||
|
||||
占星师定稿 2026-10-03 乙14(BUG-1205):只列出成立的取消条件,不自动称为
|
||||
Raja Yoga(王者格局)。函数名与结果键 `nicha_bhanga_raj` 是旧调用面,保留。
|
||||
|
||||
条件(需满足落陷 + 解除,参考dashaflow yoga.py MIT):
|
||||
1. 某行星落陷(在落陷星座)
|
||||
@@ -412,7 +415,9 @@ def calc_nicha_bhanga_raj_yoga(planets_data: Dict, houses: Dict) -> Dict:
|
||||
E. 落陷星与落陷星座主星互容(parivartana)
|
||||
F. 落陷星在Navamsa中入庙/擢升(vargottama缓解)
|
||||
|
||||
经典:落陷+落陷解除 = 王者瑜伽(先抑后扬,大器晚成)
|
||||
每条成立的条件同时写进 `cancellation_reasons`(中文)与
|
||||
`cancellation_conditions`(结构化:code / planet / frame / house / sign),
|
||||
供卡片与报告按同一份条件来源渲染。
|
||||
"""
|
||||
results = {'yogas': [], 'summary': ''}
|
||||
|
||||
@@ -471,6 +476,7 @@ def calc_nicha_bhanga_raj_yoga(planets_data: Dict, houses: Dict) -> Dict:
|
||||
|
||||
cancellation = False
|
||||
cancel_reasons = []
|
||||
cancel_conditions = []
|
||||
|
||||
# 条件A: 落陷星座主星(dispositor)在Lagna角宫
|
||||
deb_lord = SIGN_LORDS.get(deb_sign, '')
|
||||
@@ -478,6 +484,7 @@ def calc_nicha_bhanga_raj_yoga(planets_data: Dict, houses: Dict) -> Dict:
|
||||
if deb_lord_house and deb_lord_house in KENDRA:
|
||||
cancellation = True
|
||||
cancel_reasons.append(f'定位星{deb_lord}在Lagna第{deb_lord_house}宫(角宫)')
|
||||
cancel_conditions.append({'code': 'A', 'planet': deb_lord, 'frame': 'lagna', 'house': deb_lord_house})
|
||||
|
||||
# 条件B: 落陷星座主星在Moon角宫
|
||||
if deb_lord:
|
||||
@@ -485,6 +492,7 @@ def calc_nicha_bhanga_raj_yoga(planets_data: Dict, houses: Dict) -> Dict:
|
||||
if hfm and hfm in KENDRA:
|
||||
cancellation = True
|
||||
cancel_reasons.append(f'定位星{deb_lord}在Moon第{hfm}宫(角宫)')
|
||||
cancel_conditions.append({'code': 'B', 'planet': deb_lord, 'frame': 'moon', 'house': hfm})
|
||||
|
||||
# 条件C: 擢升星座主星在Lagna角宫
|
||||
exalt_sign = exaltation.get(pname, '')
|
||||
@@ -494,6 +502,7 @@ def calc_nicha_bhanga_raj_yoga(planets_data: Dict, houses: Dict) -> Dict:
|
||||
if exalt_lord_house and exalt_lord_house in KENDRA:
|
||||
cancellation = True
|
||||
cancel_reasons.append(f'擢升星主{exalt_lord}在Lagna第{exalt_lord_house}宫(角宫)')
|
||||
cancel_conditions.append({'code': 'C', 'planet': exalt_lord, 'frame': 'lagna', 'house': exalt_lord_house})
|
||||
|
||||
# 条件D: 擢升星座主星在Moon角宫
|
||||
if exalt_lord:
|
||||
@@ -501,6 +510,7 @@ def calc_nicha_bhanga_raj_yoga(planets_data: Dict, houses: Dict) -> Dict:
|
||||
if hfm and hfm in KENDRA:
|
||||
cancellation = True
|
||||
cancel_reasons.append(f'擢升星主{exalt_lord}在Moon第{hfm}宫(角宫)')
|
||||
cancel_conditions.append({'code': 'D', 'planet': exalt_lord, 'frame': 'moon', 'house': hfm})
|
||||
|
||||
# 条件E: 落陷星与定位星互容(parivartana)
|
||||
if deb_lord and deb_lord != pname:
|
||||
@@ -512,6 +522,7 @@ def calc_nicha_bhanga_raj_yoga(planets_data: Dict, houses: Dict) -> Dict:
|
||||
if lord_sign in pname_own_signs:
|
||||
cancellation = True
|
||||
cancel_reasons.append(f'{pname}与{deb_lord}互容(Parivartana)')
|
||||
cancel_conditions.append({'code': 'E', 'planet': deb_lord})
|
||||
|
||||
# 条件F: Navamsa入庙/擢升缓解(如果有navamsa数据)
|
||||
navamsa_sign = pdata.get('navamsa_sign')
|
||||
@@ -520,6 +531,7 @@ def calc_nicha_bhanga_raj_yoga(planets_data: Dict, houses: Dict) -> Dict:
|
||||
if navamsa_sign in own_signs or navamsa_sign == exaltation.get(pname, ''):
|
||||
cancellation = True
|
||||
cancel_reasons.append(f'{pname}在Navamsa中入庙/擢升({navamsa_sign})')
|
||||
cancel_conditions.append({'code': 'F', 'planet': pname, 'sign': navamsa_sign})
|
||||
|
||||
if cancellation:
|
||||
# 量化解除程度
|
||||
@@ -527,7 +539,7 @@ def calc_nicha_bhanga_raj_yoga(planets_data: Dict, houses: Dict) -> Dict:
|
||||
strength = 'very strong' if cancel_count >= 3 else 'strong' if cancel_count >= 2 else 'moderate'
|
||||
|
||||
yoga = {
|
||||
'type': 'Neecha Bhanga Raj Yoga',
|
||||
'type': 'Neecha Bhanga',
|
||||
'planet': pname,
|
||||
'debilitated_sign': deb_sign,
|
||||
'debility_lord': deb_lord,
|
||||
@@ -535,13 +547,14 @@ def calc_nicha_bhanga_raj_yoga(planets_data: Dict, houses: Dict) -> Dict:
|
||||
'exaltation_sign': exalt_sign,
|
||||
'exaltation_lord': exalt_lord,
|
||||
'cancellation_reasons': cancel_reasons,
|
||||
'cancellation_conditions': cancel_conditions,
|
||||
'cancellation_count': cancel_count,
|
||||
'strength': strength,
|
||||
'interpretation': f'Neecha Bhanga Raj Yoga——{pname}在{deb_sign}落陷但解除({"; ".join(cancel_reasons)}),先抑后扬大器晚成',
|
||||
'interpretation': f'落陷取消(Neecha Bhanga)——{pname}在{deb_sign}落陷,成立条件:{"; ".join(cancel_reasons)}',
|
||||
}
|
||||
results['yogas'].append(yoga)
|
||||
|
||||
results['summary'] = f"Neecha Bhanga Raj Yoga检测:共{len(results['yogas'])}个"
|
||||
results['summary'] = f"落陷取消(Neecha Bhanga)条件检测:共{len(results['yogas'])}个"
|
||||
return results
|
||||
|
||||
|
||||
|
||||
@@ -83,7 +83,8 @@ def test_traditional_yogas_closed_list_only_and_fixed_label() -> None:
|
||||
))
|
||||
assert layer["status"] == "executed"
|
||||
assert layer["label"] == TRADITIONAL_YOGA_LABEL == "传统格局,本站未验证,只作参考"
|
||||
assert layer["names"] == ["Sasa Yoga", "Gajakesari Yoga", "Neecha Bhanga Raja Yoga", "Budha-Aditya Yoga"]
|
||||
# BUG-1205: the Neecha Bhanga canonical is no longer called Raja Yoga.
|
||||
assert layer["names"] == ["Sasa Yoga", "Gajakesari Yoga", "落陷取消(Neecha Bhanga)", "Budha-Aditya Yoga"]
|
||||
|
||||
|
||||
def test_traditional_yogas_skip_misses_and_empty() -> None:
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Neecha Bhanga lists the conditions that hold, never "Raja Yoga" (BUG-1205).
|
||||
|
||||
Astrologer rulings 2026-10-03 乙14 (TASK-astrologer-rulings-batch1-20261003 T8):
|
||||
only the satisfied cancellation conditions are shown; the four data-driven
|
||||
`neechabhanga_*` rules are `reference_only`; the one condition source is
|
||||
`yogas_doshas.calc_nicha_bhanga_raj_yoga` (conditions A-F). Public chart:
|
||||
Barack Obama, Astro-Databank AA (repository holdout v4), Raman, mean nodes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.consultation_native_layers import build_traditional_yogas
|
||||
from scripts.jyotish_engine import compute_chart_data
|
||||
from scripts.main_yoga_detectors import neecha_bhanga_rows, with_navamsa
|
||||
from scripts.yoga_engine import YogaEngine
|
||||
from scripts.yogas_doshas import calc_nicha_bhanga_raj_yoga
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
RULES = ROOT / "references" / "yoga_rules.json"
|
||||
RAJA = re.compile(r"(neecha|neech|nicha)[ -]?bhanga[ -]?raja?\b|neechabhanga raja", re.IGNORECASE)
|
||||
|
||||
|
||||
def _case(case_id: str, library: str) -> dict:
|
||||
cases = json.loads((ROOT / "references/real_case_calibration" / library).read_text(encoding="utf-8"))["cases"]
|
||||
return next(case for case in cases if case["case_id"] == case_id)["birth"]
|
||||
|
||||
|
||||
def _planets(birth: dict) -> tuple[dict, str]:
|
||||
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")
|
||||
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()}
|
||||
return planets, chart["ascendant"]["sign"]
|
||||
|
||||
|
||||
def test_reference_only_rules_are_not_evaluated_and_carry_no_raja_name() -> None:
|
||||
rules = {rule["id"]: rule for rule in json.loads(RULES.read_text(encoding="utf-8"))["rules"]}
|
||||
ids = {"neechabhanga_basic", "neechabhanga_kendra", "neechabhanga_lord_exalted", "neechabhanga_parivartana"}
|
||||
for rule_id in ids:
|
||||
assert rules[rule_id]["reference_only"] is True
|
||||
assert "BUG-1205" in rules[rule_id]["reference_only_reason"]
|
||||
for key in ("name", "name_cn", "dedup_key"):
|
||||
assert not RAJA.search(str(rules[rule_id].get(key) or "")), (rule_id, key)
|
||||
engine = YogaEngine(str(RULES))
|
||||
assert not ids & {rule["id"] for rule in engine.rules}
|
||||
assert ids <= set(engine.reference_only_rule_ids)
|
||||
|
||||
|
||||
def test_public_chart_lists_the_conditions_from_the_one_source() -> None:
|
||||
planets, asc = _planets(_case("barack_obama_1961_aa_v4_holdout", "minute_rectification_holdout_v4.json"))
|
||||
source = calc_nicha_bhanga_raj_yoga(with_navamsa(planets), {})["yogas"]
|
||||
rows = [row for row in YogaEngine(str(RULES)).detect(planets, asc) if row.get("category") == "neecha_bhanga"]
|
||||
assert rows == neecha_bhanga_rows(planets)
|
||||
assert [row["planets"] for row in rows] == [[item["planet"]] for item in source]
|
||||
for row, item in zip(rows, source):
|
||||
assert len(row["conditions"]) == len(item["cancellation_conditions"]) == len(item["cancellation_reasons"]) > 0
|
||||
assert row["name"] == "Neecha Bhanga" and row["name_cn"] == "落陷取消"
|
||||
assert "成立条件:" in row["combination"]
|
||||
assert not RAJA.search(json.dumps(row, ensure_ascii=False))
|
||||
assert "Raja" not in row["name"] and "王" not in row["name_cn"]
|
||||
# This chart: Jupiter debilitated in Capricorn, dispositor Saturn in the 1st.
|
||||
assert rows[0]["planets"] == ["Jupiter"]
|
||||
assert rows[0]["conditions"][0] == "落陷星座主星土星在上升起第1宫(角宫)"
|
||||
|
||||
|
||||
def test_card_label_lists_conditions() -> None:
|
||||
row = {"name": "Neecha Bhanga", "planets": ["Jupiter"], "conditions": ["条件甲", "条件乙"]}
|
||||
layer = build_traditional_yogas({"raman_support": {"yogas": {"status": "executed", "hits": [], "ungoverned_detected": [row]}}})
|
||||
assert layer["names"] == ["落陷取消(Neecha Bhanga)(Jupiter;成立条件:条件甲;条件乙)"]
|
||||
|
||||
|
||||
def test_no_raja_wording_on_user_visible_surfaces() -> None:
|
||||
frontend = [path for path in (ROOT / "frontend" / "src").rglob("*") if path.suffix in {".ts", ".tsx"}]
|
||||
for path in frontend + [RULES, ROOT / "scripts/consultation_native_layers.py", ROOT / "scripts/main_yoga_detectors.py"]:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
assert not RAJA.search(text), path
|
||||
personal = (ROOT / "frontend/src/lib/personal-report-generation.ts").read_text(encoding="utf-8")
|
||||
assert 'neecha_bhanga: "special"' in personal
|
||||
|
||||
|
||||
def test_reader_report_shows_conditions_not_raja() -> None:
|
||||
import copy
|
||||
|
||||
from scripts.jyotish_engine import build_professional_report_reference_packet, cmd_full_reading
|
||||
from scripts.pl9_reader_export import _pl9_export_markdown_for_edition
|
||||
from scripts.professional_report_reference import _export_args
|
||||
|
||||
birth = _case("barack_obama_1961_aa_v4_holdout", "minute_rectification_holdout_v4.json")
|
||||
year, month, day = (int(part) for part in birth["date"].split("-"))
|
||||
hour, minute = (int(part) for part in birth["time"].split(":"))
|
||||
case = {"name": "Barack Obama", "year": year, "month": month, "day": day, "hour": hour, "minute": minute,
|
||||
"second": 0, "lat": birth["latitude"], "lon": birth["longitude"], "tz": birth["timezone_offset"],
|
||||
"node_mode": "mean", "ayanamsa": "raman", "today": "2026-10-03", "target_year": 2026, "age": 65}
|
||||
packet = dict(build_professional_report_reference_packet(cmd_full_reading(type("Args", (), dict(case))()), _export_args(dict(case)), []))
|
||||
packet["report_version"] = "pl9_personal_long_report.v3"
|
||||
markdown = _pl9_export_markdown_for_edition(copy.deepcopy(packet), "reader_main")
|
||||
rows = [line for line in markdown.splitlines() if line.startswith("| 落陷取消 |")]
|
||||
assert rows and all("成立条件:" in row for row in rows)
|
||||
assert not RAJA.search(markdown)
|
||||
assert "坏座位消王" not in markdown and "王权/事业 | 成立条件" not in markdown
|
||||
mixed = markdown[markdown.index("#### 可用混合型瑜伽"):]
|
||||
assert "| 落陷取消 | 落陷取消 |" in mixed.split("#### 可用凶性瑜伽")[0]
|
||||
Reference in New Issue
Block a user