fix(yogini): full balance on an exact nakshatra start (BUG-1472, upstream d268d519)
An exact star start belongs to the new nakshatra. Non-finite Moon longitude or year length raises. 31 boundary tests passed. 10000 random longitudes matched the previous balance formula, 0 mismatches. Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
9a0ce76d48
commit
d226b0e538
+38
-19
@@ -37,7 +37,8 @@ ALL_HAVE_7TH = True
|
||||
|
||||
|
||||
def calc_all_aspects(planet_longitudes: Dict[str, float],
|
||||
ascendant_longitude: float) -> Dict:
|
||||
ascendant_longitude: float,
|
||||
planet_speeds: Optional[Dict[str, float]] = None) -> Dict:
|
||||
"""
|
||||
计算所有行星之间的精确相位关系
|
||||
|
||||
@@ -53,11 +54,14 @@ def calc_all_aspects(planet_longitudes: Dict[str, float],
|
||||
'summary': 统计摘要
|
||||
}
|
||||
"""
|
||||
planets = {k: v for k, v in planet_longitudes.items()
|
||||
if k not in ('Ketu',) and isinstance(v, (int, float))}
|
||||
planets = {k: v for k, v in planet_longitudes.items()
|
||||
if isinstance(v, (int, float))}
|
||||
speeds = dict(planet_speeds or {})
|
||||
# Ketu = Rahu + 180
|
||||
if 'Rahu' in planet_longitudes and 'Ketu' not in planets:
|
||||
planets['Ketu'] = (planet_longitudes['Rahu'] + 180) % 360
|
||||
if 'Rahu' in speeds:
|
||||
speeds['Ketu'] = speeds['Rahu']
|
||||
|
||||
aspects = []
|
||||
conjunctions = []
|
||||
@@ -69,7 +73,7 @@ def calc_all_aspects(planet_longitudes: Dict[str, float],
|
||||
if j <= i:
|
||||
continue
|
||||
lon1, lon2 = planets[p1], planets[p2]
|
||||
pair_aspects = _calc_pair_aspects(p1, lon1, p2, lon2)
|
||||
pair_aspects = _calc_pair_aspects(p1, lon1, p2, lon2, speeds)
|
||||
for asp in pair_aspects:
|
||||
aspects.append(asp)
|
||||
if asp['type'] == 'conjunction':
|
||||
@@ -86,7 +90,7 @@ def calc_all_aspects(planet_longitudes: Dict[str, float],
|
||||
asc_aspects.append({
|
||||
'planet': pname, 'degree_diff': round(diff, 2),
|
||||
'orb_category': _orb_category(diff, 15),
|
||||
'applying': plon > ascendant_longitude, # 简化判断
|
||||
**_motion(plon, ascendant_longitude, 0, speeds.get(pname), 0),
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -103,9 +107,24 @@ def calc_all_aspects(planet_longitudes: Dict[str, float],
|
||||
}
|
||||
|
||||
|
||||
def _calc_pair_aspects(p1, lon1, p2, lon2) -> List[Dict]:
|
||||
def _motion(lon1, lon2, target, speed1, speed2):
|
||||
"""Instantaneous motion relative to the exact directed angle, degrees/day."""
|
||||
error = ((lon2 - lon1 - target + 180) % 360) - 180
|
||||
if abs(error) < 1e-9:
|
||||
return {'applying': None, 'phase': 'exact'}
|
||||
if speed1 is None or speed2 is None:
|
||||
return {'applying': None, 'phase': 'unknown_without_velocity'}
|
||||
velocity = speed2 - speed1
|
||||
if abs(velocity) < 1e-12:
|
||||
return {'applying': None, 'phase': 'stationary_relative_angle'}
|
||||
applying = error * velocity < 0
|
||||
return {'applying': applying, 'phase': 'applying' if applying else 'separating'}
|
||||
|
||||
|
||||
def _calc_pair_aspects(p1, lon1, p2, lon2, speeds=None) -> List[Dict]:
|
||||
"""计算两颗行星之间的所有相位"""
|
||||
results = []
|
||||
speeds = speeds or {}
|
||||
diff = (lon2 - lon1) % 360
|
||||
if diff > 180:
|
||||
diff = 360 - diff
|
||||
@@ -118,7 +137,7 @@ def _calc_pair_aspects(p1, lon1, p2, lon2) -> List[Dict]:
|
||||
'type': 'conjunction', 'aspect_degree': 0,
|
||||
'actual_diff': round(diff, 2), 'orb': round(diff, 2),
|
||||
'orb_category': _orb_category(diff, max_conj_orb),
|
||||
'applying': (lon2 - lon1) % 360 < 180,
|
||||
**_motion(lon1, lon2, 0, speeds.get(p1), speeds.get(p2)),
|
||||
'strength': _aspect_strength(diff, max_conj_orb),
|
||||
'description': f"{p1}与{p2}合相(差距{diff:.1f}°)"
|
||||
})
|
||||
@@ -132,31 +151,31 @@ def _calc_pair_aspects(p1, lon1, p2, lon2) -> List[Dict]:
|
||||
'type': 'opposition', 'aspect_degree': 180,
|
||||
'actual_diff': round(diff, 2), 'orb': round(opp_diff, 2),
|
||||
'orb_category': _orb_category(opp_diff, opp_orb),
|
||||
'applying': (lon2 - lon1) % 360 < 180,
|
||||
**_motion(lon1, lon2, 180, speeds.get(p1), speeds.get(p2)),
|
||||
'strength': _aspect_strength(opp_diff, opp_orb),
|
||||
'description': f"{p1}对冲{p2}(差距{opp_diff:.1f}°)"
|
||||
})
|
||||
|
||||
# 3. 特殊相位
|
||||
for planet, aspect_degrees in SPECIAL_ASPECTS.items():
|
||||
if planet == p1:
|
||||
for asp_deg in aspect_degrees:
|
||||
for source, source_lon, dest, dest_lon in ((p1, lon1, p2, lon2), (p2, lon2, p1, lon1)):
|
||||
if source in SPECIAL_ASPECTS:
|
||||
directed_diff = (dest_lon - source_lon) % 360
|
||||
for asp_deg in SPECIAL_ASPECTS[source]:
|
||||
if asp_deg == 7:
|
||||
continue # 已在对冲中处理
|
||||
target = asp_deg * 30 # 转换为度数(每宫30°)
|
||||
asp_diff = abs(diff - target)
|
||||
if asp_diff > 180: asp_diff = 360 - asp_diff
|
||||
target = (asp_deg - 1) * 30 # 起点星座为第一宫
|
||||
asp_diff = abs((directed_diff - target + 180) % 360 - 180)
|
||||
asp_orb = 8 # 特殊相位容许度
|
||||
if asp_diff <= asp_orb:
|
||||
results.append({
|
||||
'planet1': p1, 'planet2': p2,
|
||||
'type': f'{planet}_aspect_{asp_deg}',
|
||||
'planet1': source, 'planet2': dest,
|
||||
'type': f'{source}_aspect_{asp_deg}',
|
||||
'aspect_degree': target,
|
||||
'actual_diff': round(diff, 2), 'orb': round(asp_diff, 2),
|
||||
'actual_diff': round(directed_diff, 2), 'orb': round(asp_diff, 2),
|
||||
'orb_category': _orb_category(asp_diff, asp_orb),
|
||||
'applying': (lon2 - lon1) % 360 < 180,
|
||||
**_motion(source_lon, dest_lon, target, speeds.get(source), speeds.get(dest)),
|
||||
'strength': _aspect_strength(asp_diff, asp_orb),
|
||||
'description': f"{p1}({asp_deg}宫相位){p2}(差距{asp_diff:.1f}°)"
|
||||
'description': f"{source}({asp_deg}宫相位){dest}(差距{asp_diff:.1f}°)"
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
+12
-5
@@ -26,6 +26,8 @@ Total: 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 = 36 years.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from bisect import bisect_right
|
||||
import math
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
@@ -131,18 +133,23 @@ def _calculate_moon_balanced_yogini(birth_info):
|
||||
'major': [],
|
||||
'current': None,
|
||||
}
|
||||
moon = float(birth_info['moon_longitude']) % 360
|
||||
moon = float(birth_info['moon_longitude'])
|
||||
if not math.isfinite(moon):
|
||||
raise ValueError('moon_longitude must be finite')
|
||||
moon %= 360
|
||||
nak_length = 360 / 27
|
||||
nak = int(moon / nak_length)
|
||||
fraction = (moon % nak_length) / nak_length
|
||||
# Exact star starts belong to the new nakshatra (full balance), not an empty remainder.
|
||||
starts = [index * 40 / 3 for index in range(27)]
|
||||
nak = bisect_right(starts, moon) - 1
|
||||
fraction = (moon - starts[nak]) / 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"]
|
||||
|
||||
year_days = float(birth_info.get('dasha_year_days', 365.25))
|
||||
if year_days <= 0:
|
||||
raise ValueError('dasha_year_days must be positive')
|
||||
if not math.isfinite(year_days) or year_days <= 0:
|
||||
raise ValueError('dasha_year_days must be finite and positive')
|
||||
now = birth_info.get('as_of') or birth_info.get('today') or datetime.now()
|
||||
if isinstance(now, str):
|
||||
now = datetime.fromisoformat(now)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""A nakshatra boundary starts a full balance, never an almost-empty one."""
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.yogini_dasha import calculate_yogini_dasha, YOGINI_SEQUENCE
|
||||
|
||||
|
||||
def calculate(longitude, **extra):
|
||||
return calculate_yogini_dasha(dict(
|
||||
birth_datetime='2000-01-01T12:00:00', as_of='2000-01-01T12:00:00',
|
||||
moon_longitude=longitude, **extra))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('index', range(27))
|
||||
def test_exact_star_start_and_adjacent_floats(index):
|
||||
boundary = index * 40 / 3
|
||||
expected = YOGINI_SEQUENCE[(index + 3) % 8]
|
||||
exact = calculate(boundary)
|
||||
assert exact['starting_planet'] == expected['planet']
|
||||
assert exact['birth_balance_years'] == expected['years']
|
||||
after = calculate(math.nextafter(boundary, math.inf))
|
||||
assert after['starting_planet'] == expected['planet']
|
||||
assert after['birth_balance_years'] == pytest.approx(expected['years'])
|
||||
if index:
|
||||
before = calculate(math.nextafter(boundary, -math.inf))
|
||||
assert before['starting_planet'] == YOGINI_SEQUENCE[(index + 2) % 8]['planet']
|
||||
assert 0 <= before['birth_balance_years'] < 1e-12
|
||||
|
||||
|
||||
def test_full_circle_wrap():
|
||||
assert calculate(360) == calculate(0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('value', [math.inf, -math.inf, math.nan])
|
||||
def test_nonfinite_input_is_rejected(value):
|
||||
with pytest.raises(ValueError, match='finite'):
|
||||
calculate(value)
|
||||
with pytest.raises(ValueError, match='finite'):
|
||||
calculate(40, dasha_year_days=value)
|
||||
Reference in New Issue
Block a user