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>
773 lines
39 KiB
Python
773 lines
39 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) > 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()}}
|