From d226b0e5388cc3606272956bc5b9c2072685210d Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Wed, 28 Oct 2026 09:45:04 +0800 Subject: [PATCH] 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) --- scripts/aspects.py | 57 ++++++++++++------- scripts/yogini_dasha.py | 17 ++++-- ..._upstream_sync9_yogini_exact_boundaries.py | 40 +++++++++++++ 3 files changed, 90 insertions(+), 24 deletions(-) create mode 100644 tests/test_upstream_sync9_yogini_exact_boundaries.py diff --git a/scripts/aspects.py b/scripts/aspects.py index 9f2fcfec..3fe99af8 100644 --- a/scripts/aspects.py +++ b/scripts/aspects.py @@ -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 diff --git a/scripts/yogini_dasha.py b/scripts/yogini_dasha.py index 8be0a416..c72639d0 100644 --- a/scripts/yogini_dasha.py +++ b/scripts/yogini_dasha.py @@ -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) diff --git a/tests/test_upstream_sync9_yogini_exact_boundaries.py b/tests/test_upstream_sync9_yogini_exact_boundaries.py new file mode 100644 index 00000000..3c3220c9 --- /dev/null +++ b/tests/test_upstream_sync9_yogini_exact_boundaries.py @@ -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)