Files
Jyotisha/scripts/ashtottari_dasha.py
T
jesse-uxandGrok (agent loop) b35c424945 fix(ashtottari): source arcs and a shifted sub-period clock (BUG-1475, upstream 0712f98f)
Rahu uses the circular unique start. Output keys are unchanged. Upstream arc and clock tests passed.

Co-Authored-By: Grok (agent loop)
2026-10-28 09:45:07 +08:00

573 lines
21 KiB
Python

#!/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"
]
# PVR Integrated Approach Table 39 angular arcs over the 27 named stars.
# This profile includes the reduced Saturn arc and circular Rahu arc.
# Applicability and child-period profiles remain separate choices.
_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
# Table 39 has a circular Rahu arc: Uttara Bhadrapada through Bharani.
# min/max would incorrectly turn that four-star arc into the full zodiac.
starts = [idx for idx in matching if (idx - 1) % 27 not in matching]
if len(starts) != 1:
return None, None
return starts[0], starts[0] + len(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
segment_start = start_nak_idx * 40.0 / 3.0
segment_end = (end_nak_idx + 1) * 40.0 / 3.0
longitude = float(moon_longitude) % 360.0
if segment_end > 360 and longitude < segment_start:
longitude += 360
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 % 27],
"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 _shift_period_tree(value, shift: timedelta):
"""Copy a period tree and move every stored instant by the same cycle shift."""
if isinstance(value, list):
return [_shift_period_tree(item, shift) for item in value]
if isinstance(value, dict):
shifted = {}
for key, item in value.items():
if key in ("start_date", "end_date") and isinstance(item, str):
shifted[key] = (datetime.fromisoformat(item) + shift).isoformat()
else:
shifted[key] = _shift_period_tree(item, shift)
return shifted
return value
def _current_period_on_cycle(major_periods: list, birth_dt: datetime, now: datetime) -> dict | None:
"""Second and later cycles move child dates with the parent. Birth row stays put."""
if not major_periods or now < birth_dt:
return None
cycle_duration = timedelta(days=TOTAL_CYCLE * 365.25)
cycle_index = (now - birth_dt) // cycle_duration
shift = cycle_index * cycle_duration
for period in major_periods:
start = datetime.fromisoformat(period["start_date"]) + shift
end = datetime.fromisoformat(period["end_date"]) + shift
if start <= now < end:
current = _shift_period_tree(period, shift)
current["elapsed_years"] = (now - start).total_seconds() / (86400 * 365.25)
current["remaining_years"] = (end - now).total_seconds() / (86400 * 365.25)
current["cycle_number"] = cycle_index + 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)
# Later cycles repeat every 108 years. Child dates move with the parent.
# The birth row in major stays on the original timeline.
now = birth_info.get("as_of") or birth_info.get("today") or datetime.now()
if isinstance(now, str):
now = datetime.fromisoformat(now)
current_period = _current_period_on_cycle(major_periods, birth_dt, now)
result = {
"applicable": True,
"reference_date": now.isoformat(),
"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']}")