diff --git a/scripts/kalachakra_dasha.py b/scripts/kalachakra_dasha.py index ca3e56da..54467c3e 100644 --- a/scripts/kalachakra_dasha.py +++ b/scripts/kalachakra_dasha.py @@ -59,6 +59,9 @@ Reference: Brihat Parashara Hora Shastra and various classical commentaries on K """ from datetime import datetime, timedelta +import math +from bisect import bisect_right +from fractions import Fraction # --------------------------------------------------------------------------- # Constants @@ -112,6 +115,68 @@ APASAVYA_NAKSHATRAS = {3, 4, 5, 6, 7, 13, 14, 15, 16, 17, 24, 25, 26} # The actual classical mapping is more nuanced; this is a practical approximation. PADA_OFFSET_MAP = {1: 0, 2: 1, 3: 2, 4: 3} +# Classical 9-sign Kala Chakra cycles and paramayush values. +# This table is used only to improve the native runtime's birth-balance and +# Jeeva/Deha visibility; it does not claim external parity closure by itself. +CLASSICAL_SAVYA_GROUP_1 = {0, 2, 6, 8, 12, 14, 18, 20, 24} +CLASSICAL_SAVYA_GROUP_2 = {1, 7, 13, 19, 25, 26} +CLASSICAL_APASAVYA_GROUP_1 = {3, 9, 15, 21} +CLASSICAL_GROUP_SWITCH_ON_PADA_4 = {0: 1, 1: 0, 2: 3, 3: 2} +CLASSICAL_KALACHAKRA_CYCLES = ( + ( + (0, 1, 2, 3, 4, 5, 6, 7, 8), + (9, 10, 11, 7, 6, 5, 3, 4, 2), + (1, 0, 11, 10, 9, 8, 0, 1, 2), + (3, 4, 5, 6, 7, 8, 9, 10, 11), + ), + ( + (7, 6, 5, 3, 4, 2, 1, 0, 11), + (10, 9, 8, 0, 1, 2, 3, 4, 5), + (6, 7, 8, 9, 10, 11, 7, 6, 5), + (3, 4, 2, 1, 0, 11, 10, 9, 8), + ), + ( + (8, 9, 10, 11, 0, 1, 2, 4, 3), + (5, 6, 7, 11, 10, 9, 8, 7, 6), + (5, 4, 3, 2, 1, 0, 8, 9, 10), + (11, 0, 1, 2, 4, 3, 5, 6, 7), + ), + ( + (11, 10, 9, 8, 7, 6, 5, 4, 3), + (2, 1, 0, 8, 9, 10, 11, 0, 1), + (2, 4, 3, 5, 6, 7, 11, 10, 9), + (8, 7, 6, 5, 4, 3, 2, 1, 0), + ), +) +CLASSICAL_PARAMAYUSH = ( + (100.0, 85.0, 83.0, 86.0), + (100.0, 85.0, 83.0, 86.0), + (86.0, 83.0, 85.0, 100.0), + (86.0, 83.0, 85.0, 100.0), +) +CLASSICAL_RASHI_YEARS = { + 0: 7.0, + 1: 16.0, + 2: 9.0, + 3: 21.0, + 4: 5.0, + 5: 9.0, + 6: 16.0, + 7: 7.0, + 8: 10.0, + 9: 4.0, + 10: 4.0, + 11: 10.0, +} +# PVR, Integrated Approach, table 48 (printed p.292 / PDF page 304). +# Classical Kala Chakra periods are signs; ``lord`` is their sign owner, +# not a separately rotated nakshatra-lord sequence. +CLASSICAL_RASHI_LORDS = ( + "Mars", "Venus", "Mercury", "Moon", "Sun", "Mercury", + "Venus", "Mars", "Jupiter", "Saturn", "Saturn", "Jupiter", +) + + # --------------------------------------------------------------------------- # Helper Functions @@ -128,7 +193,7 @@ def _get_mode(nakshatra_index: int) -> str: return "savya" -def _build_major_periods(mode: str, start_offset: int, birth_date: datetime) -> list: +def _build_major_periods(mode: str, start_offset: int, birth_date: datetime, year_days: float = 365.25) -> list: """ Build major periods for Kalachakra Dasha. @@ -157,7 +222,7 @@ def _build_major_periods(mode: str, start_offset: int, birth_date: datetime) -> lord_idx = i % 9 # 9 lords cycle through 12 rashis lord = KALA_LORDS[lord_idx] years = years_map[rashi_idx] - end_date = current_date + timedelta(days=years * 365.25) + end_date = current_date + timedelta(days=years * year_days) periods.append({ "lord": lord, @@ -172,82 +237,375 @@ def _build_major_periods(mode: str, start_offset: int, birth_date: datetime) -> return periods +def _classical_group_for_nakshatra(nakshatra_index: int) -> int: + if nakshatra_index in CLASSICAL_SAVYA_GROUP_1: + return 0 + if nakshatra_index in CLASSICAL_SAVYA_GROUP_2: + return 1 + if nakshatra_index in CLASSICAL_APASAVYA_GROUP_1: + return 2 + return 3 + + +def _fraction_completed_in_pada(moon_longitude: float, nakshatra_index: int, pada: int) -> float: + one_pada = 360.0 / 108.0 + # Form the single rational boundary before converting to float. Adding + # separately rounded star/quarter widths can move an exact start. + pada_start = (nakshatra_index * 4 + pada - 1) * 10 / 3 + progress = (float(moon_longitude) % 360.0) - pada_start + fraction = progress / one_pada + return max(0.0, min(1.0, fraction)) + + +def _years_to_balance_parts(years: float) -> dict: + # PVR example 96 expresses 8.6 years as 8y 7m 6d: nominal 12x30 + # display units. Do not mix a sidereal year with 30-day months and add + # an undocumented inclusive day to resemble a target printout. + nominal_days = int(round(max(years, 0.0) * 360)) + whole_years, remainder = divmod(nominal_days, 360) + whole_months, day_count = divmod(remainder, 30) + return { + "years": years, + "years_whole": whole_years, + "months_whole": whole_months, + "days_whole": day_count, + "display_basis": "nominal_360_day_year_30_day_month_rounded_day; dates use dasha_year_days", + } + + +def _build_classical_major_periods( + nakshatra_index: int, + pada: int, + moon_longitude: float, + birth_date: datetime, + year_days: float = 365.25, +) -> dict: + group_index = _classical_group_for_nakshatra(nakshatra_index) + pada_index = max(0, min(3, pada - 1)) + cycle0 = list(CLASSICAL_KALACHAKRA_CYCLES[group_index][pada_index]) + paramayush = CLASSICAL_PARAMAYUSH[group_index][pada_index] + fraction_completed = _fraction_completed_in_pada(moon_longitude, nakshatra_index, pada) + completed_years = fraction_completed * paramayush + + full_years = [CLASSICAL_RASHI_YEARS[sign] for sign in cycle0] + cumulative = [] + running = 0.0 + for years in full_years: + running += years + cumulative.append(running) + idx_at_birth = next((i for i, total in enumerate(cumulative) if total > completed_years), len(cumulative) - 1) + remaining_years = cumulative[idx_at_birth] - completed_years + + next_group = group_index + next_pada_index = (pada_index + 1) % 4 + if pada_index == 3: + next_group = CLASSICAL_GROUP_SWITCH_ON_PADA_4[group_index] + cycle1 = list(CLASSICAL_KALACHAKRA_CYCLES[next_group][next_pada_index]) + progression = cycle0[idx_at_birth:] + cycle1[:idx_at_birth] + progression_years = [CLASSICAL_RASHI_YEARS[sign] for sign in progression] + progression_years[0] = remaining_years + + periods = [] + current_date = birth_date + for index, (rashi_idx, years) in enumerate(zip(progression, progression_years)): + end_date = current_date + timedelta(days=years * year_days) + periods.append({ + "lord": CLASSICAL_RASHI_LORDS[rashi_idx], + "rashi": RASHIS[rashi_idx], + "rashi_index": rashi_idx, + "years": years, + "start_date": current_date.isoformat(), + "end_date": end_date.isoformat(), + }) + current_date = end_date + + mode = "savya" if group_index in (0, 1) else "apasavya" + deha_index = cycle0[0] if mode == "savya" else cycle0[-1] + jeeva_index = cycle0[-1] if mode == "savya" else cycle0[0] + return { + "mode": mode, + "major": periods, + "total_cycle": paramayush, + "starting_lord": periods[0]["lord"] if periods else None, + "starting_rashi": periods[0]["rashi"] if periods else None, + "deha_rashi": RASHIS[deha_index], + "jeeva_rashi": RASHIS[jeeva_index], + "dasha_balance_at_birth": { + "rashi": periods[0]["rashi"] if periods else None, + **_years_to_balance_parts(remaining_years), + }, + "variant_trace": { + "family": "classical_nine_sign_progression", + "group_index": group_index, + "pada_index": pada_index, + "seed_cycle": [RASHIS[idx] for idx in cycle0], + "progression_cycle": [RASHIS[idx] for idx in progression], + "completed_paramayush_years": completed_years, + "paramayush_years": paramayush, + "dasha_year_days": year_days, + }, + } + + +def _rotate_cycle_rows(cycle_rows: list[dict], start_rashi: str | None) -> list[dict]: + if not cycle_rows: + return [] + if not start_rashi: + return list(cycle_rows) + start_index = next( + (index for index, row in enumerate(cycle_rows) if row.get("rashi") == start_rashi), + 0, + ) + return list(cycle_rows[start_index:]) + list(cycle_rows[:start_index]) + + +def _build_subperiods(parent_period: dict, cycle_rows: list[dict], total_cycle_years: float, level_key: str, year_days: float = 365.25) -> list[dict]: + if not cycle_rows: + return [] + + rotated_rows = _rotate_cycle_rows(cycle_rows, parent_period.get("rashi")) + cycle_basis_total = sum(float(row.get("years") or 0.0) for row in rotated_rows) + denominator = cycle_basis_total if cycle_basis_total > 0 else float(total_cycle_years or 0.0) + if denominator <= 0: + return [] + start_dt = datetime.fromisoformat(parent_period["start_date"]) + current_dt = start_dt + child_periods = [] + + for row in rotated_rows: + child_years = float(parent_period["years"]) * float(row["years"]) / denominator + end_dt = current_dt + timedelta(days=child_years * year_days) + child_periods.append({ + "level": level_key, + "lord": row.get("lord"), + "rashi": row.get("rashi"), + "rashi_index": row.get("rashi_index"), + "years": child_years, + "start_date": current_dt.isoformat(), + "end_date": end_dt.isoformat(), + "status": "parameter_sensitive", + "derivation": "native_kalachakra_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], total_cycle_years: float, year_days: float = 365.25) -> tuple[list[dict], list[dict]]: + cycle_rows = [ + { + "lord": row.get("lord"), + "rashi": row.get("rashi"), + "rashi_index": row.get("rashi_index"), + "years": row.get("years"), + } + for row in major_periods + ] + all_antardashas = [] + all_pratyantardashas = [] + for period in major_periods: + antardasha = _build_subperiods(period, cycle_rows, total_cycle_years, "antardasha", year_days) + period["antardasha"] = antardasha + all_antardashas.extend(antardasha) + for child in antardasha: + pratyantardasha = _build_subperiods(child, cycle_rows, total_cycle_years, "pratyantardasha", year_days) + child["pratyantardasha"] = pratyantardasha + all_pratyantardashas.extend(pratyantardasha) + return all_antardashas, all_pratyantardashas + + +def _classical_sign_stream(group: int, pada: int, count: int) -> list[int]: + """Continue through padas; never rotate the nine visible MD rows. + + PVR chapter 24, rules (4)-(5), examples 95-97. Repeated signs retain + their sequence position, so a later occurrence cannot select an earlier one. + """ + result = [] + while len(result) < count: + result.extend(CLASSICAL_KALACHAKRA_CYCLES[group][pada]) + pada += 1 + if pada == 4: + pada = 0 + group = CLASSICAL_GROUP_SWITCH_ON_PADA_4[group] + return result[:count] + + +def _attach_classical_subperiods(payload: dict) -> tuple[list[dict], list[dict]]: + """Apply published AD weights before clipping the birth balance. + + Keep the selected year convention explicit (default 365.25). Recursive PD is + an extension of AD proportional division, not independently certified by + the cited AD example. Both layers retain parameter-sensitive status. + """ + trace = payload['variant_trace'] + year_days = trace['dasha_year_days'] + stream = _classical_sign_stream(trace['group_index'], trace['pada_index'], 45) + elapsed = trace['completed_paramayush_years'] + position = 0 + while position < 8 and elapsed >= CLASSICAL_RASHI_YEARS[stream[position]]: + elapsed -= CLASSICAL_RASHI_YEARS[stream[position]] + position += 1 + + def split(offset, nominal_start, nominal_years, visible_start, visible_end, level): + sequence = stream[offset:offset + 9] + denominator = sum(CLASSICAL_RASHI_YEARS[sign] for sign in sequence) + rows = [] + cursor = nominal_start + for index, sign in enumerate(sequence): + years = nominal_years * CLASSICAL_RASHI_YEARS[sign] / denominator + end = cursor + timedelta(days=years * year_days) + # Avoid accumulated microsecond rounding at the parent's last edge. + if index == 8: + end = visible_end + start = max(cursor, visible_start) + if end > start: + row = { + 'level': level, 'lord': CLASSICAL_RASHI_LORDS[sign], + 'rashi': RASHIS[sign], 'rashi_index': sign, + 'years': (end - start).total_seconds() / (year_days * 86400), + 'start_date': start.isoformat(), 'end_date': end.isoformat(), + 'status': 'parameter_sensitive', + 'derivation': ('pvr_ch24_proportional_ad_birth_clipped' + if level == 'antardasha' else + 'recursive_pd_extension_of_pvr_ad'), + } + if level == 'antardasha': + row['pratyantardasha'] = split( + offset + index, cursor, years, start, end, 'pratyantardasha') + rows.append(row) + cursor = end + return rows + + antardashas, pratyantardashas = [], [] + for index, period in enumerate(payload['major']): + start = datetime.fromisoformat(period['start_date']) + end = datetime.fromisoformat(period['end_date']) + full_years = CLASSICAL_RASHI_YEARS[period['rashi_index']] + nominal_start = start - timedelta(days=(elapsed if index == 0 else 0) * year_days) + children = split(position + index, nominal_start, full_years, start, end, 'antardasha') + period['antardasha'] = children + antardashas.extend(children) + for child in children: + pratyantardashas.extend(child['pratyantardasha']) + return antardashas, pratyantardashas + + +_PADA_STARTS = tuple(float(Fraction(10 * index, 3)) for index in range(108)) + + +def _nakshatra_and_pada_from_longitude(longitude: float) -> tuple[int, int]: + """Own the pada that starts on an exact 10/3 degree boundary. + + The shared find_nakshatra helper still splits a float span and disagrees + at many of these 108 starts. Kalachakra uses the same boundaries as + _fraction_completed_in_pada. + """ + longitude = float(longitude) % 360.0 + quarter = bisect_right(_PADA_STARTS, longitude) - 1 + if quarter < 0: + quarter = 0 + return quarter // 4, (quarter % 4) + 1 + + # --------------------------------------------------------------------------- # Core Functions # --------------------------------------------------------------------------- def calculate_kalachakra_dasha(birth_info: dict) -> dict: - """ - Calculate Kalachakra Dasha for a native. + """Calculate Kalachakra Dasha for a native. - Args: - birth_info: dict containing: - - "moon_nakshatra_index": int (0-26) - - "moon_pada": int (1-4) - - "birth_datetime": datetime object or ISO-format string (optional) - - Returns: - dict with keys: - - "mode": str — 'savya' or 'apasavya' - - "major": list of major period dicts - - "current": dict — the currently running major period - - "total_cycle": int — sum of years in one full cycle - - "starting_lord": str - - "starting_rashi": str + A moon longitude selects the classical nine-sign progression (PVR + chapter 24). Without it, the previous simplified savya/apasavya table + stays in place so callers that only pass nakshatra and pada keep working. """ nakshatra_idx = birth_info.get("moon_nakshatra_index", 0) pada = birth_info.get("moon_pada", 1) birth_dt = birth_info.get("birth_datetime") + moon_longitude = birth_info.get("moon_longitude") + year_days = float(birth_info.get("dasha_year_days", 365.25)) + if not math.isfinite(year_days) or year_days <= 0: + raise ValueError("dasha_year_days must be finite and positive") if isinstance(birth_dt, str): birth_dt = datetime.fromisoformat(birth_dt) if birth_dt is None: birth_dt = datetime.now() - # Clamp pada to 1-4 - if not 1 <= pada <= 4: - pada = ((pada - 1) % 4) + 1 + if not isinstance(pada, int) or not 1 <= pada <= 4: + pada = ((int(pada) - 1) % 4) + 1 - mode = _get_mode(nakshatra_idx) - start_offset = PADA_OFFSET_MAP.get(pada, 0) + classical_payload = None + if moon_longitude is not None: + longitude = float(moon_longitude) + if not math.isfinite(longitude): + raise ValueError("moon_longitude must be finite") + longitude %= 360.0 + nakshatra_idx, pada = _nakshatra_and_pada_from_longitude(longitude) + classical_payload = _build_classical_major_periods( + nakshatra_index=nakshatra_idx, + pada=pada, + moon_longitude=longitude, + birth_date=birth_dt, + year_days=year_days, + ) - major_periods = _build_major_periods(mode, start_offset, birth_dt) - total_cycle = sum(p["years"] for p in major_periods) + if classical_payload is not None: + mode = classical_payload["mode"] + major_periods = classical_payload["major"] + total_cycle = classical_payload["total_cycle"] + starting_lord = classical_payload["starting_lord"] + starting_rashi = classical_payload["starting_rashi"] + else: + mode = _get_mode(nakshatra_idx) + start_offset = PADA_OFFSET_MAP.get(pada, 0) + major_periods = _build_major_periods(mode, start_offset, birth_dt, year_days) + total_cycle = sum(p["years"] for p in major_periods) + starting_lord = major_periods[0]["lord"] if major_periods else None + starting_rashi = major_periods[0]["rashi"] if major_periods else None - starting_lord = major_periods[0]["lord"] if major_periods else None - starting_rashi = major_periods[0]["rashi"] if major_periods else None - - # Determine current period. Kalachakra cycles through the generated Rashi-year - # sequence repeatedly; older natives should still return a current period. - now = datetime.now() + now = birth_info.get("as_of") or datetime.now(tz=birth_dt.tzinfo) + if isinstance(now, str): + now = datetime.fromisoformat(now) current_period = None - age_years = max((now - birth_dt).days / 365.25, 0) - current_in_cycle = age_years % total_cycle if total_cycle else age_years - 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 if total_cycle else 1 + for period in major_periods: + cycle_start = datetime.fromisoformat(period["start_date"]) + cycle_end = datetime.fromisoformat(period["end_date"]) + if cycle_start <= now < cycle_end: + current_period = period.copy() + current_period["elapsed_years"] = (now - cycle_start).total_seconds() / (year_days * 86400) + current_period["remaining_years"] = (cycle_end - now).total_seconds() / (year_days * 86400) + current_period["cycle_number"] = 1 break - cumulative += years + + if classical_payload is not None: + antardasha_periods, pratyantardasha_periods = _attach_classical_subperiods(classical_payload) + else: + antardasha_periods, pratyantardasha_periods = _attach_recursive_subperiods( + major_periods, total_cycle, year_days, + ) return { "mode": mode, + "method_profile": ( + "pvr_classical_nine_sign_md_ad" if classical_payload + else "legacy_approximation_without_moon_longitude" + ), "major": major_periods, + "antardasha": antardasha_periods, + "pratyantardasha": pratyantardasha_periods, "current": current_period, "total_cycle": total_cycle, + "dasha_year_days": year_days, "starting_lord": starting_lord, "starting_rashi": starting_rashi, + "jeeva_rashi": classical_payload.get("jeeva_rashi") if classical_payload else None, + "deha_rashi": classical_payload.get("deha_rashi") if classical_payload else None, + "dasha_balance_at_birth": classical_payload.get("dasha_balance_at_birth") if classical_payload else None, + "variant_trace": classical_payload.get("variant_trace") if classical_payload else None, } - # --------------------------------------------------------------------------- # Self-test # --------------------------------------------------------------------------- diff --git a/tests/test_dasha_master_children.py b/tests/test_dasha_master_children.py index 1754d36d..614b575b 100644 --- a/tests/test_dasha_master_children.py +++ b/tests/test_dasha_master_children.py @@ -50,7 +50,10 @@ def test_kalachakra_children_hang_on_existing_majors() -> None: source["major"], source["total_cycle"], ) assert [_major_view(row) for row in source["major"]] == before - assert "antardasha" not in source["major"][0] + # 原值: major 行没有 antardasha + # 新值: 计算函数自己挂上子运 + # 原因: 上游 f353a096 在返回前附上按比例的子运;简化附件仍只改副本 + assert source["major"][0]["antardasha"] assert [_major_view(row) for row in enriched] == before assert antardasha and pratyantar assert enriched[0]["antardasha"][-1]["end_date"] == enriched[0]["end_date"] @@ -99,7 +102,10 @@ def test_master_pack_keeps_sourced_children_and_leaves_modules_alone() -> None: assert families["narayana"]["periods"][0]["antardasha"][0]["sign"] == "Taurus" assert "antardasha" not in tribhagi["major"][0] assert "antardasha" not in yogini["major"][0] - assert "antardasha" not in kalachakra["major"][0] + # 原值: kalachakra major 行没有 antardasha + # 新值: 无月亮经度时也带按比例子运 + # 原因: 上游 f353a096 的简化路径同样在返回前附上子运 + assert kalachakra["major"][0]["antardasha"] assert "antardasha" not in narayana["mahadasha_sequence"][0] assert [_major_view(row) for row in families["kala_chakra"]["periods"]] == [ _major_view(row) for row in kalachakra["major"] diff --git a/tests/test_upstream_sync9_kalachakra_source_lords.py b/tests/test_upstream_sync9_kalachakra_source_lords.py new file mode 100644 index 00000000..871d6f02 --- /dev/null +++ b/tests/test_upstream_sync9_kalachakra_source_lords.py @@ -0,0 +1,111 @@ +"""Published examples check semantics, not merely child-table presence.""" +from datetime import datetime + +import pytest + +from scripts.kalachakra_dasha import calculate_kalachakra_dasha + + +@pytest.mark.parametrize('quarter', range(108)) +def test_exact_quarter_start_has_full_first_major_balance(quarter): + from scripts.kalachakra_dasha import _fraction_completed_in_pada + longitude = quarter * 10 / 3 + assert _fraction_completed_in_pada(longitude, quarter // 4, quarter % 4 + 1) == 0 + packet = calculate_kalachakra_dasha({ + 'moon_longitude': longitude, 'birth_datetime': datetime(2000, 1, 1), + 'as_of': '2000-01-01', + }) + first = packet['major'][0] + assert first['years'] > 0 + assert first['start_date'] < first['end_date'] + assert packet['variant_trace']['pada_index'] == quarter % 4 + assert packet['variant_trace']['completed_paramayush_years'] == 0 + + +@pytest.mark.parametrize("longitude,nakshatra,pada,first_sign,balance", [ + (45 + 50 / 60, 3, 2, "Scorpio", 4.75), # PVR example 95 + (93.0, 6, 4, "Pisces", 8.6), # PVR example 96 +]) +def test_published_birth_balances_and_sign_owners(longitude, nakshatra, pada, + first_sign, balance): + result = calculate_kalachakra_dasha({ + "moon_longitude": longitude, "moon_nakshatra_index": nakshatra, + "moon_pada": pada, "birth_datetime": datetime(2000, 1, 1), + }) + owners = dict(zip( + "Aries Taurus Gemini Cancer Leo Virgo Libra Scorpio Sagittarius Capricorn Aquarius Pisces".split(), + "Mars Venus Mercury Moon Sun Mercury Venus Mars Jupiter Saturn Saturn Jupiter".split(), + )) + assert result["starting_rashi"] == first_sign + assert result["major"][0]["years"] == pytest.approx(balance) + assert result["starting_lord"] == owners[first_sign] + if first_sign == 'Pisces': + assert [result['dasha_balance_at_birth'][key] for key in + ('years_whole','months_whole','days_whole')] == [8,7,6] + for row in result["major"] + result["antardasha"] + result["pratyantardasha"]: + assert row["lord"] == owners[row["rashi"]] + + +def test_example_97_child_sequence_and_prebirth_clipping(): + result = calculate_kalachakra_dasha({ + 'moon_longitude': 45 + 50 / 60, 'moon_nakshatra_index': 3, + 'moon_pada': 2, 'birth_datetime': datetime(2000, 1, 1), + }) + # Example 95 has already consumed 2.25 of the 7 Scorpio years. + # Sc=.49, Li=1.12, Vi=.63 total 2.24; Leo is active at birth. + first = result['major'][0]['antardasha'][0] + assert first['rashi'] == 'Leo' + assert first['years'] == pytest.approx(.34) + assert first['start_date'] == '2000-01-01T00:00:00' + leo = result['major'][3] + assert leo['rashi'] == 'Leo' + assert [row['rashi'] for row in leo['antardasha']] == [ + 'Leo', 'Cancer', 'Gemini', 'Taurus', 'Aries', 'Sagittarius', + 'Capricorn', 'Aquarius', 'Pisces', + ] + assert leo['antardasha'][0]['years'] == pytest.approx(5 * 5 / 86) + for parent in result['major'] + result['antardasha']: + children = parent.get('antardasha', parent.get('pratyantardasha')) + assert children[0]['start_date'] == parent['start_date'] + assert children[-1]['end_date'] == parent['end_date'] + assert sum(row['years'] for row in children) == pytest.approx(parent['years']) + for left, right in zip(children, children[1:]): + assert left['end_date'] == right['start_date'] + assert all(row['start_date'] < row['end_date'] for row in children) + + +def test_current_period_uses_exact_half_open_boundaries_without_repetition(): + birth = {'moon_longitude': 45 + 50 / 60, 'moon_nakshatra_index': 3, + 'moon_pada': 2, 'birth_datetime': datetime(2000, 1, 1, 12, 34, 56)} + initial = calculate_kalachakra_dasha(birth) + boundary = initial['major'][0]['end_date'] + current = calculate_kalachakra_dasha({**birth, 'as_of': boundary})['current'] + assert current['rashi'] == 'Libra' + assert current['start_date'] == boundary + assert current['end_date'] == initial['major'][1]['end_date'] + assert current['elapsed_years'] == 0 + assert calculate_kalachakra_dasha({**birth, 'as_of': '1999-01-01'})['current'] is None + assert calculate_kalachakra_dasha({**birth, 'as_of': initial['major'][-1]['end_date']})['current'] is None + + +@pytest.mark.parametrize('year_days',[360,365.25,365.256364]) +def test_selected_year_applies_at_every_depth(year_days): + birth = {'moon_longitude':93.,'moon_nakshatra_index':6,'moon_pada':4, + 'birth_datetime':datetime(2000,1,1),'dasha_year_days':year_days} + result = calculate_kalachakra_dasha(birth) + assert result['dasha_year_days'] == year_days + for row in result['major'] + result['antardasha'] + result['pratyantardasha']: + span = datetime.fromisoformat(row['end_date']) - datetime.fromisoformat(row['start_date']) + assert span.total_seconds() == pytest.approx(row['years']*year_days*86400,abs=.00002) + + +def test_actual_longitude_controls_seed_and_nonfinite_inputs_do_not_fallback(): + birth = {'moon_longitude':93.,'moon_nakshatra_index':0,'moon_pada':1, + 'birth_datetime':datetime(2000,1,1)} + result = calculate_kalachakra_dasha(birth) + assert result['starting_rashi'] == 'Pisces' + assert result['major'][0]['years'] == pytest.approx(8.6) + for key,value in [('moon_longitude',float('nan')),('dasha_year_days',0), + ('dasha_year_days',float('inf'))]: + with pytest.raises(ValueError): + calculate_kalachakra_dasha({**birth,key:value})