fix(yogini): use Goel Table I order and birth balance (BUG-1262, upstream 1918ffaf)

This commit is contained in:
jesse-ux
2026-10-07 16:48:25 +08:00
parent ab9e30560a
commit 3adb2460e3
5 changed files with 156 additions and 180 deletions
+16
View File
@@ -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
- 复发自:无
- 修复版本:本分支待发布
+4 -1
View File
@@ -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)}
+10
View File
@@ -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
View File
@@ -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]}")
+50 -14
View File
@@ -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)