Files
Jyotisha/scripts/shastihayani_dasha.py
T
Jesse_ChenandCursor 04ad3325d5
Independent Staging Quality Gate / validate (push) Successful in 11m26s
Independent Staging Quality Gate / publish (push) Successful in 13m24s
fix(relationship): freeze marriage event classes and add conditional dashas (BUG-608)
Venus/Jupiter/Moon periods no longer collapse into one marriage-opportunity line. Full reading reports the ten BPHS conditional dasha families; Skill 6.9.16.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 16:04:52 +08:00

240 lines
10 KiB
Python

#!/usr/bin/env python3
"""Source-bounded Shastihayani (60-year) Mahadasha producer.
This conditional Dasha uses a 28-nakshatra scheme that includes Abhijit and
alternates 3-star and 4-star groups. It derives the 28-star group position
from Moon longitude when explicit group inputs are not supplied.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
try:
from scripts.source_bounded_child_periods import expand_parent_seeded_equal_split_children
except ModuleNotFoundError: # pragma: no cover - scripts/ on sys.path
from source_bounded_child_periods import expand_parent_seeded_equal_split_children
YEAR_DAYS = 365.25636
TOTAL_CYCLE = 60
GROUPS = (
{"lord": "Jupiter", "years": 10, "size": 3},
{"lord": "Sun", "years": 10, "size": 4},
{"lord": "Mars", "years": 10, "size": 3},
{"lord": "Moon", "years": 6, "size": 4},
{"lord": "Mercury", "years": 6, "size": 3},
{"lord": "Venus", "years": 6, "size": 4},
{"lord": "Saturn", "years": 6, "size": 3},
{"lord": "Rahu", "years": 6, "size": 4},
)
DASHA_SEQUENCE = tuple((str(group["lord"]), int(group["years"])) for group in GROUPS)
_STAR_SPANS = (
("Ashwini", 0.0, 13.3333333333, 0, 0),
("Bharani", 13.3333333333, 26.6666666667, 0, 1),
("Krittika", 26.6666666667, 40.0, 0, 2),
("Rohini", 40.0, 53.3333333333, 1, 0),
("Mrigashira", 53.3333333333, 66.6666666667, 1, 1),
("Ardra", 66.6666666667, 80.0, 1, 2),
("Punarvasu", 80.0, 93.3333333333, 1, 3),
("Pushya", 93.3333333333, 106.6666666667, 2, 0),
("Ashlesha", 106.6666666667, 120.0, 2, 1),
("Magha", 120.0, 133.3333333333, 2, 2),
("Purva Phalguni", 133.3333333333, 146.6666666667, 3, 0),
("Uttara Phalguni", 146.6666666667, 160.0, 3, 1),
("Hasta", 160.0, 173.3333333333, 3, 2),
("Chitra", 173.3333333333, 186.6666666667, 3, 3),
("Swati", 186.6666666667, 200.0, 4, 0),
("Vishakha", 200.0, 213.3333333333, 4, 1),
("Anuradha", 213.3333333333, 226.6666666667, 4, 2),
("Jyeshtha", 226.6666666667, 240.0, 5, 0),
("Mula", 240.0, 253.3333333333, 5, 1),
("Purva Ashadha", 253.3333333333, 266.6666666667, 5, 2),
("Uttara Ashadha", 266.6666666667, 276.6666666667, 5, 3),
("Abhijit", 276.6666666667, 280.8888888889, 6, 0),
("Shravana", 280.8888888889, 293.3333333333, 6, 1),
("Dhanishta", 293.3333333333, 306.6666666667, 6, 2),
("Shatabhisha", 306.6666666667, 320.0, 7, 0),
("Purva Bhadrapada", 320.0, 333.3333333333, 7, 1),
("Uttara Bhadrapada", 333.3333333333, 346.6666666667, 7, 2),
("Revati", 346.6666666667, 360.0, 7, 3),
)
def _sign_index(longitude: float) -> int:
return int(float(longitude) % 360.0 // 30.0)
def shastihayani_applicability(*, ascendant_longitude: float, sun_longitude: float) -> dict[str, Any]:
ascendant_sign = _sign_index(ascendant_longitude)
sun_sign = _sign_index(sun_longitude)
applicable = ascendant_sign == sun_sign
return {
"applicable": applicable,
"ascendant_sign_index": ascendant_sign,
"sun_sign_index": sun_sign,
"rule_family": "bphs_shastihayani_sun_in_lagna",
"reason": (
"Sun is in the Lagna sign."
if applicable
else "Shastihayani requires Sun in the Lagna sign."
),
}
def _sequence_from(group_index: int) -> list[dict[str, Any]]:
group_index %= len(GROUPS)
return list(GROUPS[group_index:] + GROUPS[:group_index])
def resolve_shastihayani_28_position(moon_longitude: float) -> dict[str, Any]:
"""Resolve the source-bounded 28-star position used by Shastihayani."""
longitude = float(moon_longitude) % 360.0
for star_name, start, end, group_index, within_group in _STAR_SPANS:
if start <= longitude < end or (end == 360.0 and longitude == 0.0):
fraction = (longitude - start) / (end - start)
return {
"moon_longitude": longitude,
"nakshatra": star_name,
"span_start_longitude": start,
"span_end_longitude": end,
"shastihayani_group_index": group_index,
"nakshatra_index_within_group": within_group,
"nakshatra_fraction_elapsed": fraction,
"source_locator": (
"Predicting Through Shasti Hayani Dasha: 28 nakshatras including Abhijit; "
"Uttara Ashadha curtailed to Capricorn 6°40, Abhijit spans Capricorn "
"6°40 to 10°53'20, and Shravana starts from Capricorn 10°53'20."
),
}
raise ValueError("moon_longitude could not be resolved into Shastihayani 28-star spans")
def _birth_lord_and_balance(
*, group_index: int, nakshatra_index_within_group: int, nakshatra_fraction_elapsed: float
) -> tuple[str, float]:
group = GROUPS[int(group_index) % len(GROUPS)]
within = int(nakshatra_index_within_group)
if not 0 <= within < int(group["size"]):
raise ValueError("nakshatra_index_within_group must be inside the selected Shastihayani group")
fraction = float(nakshatra_fraction_elapsed)
if not 0.0 <= fraction <= 1.0:
raise ValueError("nakshatra_fraction_elapsed must be between 0 and 1")
years_per_nakshatra = float(group["years"]) / int(group["size"])
remaining_current = years_per_nakshatra * (1.0 - fraction)
remaining_full_nakshatras = int(group["size"]) - within - 1
balance_years = remaining_current + remaining_full_nakshatras * years_per_nakshatra
return str(group["lord"]), balance_years
def calculate_shastihayani_dasha(birth_info: dict[str, Any]) -> dict[str, Any]:
"""Return birth-forward MD rows; child-period boundaries stay blocked."""
required = (
"birth_datetime",
"ascendant_longitude",
"sun_longitude",
"shastihayani_group_index",
"nakshatra_index_within_group",
"nakshatra_fraction_elapsed",
)
if any(birth_info.get(field) is None for field in (
"shastihayani_group_index",
"nakshatra_index_within_group",
"nakshatra_fraction_elapsed",
)) and birth_info.get("moon_longitude") is not None:
birth_info = {
**birth_info,
**resolve_shastihayani_28_position(float(birth_info["moon_longitude"])),
}
missing = [field for field in required if birth_info.get(field) is None]
if missing:
return {
"applicable": False,
"execution_status": "blocked",
"confidence_status": "blocked",
"reason": "Missing required input: " + ", ".join(missing),
"major": [],
}
birth = birth_info["birth_datetime"]
if isinstance(birth, str):
birth = datetime.fromisoformat(birth)
applicability = shastihayani_applicability(
ascendant_longitude=float(birth_info["ascendant_longitude"]),
sun_longitude=float(birth_info["sun_longitude"]),
)
standard_table_profile = birth_info.get("standard_table_profile")
emit_standard_table = standard_table_profile == "pl9_reports_all_dasha_tables_v1"
if not applicability["applicable"] and not emit_standard_table:
return {
**applicability,
"execution_status": "not_applicable",
"confidence_status": "not_applicable",
"total_cycle": TOTAL_CYCLE,
"major": [],
}
group_index = int(birth_info["shastihayani_group_index"]) % len(GROUPS)
try:
lord, balance = _birth_lord_and_balance(
group_index=group_index,
nakshatra_index_within_group=int(birth_info["nakshatra_index_within_group"]),
nakshatra_fraction_elapsed=float(birth_info["nakshatra_fraction_elapsed"]),
)
except ValueError as exc:
return {
**applicability,
"execution_status": "blocked",
"confidence_status": "blocked",
"total_cycle": TOTAL_CYCLE,
"reason": str(exc),
"major": [],
}
year_days = float(birth_info.get("dasha_year_days") or YEAR_DAYS)
rows = []
cursor = birth
for index, group in enumerate(_sequence_from(group_index)):
years = balance if index == 0 else float(group["years"])
end = cursor + timedelta(days=years * year_days)
rows.append(
{
"level": "mahadasha",
"lord": group["lord"],
"planet": group["lord"],
"years": years,
"full_years": group["years"],
"is_birth_balance": index == 0,
"start_date": cursor.isoformat(timespec="seconds"),
"end_date": end.isoformat(timespec="seconds"),
}
)
cursor = end
child_profile = birth_info.get("child_period_profile")
rows, antardasha, pratyantardasha, period_depth_status = expand_parent_seeded_equal_split_children(
rows,
DASHA_SEQUENCE,
profile=str(child_profile) if child_profile else None,
)
return {
**({**applicability, "applicable": True} if emit_standard_table else applicability),
"execution_status": "executed",
"confidence_status": "parameter_sensitive",
"verification_status": "source_bounded_local_candidate",
"classical_applicability": applicability,
"standard_table_profile": standard_table_profile or None,
"period_depth_status": period_depth_status,
"total_cycle": TOTAL_CYCLE,
"dasha_year_days": year_days,
"starting_lord": lord,
"shastihayani_group_index": group_index,
"nakshatra_index_within_group": int(birth_info["nakshatra_index_within_group"]),
"nakshatra_fraction_elapsed": float(birth_info["nakshatra_fraction_elapsed"]),
"dasha_balance_at_birth_years": balance,
"child_period_profile": child_profile or None,
"major": rows,
"antardasha": antardasha,
"pratyantardasha": pratyantardasha,
"reason": "Local source-bounded calculation with 28-nakshatra input; PL9 standard-table mode may emit rows even when the classical Sun-in-Lagna gate is not met.",
"source_locator": "Shastihayani source passages: Sun-in-Lagna gate, 28-nakshatra groups including Abhijit, 3/4 grouping, Jupiter-to-Rahu order, and 10/10/10/6/6/6/6/6 year allotments.",
}