Round-7 option A is text-first, so Scorpio and Aquarius lords, the Ketu-only degree reversal, and node exaltation years now live in the Rath display layer. Scoring files stay unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
220 lines
11 KiB
Python
220 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
"""Honest label for the product Narayana Dasha (BUG-1215, round-4 ruling Q4).
|
||
|
||
`narayana_dasha.narayana_dasha_full_report` (consultation, report, CLI) runs
|
||
`calc_narayana_mahadasha`: always from the Lagna, always zodiacal. The
|
||
astrologer's ruling is Sanjay Rath's Narayana Dasha (stronger of Lagna / 7th,
|
||
order by the start sign); until the product switches (product decision after
|
||
the trial), every user-visible Narayana carries this label and the data carry
|
||
`profile: legacy_reference_only`. Kept outside `narayana_dasha.py`, which is in
|
||
the frozen rectification scoring identity (ERR-110 / ERR-115).
|
||
|
||
BUG-1218 (T4, product owner 2026-10-03): when the caller hands the chart, the
|
||
Sanjay Rath profile (`narayana_rath`, round-5 rules) rides along under `rath`
|
||
for display only. Scoring and timeline conclusions keep reading the legacy
|
||
fields; nothing here changes them.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
LEGACY_PROFILE = "legacy_reference_only"
|
||
LEGACY_NOTE_ZH = "旧算法(从上升起、一律顺行),待替换为 Sanjay Rath 版"
|
||
LEGACY_NOTE_EN = "Legacy algorithm (starts from the Lagna, always zodiacal); to be replaced by Sanjay Rath's version"
|
||
# Short form for the consultation evidence card (12,000-character budget).
|
||
LEGACY_LABEL_ZH = "旧算法(从上升起、一律顺行)"
|
||
|
||
|
||
RATH_LABEL_ZH = "Sanjay Rath 版(并列参考,未进打分与时间轴结论;按原书 p.43 正文(第七轮裁定))"
|
||
RATH_LABEL_EN = "Sanjay Rath version (shown alongside for reference; not used in scoring or timing conclusions; book p.43 text, round-7 ruling)"
|
||
STRENGTH_LEVEL_ZH = {
|
||
1: "占据星数", 2: "木星 / 水星 / 宫主三角色", 3: "占据星尊贵", 4: "星座性质",
|
||
5: "宫主星座内度数", 6: "宫主落奇偶相反星座", 7: "大运年数",
|
||
}
|
||
STRENGTH_LEVEL_EN = {
|
||
1: "occupants", 2: "Jupiter / Mercury / lord roles", 3: "occupant dignity", 4: "modality",
|
||
5: "lord's degree in sign", 6: "lord in a sign of opposite parity", 7: "dasha years",
|
||
}
|
||
DIRECTION_ZH = {
|
||
"table10_general": "按 Table 10", "table11_saturn_zodiacal": "土星在起运宫:顺行(Table 11)",
|
||
"table12_ketu_reversed": "计都在起运宫:反向(Table 12)",
|
||
}
|
||
DIRECTION_EN = {
|
||
"table10_general": "Table 10 order", "table11_saturn_zodiacal": "Saturn in the start sign: zodiacal (Table 11)",
|
||
"table12_ketu_reversed": "Ketu in the start sign: reversed (Table 12)",
|
||
}
|
||
|
||
|
||
def _period(row: Dict[str, Any], birth_year: Optional[int]) -> Dict[str, Any]:
|
||
out = {key: row.get(key) for key in ("sign", "lord", "years", "base_years", "dignity_adjustment", "adjustment_source")}
|
||
out["start_age"] = round(float(row["start_age"]), 2)
|
||
if row.get("end_age") is not None:
|
||
out["end_age"] = round(float(row["end_age"]), 2)
|
||
if isinstance(out["years"], float):
|
||
out["years"] = round(out["years"], 2)
|
||
decision = row.get("dual_lord_decision") or {}
|
||
if decision.get("basis_text"):
|
||
out["dual_lord_basis"] = decision["basis_text"]
|
||
out["dual_lord_basis_en"] = decision.get("basis_text_en")
|
||
if birth_year and out.get("end_age") is not None:
|
||
out["start_year"] = round(birth_year + float(row["start_age"]), 2)
|
||
out["end_year"] = round(birth_year + float(out["end_age"]), 2)
|
||
return out
|
||
|
||
|
||
def _running(rows: List[Dict[str, Any]], age: Optional[float]) -> Optional[Dict[str, Any]]:
|
||
if age is None:
|
||
return None
|
||
return next((row for row in rows if float(row["start_age"]) <= age < float(row["end_age"])), None)
|
||
|
||
|
||
def rath_narayana_view(lagna_sign_idx: int, planet_lons: Dict[str, float], *,
|
||
current_age: Optional[float] = None, birth_year: Optional[int] = None) -> Dict[str, Any]:
|
||
"""Display-only Sanjay Rath Narayana: seed, basis, MDs, running MD / AD."""
|
||
try:
|
||
from narayana_rath import antardasha, calc_narayana_rath
|
||
except ImportError: # pragma: no cover - package import
|
||
from scripts.narayana_rath import antardasha, calc_narayana_rath
|
||
result = calc_narayana_rath(int(lagna_sign_idx) % 12, planet_lons, cycle_count=2)
|
||
view: Dict[str, Any] = {
|
||
"profile": result["profile"],
|
||
"algorithm_label": RATH_LABEL_ZH,
|
||
"algorithm_label_en": RATH_LABEL_EN,
|
||
"status": result["status"],
|
||
"used_for_scoring": False,
|
||
"source": result["source"],
|
||
"dual_lord_rule": result["dual_lord_rule"],
|
||
"antardasha_start_rule": result["antardasha_start_rule"],
|
||
}
|
||
if not result.get("mahadasha"):
|
||
view.update({"reason": result.get("reason"),
|
||
"alternative_seed_signs": sorted((result.get("alternatives") or {}).keys())})
|
||
return view
|
||
level = result.get("strength_level_decided")
|
||
view.update({
|
||
"seed_sign": result["seed_sign"],
|
||
"seed_basis": result["seed_basis"],
|
||
"strength_level_decided": level,
|
||
"strength_level_text": STRENGTH_LEVEL_ZH.get(level),
|
||
"strength_level_text_en": STRENGTH_LEVEL_EN.get(level),
|
||
"direction": result["direction"],
|
||
"direction_text": DIRECTION_ZH.get(result["direction"]),
|
||
"direction_text_en": DIRECTION_EN.get(result["direction"]),
|
||
"exception_applied": result.get("exception_applied"),
|
||
"method_note": result.get("method_note"),
|
||
"method_note_en": result.get("method_note_en"),
|
||
"mahadasha": [_period(row, birth_year) for row in result["mahadasha"]],
|
||
})
|
||
md = _running(result["mahadasha"], current_age)
|
||
if md is not None:
|
||
ad = antardasha(md, planet_lons)
|
||
current: Dict[str, Any] = {"md": _period(md, birth_year)}
|
||
rows = ad.get("rows") or []
|
||
running_ad = _running(rows, current_age)
|
||
if running_ad is not None:
|
||
current["ad"] = _period(running_ad, birth_year)
|
||
view["current_dasha"] = current
|
||
view["current_md_antardasha"] = [_period(row, birth_year) for row in rows]
|
||
view["current_md_antardasha_rule"] = ad.get("antardasha_start_rule")
|
||
return view
|
||
|
||
|
||
def label_legacy_narayana(result: Any, *, lagna_sign_idx: Optional[int] = None,
|
||
planet_lons: Optional[Dict[str, float]] = None,
|
||
current_age: Optional[float] = None,
|
||
birth_year: Optional[int] = None) -> Any:
|
||
"""Add the legacy profile and note to a `narayana_dasha_full_report` result.
|
||
|
||
With the chart (`lagna_sign_idx`, `planet_lons`) the Rath view is attached
|
||
under `rath` (display only, BUG-1218); a failure there never touches the
|
||
legacy result.
|
||
"""
|
||
if isinstance(result, dict):
|
||
result["profile"] = LEGACY_PROFILE
|
||
result["algorithm_note"] = LEGACY_NOTE_ZH
|
||
result["algorithm_note_en"] = LEGACY_NOTE_EN
|
||
result["algorithm_label"] = LEGACY_LABEL_ZH
|
||
if lagna_sign_idx is not None and planet_lons:
|
||
try:
|
||
result["rath"] = rath_narayana_view(lagna_sign_idx, planet_lons,
|
||
current_age=current_age, birth_year=birth_year)
|
||
except Exception as exc: # display layer only
|
||
result["rath"] = {"status": "blocked", "reason": exc.__class__.__name__,
|
||
"algorithm_label": RATH_LABEL_ZH, "algorithm_label_en": RATH_LABEL_EN}
|
||
return result
|
||
|
||
|
||
def rath_report_lines_zh(rath: Any) -> List[str]:
|
||
"""Reference-report block for the Sanjay Rath Narayana beside the legacy
|
||
line (BUG-1218, display only): summary, 24 MD rows, running MD's ADs."""
|
||
summary = rath_summary_line_zh(rath)
|
||
if summary is None:
|
||
return []
|
||
lines = [summary]
|
||
if not rath.get("mahadasha"):
|
||
return lines
|
||
if rath.get("direction_text"):
|
||
lines.append(f"- 顺序:{rath['direction_text']}" + (f";{rath['method_note']}" if rath.get("method_note") else ""))
|
||
lines.extend(["",
|
||
"| Rath MD | 星座 | 宫主 | 起始年龄 | 结束年龄 | 基本年数 | 修正 | 最终 |",
|
||
"|---------|------|------|----------|----------|----------|------|------|"])
|
||
for index, row in enumerate(rath["mahadasha"], start=1):
|
||
lines.append("| {index} | {sign} | {lord} | {start} | {end} | {base} | {adjustment} | {years} |".format(
|
||
index=index, sign=row.get("sign"), lord=row.get("lord") or "未决",
|
||
start=row.get("start_age"), end=row.get("end_age"),
|
||
base="—" if row.get("base_years") is None else row.get("base_years"),
|
||
adjustment="—" if row.get("dignity_adjustment") is None else row.get("dignity_adjustment"),
|
||
years="—" if row.get("years") is None else row.get("years")))
|
||
seen = set()
|
||
for row in rath["mahadasha"]:
|
||
sign = row.get("sign")
|
||
basis = row.get("dual_lord_basis")
|
||
if sign in ("Scorpio", "Aquarius") and basis and sign not in seen:
|
||
seen.add(sign)
|
||
lines.append(f"- 双主星 {sign}:{basis}")
|
||
md = (rath.get("current_dasha") or {}).get("md")
|
||
rule = rath.get("current_md_antardasha_rule") or {}
|
||
if md and rath.get("current_md_antardasha"):
|
||
compared = rule.get("compared") or ["-", "-"]
|
||
lines.extend([
|
||
"",
|
||
f"- 当前大运 {md.get('sign')} 的子运:比较 {compared[0]} 与 {compared[-1]},较强 {rule.get('stronger_sign')}"
|
||
f"(第 {rule.get('strength_level_decided')} 级),从宫主 {rule.get('lord')} 所在 {rule.get('start_sign')} 起,12 等分",
|
||
*_ad_direction_lines_zh(rule.get("direction")),
|
||
"",
|
||
"| Rath AD | 星座 | 起始年龄 | 结束年龄 |",
|
||
"|---------|------|----------|----------|",
|
||
])
|
||
for index, row in enumerate(rath["current_md_antardasha"], start=1):
|
||
lines.append(f"| {index} | {row.get('sign')} | {row.get('start_age')} | {row.get('end_age')} |")
|
||
return lines
|
||
|
||
|
||
AD_DIRECTION_WORD_ZH = {"zodiacal": "顺行", "reverse": "逆行"}
|
||
|
||
|
||
def _ad_direction_lines_zh(direction: Any) -> List[str]:
|
||
"""AD direction applied + the book's two other statements, flagged as a
|
||
book divergence and not applied (round-6 ruling Q7, BUG-1225)."""
|
||
if not isinstance(direction, dict) or not direction.get("rule_text"):
|
||
return []
|
||
others = ";".join(
|
||
f"{row.get('rule_text')} → {AD_DIRECTION_WORD_ZH.get(row.get('direction'), '-')}"
|
||
for row in direction.get("book_divergence") or [] if isinstance(row, dict))
|
||
line = f"- 子运方向:{direction['rule_text']},{AD_DIRECTION_WORD_ZH.get(direction.get('direction'), '-')}"
|
||
return [line + (f"(书内分歧,未采用:{others})" if others else "")]
|
||
|
||
|
||
def rath_summary_line_zh(rath: Any) -> Optional[str]:
|
||
"""One report line naming the Rath start sign and running MD (BUG-1218)."""
|
||
if not isinstance(rath, dict) or not rath:
|
||
return None
|
||
if not rath.get("seed_sign"):
|
||
return f"- 并列:{RATH_LABEL_ZH}:起运宫未定,{rath.get('reason') or rath.get('status')}"
|
||
md = (rath.get("current_dasha") or {}).get("md") or {}
|
||
running = f";当前大运 {md.get('sign')},宫主 {md.get('lord') or '未决'}" if md else ""
|
||
return (f"- 并列:{RATH_LABEL_ZH}:起运宫 {rath.get('seed_sign')}"
|
||
f"(第 {rath.get('strength_level_decided')} 级:{rath.get('strength_level_text')}){running}")
|
||
|