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:
co-authored by
Grok (agent loop)
parent
b35c424945
commit
96312893b7
+405
-47
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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})
|
||||
Reference in New Issue
Block a user