diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 533f422d..3f4fc091 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -17046,3 +17046,19 @@ - 相关记录:BUG-1220 - 复发自:无 - 修复版本:本分支待发布 + +## BUG-1268 | 落陷取消只查两种条件,第一大运的小运从截断点起排 + +- 状态:resolved +- 首次发现:2026-10-07 +- 最近更新:2026-10-07 +- 影响面:尊贵判断里的落陷取消;全盘报告的 Vimshottari 小运;Bhava Bala 表;八字 Chara Karaka 表 +- 用户现象:落陷取消只在定位星入庙或旺星同宫时成立。出生落在第一大运中途时,小运从截断后的起点重排,而不是从完整大运起点排完再裁。Bhava Bala 的合计行会被印成多一宫。Karaka 表没有用于排序的度数 +- 触发条件:判断落陷取消;渲染第一大运被出生截断后的小运;渲染 Bhava Bala 或 Karaka 表 +- 根因:尊贵路径只记录了定位星入庙、旺星与落陷星同宫。上游 `4a807e7d` 另加定位星在月亮的四正宫、定位星在上升的四正宫。小运以前用截断后的可见段长度重算。Bhava 渲染把 houses 列表整段打出,合计行没有宫号。Karaka 表只有盘面度数 +- 修复:尊贵证据的 `conditions_checked` 加入上述两条件,并把宫位送进尊贵上下文。命中只记取消条件,`royal_yoga_label_allowed` 为假,不标成 Raja Yoga。卡片仍走原有的 `calc_nicha_bhanga_raj_yoga`。第一大运的小运和更小运从完整父段起点排,显示时裁到出生之后,并保留 `unclipped_start_datetime`。Bhava 表改用带宫号的行。Karaka 表增加 Ranking degree 列,取 `effective_degree_in_sign` +- 验证:`tests/test_neecha_bhanga_dignity_evidence.py`;`tests/test_neecha_bhanga_conditions.py`;`tests/test_bhava_bala_formal.py`;`tests/test_chara_karaka_8_bphs_order.py` +- 防复发:上述测试锁住条件名、未裁起点和宫号过滤 +- 相关记录:BUG-1205、BUG-1261 +- 复发自:无 +- 修复版本:本分支待发布 diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 3d0c814b..9162495b 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -612,32 +612,83 @@ def _build_planetary_friendship_snapshot(planets_data): ), } -def _check_neecha_bhanga(planet, sign, planets_data): - """Check for Cancellation of Debilitation.""" +def _neecha_bhanga_evidence(planet, sign, planets_data): + """Record cancellation conditions. A hit is not a Raja Yoga.""" + evidence = { + 'planet': planet, + 'debilitation_sign': sign, + '成立': False, + 'reasons': [], + 'conditions_checked': [ + 'dispositor_exalted', + 'exaltation_lord_conjunct_debilitation_sign', + 'dispositor_kendra_from_moon', + 'dispositor_kendra_from_lagna', + ], + 'royal_yoga_label_allowed': False, + 'claim_boundary': ( + 'Neecha Bhanga evidence records cancellation conditions only; it does not by itself prove a Raja Yoga, ' + 'complete reversal of weakness, or a guaranteed outcome.' + ), + } if not planets_data: - return False - + evidence['status'] = 'blocked_missing_planets' + return evidence + sign_lord = SIGN_LORDS.get(sign) - - # Condition 1: Dispositor is exalted. + lord_data = planets_data.get(sign_lord, {}) if sign_lord else {} + moon_sign = (planets_data.get('Moon') or {}).get('sign') if isinstance(planets_data.get('Moon'), dict) else None + lord_sign = lord_data.get('sign') if isinstance(lord_data, dict) else None + signs = list(SIGN_LORDS) + if lord_sign in signs and moon_sign in signs: + moon_house = (signs.index(lord_sign) - signs.index(moon_sign)) % 12 + 1 + if moon_house in (1, 4, 7, 10): + evidence['成立'] = True + evidence['reasons'].append({ + 'condition': 'dispositor_kendra_from_moon', + 'planet': sign_lord, + 'house_from_moon': moon_house, + }) + if isinstance(lord_data, dict) and lord_data.get('house') in (1, 4, 7, 10): + evidence['成立'] = True + evidence['reasons'].append({ + 'condition': 'dispositor_kendra_from_lagna', + 'planet': sign_lord, + 'house': lord_data['house'], + }) + if sign_lord and sign_lord in planets_data: lord_sign = planets_data[sign_lord].get('sign') if lord_sign and EXALTATION.get(sign_lord) == lord_sign: - return True - - # Condition 2: Planet exalted in this sign is conjunct. + evidence['成立'] = True + evidence['reasons'].append({ + 'condition': 'dispositor_exalted', + 'planet': sign_lord, + 'sign': lord_sign, + }) + exalted_planet = None for p, ex_sign in EXALTATION.items(): if ex_sign == sign: exalted_planet = p break - if exalted_planet and exalted_planet in planets_data: ex_p_sign = planets_data[exalted_planet].get('sign') if ex_p_sign == sign: - return True - - return False + evidence['成立'] = True + evidence['reasons'].append({ + 'condition': 'exaltation_lord_conjunct_debilitation_sign', + 'planet': exalted_planet, + 'sign': sign, + }) + + evidence['status'] = 'executed' + return evidence + + +def _check_neecha_bhanga(planet, sign, planets_data): + """Boolean compatibility wrapper. Details live on the evidence helper.""" + return bool(_neecha_bhanga_evidence(planet, sign, planets_data).get('成立')) def _build_dignity_context(chart_data): @@ -652,6 +703,7 @@ def _build_dignity_context(chart_data): context[pn] = { 'sign': pd.get('sign'), 'degree_in_sign': pd.get('degree_in_sign', pd.get('degree', 0) % 30), + 'house': pd.get('house'), } return context @@ -6808,7 +6860,8 @@ def render_pl9_markdown(packet: dict) -> str: bhava_bala = strength_sheet.get('bhava_bala') if isinstance(strength_sheet.get('bhava_bala'), dict) else {} strongest = _humanize_reader_token(shadbala.get('strongest')) if isinstance(shadbala, dict) else None weakest = _humanize_reader_token(shadbala.get('weakest')) if isinstance(shadbala, dict) else None - houses = bhava_bala.get('houses') if isinstance(bhava_bala.get('houses'), list) else [] + from special_lagnas import bhava_house_rows + houses = bhava_house_rows(bhava_bala) top_house = None top_score = None weak_house = None @@ -7653,7 +7706,8 @@ def render_pl9_markdown(packet: dict) -> str: ) out.append('') if bhava_bala: - rows = bhava_bala.get('houses') if isinstance(bhava_bala.get('houses'), list) else [] + from special_lagnas import bhava_house_rows + rows = bhava_house_rows(bhava_bala) if rows: out.extend([ '#### Bhava Bala(三分量)', @@ -12470,29 +12524,51 @@ def cmd_dasha(args): today = datetime.strptime(args.today, "%Y-%m-%d") if args.today else datetime.now() current = None + year_days = 365.25 for d in timeline: - ds = datetime.strptime(d["start"], "%Y-%m-%d"); de = datetime.strptime(d["end"], "%Y-%m-%d") - total_days = (de - ds).days; li = DASHA_ORDER.index(d["lord"]) - # v3.7.2: 为所有 Mahadasha 计算 Antardasha(不仅当前大运) - sub = []; sdt = ds + # 出生只截断第一大运的可见段。小运和更小运从完整父段起点排,再裁到出生之后。 + parent_start = datetime.fromisoformat(d["start_datetime"]) + parent_end = datetime.fromisoformat(d["end_datetime"]) + visible_origin = birth_dt if d.get("is_balance") else parent_start + total_duration = timedelta(days=float(d["full_years"]) * year_days) + li = DASHA_ORDER.index(d["lord"]) + sub = [] + sdt = parent_start for j in range(9): - sl = DASHA_ORDER[(li + j) % 9]; sd = total_days * DASHA_YEARS[sl] / 120 - se = sdt + timedelta(days=sd) - is_cur = sdt <= today < se + sl = DASHA_ORDER[(li + j) % 9] + se = parent_end if j == 8 else sdt + (total_duration * (DASHA_YEARS[sl] / 120.0)) + if se <= visible_origin: + sdt = se + continue + visible_start = max(sdt, visible_origin) + children = _build_pratyantar_timeline(d["lord"], sl, sdt, se, reference_dt=today) + visible_children = [] + for child in children: + child_start = datetime.fromisoformat(child["start_datetime"]) + child_end = datetime.fromisoformat(child["end_datetime"]) + if child_end <= visible_origin: + continue + if child_start < visible_origin: + child["unclipped_start_datetime"] = child_start.isoformat(timespec="seconds") + child["start"] = visible_origin.strftime("%Y-%m-%d") + child["start_datetime"] = visible_origin.isoformat(timespec="seconds") + child["is_current"] = bool(visible_origin <= today < child_end) + visible_children.append(child) sub.append({ "lord": sl, "lord_cn": PLANET_CN[sl], - "start": sdt.strftime("%Y-%m-%d"), + "start": visible_start.strftime("%Y-%m-%d"), "end": se.strftime("%Y-%m-%d"), - "start_datetime": sdt.isoformat(timespec="seconds"), + "start_datetime": visible_start.isoformat(timespec="seconds"), + "unclipped_start_datetime": sdt.isoformat(timespec="seconds"), "end_datetime": se.isoformat(timespec="seconds"), - "is_current": is_cur, - "pratyantar_dasha_timeline": _build_pratyantar_timeline( - d["lord"], sl, sdt, se, reference_dt=today, - ), + "is_current": visible_start <= today < se, + "pratyantar_dasha_timeline": visible_children, }) sdt = se d["antardasha_timeline"] = sub + ds = datetime.strptime(d["start"], "%Y-%m-%d") + de = datetime.strptime(d["end"], "%Y-%m-%d") if ds <= today < de: d["is_current"] = True # 从 antardasha 列表中提取当前正在运行的 antardasha diff --git a/scripts/pl9_reader_export.py b/scripts/pl9_reader_export.py index 13fe9681..bd95686c 100644 --- a/scripts/pl9_reader_export.py +++ b/scripts/pl9_reader_export.py @@ -1197,11 +1197,11 @@ def render_pl9_parity_markdown(packet: dict) -> str: ) lines.append('') bhava_bala = _dict(strengths.get('bhava_bala')) - bhava_rows = _list(bhava_bala.get('houses')) or [ - {'house': key, **value} - for key, value in bhava_bala.items() - if str(key).isdigit() and isinstance(value, dict) - ] + try: + from special_lagnas import bhava_house_rows + except ImportError: + from scripts.special_lagnas import bhava_house_rows + bhava_rows = bhava_house_rows(bhava_bala) if bhava_rows: # BUG-1210 (甲13): the formal three components (scripts/bhava_bala.py); # the local +2 / -1.5 house score is never shown. @@ -1211,7 +1211,7 @@ def render_pl9_parity_markdown(packet: dict) -> str: '| House | Sign | Lord | Bhavadhipati | Dig | Drishti | Total (Virupa) | Total (Rupa) |', '|-------|------|------|--------------|-----|---------|----------------|--------------|', ]) - for row in bhava_rows[:12]: + for row in bhava_rows: if not isinstance(row, dict) or row.get('total') is None: continue lines.append( @@ -1292,8 +1292,8 @@ def render_pl9_parity_markdown(packet: dict) -> str: lines.extend(['### Jaimini Karaka、Arudha Pada 与特殊 Lagna', '']) if karaka_table: lines.extend([ - '| Karaka | Planet | Degree | Domain |', - '|--------|--------|--------|--------|', + '| Karaka | Planet | Degree | Ranking degree | Domain |', + '|--------|--------|--------|----------------|--------|', ]) for name, row in karaka_table.items(): if not isinstance(row, dict): @@ -1301,6 +1301,7 @@ def render_pl9_parity_markdown(packet: dict) -> str: lines.append( f"| {_cell(row.get('cn_name') or name)} | {_cell(_token(row.get('planet')))} | " f"{_cell(_degree(row.get('degree_in_sign') or row.get('sign_degree')))} | " + f"{_cell(_degree(row.get('effective_degree_in_sign', row.get('degree_in_sign'))))} | " f"{_cell(row.get('domain'))} |" ) lines.append('') diff --git a/tests/test_neecha_bhanga_dignity_evidence.py b/tests/test_neecha_bhanga_dignity_evidence.py new file mode 100644 index 00000000..0c8a09a7 --- /dev/null +++ b/tests/test_neecha_bhanga_dignity_evidence.py @@ -0,0 +1,73 @@ +"""Dignity-path Neecha Bhanga records the two kendra conditions (BUG-1268).""" + +from __future__ import annotations + +from datetime import datetime + +from scripts.jyotish_engine import ( + _build_dignity_context, + _get_dignity_level, + _neecha_bhanga_evidence, + cmd_dasha, +) + + +def _capricorn_jupiter(**extra_planets): + planets = { + "Jupiter": {"sign": "Capricorn", "degree": 10.0, "house": 10}, + "Moon": {"sign": "Gemini", "degree": 5.0, "house": 3}, + "Mars": {"sign": "Leo", "degree": 5.0, "house": 5}, + } + planets.update(extra_planets) + return planets + + +def test_dispositor_kendra_from_lagna_is_checked_and_is_not_a_raja_yoga() -> None: + planets = _capricorn_jupiter(Saturn={"sign": "Aries", "degree": 4.0, "house": 1}) + context = _build_dignity_context(planets) + assert context["Saturn"]["house"] == 1 + evidence = _neecha_bhanga_evidence("Jupiter", "Capricorn", context) + assert evidence["conditions_checked"] == [ + "dispositor_exalted", + "exaltation_lord_conjunct_debilitation_sign", + "dispositor_kendra_from_moon", + "dispositor_kendra_from_lagna", + ] + assert evidence["royal_yoga_label_allowed"] is False + assert "Raja Yoga" in evidence["claim_boundary"] + assert {row["condition"] for row in evidence["reasons"]} == {"dispositor_kendra_from_lagna"} + assert _get_dignity_level("Jupiter", "Capricorn", 10.0, context) == "NEECHA_BHANGA" + + +def test_dispositor_kendra_from_moon_is_checked() -> None: + planets = _capricorn_jupiter( + Saturn={"sign": "Cancer", "degree": 4.0, "house": 5}, + Moon={"sign": "Cancer", "degree": 8.0, "house": 5}, + ) + context = _build_dignity_context(planets) + evidence = _neecha_bhanga_evidence("Jupiter", "Capricorn", context) + assert {row["condition"] for row in evidence["reasons"]} == {"dispositor_kendra_from_moon"} + assert evidence["reasons"][0]["house_from_moon"] == 1 + + +def test_first_mahadasha_antardasha_keeps_the_unclipped_parent_start() -> None: + # 原值 / 新值 / 原因: 旧小运从截断后的可见段重排;新值保留未截断起点并裁掉出生前已结束的小运。 + args = type("Args", (), { + "moon_lon": 79.0, + "nakshatra": None, + "pada": None, + "birthdate": "1990-01-15", + "today": "2026-05-04", + })() + first = cmd_dasha(args)["timeline"][0] + birth = datetime(1990, 1, 15) + assert first["is_balance"] is True + ads = first["antardasha_timeline"] + assert ads + for row in ads: + assert datetime.fromisoformat(row["start_datetime"]) >= birth + assert datetime.fromisoformat(row["end_datetime"]) > birth + assert "unclipped_start_datetime" in row + straddling = [row for row in ads if row["unclipped_start_datetime"] != row["start_datetime"]] + assert straddling + assert datetime.fromisoformat(straddling[0]["unclipped_start_datetime"]) < birth