#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ ashtottari_dasha.py — Ashtottari Dasha System (108-Year Cycle) Reference: Brihat Parashara Hora Shastra (BPHS), Chapter 19 Ashtottari Dasha is a conditional dasha system with a total cycle of 108 years. It is applicable only when the Moon is in specific Nakshatras: - Krishna Paksha (waning Moon): Rohini, Ardra, Pushya, Ashlesha, Magha, Revati - Shukla Paksha (waxing Moon): Ashwini, Mrigashira, Punarvasu, Chitra, Shravana, Dhanishta The 8 planetary lords and their year allotments: 1. Sun — 6 years 2. Moon — 15 years 3. Mars — 8 years 4. Mercury — 17 years 5. Saturn — 10 years 6. Jupiter — 19 years 7. Rahu — 12 years 8. Venus — 21 years Total: 6 + 15 + 8 + 17 + 10 + 19 + 12 + 21 = 108 years. The dasha sequence always runs: Sun → Moon → Mars → Mercury → Saturn → Jupiter → Rahu → Venus. The starting lord is determined by the Moon's Nakshatra at birth. """ from datetime import datetime, timedelta from ashtottari_rule_profiles import ( RULE_FAMILY_MOON_NAKSHATRA_PLUS_PAKSHA, RULE_FAMILY_RAHU_FROM_LAGNA_LORD, SHUKLA_PAKSHA_NAKSHATRAS, KRISHNA_PAKSHA_NAKSHATRAS, evaluate_moon_nakshatra_plus_paksha_rule, evaluate_rahu_from_lagna_lord_rule, ) # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- # Nakshatra indices (0-based): Ashwini=0, Bharani=1, ..., Revati=26 NAKSHATRAS = [ "Ashwini", "Bharani", "Krittika", "Rohini", "Mrigashira", "Ardra", "Punarvasu", "Pushya", "Ashlesha", "Magha", "Purva Phalguni", "Uttara Phalguni", "Hasta", "Chitra", "Swati", "Vishakha", "Anuradha", "Jyeshtha", "Mula", "Purva Ashadha", "Uttara Ashadha", "Shravana", "Dhanishta", "Shatabhisha", "Purva Bhadrapada", "Uttara Bhadrapada", "Revati" ] # Candidate 28-nakshatra cycle used by the PL9 sample-admitted path. # This preserves the sample's Mula -> Mercury balance note while leaving the # legacy Moon-whitelist rule available for research comparison. _CANDIDATE_NAKSHATRA_STARTING_LORDS = { "Ardra": "Sun", "Punarvasu": "Sun", "Pushya": "Sun", "Ashlesha": "Sun", "Magha": "Moon", "Purva Phalguni": "Moon", "Uttara Phalguni": "Moon", "Hasta": "Mars", "Chitra": "Mars", "Swati": "Mars", "Vishakha": "Mars", "Anuradha": "Mercury", "Jyeshtha": "Mercury", "Mula": "Mercury", "Purva Ashadha": "Saturn", "Uttara Ashadha": "Saturn", "Shravana": "Saturn", "Dhanishta": "Jupiter", "Shatabhisha": "Jupiter", "Purva Bhadrapada": "Jupiter", "Uttara Bhadrapada": "Rahu", "Revati": "Rahu", "Ashwini": "Rahu", "Bharani": "Rahu", "Krittika": "Venus", "Rohini": "Venus", "Mrigashira": "Venus", } # Ashtottari planetary sequence and year allotments (BPHS Ch.19) DASHA_SEQUENCE = [ {"planet": "Sun", "years": 6}, {"planet": "Moon", "years": 15}, {"planet": "Mars", "years": 8}, {"planet": "Mercury", "years": 17}, {"planet": "Saturn", "years": 10}, {"planet": "Jupiter", "years": 19}, {"planet": "Rahu", "years": 12}, {"planet": "Venus", "years": 21}, ] TOTAL_CYCLE = 108 # years _PLANET_ABBR = { "Sun": "Su", "Moon": "Mo", "Mars": "Ma", "Mercury": "Me", "Saturn": "Sa", "Jupiter": "Ju", "Rahu": "Ra", "Venus": "Ve", } # Mapping from starting Nakshatra index to first dasha lord index in DASHA_SEQUENCE # Per BPHS Ch.19: # - Rohini (3), Ardra (5), Pushya (7), Ashlesha (8), Magha (9), Revati (26) # → start from Sun (0) # - Ashwini (0), Mrigashira (4), Punarvasu (6) # → start from Mars (2) # - Chitra (13), Shravana (20), Dhanishta (22) # → start from Jupiter (5) STARTING_LORD_MAP = { 3: 0, # Rohini -> Sun 5: 0, # Ardra -> Sun 7: 0, # Pushya -> Sun 8: 0, # Ashlesha -> Sun 9: 0, # Magha -> Sun 26: 0, # Revati -> Sun 0: 2, # Ashwini -> Mars 4: 2, # Mrigashira -> Mars 6: 2, # Punarvasu -> Mars 13: 5, # Chitra -> Jupiter 20: 5, # Shravana -> Jupiter 22: 5, # Dhanishta -> Jupiter } # --------------------------------------------------------------------------- # Core Functions # --------------------------------------------------------------------------- def is_ashtottari_applicable(moon_nakshatra_index: int, is_shukla_paksha: bool) -> bool: """ Check whether Ashtottari Dasha is applicable for the given birth conditions. Args: moon_nakshatra_index: 0-based index of Moon's Nakshatra (0 = Ashwini). is_shukla_paksha: True if birth is in Shukla Paksha (waxing Moon), else False. Returns: bool: True if Ashtottari Dasha applies. """ applicable, _ = evaluate_moon_nakshatra_plus_paksha_rule(moon_nakshatra_index, is_shukla_paksha) return applicable def _moon_nakshatra_name(moon_nakshatra_index: int) -> str: if moon_nakshatra_index is None: return "unknown" try: return NAKSHATRAS[moon_nakshatra_index] except Exception: return "unknown" def _candidate_nakshatra_starting_lord(moon_nakshatra_index: int) -> str: return _CANDIDATE_NAKSHATRA_STARTING_LORDS.get(_moon_nakshatra_name(moon_nakshatra_index), "Sun") def _candidate_lord_nakshatra_span(starting_planet: str) -> tuple[int, int] | tuple[None, None]: matching = [ idx for idx, name in enumerate(NAKSHATRAS) if _CANDIDATE_NAKSHATRA_STARTING_LORDS.get(name) == starting_planet ] if not matching: return None, None return matching[0], matching[-1] def _years_to_balance_parts(years: float) -> dict: total_days = max(years, 0.0) * 365.256364 whole_years = int(total_days // 365.256364) remaining_days = total_days - whole_years * 365.256364 whole_months = int(remaining_days // 30.0) residual_days = remaining_days - whole_months * 30.0 day_count = int(round(residual_days)) if residual_days > 0: day_count += 1 if day_count >= 30: whole_months += day_count // 30 day_count = day_count % 30 if whole_months >= 12: whole_years += whole_months // 12 whole_months = whole_months % 12 return { "years": years, "years_whole": whole_years, "months_whole": whole_months, "days_whole": day_count, } def _years_to_balance_parts_360(years: float) -> dict: total_days = max(years, 0.0) * 360.0 whole_years = int(total_days // 360.0) remaining_days = total_days - whole_years * 360.0 whole_months = int(remaining_days // 30.0) residual_days = remaining_days - whole_months * 30.0 day_count = int(round(residual_days)) if residual_days > 0: day_count += 1 if day_count >= 30: whole_months += day_count // 30 day_count = day_count % 30 if whole_months >= 12: whole_years += whole_months // 12 whole_months = whole_months % 12 return { "years": years, "years_whole": whole_years, "months_whole": whole_months, "days_whole": day_count, } def _calc_candidate_balance_and_birth_chain(moon_longitude: float, starting_planet: str) -> tuple[dict | None, dict | None]: start_nak_idx, end_nak_idx = _candidate_lord_nakshatra_span(starting_planet) if start_nak_idx is None or end_nak_idx is None: return None, None one_star = 360.0 / 27.0 segment_start = start_nak_idx * one_star segment_end = (end_nak_idx + 1) * one_star longitude = float(moon_longitude) % 360.0 if not (segment_start <= longitude <= segment_end): return None, None span = segment_end - segment_start elapsed = longitude - segment_start remaining_ratio = max(0.0, min(1.0, (segment_end - longitude) / span)) md_years = next(lord["years"] for lord in DASHA_SEQUENCE if lord["planet"] == starting_planet) remaining_years = md_years * remaining_ratio def _sequence_from(lord: str) -> list[str]: idx = next(i for i, item in enumerate(DASHA_SEQUENCE) if item["planet"] == lord) planets = [item["planet"] for item in DASHA_SEQUENCE] return planets[idx:] + planets[:idx] def _child_periods(parent_lord: str, parent_duration_years: float) -> list[dict]: rows = [] for child_lord in _sequence_from(parent_lord): child_years = parent_duration_years * next( item["years"] for item in DASHA_SEQUENCE if item["planet"] == child_lord ) / TOTAL_CYCLE rows.append({"lord": child_lord, "years": child_years}) return rows elapsed_in_md = md_years - remaining_years hierarchy = [starting_planet] parent_lord = starting_planet parent_duration = md_years elapsed_within_parent = elapsed_in_md for _depth in range(4): rows = _child_periods(parent_lord, parent_duration) cursor = 0.0 selected = rows[-1] for row in rows: next_cursor = cursor + row["years"] if cursor <= elapsed_within_parent < next_cursor: selected = row elapsed_within_parent = elapsed_within_parent - cursor break cursor = next_cursor hierarchy.append(selected["lord"]) parent_lord = selected["lord"] parent_duration = selected["years"] balance = { "planet": starting_planet, "segment_start_nakshatra": NAKSHATRAS[start_nak_idx], "segment_end_nakshatra": NAKSHATRAS[end_nak_idx], "segment_span_nakshatras": end_nak_idx - start_nak_idx + 1, "display_calendar": "360_day_traditional", **_years_to_balance_parts_360(remaining_years), } birth_chain = { "lords": hierarchy, "compact": "-".join(_PLANET_ABBR.get(lord, lord[:2]) for lord in hierarchy), } return balance, birth_chain def _timeline_start_with_birth_balance( birth_dt: datetime, moon_longitude, starting_planet: str, ) -> tuple[datetime, bool]: """Shift the first mahadasha so birth falls inside the elapsed portion. Both applicability families use the starting lord's nakshatra group. When the Moon longitude is missing, or it does not fall in that group, the timeline stays on the birth instant. """ if moon_longitude is None: return birth_dt, False balance, _birth_chain = _calc_candidate_balance_and_birth_chain( moon_longitude=float(moon_longitude), starting_planet=starting_planet, ) if balance is None: return birth_dt, False md_years = next(lord["years"] for lord in DASHA_SEQUENCE if lord["planet"] == starting_planet) elapsed_years = md_years - float(balance["years"]) if elapsed_years == 0: return birth_dt, False return birth_dt - timedelta(days=elapsed_years * 365.25), True def _current_period_from_shifted_majors(major_periods: list, now: datetime) -> dict | None: if not major_periods: return None first = datetime.fromisoformat(major_periods[0]["start_date"]) last = datetime.fromisoformat(major_periods[-1]["end_date"]) span = last - first if span.total_seconds() <= 0 or now < first: return None cycles = int((now - first).total_seconds() // span.total_seconds()) for period in major_periods: start = datetime.fromisoformat(period["start_date"]) + cycles * span end = datetime.fromisoformat(period["end_date"]) + cycles * span if start <= now < end: current = period.copy() current["start_date"] = start.isoformat() current["end_date"] = end.isoformat() current["elapsed_years"] = (now - start).days / 365.25 current["remaining_years"] = (end - now).days / 365.25 current["cycle_number"] = cycles + 1 return current return None def _build_major_periods(start_lord_idx: int, birth_date: datetime) -> list: """ Build the full list of major periods (Mahadashas) given the starting lord. Returns a list of dicts with keys: planet, years, start_date, end_date. """ periods = [] seq_len = len(DASHA_SEQUENCE) current_date = birth_date for i in range(seq_len): idx = (start_lord_idx + i) % seq_len lord = DASHA_SEQUENCE[idx] end_date = current_date + timedelta(days=lord["years"] * 365.25) periods.append({ "planet": lord["planet"], "years": lord["years"], "start_date": current_date.isoformat(), "end_date": end_date.isoformat(), }) current_date = end_date return periods def _sequence_from_lord(starting_planet: str) -> list[dict]: start_idx = next( idx for idx, lord in enumerate(DASHA_SEQUENCE) if lord["planet"] == starting_planet ) return DASHA_SEQUENCE[start_idx:] + DASHA_SEQUENCE[:start_idx] def _build_subperiods(parent_period: dict, level_key: str) -> list[dict]: start_dt = datetime.fromisoformat(parent_period["start_date"]) current_dt = start_dt child_periods = [] for lord in _sequence_from_lord(parent_period["planet"]): child_years = float(parent_period["years"]) * float(lord["years"]) / float(TOTAL_CYCLE) end_dt = current_dt + timedelta(days=child_years * 365.25) child_periods.append({ "level": level_key, "planet": lord["planet"], "lord": lord["planet"], "years": child_years, "start_date": current_dt.isoformat(), "end_date": end_dt.isoformat(), "status": "parameter_sensitive", "derivation": "native_ashtottari_recursive_proportional_variant", }) current_dt = end_dt if child_periods: child_periods[-1]["end_date"] = parent_period["end_date"] return child_periods def _attach_recursive_subperiods(major_periods: list[dict]) -> tuple[list[dict], list[dict]]: all_antardashas = [] all_pratyantardashas = [] for period in major_periods: antardasha = _build_subperiods(period, "antardasha") period["antardasha"] = antardasha all_antardashas.extend(antardasha) for child in antardasha: pratyantardasha = _build_subperiods(child, "pratyantardasha") child["pratyantardasha"] = pratyantardasha all_pratyantardashas.extend(pratyantardasha) return all_antardashas, all_pratyantardashas def calculate_ashtottari_dasha(birth_info: dict) -> dict: """ Calculate Ashtottari Dasha for a native. Args: birth_info: dict containing: - "moon_nakshatra_index": int (0-26) - "is_shukla_paksha": bool - "birth_datetime": datetime object or ISO-format string Returns: dict with keys: - "applicable": bool — whether this dasha system applies - "major": list of major period dicts (planet, years, start/end dates) - "current": dict — the currently running major period - "total_cycle": int — 108 - "starting_planet": str — first dasha lord """ moon_idx = birth_info.get("moon_nakshatra_index") is_shukla = birth_info.get("is_shukla_paksha", True) birth_dt = birth_info.get("birth_datetime") moon_longitude = birth_info.get("moon_longitude") rule_family = birth_info.get("applicability_rule_family") or RULE_FAMILY_MOON_NAKSHATRA_PLUS_PAKSHA if isinstance(birth_dt, str): birth_dt = datetime.fromisoformat(birth_dt) if birth_dt is None: birth_dt = datetime.now() if rule_family == RULE_FAMILY_RAHU_FROM_LAGNA_LORD: applicable, applicability_reason = evaluate_rahu_from_lagna_lord_rule(birth_info) starting_planet = _candidate_nakshatra_starting_lord(moon_idx) else: applicable, applicability_reason = evaluate_moon_nakshatra_plus_paksha_rule(moon_idx, is_shukla) starting_planet = None if not applicable: result = { "applicable": False, "major": [], "current": None, "total_cycle": TOTAL_CYCLE, "starting_planet": None, "reason": f"Moon Nakshatra '{NAKSHATRAS[moon_idx]}' does not qualify for Ashtottari Dasha under {'Shukla' if is_shukla else 'Krishna'} Paksha.", } if rule_family == RULE_FAMILY_RAHU_FROM_LAGNA_LORD: result.update({ "execution_status": "blocked", "confidence_status": "blocked", "verification_status": "unverified", }) return result if starting_planet is None: start_lord_idx = STARTING_LORD_MAP.get(moon_idx, 0) starting_planet = DASHA_SEQUENCE[start_lord_idx]["planet"] else: start_lord_idx = next( idx for idx, lord in enumerate(DASHA_SEQUENCE) if lord["planet"] == starting_planet ) timeline_start, balance_shifted = _timeline_start_with_birth_balance( birth_dt, moon_longitude, starting_planet, ) major_periods = _build_major_periods(start_lord_idx, timeline_start) antardasha_periods, pratyantardasha_periods = _attach_recursive_subperiods(major_periods) # Determine current period. Major periods repeat every 108 years; older natives # should still return a current period instead of None after the first cycle. # A shifted timeline is read from the period dates. An unshifted timeline keeps # the previous birth-anchored walk so charts without a Moon longitude stay put. now = datetime.now() current_period = None if balance_shifted: current_period = _current_period_from_shifted_majors(major_periods, now) else: age_years = max((now - birth_dt).days / 365.25, 0) current_in_cycle = age_years % TOTAL_CYCLE cumulative = 0.0 for p in major_periods: years = p["years"] if cumulative <= current_in_cycle < cumulative + years: cycle_start = birth_dt + timedelta(days=(age_years - current_in_cycle + cumulative) * 365.25) cycle_end = cycle_start + timedelta(days=years * 365.25) current_period = p.copy() current_period["start_date"] = cycle_start.isoformat() current_period["end_date"] = cycle_end.isoformat() current_period["elapsed_years"] = (now - cycle_start).days / 365.25 current_period["remaining_years"] = (cycle_end - now).days / 365.25 current_period["cycle_number"] = int(age_years // TOTAL_CYCLE) + 1 break cumulative += years result = { "applicable": True, "major": major_periods, "antardasha": antardasha_periods, "pratyantardasha": pratyantardasha_periods, "current": current_period, "total_cycle": TOTAL_CYCLE, "starting_planet": starting_planet, } if rule_family == RULE_FAMILY_RAHU_FROM_LAGNA_LORD: result.update({ "execution_status": "executed", "confidence_status": "parameter_sensitive", "verification_status": "unverified", }) return result # --------------------------------------------------------------------------- # Self-test # --------------------------------------------------------------------------- if __name__ == "__main__": # Example 1: Applicable case — Moon in Rohini, Krishna Paksha test_birth_1 = { "moon_nakshatra_index": 3, # Rohini "is_shukla_paksha": False, # Krishna Paksha "birth_datetime": datetime(1990, 6, 15, 10, 30), } result_1 = calculate_ashtottari_dasha(test_birth_1) print("=" * 60) print("Test 1: Moon in Rohini, Krishna Paksha") print("=" * 60) print(f"Applicable: {result_1['applicable']}") print(f"Starting Planet: {result_1['starting_planet']}") print(f"Total Cycle: {result_1['total_cycle']} years") print("Major Periods:") for p in result_1["major"]: print(f" {p['planet']:10s} | {p['years']:2d} years | {p['start_date'][:10]} → {p['end_date'][:10]}") print(f"Current Period: {result_1['current']}") print() # Example 2: Applicable case — Moon in Ashwini, Shukla Paksha test_birth_2 = { "moon_nakshatra_index": 0, # Ashwini "is_shukla_paksha": True, # Shukla Paksha "birth_datetime": datetime(1985, 3, 20, 8, 0), } result_2 = calculate_ashtottari_dasha(test_birth_2) print("=" * 60) print("Test 2: Moon in Ashwini, Shukla Paksha") print("=" * 60) print(f"Applicable: {result_2['applicable']}") print(f"Starting Planet: {result_2['starting_planet']}") print(f"Total Cycle: {result_2['total_cycle']} years") for p in result_2["major"]: print(f" {p['planet']:10s} | {p['years']:2d} years | {p['start_date'][:10]} → {p['end_date'][:10]}") print(f"Current Period: {result_2['current']}") print() # Example 3: Non-applicable case — Moon in Bharani, Shukla Paksha test_birth_3 = { "moon_nakshatra_index": 1, # Bharani "is_shukla_paksha": True, "birth_datetime": datetime(2000, 1, 1, 0, 0), } result_3 = calculate_ashtottari_dasha(test_birth_3) print("=" * 60) print("Test 3: Moon in Bharani, Shukla Paksha (Non-applicable)") print("=" * 60) print(f"Applicable: {result_3['applicable']}") print(f"Reason: {result_3['reason']}")