fix(yogini): use Goel Table I order and birth balance (BUG-1262, upstream 1918ffaf)
This commit is contained in:
@@ -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
|
||||
- 复发自:无
|
||||
- 修复版本:本分支待发布
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+76
-165
@@ -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]}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user