Files
Jyotisha/scripts/narayana_rath.py
T
Jesse_ChenandCursor baf4ae8b34
Independent Staging Quality Gate / validate (push) Failing after 16m17s
Independent Staging Quality Gate / publish (push) Skipped
feat(narayana-rath): follow book p.43 for dual lords and node years (BUG-1227~1229)
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>
2026-10-04 18:37:34 +08:00

773 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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) > none >
debilitated — best rank, then the number of dignified occupants; Rahu /
Ketu by Rath Table 9 (p.41-42, Phalita set; round-6 ruling Q2,
BUG-1224), debilitation below none per p.67;
4. dual > fixed > movable;
5. the lord's degree inside its sign (higher is stronger; only Ketu is
30 - degree, and Ketu at 0° compares as 30; Rahu and the seven grahas
use the raw degree, retrograde included — round-7 ruling Q3, p.71
footnote 42, BUG-1228);
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` count, then exalted +1 / debilitated
-1. The seven grahas use the frozen dignity table. Rahu / Ketu use the
Phalita pair (Rahu exalted Gemini / debilitated Sagittarius, Ketu the
reverse) and are clamped to 0–12 (p.40 (a), round-7, BUG-1229). Scorpio /
Aquarius lords follow book p.43 (a)–(e) with the round-7 ruling (option A):
(d) compares the signs the two lords occupy with this module's seven
levels; (c) counts only Scorpio / Aquarius themselves, not Mars in Aries or
Saturn in Capricorn; same-sign identity uses the level-5 degree rule and
returns undecided on an exact tie (BUG-1227). 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 (direction by the start sign's odd/even, as in Table 25;
round-6 ruling Q7); Saturn in the MD sign -> Table 11 (zodiacal); Ketu in the
MD sign -> Table 14 (Ketu first when both) [p.51]. The book's other two
statements (p.47 J.S. 1.1.34: by the MD sign; J.S. 2.4.31-32: by the MD sign
or its lord's sign) disagree with Table 25; each AD rule carries them as
`direction.book_divergence` (BUG-1225), not applied.
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 = "rath_p43_text_first_round7_v1"
P43_SOURCE = "p.43; p.116; p.71 fn 42; Jaimini course p.276"
P43_VERSION_ZH = "按原书 p.43 正文(第七轮裁定)"
P43_VERSION_EN = "Book p.43 text (round-7 ruling)"
_DUAL_PAIRS = {7: ("Mars", "Ketu"), 10: ("Saturn", "Rahu")}
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."),
},
}
# Book software verdicts the general rule does not follow (round-6 ruling
# Q6b, BUG-1226): Table 25 (Krishnaraja Wadiyar IV) takes Gemini over
# Sagittarius and Virgo over Pisces; the seven general levels decide
# Sagittarius / Pisces at level 5 (Mercury 1.3 deg vs Jupiter 10.0 deg). Only
# the book software's Rule 8 at the seventh level separates them that way — a
# software special case. The general result is kept and every output carries
# this note.
BOOK_SOFTWARE_RULE8 = "book_software_rule8_exception"
BOOK_SOFTWARE_DIFFERENCES = {
BOOK_SOFTWARE_RULE8: {
"book_table": "Table 25",
"pairs": [{"book": "Gemini", "general_rule": "Sagittarius", "general_level": 5},
{"book": "Virgo", "general_rule": "Pisces", "general_level": 5}],
"kept": "general_rule",
"note": "原书软件判定差异:原书软件用第 7 级 Rule 8 取双子、处女;通则第 5 级取射手、双鱼,保持通则",
"note_en": ("Book software verdict differs: the book's software used Rule 8 at the seventh level to take "
"Gemini and Virgo; the general rule takes Sagittarius and Pisces at level 5 and is kept"),
"ruling": "astrologer round 6, 2026-10-04, Q6b (p.36, p.95)",
},
}
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; round 6, 2026-10-04, Q2 (node dignity); round 7, 2026-10-04, Q1 option A (p.43), Q3 (only Ketu reversed)",
"dual_lord": P43_SOURCE,
"dual_lord_version": P43_VERSION_ZH,
"dual_lord_version_en": P43_VERSION_EN,
"node_dignity": "Table 9 p.41-42 (Phalita exaltation Rahu Gemini / Ketu Sagittarius); debilitated weaker p.67",
"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 _strength_degree(planet: str, lon: float) -> float:
"""Degree used when a higher value is stronger (BUG-1228, round-7 Q3).
Grahas and Rahu keep the raw degree inside the sign. Only Ketu is
reversed (p.71 footnote 42). Ketu at 0° compares as 30, not 0.
"""
degree = float(lon) % 30.0
if planet == "Ketu":
return 30.0 - degree
return degree
def _ck_degree(planet: str, lon: float) -> float:
return _strength_degree(planet, lon)
def rath_period_years(sign_idx: int, lord: str, lord_sign_idx: int) -> Dict[str, Any]:
"""Base years, dignity adjustment and final years (BUG-1229).
The count is the frozen odd/even-footed duration. Graha exaltation /
debilitation stays in `calculate_narayana_period_years`. Rahu / Ketu add
or subtract one year on the Phalita exaltation pair only, then clamp to
0–12 (p.40 (a)).
"""
base = nd.calculate_narayana_duration(sign_idx, lord_sign_idx, profile=DURATION_PROFILE)
if lord in RATH_NODE_DIGNITY:
table = RATH_NODE_DIGNITY[lord]
if lord_sign_idx in table["exalted"]:
adjustment = 1
elif lord_sign_idx in table["debilitated"]:
adjustment = -1
else:
adjustment = 0
source = "p40a_phalita_nodes"
years = max(0, min(12, base + adjustment))
else:
years, adjustment = nd.calculate_narayana_period_years(
sign_idx, lord, lord_sign_idx, profile=DURATION_PROFILE)
source = "p40a_grahas"
return {
"lord": lord,
"base_years": base,
"dignity_adjustment": adjustment,
"years": years,
"adjustment_source": source,
}
_BASIS = {
"a": (
"两主都在本宫,年数 12;身份按同宫经度,只倒算计都",
"Both lords are in the sign, so the period is 12 years; identity is by same-sign degree, and only Ketu is reversed",
),
"b": (
"两主同在另一宫,年数数到该宫;身份按同宫经度,只倒算计都",
"Both lords share another sign, so the years are counted to it; identity is by same-sign degree, and only Ketu is reversed",
),
"c": (
"一主在天蝎或水瓶本宫,取在外面的那颗;火星在白羊、土星在摩羯不算本宫",
"One lord is in Scorpio or Aquarius itself, so the lord outside it is taken; Mars in Aries and Saturn in Capricorn do not count as that own sign",
),
"d": (
"两主分落别的星座,按七级比较所落星座",
"The two lords occupy different signs, compared on the seven strength levels",
),
"e": (
"两主所落星座七级全平,取年数较多的一颗",
"The occupied signs tie on all seven levels, so the lord giving more years is taken",
),
}
def _dual_decision(*, status: str, rule: Optional[str], lord: Optional[str], compared: List[Dict[str, Any]],
strength_level_decided: Optional[int] = None, reason: Optional[str] = None,
period: Optional[Dict[str, Any]] = None, identity_rule: Optional[str] = None) -> Dict[str, Any]:
if reason == "resolution_cycle":
zh, en = ("双主星判定遇到循环,返回未决", "Dual-lord resolution hit a cycle and stays undecided")
elif rule is None:
zh, en = ("", "")
else:
zh, en = _BASIS.get(rule, ("双主星未决", "Dual lord undecided"))
if rule == "d" and strength_level_decided and lord:
zh = f"{zh},第 {strength_level_decided} 级取 {lord}"
en = f"{en}; level {strength_level_decided} takes {lord}"
elif lord and rule in {"a", "b", "c", "e"}:
zh = f"{zh},取 {lord}"
en = f"{en}; takes {lord}"
return {
"status": status,
"rule": rule,
"identity_rule": identity_rule,
"lord": lord,
"compared": compared,
"strength_level_decided": strength_level_decided,
"basis_text": zh,
"basis_text_en": en,
"source": P43_SOURCE,
"reason": reason,
"period": period,
}
def _same_sign_identity(first: str, second: str, planet_lons: Dict[str, float]) -> Dict[str, Any]:
"""p.116 plus p.71 footnote 42. Exact equality stays undecided."""
first_degree = _strength_degree(first, planet_lons[first])
second_degree = _strength_degree(second, planet_lons[second])
if first_degree == second_degree:
return {"status": "undecided", "lord": None, "reason": "equal_degree",
"degrees": {first: first_degree, second: second_degree}}
winner = first if first_degree > second_degree else second
return {"status": "selected", "lord": winner, "reason": None,
"degrees": {first: first_degree, second: second_degree}}
def resolve_dual_lord(sign_idx: int, planet_lons: Dict[str, float], *,
_resolution_path: Tuple[int, ...] = ()) -> Dict[str, Any]:
"""p.43 (a)–(e) for Scorpio / Aquarius. Other signs return their one lord.
Comparing the signs the two lords occupy can ask for another dual lord.
A sign already on `_resolution_path` stays undecided instead of looping.
"""
lons = _grahas(planet_lons)
pair = _DUAL_PAIRS.get(sign_idx)
if pair is None:
lord = SIGN_LORDS[SIGNS[sign_idx]]
return _dual_decision(status="selected", rule=None, lord=lord, compared=[], period=None)
if sign_idx in _resolution_path:
return _dual_decision(status="undecided", rule=None, lord=None, compared=[], reason="resolution_cycle")
path = _resolution_path + (sign_idx,)
first, second = pair
first_sign = nd._get_planet_sign(first, lons)
second_sign = nd._get_planet_sign(second, lons)
if first_sign is None or second_sign is None:
return _dual_decision(status="selected", rule=None, lord=SIGN_LORDS[SIGNS[sign_idx]], compared=[],
reason="longitude_missing")
compared = [
{"lord": first, "sign": SIGNS[first_sign], "sign_idx": first_sign},
{"lord": second, "sign": SIGNS[second_sign], "sign_idx": second_sign},
]
home_first = first_sign == sign_idx
home_second = second_sign == sign_idx
if home_first and home_second:
identity = _same_sign_identity(first, second, lons)
period = {"lord": identity["lord"], "base_years": 12, "dignity_adjustment": 0, "years": 12,
"adjustment_source": "p43a_both_in_sign"}
return _dual_decision(status=identity["status"], rule="a", lord=identity["lord"], compared=compared,
reason=identity["reason"], period=period, identity_rule="p116_longitude")
if first_sign == second_sign:
identity = _same_sign_identity(first, second, lons)
if identity["status"] != "selected":
left = rath_period_years(sign_idx, first, first_sign)
right = rath_period_years(sign_idx, second, second_sign)
period = left if left["years"] == right["years"] else None
if period is not None:
period = {**period, "lord": None}
return _dual_decision(status="undecided", rule="b", lord=None, compared=compared,
reason="equal_degree", period=period, identity_rule="p116_longitude")
period = rath_period_years(sign_idx, identity["lord"], first_sign)
return _dual_decision(status="selected", rule="b", lord=identity["lord"], compared=compared,
period=period, identity_rule="p116_longitude")
if home_first != home_second:
lord = second if home_first else first
lord_sign = second_sign if home_first else first_sign
return _dual_decision(status="selected", rule="c", lord=lord, compared=compared,
period=rath_period_years(sign_idx, lord, lord_sign))
comparison = compare_signs(first_sign, second_sign, lons, _resolution_path=path)
if comparison["status"] == "selected":
winner = comparison["selected_sign_idx"]
lord = first if first_sign == winner else second
return _dual_decision(status="selected", rule="d", lord=lord, compared=compared,
strength_level_decided=comparison["strength_level_decided"],
period=rath_period_years(sign_idx, lord, winner))
if comparison["status"] == "undecided":
return _dual_decision(status="undecided", rule="d", lord=None, compared=compared,
reason=comparison.get("reason") or "dual_lord_unresolved")
left = rath_period_years(sign_idx, first, first_sign)
right = rath_period_years(sign_idx, second, second_sign)
if left["years"] == right["years"]:
return _dual_decision(status="undecided", rule="e", lord=None, compared=compared, reason="equal_years",
period={**left, "lord": None})
period = left if left["years"] > right["years"] else right
return _dual_decision(status="selected", rule="e", lord=period["lord"], compared=compared, period=period)
def _lord(sign_idx: int, planet_lons: Dict[str, float], *,
_resolution_path: Tuple[int, ...] = ()) -> Optional[str]:
return resolve_dual_lord(sign_idx, planet_lons, _resolution_path=_resolution_path).get("lord")
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,
}
# Rahu / Ketu dignity inside Rath Narayana only (round-6 ruling Q2, Rath
# Table 9 p.41-42): own sign Rahu Aquarius / Ketu Scorpio; moolatrikona Rahu
# Virgo / Ketu Pisces; exaltation for Phalita dasas such as Narayana Rahu
# Gemini / Ketu Sagittarius (the Taurus / Scorpio pair is for Ayur dasas
# only), debilitation in the opposite signs. Not the natal dignity table
# (BUG-1211 "nodes have no own sign" still holds on the D1 main path).
RATH_NODE_DIGNITY = {
"Rahu": {"exalted": {2}, "moolatrikona": 5, "own": {10}, "debilitated": {8}},
"Ketu": {"exalted": {8}, "moolatrikona": 11, "own": {7}, "debilitated": {2}},
}
DEBILITATED_RANK = -1
def _occupant_dignity(planet: str, sign_idx: int) -> int:
"""Level 3 rank: exalted 3 > moolatrikona 2 > own 1 > none 0 > debilitated -1.
Debilitated below none: p.67 (Indira, Table 18) "Ketu is debilitated and
as per Rule (3) of first source of strength is weaker than Mars" (Mars in
Leo, no dignity). Applied to all nine bodies (Rule 3 is general).
"""
node = RATH_NODE_DIGNITY.get(planet)
if node is not None:
exalted, moolatrikona, own, debilitated = node["exalted"], node["moolatrikona"], node["own"], node["debilitated"]
else:
table = nd._NARAYANA_DIGNITY.get(planet)
if table is None:
return 0
exalted, debilitated = table["exalted"], table["debilitated"]
moolatrikona = _RATH_CLASSICAL_MOOLATRIKONA_SIGNS[planet]
own = {idx for idx, sign in enumerate(SIGNS) if SIGN_LORDS[sign] == planet}
if sign_idx in exalted:
return 3
if sign_idx == moolatrikona:
return 2
if sign_idx in own:
return 1
if sign_idx in debilitated:
return DEBILITATED_RANK
return 0
def sign_strength(sign_idx: int, planet_lons: Dict[str, float], *,
_resolution_path: Tuple[int, ...] = ()) -> Dict[str, Any]:
"""All seven level values for one sign (see module docstring)."""
lons = _grahas(planet_lons)
decision = resolve_dual_lord(sign_idx, lons, _resolution_path=_resolution_path)
lord = decision.get("lord")
row = _role_candidate(sign_idx, lons, lord or SIGN_LORDS[SIGNS[sign_idx]])
row["dual_lord_decision"] = decision
row["lord_undecided"] = not lord
if not lord:
row["lord"] = None
row["role_factors"] = []
row["aspect_factors"] = []
row["aspect_factor_count"] = None
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)
debilitated = sum(1 for value in ranks.values() if value == DEBILITATED_RANK)
lord_sign_idx = nd._get_planet_sign(lord, lons) if lord else None
if lord_sign_idx is None:
lord_sign_idx = sign_idx
period = rath_period_years(sign_idx, lord, lord_sign_idx) if lord else None
years = period["years"] if period else None
degree = _strength_degree(lord, lons[lord]) if lord and lord in lons else None
row.update({
"occupant_dignity": {"ranks": ranks, "best_rank": best, "dignified_count": dignified,
"debilitated_count": debilitated},
"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] if lord else None,
"lord_degree_in_sign": degree,
"lord_opposite_parity": (lord_sign_idx % 2 != sign_idx % 2) if lord else None,
"dasha_years": years,
"period": period,
})
row["level_values"] = [
row["occupant_count"],
row["aspect_factor_count"],
(best, dignified),
{"movable": 0, "fixed": 1, "dual": 2}[row["modality"]],
degree if degree is not None else -1.0,
int(row["lord_opposite_parity"] or False),
years if years is not None else -1,
]
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,
_resolution_path: Tuple[int, ...] = ()) -> 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, _resolution_path=_resolution_path),
second_idx: sign_strength(second_idx, planet_lons, _resolution_path=_resolution_path),
}
base = {"candidates": {SIGNS[idx]: row for idx, row in candidates.items()},
"book_example_exception": None}
for level, name in enumerate(LEVELS):
if level >= 1 and (candidates[first_idx].get("lord_undecided") or candidates[second_idx].get("lord_undecided")):
return {**base, "status": "undecided", "reason": "dual_lord_unresolved",
"strength_level_decided": None, "strength_level_name": None}
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
# AD direction (round-6 ruling Q7): default by the odd/even of the AD start
# sign (Table 13 practice, as the book's Table 25 shows); exceptions p.51:
# Saturn in the MD sign -> always zodiacal (Table 11); Ketu in the MD sign ->
# reversed (Table 14); both -> Ketu first (product owner 2026-10-03). Two
# other statements in the book disagree with Table 25 and are reported, not
# applied (`book_divergence`).
AD_DIRECTION_RULE = {
"rath_table13_v1": ("按子运起点星座奇偶(Table 13)", "by the odd/even of the AD start sign (Table 13)"),
"rath_saturn_table11_v1": ("大运星座有土星:一律顺行(Table 11)", "Saturn in the MD sign: always zodiacal (Table 11)"),
"rath_ketu_table14_v1": ("大运星座有计都:反向(Table 14)", "Ketu in the MD sign: reversed (Table 14)"),
}
AD_BOOK_DIVERGENCE = (
("rath_p47_js_1_1_34", "Rath p.47 (J.S. 1.1.34)",
"p.47(J.S. 1.1.34)看大运星座奇偶", "p.47 (J.S. 1.1.34) by the odd/even of the MD sign"),
("js_2_4_31_32", "J.S. 2.4.31-32 (Rath p.47-48)",
"J.S. 2.4.31–32 看大运星座或其宫主所落星座", "J.S. 2.4.31-32 by the MD sign or the sign its lord occupies"),
)
def _is_odd_sign(sign_idx: int) -> bool:
return sign_idx % 2 == 0 # Aries (index 0) is the first, odd sign
def ad_direction(md_sign_idx: int, start_sign_idx: int, profile: str, planet_lons: Dict[str, float]) -> Dict[str, Any]:
"""Applied AD direction plus the two book statements it does not follow."""
order = nd.build_narayana_antardasha_order_sequence(start_sign_idx, profile=profile)
applied = "zodiacal" if order[1] == (start_sign_idx + 1) % 12 else "reverse"
lord_sign = nd._get_planet_sign(_lord(md_sign_idx, planet_lons), planet_lons)
by_rule = {
"rath_p47_js_1_1_34": "zodiacal" if _is_odd_sign(md_sign_idx) else "reverse",
"js_2_4_31_32": "zodiacal" if _is_odd_sign(md_sign_idx) or (lord_sign is not None and _is_odd_sign(lord_sign)) else "reverse",
}
zh, en = AD_DIRECTION_RULE[profile]
return {
"direction": applied,
"rule": profile,
"rule_text": zh,
"rule_text_en": en,
"book_divergence": [
{"id": key, "source": source, "rule_text": rule_zh, "rule_text_en": rule_en,
"direction": by_rule[key], "differs": by_rule[key] != applied, "applied": False}
for key, source, rule_zh, rule_en in AD_BOOK_DIVERGENCE
],
}
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):
decision = resolve_dual_lord(sign_idx, planet_lons)
period = decision.get("period")
lord = decision.get("lord")
if period is None and lord:
lord_sign_idx = nd._get_planet_sign(lord, planet_lons)
if lord_sign_idx is None:
lord_sign_idx = sign_idx
period = rath_period_years(sign_idx, lord, lord_sign_idx)
elif period is None:
lord_sign_idx = sign_idx
else:
lord_sign_idx = nd._get_planet_sign(lord, planet_lons) if lord else None
if lord_sign_idx is None:
lord_sign_idx = sign_idx
years = None if period is None else period["years"]
row = {
"sign": SIGNS[sign_idx], "sign_idx": sign_idx, "lord": lord,
"lord_in_sign": SIGNS[lord_sign_idx] if lord_sign_idx is not None else None,
"lord_sign_idx": lord_sign_idx,
"years": years,
"base_years": None if period is None else period["base_years"],
"dignity_adjustment": None if period is None else period["dignity_adjustment"],
"adjustment_source": None if period is None else period["adjustment_source"],
"cycle_index": 1,
"period_key": f"narayana_rath:1:{position:02d}:{SIGNS[sign_idx]}",
"start_age": round(cursor, 4),
"end_age": None if years is None else round(cursor + years, 4),
}
if sign_idx in _DUAL_PAIRS:
row["dual_lord_decision"] = decision
rows.append(row)
if years is None:
return {**order, "seed_sign": SIGNS[seed_sign_idx], "seed_sign_idx": seed_sign_idx,
"mahadasha": rows, "status": "undecided"}
cursor += years
if cycle_count == 2:
for position, first in enumerate(list(rows), start=1):
years = 12 - first["years"]
rows.append({**first, "years": years, "base_years": None, "dignity_adjustment": None,
"adjustment_source": 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"]
decision = resolve_dual_lord(stronger, planet_lons)
lord = decision.get("lord")
if not lord:
return {"status": "blocked", "reason": decision.get("reason") or "dual_lord_undecided", "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],
"dual_lord_decision": decision}}
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": decision.get("rule") if stronger in _DUAL_PAIRS else None,
"dual_lord_decision": decision if stronger in _DUAL_PAIRS else None,
"start_sign": SIGNS[start],
"order_profile": profile,
"method_note": note,
"direction": ad_direction(md_sign_idx, start, profile, planet_lons),
},
}
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)",
"known_book_differences": BOOK_SOFTWARE_DIFFERENCES,
}
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()}}