diff --git a/scripts/yogini_dasha.py b/scripts/yogini_dasha.py index 00ddf62f..8be0a416 100644 --- a/scripts/yogini_dasha.py +++ b/scripts/yogini_dasha.py @@ -61,10 +61,63 @@ def calculate_yogini_dasha(birth_info: dict) -> dict: moon_longitude: sidereal degrees, required as_of / today: observation instant; report callers should pass it dasha_year_days: fixed-day year, default 365.25 + + Major rows stay free of sub-periods. Callers that need antardashas use + ``yogini_antardashas`` on a copy. """ return _calculate_moon_balanced_yogini(birth_info) +def _as_datetime(value): + if isinstance(value, datetime): + return value + return datetime.fromisoformat(str(value)) + + +def yogini_antardashas(major_row: dict, *, birth_dt, year_days: float) -> list: + """Proportional antardashas inside one Yogini mahadasha. + + Eight sub-periods start at this mahadasha's own Yogini and follow Goel + Table I. Each length is ``full_years * sub_years / 36``. They are laid out + from ``unclipped_start_date``. Sub-periods that end at or before birth are + dropped. The one that crosses birth keeps ``unclipped_start_date`` and + starts at birth. The last sub-period ends on the mahadasha ``end_date``. + """ + birth = _as_datetime(birth_dt) + year_length = float(year_days) + if year_length <= 0: + raise ValueError('dasha_year_days must be positive') + start_idx = next( + index for index, item in enumerate(YOGINI_SEQUENCE) if item['name'] == major_row['yogini'] + ) + full_years = float(major_row['full_years']) + cursor = _as_datetime(major_row['unclipped_start_date']) + major_end = _as_datetime(major_row['end_date']) + rows = [] + for offset in range(len(YOGINI_SEQUENCE)): + item = YOGINI_SEQUENCE[(start_idx + offset) % len(YOGINI_SEQUENCE)] + span_days = full_years * item['years'] / TOTAL_CYCLE * year_length + end = major_end if offset == len(YOGINI_SEQUENCE) - 1 else cursor + timedelta(days=span_days) + if end <= birth: + cursor = end + continue + visible_start = birth if cursor < birth else cursor + row = { + 'level': 'antardasha', + 'planet': item['planet'], + 'yogini': item['name'], + 'lord': item['planet'], + 'years': (end - visible_start).total_seconds() / (86400 * year_length), + 'start_date': visible_start.isoformat(timespec='seconds'), + 'end_date': end.isoformat(timespec='seconds'), + } + if cursor < birth: + row['unclipped_start_date'] = cursor.isoformat(timespec='seconds') + rows.append(row) + cursor = end + return rows + + def _calculate_moon_balanced_yogini(birth_info): """Goel Table I order, angular birth balance, explicit fixed-day year.""" birth_dt = birth_info.get("birth_datetime") diff --git a/tests/test_yogini_seed_profile.py b/tests/test_yogini_seed_profile.py index 52a7e85d..caeaa6e0 100644 --- a/tests/test_yogini_seed_profile.py +++ b/tests/test_yogini_seed_profile.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta import pytest -from scripts.yogini_dasha import YOGINI_SEQUENCE, calculate_yogini_dasha +from scripts.yogini_dasha import YOGINI_SEQUENCE, TOTAL_CYCLE, calculate_yogini_dasha, yogini_antardashas def test_yogini_classical_sequence_and_balance(): @@ -57,3 +57,77 @@ def test_yogini_explicit_reference_controls_current_cycle(): # Ashwini start: (0 + 3) % 8 is Bhramari / Mars, and the balance is the whole period. assert first['starting_planet'] == 'Mars' assert first['birth_balance_years'] == pytest.approx(4.0) + + +def _sub_names(start_name): + start = next(index for index, item in enumerate(YOGINI_SEQUENCE) if item['name'] == start_name) + return [YOGINI_SEQUENCE[(start + offset) % 8]['name'] for offset in range(8)] + + +def test_yogini_antardasha_proportional_on_unclipped_seed(): + # Moon at Ashwini 0°: Bhramari opens on the birth instant, so nothing is clipped. + birth = datetime(2000, 1, 1) + year_days = 360 + result = calculate_yogini_dasha({ + 'birth_datetime': birth, + 'moon_longitude': 0.0, + 'as_of': '2000-01-02', + 'dasha_year_days': year_days, + }) + assert all('antardasha' not in row for row in result['major']) + major = result['major'][0] + assert major['unclipped_start_date'] == major['start_date'] + subs = yogini_antardashas(major, birth_dt=birth, year_days=year_days) + assert [row['yogini'] for row in subs] == _sub_names('Bhramari') + assert all('unclipped_start_date' not in row for row in subs) + assert subs[0]['start_date'] == major['start_date'] + assert subs[-1]['end_date'] == major['end_date'] + assert all(left['end_date'] == right['start_date'] for left, right in zip(subs, subs[1:])) + for row, item in zip(subs, YOGINI_SEQUENCE[3:] + YOGINI_SEQUENCE[:3]): + assert row['level'] == 'antardasha' + assert row['planet'] == row['lord'] == item['planet'] + assert row['years'] == pytest.approx(major['full_years'] * item['years'] / TOTAL_CYCLE) + visible = sum( + (datetime.fromisoformat(row['end_date']) - datetime.fromisoformat(row['start_date'])).total_seconds() + for row in subs + ) + major_span = ( + datetime.fromisoformat(major['end_date']) - datetime.fromisoformat(major['start_date']) + ).total_seconds() + assert abs(visible - major_span) <= 86400 + + +def test_yogini_antardasha_clips_the_birth_crossing_subperiod(): + # Mid-Pushya is the same seed as test_yogini_classical_sequence_and_balance, + # shifted so the elapsed balance falls inside Siddha rather than on a boundary. + birth = datetime(2000, 1, 1, 12) + year_days = 360 + moon = (7 + 0.6) * (360 / 27) + result = calculate_yogini_dasha({ + 'birth_datetime': birth, + 'moon_longitude': moon, + 'as_of': '2000-01-02', + 'dasha_year_days': year_days, + }) + first = result['major'][0] + later = result['major'][1] + first_subs = yogini_antardashas(first, birth_dt=birth, year_days=year_days) + later_subs = yogini_antardashas(later, birth_dt=birth, year_days=year_days) + assert first_subs[0]['yogini'] == 'Siddha' + assert first_subs[0]['start_date'] == first['start_date'] == birth.isoformat(timespec='seconds') + assert datetime.fromisoformat(first_subs[0]['unclipped_start_date']) < birth + assert [row['yogini'] for row in first_subs] == _sub_names('Dhanya')[-4:] + assert later_subs[0]['start_date'] == later['start_date'] + assert [row['yogini'] for row in later_subs] == _sub_names(later['yogini']) + assert 'unclipped_start_date' not in later_subs[0] + for major, subs in ((first, first_subs), (later, later_subs)): + assert all(left['end_date'] == right['start_date'] for left, right in zip(subs, subs[1:])) + assert subs[-1]['end_date'] == major['end_date'] + visible = sum( + (datetime.fromisoformat(row['end_date']) - datetime.fromisoformat(row['start_date'])).total_seconds() + for row in subs + ) + major_span = ( + datetime.fromisoformat(major['end_date']) - datetime.fromisoformat(major['start_date']) + ).total_seconds() + assert abs(visible - major_span) == 0