diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index bed3ca81..04d2e3f8 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -16948,4 +16948,20 @@ - 防复发:全自宫反例与 ValueError 断言 - 相关记录:BUG-1260、BUG-1205 - 复发自:无 +- 修复版本:本分支 `ab9e3056` + +## BUG-1262 | Yogini 顺序与出生余额不符合 Goel Classical Table I + +- 状态:resolved +- 首次发现:2026-10-07 +- 最近更新:2026-10-07 +- 影响面:`scripts/yogini_dasha.py`;全盘报告的 Yogini 模块 +- 用户现象:第一段从出生起算满周期,主星和年数与原书表不一致 +- 触发条件:计算 Yogini 大运 +- 根因:顺序仍是 Maya / Bhadrika / Jaya / Rati / Mangala / Pingala / Dhanya / Bhramari,没有月亮黄经时还按上升星座起运,第一段从出生起给满年。对照 V.P. Goel《Predict Effectively through Yogini Dasha》Classical 章 Table I:顺序是 Mangala 月 1、Pingala 日 2、Dhanya 木 3、Bhramari 火 4、Bhadrika 水 5、Ulka 土 6、Siddha 金 7、Sankata 罗 8;起运为从 Ashwini 起的月宿序号加 3 再除以 8 的余数;第一段是该宿未走完的余额 +- 修复:按该表改顺序;必须有月亮黄经,按角度余额裁第一段,并保留未裁切的起点。全盘装配传入月亮黄经。没有月亮黄经时返回 blocked,不再用上升星座编一段 +- 验证:`tests/test_yogini_seed_profile.py`(顺序、Pushya 中点余额 1.5 年、缺黄经 blocked、显式 as_of 决定当前周) +- 防复发:上述测试。`scripts/extended_dashas.py` 的另一套 Yogini 仍按月宿对 8 取余且不裁余额,全盘报告不走它 +- 相关记录:BUG-1260 +- 复发自:无 - 修复版本:本分支待发布 diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 5fd87a3f..7e8ca5af 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -16714,7 +16714,10 @@ def cmd_full_reading(args): try: from yogini_dasha import calculate_yogini_dasha - yogini_data = calculate_yogini_dasha(birth_info_for_alt_dasha) + yogini_data = calculate_yogini_dasha({ + **birth_info_for_alt_dasha, + 'moon_longitude': moon_lon, + }) report['modules']['yogini_dasha'] = yogini_data except Exception as alt_e: yogini_data = {'error': str(alt_e)} diff --git a/scripts/orchestrator_bridge.py b/scripts/orchestrator_bridge.py index 2fbf63e7..2df5627d 100644 --- a/scripts/orchestrator_bridge.py +++ b/scripts/orchestrator_bridge.py @@ -302,6 +302,16 @@ class OrchestratorBridge: else: info["moon_pada"] = 1 + moon_longitude = data.get("moon_longitude") + if moon_longitude is None: + chart = data.get("chart") or data.get("natal_chart") or {} + planets = data.get("planets") or chart.get("planets") or {} + moon = planets.get("Moon") or planets.get("moon") if isinstance(planets, dict) else None + if isinstance(moon, dict): + moon_longitude = moon.get("longitude", moon.get("degree", moon.get("lon"))) + if moon_longitude is not None: + info["moon_longitude"] = float(moon_longitude) + return info if info.get("birth_datetime") else None def _add_dasha_timing_anchor( diff --git a/scripts/yogini_dasha.py b/scripts/yogini_dasha.py index c7e2f195..00ddf62f 100644 --- a/scripts/yogini_dasha.py +++ b/scripts/yogini_dasha.py @@ -7,27 +7,22 @@ The Yogini Dasha is a Nakshatra-based dasha system with a total cycle of 36 year It consists of 8 Yoginis (female planetary deities), each ruling a specific number of years. The sequence is fixed and cycles continuously. -The 8 Yoginis, their year allotments, and associated planets: +Order follows Goel, Classical chapter, Table I. The first period is the +remaining birth balance of that Yogini, not a full period counted from birth. +Seed is ``(zero_based_moon_nakshatra + 3) mod 8``. Moon longitude is required. Order | Yogini | Years | Planet ------|-----------|-------|-------- - 1 | Maya | 1 | Sun - 2 | Bhadrika | 2 | Moon - 3 | Jaya | 3 | Mars - 4 | Rati | 4 | Mercury - 5 | Mangala | 5 | Jupiter - 6 | Pingala | 6 | Saturn - 7 | Dhanya | 7 | Venus - 8 | Bhramari | 8 | Rahu + 1 | Mangala | 1 | Moon + 2 | Pingala | 2 | Sun + 3 | Dhanya | 3 | Jupiter + 4 | Bhramari | 4 | Mars + 5 | Bhadrika | 5 | Mercury + 6 | Ulka | 6 | Saturn + 7 | Siddha | 7 | Venus + 8 | Sankata | 8 | Rahu Total: 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 = 36 years. - -Starting Yogini: - When the Moon's nakshatra index is supplied, use the standard - ``(nakshatra_index + 3) mod 8`` seed. The older Lagna-rashi seed remains - an explicit compatibility fallback when no Moon nakshatra is available. - -Reference: Various classical and modern Jyotish texts on Yogini Dasha. """ from datetime import datetime, timedelta @@ -43,120 +38,84 @@ RASHIS = [ "Sagittarius", "Capricorn", "Aquarius", "Pisces" ] -# Yogini sequence: fixed order, never changes +# Goel Classical chapter Table I. Do not start a full period at birth. YOGINI_SEQUENCE = [ - {"name": "Maya", "years": 1, "planet": "Sun"}, - {"name": "Bhadrika", "years": 2, "planet": "Moon"}, - {"name": "Jaya", "years": 3, "planet": "Mars"}, - {"name": "Rati", "years": 4, "planet": "Mercury"}, - {"name": "Mangala", "years": 5, "planet": "Jupiter"}, - {"name": "Pingala", "years": 6, "planet": "Saturn"}, - {"name": "Dhanya", "years": 7, "planet": "Venus"}, - {"name": "Bhramari", "years": 8, "planet": "Rahu"}, + {"name": "Mangala", "years": 1, "planet": "Moon"}, + {"name": "Pingala", "years": 2, "planet": "Sun"}, + {"name": "Dhanya", "years": 3, "planet": "Jupiter"}, + {"name": "Bhramari", "years": 4, "planet": "Mars"}, + {"name": "Bhadrika", "years": 5, "planet": "Mercury"}, + {"name": "Ulka", "years": 6, "planet": "Saturn"}, + {"name": "Siddha", "years": 7, "planet": "Venus"}, + {"name": "Sankata", "years": 8, "planet": "Rahu"}, ] TOTAL_CYCLE = 36 # years -# Lagna Rashi to starting Yogini index mapping -# Odd Rashis (0,4,8,2,6,10) → start from Maya (0) -# Even Rashis (1,5,9,3,7,11) → start from Bhadrika (1) -def _get_start_yogini_index(lagna_rashi_index: int) -> int: - """Return starting Yogini index based on Ascendant Rashi.""" - if lagna_rashi_index % 2 == 0: - return 0 # Maya - else: - return 1 # Bhadrika - - -# --------------------------------------------------------------------------- -# Core Functions -# --------------------------------------------------------------------------- - -def _build_major_periods(start_idx: int, birth_date: datetime) -> list: - """ - Build the full list of major Yogini periods. - - Returns a list of dicts with keys: yogini, planet, years, start_date, end_date. - """ - periods = [] - seq_len = len(YOGINI_SEQUENCE) - current_date = birth_date - - for i in range(seq_len): - idx = (start_idx + i) % seq_len - yogini = YOGINI_SEQUENCE[idx] - end_date = current_date + timedelta(days=yogini["years"] * 365.25) - periods.append({ - "yogini": yogini["name"], - "planet": yogini["planet"], - "years": yogini["years"], - "start_date": current_date.isoformat(), - "end_date": end_date.isoformat(), - }) - current_date = end_date - - return periods - def calculate_yogini_dasha(birth_info: dict) -> dict: - """ - Calculate Yogini Dasha for a native. + """Yogini from the Moon's nakshatra, with the remaining birth balance. - Args: - birth_info: dict containing: - - "lagna_rashi_index": int (0 = Aries, ..., 11 = Pisces) - - "birth_datetime": datetime object or ISO-format string (optional) - - "moon_nakshatra_index": 0-based nakshatra index (optional) - - Returns: - dict with keys: - - "major": list of major period dicts - - "current": dict — the currently running major period - - "total_cycle": int — 36 - - "starting_yogini": str — first Yogini name - - "starting_planet": str — planet of first Yogini + birth_info: + birth_datetime: datetime or ISO string + moon_longitude: sidereal degrees, required + as_of / today: observation instant; report callers should pass it + dasha_year_days: fixed-day year, default 365.25 """ - lagna_idx = birth_info.get("lagna_rashi_index", 0) + return _calculate_moon_balanced_yogini(birth_info) + + +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") if isinstance(birth_dt, str): birth_dt = datetime.fromisoformat(birth_dt) - if birth_dt is None: - birth_dt = datetime.now() - - moon_nakshatra_index = birth_info.get("moon_nakshatra_index") - if moon_nakshatra_index is not None: - moon_nakshatra_index = int(moon_nakshatra_index) % 27 - start_idx = (moon_nakshatra_index + 3) % 8 - seed_method = 'moon_nakshatra_plus_3_mod_8' - else: - start_idx = _get_start_yogini_index(lagna_idx) - seed_method = 'legacy_lagna_rashi_compatibility' + if birth_dt is None or birth_info.get('moon_longitude') is None: + return { + 'status': 'blocked', + 'reason': 'birth_datetime_and_moon_longitude_required', + 'major': [], + 'current': None, + } + moon = float(birth_info['moon_longitude']) % 360 + nak_length = 360 / 27 + nak = int(moon / nak_length) + fraction = (moon % nak_length) / nak_length + start_idx = (nak + 3) % 8 + seed_method = 'moon_nakshatra_plus_3_mod_8_with_angular_balance' starting_yogini = YOGINI_SEQUENCE[start_idx]["name"] starting_planet = YOGINI_SEQUENCE[start_idx]["planet"] - major_periods = _build_major_periods(start_idx, birth_dt) - - # Determine current period. Yogini repeats every 36 years; older natives - # should still return a current period instead of None after the first cycle. - now = datetime.now() + year_days = float(birth_info.get('dasha_year_days', 365.25)) + if year_days <= 0: + raise ValueError('dasha_year_days must be positive') + now = birth_info.get('as_of') or birth_info.get('today') or datetime.now() + if isinstance(now, str): + now = datetime.fromisoformat(now) + first_years = YOGINI_SEQUENCE[start_idx]['years'] + cursor = birth_dt - timedelta(days=fraction * first_years * year_days) + major_periods = [] + cycles = max(3, int(max(0, (now - birth_dt).total_seconds()) / (86400 * year_days * 36)) + 2) current_period = None - age_years = max((now - birth_dt).days / 365.25, 0) - current_in_cycle = age_years % TOTAL_CYCLE - 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 - break - cumulative += years + for i in range(8 * cycles): + item = YOGINI_SEQUENCE[(start_idx + i) % 8] + end = cursor + timedelta(days=item['years'] * year_days) + visible_start = max(cursor, birth_dt) + row = { + 'yogini': item['name'], + 'planet': item['planet'], + 'full_years': item['years'], + 'years': (end - visible_start).total_seconds() / (86400 * year_days), + 'start_date': visible_start.isoformat(timespec='seconds'), + 'end_date': end.isoformat(timespec='seconds'), + 'unclipped_start_date': cursor.isoformat(timespec='seconds'), + 'cycle_number': i // 8 + 1, + } + major_periods.append(row) + if visible_start <= now < end: + current_period = dict(row) + cursor = end return { "major": major_periods, @@ -165,56 +124,8 @@ def calculate_yogini_dasha(birth_info: dict) -> dict: "starting_yogini": starting_yogini, "starting_planet": starting_planet, "seed_method": seed_method, + "reference_date": now.isoformat(timespec='seconds'), + "dasha_year_days": year_days, + "birth_balance_years": first_years * (1 - fraction), + "source": "BPHS Yogini; Goel Table I; angular balance and fixed-day year", } - - -# --------------------------------------------------------------------------- -# Self-test -# --------------------------------------------------------------------------- - -if __name__ == "__main__": - # Example 1: Ascendant in Aries (odd sign) → starts from Maya - test_birth_1 = { - "lagna_rashi_index": 0, # Aries - "birth_datetime": datetime(1990, 6, 15, 10, 30), - } - result_1 = calculate_yogini_dasha(test_birth_1) - print("=" * 60) - print("Test 1: Ascendant in Aries (odd sign)") - print("=" * 60) - print(f"Starting Yogini : {result_1['starting_yogini']} ({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['yogini']:10s} | {p['planet']:8s} | {p['years']} years | {p['start_date'][:10]} → {p['end_date'][:10]}") - print(f"Current Period : {result_1['current']}") - print() - - # Example 2: Ascendant in Taurus (even sign) → starts from Bhadrika - test_birth_2 = { - "lagna_rashi_index": 1, # Taurus - "birth_datetime": datetime(1985, 3, 20, 8, 0), - } - result_2 = calculate_yogini_dasha(test_birth_2) - print("=" * 60) - print("Test 2: Ascendant in Taurus (even sign)") - print("=" * 60) - print(f"Starting Yogini : {result_2['starting_yogini']} ({result_2['starting_planet']})") - print(f"Total Cycle : {result_2['total_cycle']} years") - for p in result_2["major"]: - print(f" {p['yogini']:10s} | {p['planet']:8s} | {p['years']} years | {p['start_date'][:10]} → {p['end_date'][:10]}") - print(f"Current Period : {result_2['current']}") - print() - - # Example 3: Ascendant in Scorpio (even sign) - test_birth_3 = { - "lagna_rashi_index": 7, # Scorpio - "birth_datetime": datetime(2000, 1, 1, 0, 0), - } - result_3 = calculate_yogini_dasha(test_birth_3) - print("=" * 60) - print("Test 3: Ascendant in Scorpio (even sign)") - print("=" * 60) - print(f"Starting Yogini : {result_3['starting_yogini']} ({result_3['starting_planet']})") - for p in result_3["major"]: - print(f" {p['yogini']:10s} | {p['planet']:8s} | {p['years']} years | {p['start_date'][:10]} → {p['end_date'][:10]}") diff --git a/tests/test_yogini_seed_profile.py b/tests/test_yogini_seed_profile.py index 6736f756..52a7e85d 100644 --- a/tests/test_yogini_seed_profile.py +++ b/tests/test_yogini_seed_profile.py @@ -1,23 +1,59 @@ -from datetime import datetime +from datetime import datetime, timedelta -from scripts.yogini_dasha import calculate_yogini_dasha +import pytest + +from scripts.yogini_dasha import YOGINI_SEQUENCE, calculate_yogini_dasha -def test_yogini_uses_moon_nakshatra_plus_three_seed_when_available() -> None: +def test_yogini_classical_sequence_and_balance(): + # Goel Classical chapter Table I: lord and years, not the older Maya/Jaya/Rati list. + assert [(row['name'], row['planet'], row['years']) for row in YOGINI_SEQUENCE] == [ + ('Mangala', 'Moon', 1), + ('Pingala', 'Sun', 2), + ('Dhanya', 'Jupiter', 3), + ('Bhramari', 'Mars', 4), + ('Bhadrika', 'Mercury', 5), + ('Ulka', 'Saturn', 6), + ('Siddha', 'Venus', 7), + ('Sankata', 'Rahu', 8), + ] + birth = datetime(2000, 1, 1, 12) + # Mid-Pushya (eighth nakshatra): remainder 3 is Dhanya, half of 3 years left. + result = calculate_yogini_dasha({ + 'birth_datetime': birth, + 'moon_longitude': 100.0, + 'as_of': '2000-01-02', + 'dasha_year_days': 360, + }) + assert result['starting_planet'] == 'Jupiter' + assert result['starting_yogini'] == 'Dhanya' + assert result['birth_balance_years'] == pytest.approx(1.5) + assert datetime.fromisoformat(result['major'][0]['end_date']) == birth + timedelta(days=540) + assert result['major'][0]['years'] == pytest.approx(1.5) + assert result['current']['planet'] == 'Jupiter' + assert result['major'][1]['planet'] == 'Mars' + assert all(a['end_date'] == b['start_date'] for a, b in zip(result['major'], result['major'][1:])) + + +def test_yogini_no_invented_lagna_fallback_or_missing_balance(): + # 原值: lagna-only input started at Maya via legacy_lagna_rashi_compatibility + # 新值: status blocked,major 为空 + # 原因: BUG-1262,没有月亮黄经就不能编一段从出生起算的满周期 result = calculate_yogini_dasha({ 'lagna_rashi_index': 0, 'moon_nakshatra_index': 0, - 'birth_datetime': datetime(1990, 1, 1), + 'birth_datetime': datetime(2000, 1, 1), }) - assert result['starting_yogini'] == 'Rati' - assert result['starting_planet'] == 'Mercury' - assert result['seed_method'] == 'moon_nakshatra_plus_3_mod_8' + assert result['status'] == 'blocked' + assert result['major'] == [] -def test_yogini_keeps_legacy_seed_only_without_moon_nakshatra() -> None: - result = calculate_yogini_dasha({ - 'lagna_rashi_index': 0, - 'birth_datetime': datetime(1990, 1, 1), - }) - assert result['starting_yogini'] == 'Maya' - assert result['seed_method'] == 'legacy_lagna_rashi_compatibility' +def test_yogini_explicit_reference_controls_current_cycle(): + args = {'birth_datetime': '2000-01-01', 'moon_longitude': 0.0, 'dasha_year_days': 360} + first = calculate_yogini_dasha(dict(args, as_of='2000-01-02')) + later = calculate_yogini_dasha(dict(args, as_of='2040-01-01')) + assert first['current']['cycle_number'] == 1 + assert later['current']['cycle_number'] == 2 + # 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)