#!/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()}}