#!/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.", }