Files
Jyotisha/scripts/narayana_rath.py
T
Jesse_ChenandClaude Opus 5.5 735d66fd65 feat(narayana): Table 18 as a flagged book example with the explicit seed; book software-verdict and Table 25 tests (BUG-1218)
Red line 3 revised by the product owner (2026-10-03 evening, audit da95d92d):
Indira Table 18 is a book-internal contradiction. calc_narayana_rath(...,
seed_sign_idx, book_example="rath_p66_indira_ak") reproduces it 12/12 flagged
book_example_exception and carries the general rule's Capricorn unchanged.
New tests: five D1 software-verdict tables 28/30 (two Wadiyar level-5 misses
pinned), co-lords 8/10, Table 25 sub-periods 24/36 (Gemini MD 0/12 pinned).

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

423 lines
21 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.
Table 18 (Indira) is a book-internal contradiction (BOOK_EXAMPLES): the
general rule's Capricorn is output as is; the book's Cancer only with the
start sign given explicitly and flagged (product owner, 2026-10-03 evening).
Not wired into scoring.
"""
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"
# Book examples whose printed start sign the general rule does not give.
# Product owner ruling 2026-10-03 (evening, red line 3 revised): reproduce
# them only with the start sign given explicitly, flagged; never by code that
# bends the general rule.
BOOK_EXAMPLES = {
INDIRA_EXCEPTION: {
"book_table": "Table 18",
"book_start_sign": "Cancer",
"book_internal_contradiction": True,
"evidence": ("Hand example (PDF Page 62; astrologer cites p.65-66) concludes Cancer because "
"'Saturn is the Atmakaraka and has gained the highest longitude', but Saturn sits in "
"Cancer and rules Capricorn, so rules 5 and 6 read literally point to Capricorn; the "
"book's own software verdict for the same native (Table 27) also has Capricorn "
"stronger than Cancer. Audit docs/research/shared_engine_reuse_audit_2026_10_03.md N1."),
},
}
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,
book_example: Optional[str] = None) -> Dict[str, Any]:
"""Rath Narayana Dasha for one chart.
`seed_sign_idx` overrides the start sign (book-table replay only); with
`book_example` (a key of BOOK_EXAMPLES) the output is flagged
`book_example_exception` and still carries the general rule's own
verdict, unchanged, in `general_rule_selection`.
`allow_book_example` lets the Indira AK example decide a full seven-level
tie (flagged in the output).
"""
if book_example is not None and (seed_sign_idx is None or book_example not in BOOK_EXAMPLES):
raise ValueError("book_example needs an explicit seed_sign_idx and a known BOOK_EXAMPLES key")
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": book_example}
if book_example is not None:
general = compare_signs(lagna_sign_idx, (lagna_sign_idx + 6) % 12, planet_lons)
base["general_rule_selection"] = {
key: general.get(key) for key in
("status", "selected_sign", "strength_level_decided", "strength_level_name", "reason")
}
base["book_example"] = BOOK_EXAMPLES[book_example]
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" and book_example is not None:
status, basis = "book_example_exception", f"explicit_seed:book_example:{book_example}"
elif 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()}}