fix(kalachakra): classical sign periods when the Moon longitude is present (BUG-1476, upstream f353a096)

Without a Moon longitude the simplified table remains. Exact pada starts use a local boundary table because shared find_nakshatra misclassifies 50 of 108 starts. Two existing child-period assertions now expect antardasha; original, new value, and reason are in the test comments. 116 source tests passed.

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-28 09:45:08 +08:00
co-authored by Grok (agent loop)
parent b35c424945
commit 96312893b7
3 changed files with 524 additions and 49 deletions
+405 -47
View File
@@ -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
# ---------------------------------------------------------------------------
+8 -2
View File
@@ -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"]
@@ -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})