fix(dignity): record Neecha Bhanga kendras and clip the first dasha from its full parent (BUG-1268, upstream 4a807e7d)

This commit is contained in:
jesse-ux
2026-10-07 17:40:58 +08:00
parent f02d27a73f
commit edeec41309
4 changed files with 202 additions and 36 deletions
+16
View File
@@ -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
- 复发自:无
- 修复版本:本分支待发布
+104 -28
View File
@@ -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
+9 -8
View File
@@ -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('')
@@ -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