docs(tasks): Niryaana Shoola start sign follows upstream ayur rasi strength (9f08b57b)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
463e13fa23
commit
9f5c0084db
@@ -0,0 +1,3 @@
|
||||
# 上游 Niryaana Shoola @ 9f08b57b(2026-10-18)
|
||||
|
||||
起运星座按 `pvr_niryaana_shoola_ayur_rasi_strength_v1` 选:比较第二宫与第八宫,依次看宫内行星数、所有行星的星座相位数(Jaimini 星座相位,宫主不加权)、宫内有无入旺行星;仍平手则 `blocked`,不硬选。行星集合与上游 full-reading 传入的一致(含罗睺、计都)。只出主运。
|
||||
@@ -0,0 +1,263 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Source-bounded Niryaana Shoola Dasha Mahadasha producer.
|
||||
|
||||
Niryaana Shoola and Shoola are adjacent, but distinct, Jaimini systems. This
|
||||
module implements the former only. The latter retains its Lagna/seventh,
|
||||
fixed-nine-year implementation in ``scripts/shoola_dasha.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from scripts.dasha_profile_arbitration import attach_profile_arbitration
|
||||
from scripts.rashi_dasha_explicit_contract import add_calendar_years
|
||||
|
||||
|
||||
PROFILE_ID = "pvr_niryaana_shoola_v1"
|
||||
LEGACY_MISCLASSIFIED_PROFILE_ID = "goel_nirayana_shoola_v1"
|
||||
PYJHORA_PROFILE_ID = "pyjhora_2nd_8th_observation_v1"
|
||||
TOTAL_CYCLE = 96
|
||||
SIGNS = (
|
||||
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra",
|
||||
"Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
|
||||
)
|
||||
SIGN_LORDS = {
|
||||
0: "Mars", 1: "Venus", 2: "Mercury", 3: "Moon", 4: "Sun", 5: "Mercury",
|
||||
6: "Venus", 7: "Mars", 8: "Jupiter", 9: "Saturn", 10: "Saturn", 11: "Jupiter",
|
||||
}
|
||||
_MOVABLE_SIGNS = {0, 3, 6, 9}
|
||||
_FIXED_SIGNS = {1, 4, 7, 10}
|
||||
_EXALTED_SIGNS = {
|
||||
"Sun": {0}, "Moon": {1}, "Mars": {9}, "Mercury": {5},
|
||||
"Jupiter": {3}, "Venus": {11}, "Saturn": {6},
|
||||
}
|
||||
|
||||
|
||||
def _sign_index(longitude: float) -> int:
|
||||
return int(float(longitude) % 360.0 // 30.0)
|
||||
|
||||
|
||||
def _forward_order(start: int) -> list[int]:
|
||||
return [(int(start) + offset) % 12 for offset in range(12)]
|
||||
|
||||
|
||||
def _reverse_order(start: int) -> list[int]:
|
||||
return [(int(start) - offset) % 12 for offset in range(12)]
|
||||
|
||||
|
||||
def _rasi_aspects(source_sign_index: int, target_sign_index: int) -> bool:
|
||||
"""Return the standard Jaimini sign aspect relation."""
|
||||
if source_sign_index in _MOVABLE_SIGNS:
|
||||
return target_sign_index in _FIXED_SIGNS and target_sign_index != (
|
||||
(source_sign_index + 1) % 12
|
||||
)
|
||||
if source_sign_index in _FIXED_SIGNS:
|
||||
return target_sign_index in _MOVABLE_SIGNS and target_sign_index != (
|
||||
(source_sign_index - 1) % 12
|
||||
)
|
||||
return target_sign_index in {2, 5, 8, 11} and target_sign_index != source_sign_index
|
||||
|
||||
|
||||
def select_niryaana_shoola_start_sign(
|
||||
*, ascendant_longitude: float, planet_longitudes: dict[str, float],
|
||||
) -> dict[str, Any]:
|
||||
"""Select the stronger second/eighth rasi under PVR's ayur-dasha scope.
|
||||
|
||||
PVR's general rasi rules are expressly adapted for ayur dashas: all rasi
|
||||
aspects matter equally and the sign lord's aspect has no special weight.
|
||||
The text does not provide a further longevity-specific tie-break, so a
|
||||
tie remains blocked instead of borrowing Narayana's lord rules.
|
||||
"""
|
||||
lagna = _sign_index(ascendant_longitude)
|
||||
second, eighth = (lagna + 1) % 12, (lagna + 7) % 12
|
||||
positions = {
|
||||
name: float(longitude)
|
||||
for name, longitude in planet_longitudes.items()
|
||||
if isinstance(longitude, (int, float))
|
||||
}
|
||||
candidates = {}
|
||||
for sign_index in (second, eighth):
|
||||
occupants = sorted(
|
||||
name for name, longitude in positions.items()
|
||||
if _sign_index(longitude) == sign_index
|
||||
)
|
||||
aspecting = sorted(
|
||||
name for name, longitude in positions.items()
|
||||
if _rasi_aspects(_sign_index(longitude), sign_index)
|
||||
)
|
||||
exalted = sorted(
|
||||
name for name in occupants
|
||||
if sign_index in _EXALTED_SIGNS.get(name, set())
|
||||
)
|
||||
candidates[sign_index] = {
|
||||
"sign_index": sign_index,
|
||||
"sign": SIGNS[sign_index],
|
||||
"occupants": occupants,
|
||||
"occupant_count": len(occupants),
|
||||
"rasi_aspecting_planets": aspecting,
|
||||
"rasi_aspect_count": len(aspecting),
|
||||
"exalted_occupants": exalted,
|
||||
}
|
||||
second_score = (
|
||||
candidates[second]["occupant_count"],
|
||||
candidates[second]["rasi_aspect_count"],
|
||||
bool(candidates[second]["exalted_occupants"]),
|
||||
)
|
||||
eighth_score = (
|
||||
candidates[eighth]["occupant_count"],
|
||||
candidates[eighth]["rasi_aspect_count"],
|
||||
bool(candidates[eighth]["exalted_occupants"]),
|
||||
)
|
||||
payload = {
|
||||
"profile": "pvr_niryaana_shoola_ayur_rasi_strength_v1",
|
||||
"ascendant_sign_index": lagna,
|
||||
"second_sign_index": second,
|
||||
"eighth_sign_index": eighth,
|
||||
"candidates": candidates,
|
||||
"selection_levels": [
|
||||
"occupant_count",
|
||||
"all_planet_rasi_aspect_count",
|
||||
"exalted_occupant_presence",
|
||||
],
|
||||
}
|
||||
if second_score == eighth_score:
|
||||
return {
|
||||
**payload,
|
||||
"status": "blocked",
|
||||
"reason": "niryaana_shoola_ayur_rasi_strength_tie_requires_source_rule",
|
||||
}
|
||||
selected = second if second_score > eighth_score else eighth
|
||||
return {
|
||||
**payload,
|
||||
"status": "selected",
|
||||
"selected_sign_idx": selected,
|
||||
"selected_sign": SIGNS[selected],
|
||||
}
|
||||
|
||||
|
||||
def resolve_lagna_seventh_tie_break(
|
||||
*,
|
||||
lagna_sign_index: int,
|
||||
planets: dict[str, dict[str, Any]] | None = None,
|
||||
planet_strengths: dict[str, float] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Explicit Shoola-only tie-break retained for its separate producer."""
|
||||
planets = planets if isinstance(planets, dict) else {}
|
||||
strengths = planet_strengths if isinstance(planet_strengths, dict) else {}
|
||||
rows = []
|
||||
for label, sign in (("lagna", int(lagna_sign_index) % 12), ("seventh", (int(lagna_sign_index) + 6) % 12)):
|
||||
lord = SIGN_LORDS[sign]
|
||||
lord_data = planets.get(lord) if isinstance(planets.get(lord), dict) else {}
|
||||
degree = lord_data.get("degree_in_sign", lord_data.get("degree"))
|
||||
if not isinstance(degree, (int, float)) and isinstance(lord_data.get("lon"), (int, float)):
|
||||
degree = float(lord_data["lon"]) % 30.0
|
||||
occupants = [
|
||||
name for name, data in planets.items()
|
||||
if isinstance(data, dict) and int(data.get("sign_idx", -1)) % 12 == sign
|
||||
]
|
||||
rows.append({
|
||||
"label": label, "sign_index": sign, "sign": SIGNS[sign],
|
||||
"occupants": occupants, "occupant_count": len(occupants), "lord": lord,
|
||||
"lord_strength": float(strengths[lord]) if isinstance(strengths.get(lord), (int, float)) else None,
|
||||
"lord_degree_in_sign": float(degree) if isinstance(degree, (int, float)) else None,
|
||||
})
|
||||
keys = [
|
||||
(row["occupant_count"], row["lord_strength"] if row["lord_strength"] is not None else float("-inf"), row["lord_degree_in_sign"] if row["lord_degree_in_sign"] is not None else float("-inf"))
|
||||
for row in rows
|
||||
]
|
||||
if keys[0] == keys[1]:
|
||||
return {"status": "blocked", "reason": "Tie-break evidence remains equal after occupants, lord strength, and lord degree.", "rows": rows}
|
||||
selected = rows[0] if keys[0] > keys[1] else rows[1]
|
||||
return {"status": "selected", "selected": selected, "rows": rows, "rule": "occupants_then_sign_lord_strength_then_sign_lord_degree"}
|
||||
|
||||
|
||||
def _sign_years(sign_index: int) -> int:
|
||||
"""PVR Niryaana Shoola: movable/fixed/dual = 7/8/9 years."""
|
||||
if int(sign_index) % 12 in {1, 4, 7, 10}:
|
||||
return 8
|
||||
if int(sign_index) % 12 in {2, 5, 8, 11}:
|
||||
return 9
|
||||
return 7
|
||||
|
||||
|
||||
def niryaana_shoola_applicability(
|
||||
*, ascendant_longitude: float, second_strength: float, eighth_strength: float,
|
||||
) -> dict[str, Any]:
|
||||
"""Select the stronger second/eighth sign; the source supplies no tie-break."""
|
||||
lagna = _sign_index(ascendant_longitude)
|
||||
second, eighth = (lagna + 1) % 12, (lagna + 7) % 12
|
||||
second_strength, eighth_strength = float(second_strength), float(eighth_strength)
|
||||
if second_strength == eighth_strength:
|
||||
return {
|
||||
"applicable": False, "execution_status": "blocked", "ascendant_sign_index": lagna,
|
||||
"second_sign_index": second, "eighth_sign_index": eighth,
|
||||
"second_strength": second_strength, "eighth_strength": eighth_strength,
|
||||
"rule_family": "pvr_niryaana_shoola_2nd_8th_modality",
|
||||
"reason": "The cited Niryaana Shoola rule does not provide a tie-break for equal second/eighth strength.",
|
||||
}
|
||||
start = second if second_strength > eighth_strength else eighth
|
||||
return {
|
||||
"applicable": True, "ascendant_sign_index": lagna, "second_sign_index": second,
|
||||
"eighth_sign_index": eighth, "second_strength": second_strength,
|
||||
"eighth_strength": eighth_strength, "starting_sign_index": start,
|
||||
"starting_sign": SIGNS[start], "direction": "forward" if start % 2 == 0 else "reverse",
|
||||
"rule_family": "pvr_niryaana_shoola_2nd_8th_modality",
|
||||
"reason": "Niryaana Shoola starts from the stronger second/eighth sign; odd seeds run forward and even seeds reverse.",
|
||||
}
|
||||
|
||||
|
||||
def calculate_niryaana_shoola_dasha(birth_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Calculate source-bounded Niryaana Shoola Mahadashas only.
|
||||
|
||||
PVR's chapter proposes borrowing Narayana rules for children rather than
|
||||
specifying its own AD/PD algorithm, so child periods remain deliberately
|
||||
blocked. This system is for longevity analysis and is not relationship
|
||||
timing evidence.
|
||||
"""
|
||||
profile_id = birth_info.get("profile_id") or PROFILE_ID
|
||||
if profile_id == LEGACY_MISCLASSIFIED_PROFILE_ID:
|
||||
return {
|
||||
"applicable": False, "execution_status": "blocked", "confidence_status": "blocked",
|
||||
"profile_id": profile_id, "major": [], "antardasha": [], "pratyantardasha": [],
|
||||
"reason": "goel_nirayana_shoola_v1 conflates Niryaana Shoola with Shoola Dasha; choose pvr_niryaana_shoola_v1 or the separate Shoola producer.",
|
||||
}
|
||||
if profile_id not in {PROFILE_ID, PYJHORA_PROFILE_ID}:
|
||||
return {"applicable": False, "execution_status": "blocked", "confidence_status": "blocked", "profile_id": profile_id, "major": [], "reason": "Unsupported Niryaana Shoola profile_id: " + str(profile_id)}
|
||||
required = ("birth_datetime", "ascendant_longitude", "second_strength", "eighth_strength")
|
||||
missing = [field for field in required if birth_info.get(field) is None]
|
||||
if missing:
|
||||
return {"applicable": False, "execution_status": "blocked", "confidence_status": "blocked", "profile_id": profile_id, "major": [], "reason": "Missing required input: " + ", ".join(missing)}
|
||||
birth = birth_info["birth_datetime"]
|
||||
if isinstance(birth, str):
|
||||
birth = datetime.fromisoformat(birth)
|
||||
applicability = niryaana_shoola_applicability(
|
||||
ascendant_longitude=float(birth_info["ascendant_longitude"]),
|
||||
second_strength=float(birth_info["second_strength"]),
|
||||
eighth_strength=float(birth_info["eighth_strength"]),
|
||||
)
|
||||
if not applicability["applicable"]:
|
||||
return {**applicability, "profile_id": profile_id, "total_cycle": TOTAL_CYCLE, "major": [], "antardasha": [], "pratyantardasha": [], "confidence_status": "blocked"}
|
||||
order = _forward_order(applicability["starting_sign_index"]) if applicability["direction"] == "forward" else _reverse_order(applicability["starting_sign_index"])
|
||||
try:
|
||||
rows, cursor = [], birth
|
||||
for sign_index in order:
|
||||
years = _sign_years(sign_index)
|
||||
end = add_calendar_years(cursor, years)
|
||||
rows.append({"level": "mahadasha", "sign_index": sign_index, "sign": SIGNS[sign_index], "years": years, "start_date": cursor.isoformat(timespec="seconds"), "end_date": end.isoformat(timespec="seconds")})
|
||||
cursor = end
|
||||
except ValueError:
|
||||
return {**applicability, "profile_id": profile_id, "execution_status": "blocked", "confidence_status": "blocked", "major": [], "reason": "Calendar-year anniversary cannot be represented for this birth date."}
|
||||
payload = {
|
||||
**applicability, "profile_id": profile_id, "execution_status": "executed",
|
||||
"confidence_status": "source_bounded", "verification_status": "pvr_rule_replay",
|
||||
"period_depth_status": "mahadasha_executed_ad_pd_blocked_source_rule",
|
||||
"total_cycle": TOTAL_CYCLE, "duration_basis": "calendar_years", "major": rows,
|
||||
"antardasha": [], "pratyantardasha": [],
|
||||
"relationship_applicability": "not_applicable_longevity_specific",
|
||||
"reason": "PVR Niryaana Shoola MD replay. Its child-period rule is not independently specified, and longevity-specific periods are not relationship timing evidence.",
|
||||
"source_locator": "PVR, An Integrated Approach to Vedic Astrology, ch. 22.2, source pages 285-292.",
|
||||
}
|
||||
attach_profile_arbitration(payload, "niryaana_shoola", profile_id)
|
||||
return payload
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Regression tests for the distinct Niryaana Shoola rule family."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from scripts.niryaana_shoola_dasha import (
|
||||
PROFILE_ID,
|
||||
calculate_niryaana_shoola_dasha,
|
||||
niryaana_shoola_applicability,
|
||||
select_niryaana_shoola_start_sign,
|
||||
)
|
||||
|
||||
|
||||
def test_niryaana_shoola_selects_stronger_second_or_eighth() -> None:
|
||||
second = niryaana_shoola_applicability(
|
||||
ascendant_longitude=0.0, second_strength=2.0, eighth_strength=1.0,
|
||||
)
|
||||
eighth = niryaana_shoola_applicability(
|
||||
ascendant_longitude=0.0, second_strength=1.0, eighth_strength=2.0,
|
||||
)
|
||||
assert second["starting_sign"] == "Taurus"
|
||||
assert second["direction"] == "reverse"
|
||||
assert eighth["starting_sign"] == "Scorpio"
|
||||
assert eighth["direction"] == "reverse"
|
||||
|
||||
|
||||
def test_niryaana_shoola_uses_ayur_specific_rasi_strength() -> None:
|
||||
selection = select_niryaana_shoola_start_sign(
|
||||
ascendant_longitude=0.0,
|
||||
planet_longitudes={"Mars": 31.0, "Sun": 91.0},
|
||||
)
|
||||
|
||||
assert selection["status"] == "selected"
|
||||
assert selection["selected_sign"] == "Taurus"
|
||||
assert selection["selection_levels"] == [
|
||||
"occupant_count",
|
||||
"all_planet_rasi_aspect_count",
|
||||
"exalted_occupant_presence",
|
||||
]
|
||||
assert selection["candidates"][1]["occupants"] == ["Mars"]
|
||||
assert selection["candidates"][7]["rasi_aspecting_planets"] == ["Sun"]
|
||||
|
||||
|
||||
def test_niryaana_shoola_blocks_unresolved_ayur_rasi_strength_tie() -> None:
|
||||
selection = select_niryaana_shoola_start_sign(
|
||||
ascendant_longitude=0.0,
|
||||
planet_longitudes={},
|
||||
)
|
||||
|
||||
assert selection["status"] == "blocked"
|
||||
assert selection["reason"] == "niryaana_shoola_ayur_rasi_strength_tie_requires_source_rule"
|
||||
|
||||
|
||||
def test_niryaana_shoola_blocks_equal_second_eighth_without_invented_tie_break() -> None:
|
||||
result = niryaana_shoola_applicability(
|
||||
ascendant_longitude=0.0, second_strength=1.0, eighth_strength=1.0,
|
||||
)
|
||||
assert result["execution_status"] == "blocked"
|
||||
assert "does not provide a tie-break" in result["reason"]
|
||||
|
||||
|
||||
def test_niryaana_shoola_uses_modality_years_and_parity_direction() -> None:
|
||||
result = calculate_niryaana_shoola_dasha({
|
||||
"profile_id": PROFILE_ID,
|
||||
"birth_datetime": datetime(2000, 1, 1, 12, 0),
|
||||
"ascendant_longitude": 15.0,
|
||||
"second_strength": 2.0,
|
||||
"eighth_strength": 1.0,
|
||||
})
|
||||
assert result["execution_status"] == "executed"
|
||||
assert result["total_cycle"] == 96
|
||||
assert [row["sign"] for row in result["major"][:3]] == ["Taurus", "Aries", "Pisces"]
|
||||
assert [row["years"] for row in result["major"][:3]] == [8, 7, 9]
|
||||
assert result["period_depth_status"] == "mahadasha_executed_ad_pd_blocked_source_rule"
|
||||
assert result["antardasha"] == []
|
||||
assert result["relationship_applicability"] == "not_applicable_longevity_specific"
|
||||
|
||||
|
||||
def test_legacy_goel_profile_is_blocked_as_misclassified() -> None:
|
||||
result = calculate_niryaana_shoola_dasha({
|
||||
"profile_id": "goel_nirayana_shoola_v1",
|
||||
"birth_datetime": datetime(2000, 1, 1, 12, 0),
|
||||
"ascendant_longitude": 0.0,
|
||||
"lagna_strength": 2.0,
|
||||
"seventh_strength": 1.0,
|
||||
})
|
||||
assert result["execution_status"] == "blocked"
|
||||
assert "conflates Niryaana Shoola with Shoola Dasha" in result["reason"]
|
||||
|
||||
|
||||
def test_pyjhora_observation_profile_replays_same_native_rule_shape() -> None:
|
||||
result = calculate_niryaana_shoola_dasha({
|
||||
"profile_id": "pyjhora_2nd_8th_observation_v1",
|
||||
"birth_datetime": "2000-01-01T00:00:00",
|
||||
"ascendant_longitude": 15.0,
|
||||
"second_strength": 2.0,
|
||||
"eighth_strength": 1.0,
|
||||
})
|
||||
assert result["execution_status"] == "executed"
|
||||
assert result["starting_sign"] == "Taurus"
|
||||
assert result["profile_arbitration"]["selection_status"] == "explicit_profile_selected"
|
||||
Reference in New Issue
Block a user