From df21e591e8a6720e1d53a26b869439ed1836fd32 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 3 Oct 2026 11:38:52 +0800 Subject: [PATCH] fix(yoga): Neecha Bhanga lists the conditions that hold, never Raja Yoga (T8, BUG-1205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE --- .../src/lib/personal-report-generation.ts | 4 +- references/yoga_rules.json | 38 +++-- scripts/consultation_native_layers.py | 21 ++- scripts/jyotish_api_server.py | 7 + scripts/main_yoga_detectors.py | 143 ++++++++++++++++++ scripts/pl9_reader_export.py | 4 +- scripts/yoga_engine.py | 16 +- scripts/yogas_doshas.py | 23 ++- .../test_consultation_yoga_detection_merge.py | 3 +- tests/test_neecha_bhanga_conditions.py | 109 +++++++++++++ 10 files changed, 342 insertions(+), 26 deletions(-) create mode 100644 scripts/main_yoga_detectors.py create mode 100644 tests/test_neecha_bhanga_conditions.py diff --git a/frontend/src/lib/personal-report-generation.ts b/frontend/src/lib/personal-report-generation.ts index 6c3794a9..8e8b772f 100644 --- a/frontend/src/lib/personal-report-generation.ts +++ b/frontend/src/lib/personal-report-generation.ts @@ -1163,7 +1163,9 @@ function safeDomainLabel(value: unknown): string | null { } const YOGA_CATEGORY_ALIASES: Readonly> = { - 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", diff --git a/references/yoga_rules.json b/references/yoga_rules.json index 4c06750f..2b99bb0b 100644 --- a/references/yoga_rules.json +++ b/references/yoga_rules.json @@ -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", diff --git a/scripts/consultation_native_layers.py b/scripts/consultation_native_layers.py index dd7d1c30..13837e20 100644 --- a/scripts/consultation_native_layers.py +++ b/scripts/consultation_native_layers.py @@ -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 ] diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 19114f56..beddf560 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -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 diff --git a/scripts/main_yoga_detectors.py b/scripts/main_yoga_detectors.py new file mode 100644 index 00000000..c022d37a --- /dev/null +++ b/scripts/main_yoga_detectors.py @@ -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) diff --git a/scripts/pl9_reader_export.py b/scripts/pl9_reader_export.py index 26d66d0d..e737c4da 100644 --- a/scripts/pl9_reader_export.py +++ b/scripts/pl9_reader_export.py @@ -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' diff --git a/scripts/yoga_engine.py b/scripts/yoga_engine.py index af1403c5..5566b9b3 100644 --- a/scripts/yoga_engine.py +++ b/scripts/yoga_engine.py @@ -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 diff --git a/scripts/yogas_doshas.py b/scripts/yogas_doshas.py index 59dcf3a8..beee6104 100644 --- a/scripts/yogas_doshas.py +++ b/scripts/yogas_doshas.py @@ -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 diff --git a/tests/test_consultation_yoga_detection_merge.py b/tests/test_consultation_yoga_detection_merge.py index 295f782a..8c3549a5 100644 --- a/tests/test_consultation_yoga_detection_merge.py +++ b/tests/test_consultation_yoga_detection_merge.py @@ -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: diff --git a/tests/test_neecha_bhanga_conditions.py b/tests/test_neecha_bhanga_conditions.py new file mode 100644 index 00000000..a3acef24 --- /dev/null +++ b/tests/test_neecha_bhanga_conditions.py @@ -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]