Files
Jyotisha/scripts/narayana_rath.py
T
Jesse_ChenandClaude Opus 5.5 c41555f69e feat(narayana): Rath start-sign seven-level rule, Ketu/Saturn exceptions, AD start rule; Table 18 not reproduced, not wired (BUG-1218)
Astrologer round-5 Q1-Q4 in scripts/narayana_rath.py (frozen narayana_dasha.py
untouched). Levels 1-2 are upstream c7117f92's rath_p36_role_conjunction_v1
counting (test compares with the upstream function); Ketu reversal is the
upstream rath_ketu_table12_v1 branch; Ketu first when Saturn shares the seed.
Book tables: 15 Cancer (level 1) and 17 Pisces (level 2) reproduce; 18 gives
Capricorn at level 5, so the Indira AK example (full tie only) is never
reached -> red line 3: no T4 wiring, no T5 trial.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-03 21:09:31 +08:00

390 lines
19 KiB
Python

#!/usr/bin/env python3
"""Narayana Dasha, Sanjay Rath profile (`sanjay_rath`) — research only (BUG-1215 / BUG-1218).
Astrologer round-4 ruling (Q4): the second time axis should be Sanjay Rath's
Narayana Dasha. Round-5 ruling 2026-10-03 fixed the open points (book pages
in brackets; Sanjay Rath, *Narayana Dasa*):
Start sign = the stronger of Lagna and 7th [p.48]. General rule, seven levels,
stop at the first level that separates the two signs, no weighting [p.35-36,
p.63]:
1. more occupants (actual bodies, Rahu/Ketu included);
2. more support from three roles — Jupiter, Mercury and the sign lord — each
role counted once when the planet is in the sign or casts Rasi Drishti on
it [p.48; no graha drishti]; roles are not de-duplicated by planet
(Jupiter as Jupiter and as lord counts twice) [p.36];
3. occupant dignity: exalted > moolatrikona > own sign (whole sign) — best
rank, then the number of dignified occupants;
4. dual > fixed > movable;
5. the lord's degree inside its sign, Chara Karaka sense (higher is
stronger; Rahu / Ketu as 30 - degree);
6. the lord placed in a sign of the opposite parity to the sign it rules;
7. the sign giving the longer dasha.
"Contains the AK" / "lord is the AK" are not general rules [p.35]. The
Indira example (p.65-66) decides Cancer by the AK; it is kept as a flagged
book example (`book_example_exception: rath_p66_indira_ak`), used only when
all seven levels tie.
MD order: Table 10 by the start sign [p.51]; Saturn in the start sign -> zodiacal,
sign by sign (Table 11) [p.51-53]; Ketu in the start sign -> Table 10 direction
reversed, keeping the fixed / dual stepping (not the whole table read
backwards) [p.51, p.53 Table 12; p.54 Table 14 is the AD table]. Saturn and
Ketu both in the start sign: Ketu first (PyJHora's practice, product owner
ruling 2026-10-03, `method_note`).
Years: `jaimini_odd_footed_dignity_v2` (odd/even-footed count, fixed-sign
exception, exalted +1 / debilitated -1); Scorpio / Aquarius lords by
`jaimini_dual_lord_source_v1`. Cycle 2 = 12 minus cycle 1.
AD [p.48 Arambha Rasi, p.50 note 2]: compare the MD sign and its 7th with the
same seven levels, take the stronger, start from the sign holding that sign's
lord (dual lords by the same rule), twelve equal parts. Order: Table 13 from
the start sign; Saturn in the MD sign -> Table 11 (zodiacal); Ketu in the MD
sign -> Table 14 (Ketu first when both).
Ported verbatim from upstream yinduzhanxing c7117f92 `scripts/narayana_dasha.py`:
`_rath_ketu_table12_sequence` (the `rath_ketu_table12_v1` branch of
`build_narayana_sign_sequence`), `_RATH_CLASSICAL_MOOLATRIKONA_SIGNS`, and the
level 1-2 counting of `select_narayana_seed_sign(strength_profile=
"rath_p36_role_conjunction_v1")` (`_role_candidate`; tests compare it with the
upstream function). Tables 10 / 11 / 13 / 14, `_rasi_aspects`, the year and
dual-lord functions are the same in both repositories and are reused from the
frozen local `narayana_dasha.py`, which this module does not modify.
Not wired into scoring, consultation or reports (task red line 3: Table 18 is
not reproduced by the general rule, see tests/test_narayana_rath_book_tables.py).
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
try: # scripts/ on sys.path
import narayana_dasha as nd
from jaimini import calc_chara_karaka_8
except ImportError: # pragma: no cover - package import
from scripts import narayana_dasha as nd
from scripts.jaimini import calc_chara_karaka_8
PROFILE = "sanjay_rath"
DURATION_PROFILE = "jaimini_odd_footed_dignity_v2"
DUAL_LORD_PROFILE = "jaimini_dual_lord_source_v1"
ROLE_PROFILE = "rath_p36_role_conjunction_v1"
INDIRA_EXCEPTION = "rath_p66_indira_ak"
KETU_FIRST_NOTE = "PyJHora 兼容:计都优先"
KETU_FIRST_NOTE_EN = "PyJHora-compatible: Ketu takes precedence"
SOURCE = {
"book": "Sanjay Rath, Narayana Dasa",
"start_sign": "p.48 (stronger of Lagna / 7th); levels p.35-36, p.63; Rasi Drishti p.48",
"md_order": "p.51 Table 10; Saturn p.51-53 Table 11; Ketu p.51, p.53 Table 12",
"ad": "p.48 Arambha Rasi, p.50 note 2; Tables 13 / 11 / 14 (p.54)",
"indira_example": "p.65-66 (AK), Table 18 p.67",
"ruling": "astrologer round 5, 2026-10-03, Q1-Q4; product owner: Ketu first",
"port": "upstream yinduzhanxing c7117f92 scripts/narayana_dasha.py",
}
SIGNS = nd.SIGNS
SIGN_LORDS = nd.SIGN_LORDS
GRAHAS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
LEVELS = (
"occupant_count",
"role_conjunction_or_rasi_aspect",
"occupant_dignity",
"modality",
"lord_degree_in_sign",
"lord_opposite_parity",
"longer_dasha",
)
# Ported from upstream c7117f92 (Rath p80, whole-sign scope).
_RATH_CLASSICAL_MOOLATRIKONA_SIGNS = dict(Sun=4, Moon=1, Mars=0,
Mercury=5, Jupiter=8, Venus=6, Saturn=10) # Rath p80, whole-sign scope.
def _rath_ketu_table12_sequence(seed_sign_idx: int) -> List[int]:
# Ported from upstream c7117f92 build_narayana_sign_sequence,
# profile "rath_ketu_table12_v1".
# Rath pp.53-54 Table12 note: reverse Table10 direction, retain motion.
return [(2 * seed_sign_idx - sign) % 12
for sign in nd._NARAYANA_RATH_GENERAL_SEQUENCE[seed_sign_idx]]
def _grahas(planet_lons: Dict[str, float]) -> Dict[str, float]:
return {planet: float(planet_lons[planet]) for planet in GRAHAS if planet_lons.get(planet) is not None}
def _lord(sign_idx: int, planet_lons: Dict[str, float]) -> str:
return nd._resolve_narayana_lord(sign_idx, planet_lons, dual_lord_profile=DUAL_LORD_PROFILE)
def _role_candidate(sign_idx: int, planet_lons: Dict[str, float], lord: str) -> Dict[str, Any]:
"""Levels 1-2 for one sign: upstream `select_narayana_seed_sign` with
strength_profile="rath_p36_role_conjunction_v1" (c7117f92), per sign."""
occupants = sorted(
planet
for planet in planet_lons
if nd._get_planet_sign(planet, planet_lons) == sign_idx
)
aspect_factors = []
role_factors = []
roles = (("jupiter", "Jupiter"), ("mercury", "Mercury"), ("sign_lord", lord))
for role, planet in roles:
planet_sign_idx = nd._get_planet_sign(planet, planet_lons)
conjunction = planet_sign_idx == sign_idx
if planet_sign_idx is not None and (conjunction or nd._rasi_aspects(planet_sign_idx, sign_idx)):
aspect_factors.append(planet)
role_factors.append(dict(role=role, planet=planet, contact="conjunction" if conjunction else "rasi_aspect"))
return {
"sign": SIGNS[sign_idx],
"lord": lord,
"occupants": occupants,
"occupant_count": len(occupants),
"aspect_factors": aspect_factors,
"aspect_factor_count": len(aspect_factors),
"role_factors": role_factors,
}
def _occupant_dignity(planet: str, sign_idx: int) -> int:
table = nd._NARAYANA_DIGNITY.get(planet)
if table is None: # Rahu / Ketu: no ruling on node dignity; not counted.
return 0
if sign_idx in table["exalted"]:
return 3
if sign_idx == _RATH_CLASSICAL_MOOLATRIKONA_SIGNS[planet]:
return 2
if SIGN_LORDS[SIGNS[sign_idx]] == planet:
return 1
return 0
def _ck_degree(planet: str, lon: float) -> float:
degree = float(lon) % 30.0
return 30.0 - degree if planet in ("Rahu", "Ketu") else degree
def sign_strength(sign_idx: int, planet_lons: Dict[str, float]) -> Dict[str, Any]:
"""All seven level values for one sign (see module docstring)."""
lons = _grahas(planet_lons)
lord = _lord(sign_idx, lons)
row = _role_candidate(sign_idx, lons, lord)
ranks = {planet: _occupant_dignity(planet, sign_idx) for planet in row["occupants"]}
best = max(ranks.values(), default=0)
dignified = sum(1 for value in ranks.values() if value > 0)
lord_sign_idx = nd._get_planet_sign(lord, lons)
if lord_sign_idx is None:
lord_sign_idx = sign_idx
years, _ = nd.calculate_narayana_period_years(sign_idx, lord, lord_sign_idx, profile=DURATION_PROFILE)
row.update({
"occupant_dignity": {"ranks": ranks, "best_rank": best, "dignified_count": dignified},
"modality": "dual" if sign_idx in (2, 5, 8, 11) else "fixed" if sign_idx in nd._FIXED_SIGNS else "movable",
"lord_sign": SIGNS[lord_sign_idx],
"lord_degree_in_sign": round(_ck_degree(lord, lons[lord]), 6) if lord in lons else None,
"lord_opposite_parity": lord_sign_idx % 2 != sign_idx % 2,
"dasha_years": years,
})
row["level_values"] = [
row["occupant_count"],
row["aspect_factor_count"],
(best, dignified),
{"movable": 0, "fixed": 1, "dual": 2}[row["modality"]],
row["lord_degree_in_sign"] if row["lord_degree_in_sign"] is not None else -1.0,
int(row["lord_opposite_parity"]),
years,
]
return row
def _atmakaraka(planet_lons: Dict[str, float]) -> Optional[str]:
lons = {planet: lon for planet, lon in _grahas(planet_lons).items() if planet != "Ketu"}
table = calc_chara_karaka_8(lons).get("karaka_table_8") or {}
for row in table.values():
if row.get("rank") == 1:
return row.get("planet")
return None
def compare_signs(first_idx: int, second_idx: int, planet_lons: Dict[str, float],
*, allow_book_example: bool = False) -> Dict[str, Any]:
"""Stronger of two signs by the seven general levels.
`allow_book_example`: only when all seven tie, the Indira example's AK
tie-break may decide; the result then carries `book_example_exception`.
"""
candidates = {first_idx: sign_strength(first_idx, planet_lons), second_idx: sign_strength(second_idx, planet_lons)}
base = {"candidates": {SIGNS[idx]: row for idx, row in candidates.items()},
"book_example_exception": None}
for level, name in enumerate(LEVELS):
a = candidates[first_idx]["level_values"][level]
b = candidates[second_idx]["level_values"][level]
if a != b:
winner = first_idx if a > b else second_idx
return {**base, "status": "selected", "selected_sign_idx": winner, "selected_sign": SIGNS[winner],
"strength_level_decided": level + 1, "strength_level_name": name}
if allow_book_example:
ak = _atmakaraka(planet_lons)
holders = [idx for idx in (first_idx, second_idx) if ak and nd._get_planet_sign(ak, planet_lons) == idx]
if len(holders) == 1:
winner = holders[0]
return {**base, "status": "selected", "selected_sign_idx": winner, "selected_sign": SIGNS[winner],
"strength_level_decided": None, "strength_level_name": "book_example_atmakaraka",
"book_example_exception": INDIRA_EXCEPTION, "atmakaraka": ak}
return {**base, "status": "tie", "reason": "all_seven_general_levels_tie",
"strength_level_decided": None, "strength_level_name": None}
def md_order(seed_sign_idx: int, planet_lons: Dict[str, float]) -> Dict[str, Any]:
"""MD sign order for one start sign, with the exception that decided it."""
saturn = nd._get_planet_sign("Saturn", planet_lons) == seed_sign_idx
ketu = nd._get_planet_sign("Ketu", planet_lons) == seed_sign_idx
method_note = None
if ketu:
order = _rath_ketu_table12_sequence(seed_sign_idx)
direction = "table12_ketu_reversed"
exception = "ketu_in_start_sign"
if saturn:
method_note = KETU_FIRST_NOTE
elif saturn:
order = nd.build_narayana_sign_sequence(seed_sign_idx, profile="rath_saturn_table11_v1")
direction = "table11_saturn_zodiacal"
exception = "saturn_in_start_sign"
else:
order = nd.build_narayana_sign_sequence(seed_sign_idx, profile="rath_general_table10_v1")
direction = "table10_general"
exception = None
return {"order": order, "direction": direction, "exception_applied": exception,
"method_note": method_note, "method_note_en": KETU_FIRST_NOTE_EN if method_note else None,
"saturn_in_seed": saturn, "ketu_in_seed": ketu}
def _ad_order_profile(md_sign_idx: int, planet_lons: Dict[str, float]) -> Tuple[str, Optional[str]]:
ketu = nd._get_planet_sign("Ketu", planet_lons) == md_sign_idx
saturn = nd._get_planet_sign("Saturn", planet_lons) == md_sign_idx
if ketu:
return "rath_ketu_table14_v1", (KETU_FIRST_NOTE if saturn else None)
if saturn:
return "rath_saturn_table11_v1", None
return "rath_table13_v1", None
def mahadasha_sequence(seed_sign_idx: int, planet_lons: Dict[str, float],
*, start_age: float = 0.0, cycle_count: int = 2) -> Dict[str, Any]:
"""Rath MD rows from one start sign (cycle 2 = twelve minus cycle 1)."""
order = md_order(seed_sign_idx, planet_lons)
rows: List[Dict[str, Any]] = []
cursor = float(start_age)
for position, sign_idx in enumerate(order["order"], start=1):
lord = _lord(sign_idx, planet_lons)
lord_sign_idx = nd._get_planet_sign(lord, planet_lons)
if lord_sign_idx is None:
lord_sign_idx = sign_idx
years, adjustment = nd.calculate_narayana_period_years(sign_idx, lord, lord_sign_idx, profile=DURATION_PROFILE)
rows.append({
"sign": SIGNS[sign_idx], "sign_idx": sign_idx, "lord": lord,
"lord_in_sign": SIGNS[lord_sign_idx], "lord_sign_idx": lord_sign_idx,
"years": years, "dignity_adjustment": adjustment, "cycle_index": 1,
"period_key": f"narayana_rath:1:{position:02d}:{SIGNS[sign_idx]}",
"start_age": round(cursor, 4), "end_age": round(cursor + years, 4),
})
cursor += years
if cycle_count == 2:
for position, first in enumerate(list(rows), start=1):
years = 12 - first["years"]
rows.append({**first, "years": years, "dignity_adjustment": None, "cycle_index": 2,
"period_key": f"narayana_rath:2:{position:02d}:{first['sign']}",
"start_age": round(cursor, 4), "end_age": round(cursor + years, 4)})
cursor += years
return {**order, "seed_sign": SIGNS[seed_sign_idx], "seed_sign_idx": seed_sign_idx, "mahadasha": rows}
def antardasha(md_row: Dict[str, Any], planet_lons: Dict[str, float]) -> Dict[str, Any]:
"""Twelve equal ADs: start from the sign of the lord of the stronger of
the MD sign and its 7th (round-5 Q4); order Table 13 / 11 / 14."""
md_sign_idx = md_row["sign_idx"]
seventh = (md_sign_idx + 6) % 12
comparison = compare_signs(md_sign_idx, seventh, planet_lons)
profile, note = _ad_order_profile(md_sign_idx, planet_lons)
if comparison["status"] != "selected":
return {"status": "blocked", "reason": comparison.get("reason"), "rows": [],
"antardasha_start_rule": {"rule": "stronger_of_md_sign_and_seventh_then_its_lord_sign",
"compared": [SIGNS[md_sign_idx], SIGNS[seventh]],
"strength_level_decided": None}}
stronger = comparison["selected_sign_idx"]
lord = _lord(stronger, planet_lons)
start = nd._get_planet_sign(lord, planet_lons)
if start is None:
return {"status": "blocked", "reason": "stronger_sign_lord_longitude_missing", "rows": []}
rows = nd.subdivide_narayana_equal_order(
md_row, start_sign_idx=start, order_profile=profile, level="AD",
parent_key="parent_md", parent_name=md_row["sign"],
)
for row in rows:
row["start_sign_source"] = "lord_of_stronger_md_or_seventh_sign"
return {
"status": "selected",
"rows": rows,
"antardasha_start_rule": {
"rule": "stronger_of_md_sign_and_seventh_then_its_lord_sign",
"compared": [SIGNS[md_sign_idx], SIGNS[seventh]],
"stronger_sign": SIGNS[stronger],
"strength_level_decided": comparison["strength_level_decided"],
"lord": lord,
"dual_lord_rule": DUAL_LORD_PROFILE if stronger in (7, 10) else None,
"start_sign": SIGNS[start],
"order_profile": profile,
"method_note": note,
},
}
def calc_narayana_rath(lagna_sign_idx: int, planet_lons: Dict[str, float],
*, seed_sign_idx: Optional[int] = None, cycle_count: int = 2,
allow_book_example: bool = False) -> Dict[str, Any]:
"""Rath Narayana Dasha for one chart.
`seed_sign_idx` overrides the start sign (book-table replay only).
`allow_book_example` lets the Indira AK example decide a full seven-level
tie (flagged in the output).
"""
base = {
"profile": PROFILE,
"duration_profile": DURATION_PROFILE,
"dual_lord_rule": DUAL_LORD_PROFILE,
"source": SOURCE,
"lagna_sign": SIGNS[lagna_sign_idx],
"antardasha_start_rule": "stronger_of_md_sign_and_seventh_then_its_lord_sign (p.48, p.50 note 2)",
}
if seed_sign_idx is not None:
selection = {"status": "explicit", "selected_sign_idx": seed_sign_idx,
"strength_level_decided": None, "book_example_exception": None}
else:
selection = compare_signs(lagna_sign_idx, (lagna_sign_idx + 6) % 12, planet_lons,
allow_book_example=allow_book_example)
base["seed_selection"] = selection
base["strength_level_decided"] = selection.get("strength_level_decided")
base["book_example_exception"] = selection.get("book_example_exception")
if selection["status"] in ("selected", "explicit"):
seq = mahadasha_sequence(selection["selected_sign_idx"], planet_lons, cycle_count=cycle_count)
if selection["status"] == "explicit":
status, basis = "explicit_seed", "explicit_seed"
elif selection.get("book_example_exception"):
status, basis = "book_example_exception", f"book_example:{selection['book_example_exception']}"
else:
status = "partial_verified"
basis = f"stronger_of_lagna_seventh:level_{selection['strength_level_decided']}_{selection['strength_level_name']}"
return {**base, "status": status, "seed_sign": seq["seed_sign"], "seed_basis": basis,
"direction": seq["direction"], "exception_applied": seq["exception_applied"],
"method_note": seq["method_note"], "method_note_en": seq["method_note_en"],
"mahadasha": seq["mahadasha"]}
alternatives = {
SIGNS[idx]: mahadasha_sequence(idx, planet_lons, cycle_count=cycle_count)
for idx in (lagna_sign_idx, (lagna_sign_idx + 6) % 12)
}
return {**base, "status": "parameter_sensitive", "reason": selection.get("reason"),
"seed_sign": None, "seed_basis": "tie_lagna_seventh_both_listed",
"direction": None, "exception_applied": None, "method_note": None, "method_note_en": None,
"mahadasha": None,
"alternatives": {sign: {"seed_sign": seq["seed_sign"], "direction": seq["direction"],
"exception_applied": seq["exception_applied"],
"mahadasha": seq["mahadasha"]} for sign, seq in alternatives.items()}}