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

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

198 lines
10 KiB
Python

"""Sanjay Rath Narayana rules from the astrologer's round-5 ruling (BUG-1218).
Constructed charts (synthetic longitudes, no birth data) exercise each of the
seven start-sign levels, the Indira book-example path, the Saturn / Ketu
exceptions and the AD start rule. Two cases are ported from upstream
yinduzhanxing c7117f92: `tests/test_rath_role_strength.py::test_p36_two_roles_for_jupiter_and_one_opposite`
and the Saturn-seed case of `tests/test_varsha_narayana_native.py`
(`test_published_muntha_seed_saturn_exception_and_first_five_lengths`, MD part).
"""
from __future__ import annotations
import importlib.util
import random
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
for entry in (str(ROOT), str(ROOT / "scripts")):
if entry not in sys.path:
sys.path.insert(0, entry)
from scripts import narayana_dasha as nd # noqa: E402
from scripts.narayana_rath import ( # noqa: E402
GRAHAS, INDIRA_EXCEPTION, KETU_FIRST_NOTE, SIGN_LORDS, SIGNS, _role_candidate, antardasha,
calc_narayana_rath, compare_signs, md_order, mahadasha_sequence,
)
UPSTREAM = Path("/workspace/yinduzhanxing")
UPSTREAM_SHA = "c7117f92"
# (sign A, sign B, longitudes, expected winner, expected level). A/B are Lagna
# and 7th except level 4: opposite signs always share modality, so level 4 is
# shown on Aries vs Gemini (the comparator is generic).
LEVEL_CASES = {
1: (0, 6, {"Sun": 116.58, "Moon": 54.31, "Mars": 234.34, "Mercury": 26.08, "Jupiter": 192.92,
"Venus": 131.65, "Saturn": 20.88, "Rahu": 182.68, "Ketu": 2.68}, "Aries"),
2: (1, 7, {"Sun": 341.18, "Moon": 207.76, "Mars": 142.8, "Mercury": 351.45, "Jupiter": 16.77,
"Venus": 309.05, "Saturn": 104.26, "Rahu": 51.93, "Ketu": 231.93}, "Scorpio"),
3: (0, 6, {"Sun": 222.88, "Moon": 43.32, "Mars": 22.23, "Mercury": 245.64, "Jupiter": 191.06,
"Venus": 173.7, "Saturn": 279.54, "Rahu": 317.96, "Ketu": 137.96}, "Aries"),
4: (0, 2, {"Sun": 95.0, "Moon": 125.0, "Mars": 35.0, "Mercury": 45.0, "Jupiter": 55.0,
"Venus": 155.0, "Saturn": 275.0, "Rahu": 305.0, "Ketu": 125.0}, "Gemini"),
5: (9, 3, {"Sun": 30.94, "Moon": 150.54, "Mars": 86.64, "Mercury": 198.38, "Jupiter": 21.28,
"Venus": 203.56, "Saturn": 341.08, "Rahu": 227.03, "Ketu": 47.03}, "Capricorn"),
6: (3, 9, {"Sun": 136.2, "Moon": 178.62, "Mars": 80.38, "Mercury": 202.16, "Jupiter": 260.66,
"Venus": 355.0, "Saturn": 268.62, "Rahu": 0.8, "Ketu": 180.8}, "Capricorn"),
7: (6, 0, {"Sun": 195.37, "Moon": 223.57, "Mars": 268.44, "Mercury": 0.02, "Jupiter": 349.15,
"Venus": 328.44, "Saturn": 266.23, "Rahu": 223.1, "Ketu": 43.1}, "Aries"),
}
# Aries vs Libra tie on all seven levels: two occupants each (Venus + Moon in
# Aries, Mars + Sun in Libra), no role contact, no dignity, lords Mars / Venus
# both at 10 deg in each other's sign, six years each. Moon 29.5 deg is the AK.
TIE = {"Sun": 185.0, "Moon": 29.5, "Mars": 190.0, "Mercury": 335.0, "Jupiter": 65.0,
"Venus": 10.0, "Saturn": 275.0, "Rahu": 85.0, "Ketu": 265.0}
@pytest.mark.parametrize("level", sorted(LEVEL_CASES))
def test_each_level_decides_and_earlier_levels_tie(level: int) -> None:
first, second, lons, winner = LEVEL_CASES[level]
result = compare_signs(first, second, lons)
assert result["status"] == "selected"
assert (result["selected_sign"], result["strength_level_decided"]) == (winner, level)
a, b = (result["candidates"][SIGNS[idx]]["level_values"] for idx in (first, second))
assert a[:level - 1] == b[:level - 1]
assert a[level - 1] != b[level - 1]
def test_level_two_counts_roles_not_planets() -> None:
"""Pisces with Jupiter inside: Jupiter as Jupiter and as lord = two roles."""
lons = {"Sun": 0.0, "Moon": 30.0, "Mars": 165.0, "Mercury": 120.0, "Jupiter": 345.0,
"Venus": 60.0, "Saturn": 90.0, "Rahu": 180.0, "Ketu": 240.0}
result = compare_signs(11, 5, lons)
assert (result["selected_sign"], result["strength_level_decided"]) == ("Pisces", 2)
assert [row["role"] for row in result["candidates"]["Pisces"]["role_factors"]] == ["jupiter", "sign_lord"]
def test_ported_upstream_p36_two_roles_for_jupiter_and_one_opposite() -> None:
# Ported from upstream c7117f92 tests/test_rath_role_strength.py.
positions = dict(Jupiter=345., Mars=165., Mercury=120., Sun=0., Moon=30.,
Venus=60., Saturn=90., Rahu=180., Ketu=240.)
r = calc_narayana_rath(11, positions)
assert r["seed_sign"] == "Pisces"
candidates = r["seed_selection"]["candidates"]
assert candidates["Pisces"]["aspect_factor_count"] == 2
assert candidates["Virgo"]["aspect_factor_count"] == 1
assert candidates["Pisces"]["role_factors"] == [
dict(role="jupiter", planet="Jupiter", contact="conjunction"),
dict(role="sign_lord", planet="Jupiter", contact="conjunction")]
# The legacy aspect-only rule (frozen narayana_dasha) picks Virgo here.
assert nd.select_narayana_seed_sign(11, positions)["selected_sign_idx"] == 5
def _upstream_narayana():
if not (UPSTREAM / ".git").exists():
pytest.skip("upstream checkout not on this machine")
source = subprocess.run(["git", "show", f"{UPSTREAM_SHA}:scripts/narayana_dasha.py"], cwd=UPSTREAM,
capture_output=True, text=True, check=False)
if source.returncode != 0:
pytest.skip("upstream commit not available")
spec = importlib.util.spec_from_loader("upstream_narayana_c7117f92", loader=None)
module = importlib.util.module_from_spec(spec)
exec(compile(source.stdout, "upstream_narayana_dasha.py", "exec"), module.__dict__)
return module
def test_levels_one_two_equal_upstream_role_profile() -> None:
"""Levels 1-2 are upstream's rath_p36_role_conjunction_v1 counting."""
upstream = _upstream_narayana()
rng = random.Random(1003)
for _ in range(400):
lons = {planet: rng.uniform(0, 360) for planet in GRAHAS}
lagna = rng.randrange(12)
theirs = upstream.select_narayana_seed_sign(lagna, lons, strength_profile="rath_p36_role_conjunction_v1")
for idx in (lagna, (lagna + 6) % 12):
ours = _role_candidate(idx, lons, SIGN_LORDS[SIGNS[idx]])
for key in ("occupants", "occupant_count", "aspect_factors", "aspect_factor_count", "role_factors"):
assert ours[key] == theirs["candidates"][idx][key]
def test_full_tie_uses_the_indira_example_only_when_allowed() -> None:
plain = compare_signs(0, 6, TIE)
assert plain["status"] == "tie" and plain["strength_level_decided"] is None
flagged = compare_signs(0, 6, TIE, allow_book_example=True)
assert (flagged["selected_sign"], flagged["book_example_exception"], flagged["atmakaraka"]) == ("Aries", INDIRA_EXCEPTION, "Moon")
result = calc_narayana_rath(0, TIE, allow_book_example=True)
assert result["status"] == "book_example_exception"
assert result["book_example_exception"] == INDIRA_EXCEPTION
tied = calc_narayana_rath(0, TIE)
assert tied["status"] == "parameter_sensitive" and tied["mahadasha"] is None
assert set(tied["alternatives"]) == {"Aries", "Libra"}
def test_book_example_never_overrides_a_general_level() -> None:
first, second, lons, winner = LEVEL_CASES[5]
assert compare_signs(first, second, lons, allow_book_example=True)["book_example_exception"] is None
def _with(lons: dict, **moves: float) -> dict:
return {**lons, **moves}
BASE = {"Sun": 95.0, "Moon": 125.0, "Mars": 35.0, "Mercury": 45.0, "Jupiter": 55.0,
"Venus": 155.0, "Saturn": 275.0, "Rahu": 305.0, "Ketu": 125.0}
def test_ketu_reverses_table10_keeping_the_steps() -> None:
lons = _with(BASE, Rahu=245.0, Ketu=65.0) # Ketu in Gemini (dual: steps of 4/5/... differ from a read-back)
table10 = nd.build_narayana_sign_sequence(2, profile="rath_general_table10_v1")
order = md_order(2, lons)
assert order["direction"] == "table12_ketu_reversed" and order["exception_applied"] == "ketu_in_start_sign"
assert order["order"] == [2, 6, 10, 11, 3, 7, 8, 0, 4, 5, 9, 1]
assert [(b - a) % 12 for a, b in zip(order["order"], order["order"][1:])] == \
[(a - b) % 12 for a, b in zip(table10, table10[1:])]
assert order["order"] != [2] + table10[:0:-1] # not the table read backwards
def test_saturn_runs_zodiacal_and_ketu_wins_when_both() -> None:
saturn = md_order(1, _with(BASE, Saturn=40.0))
assert saturn["direction"] == "table11_saturn_zodiacal"
assert saturn["order"] == [(1 + i) % 12 for i in range(12)]
both = md_order(1, _with(BASE, Saturn=40.0, Rahu=215.0, Ketu=35.0))
assert both["direction"] == "table12_ketu_reversed"
assert both["method_note"] == KETU_FIRST_NOTE == "PyJHora 兼容:计都优先"
def test_ported_upstream_varsha_saturn_seed_order_and_lengths() -> None:
# Ported from upstream c7117f92 tests/test_varsha_narayana_native.py: PVR
# textbook Chart 67 D9 positions; seed Scorpio holds Saturn -> Table 11;
# first five MD [7, 4, 2, 3, 11] with Ketu as Scorpio's lord.
rasi = dict(Sun=47+4/60, Moon=184+48/60, Mars=113+53/60, Mercury=64+47/60, Jupiter=160+59/60,
Venus=1+37/60, Saturn=306+29/60, Rahu=228+38/60, Ketu=48+38/60)
d9 = {planet: lon * 9 % 360 for planet, lon in rasi.items()}
seq = mahadasha_sequence(7, d9, cycle_count=1)
first = seq["mahadasha"][:5]
assert seq["direction"] == "table11_saturn_zodiacal"
assert [row["sign_idx"] for row in first] == [7, 8, 9, 10, 11]
assert [row["years"] for row in first] == [7, 4, 2, 3, 11]
assert first[0]["lord"] == "Ketu"
def test_output_fields() -> None:
first, second, lons, _ = LEVEL_CASES[1]
result = calc_narayana_rath(first, lons)
for key in ("profile", "seed_sign", "seed_basis", "strength_level_decided", "direction", "exception_applied",
"dual_lord_rule", "mahadasha", "antardasha_start_rule", "source", "status"):
assert key in result, key
assert result["profile"] == "sanjay_rath"
assert result["seed_basis"] == "stronger_of_lagna_seventh:level_1_occupant_count"
assert len(result["mahadasha"]) == 24
ad = antardasha(result["mahadasha"][0], lons)
assert ad["status"] == "selected" and len(ad["rows"]) == 12
assert ad["antardasha_start_rule"]["strength_level_decided"] in range(1, 8)