From 08376cf0a84b7089da4c294b0eaf9d8a8d1bfdf7 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 2 Oct 2026 13:55:58 +0800 Subject: [PATCH] fix(shadbala): Ayana hemisphere after wrap, civil-weekday hora lord, continuous mean motion, short-arc Chesta midpoint (upstream 0afa2780) Ported from yinduzhanxing 0afa2780 (algorithm hunks and its four regression tests; the PyJHora / profile-metadata hunks do not apply to our file). The archived-PyJHora Kala test follows upstream's rewrite. The V.P. Jain benchmark expectations still need the three-column update (follow-up in this branch). TASK-upstream-sync4-20261002 T3. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE --- scripts/shadbala.py | 52 +++++++++++++------ tests/test_chesta_circular_midpoint.py | 31 +++++++++++ tests/test_shadbala_ayana_wrap.py | 13 +++++ tests/test_shadbala_hora_civil_weekday.py | 30 +++++++++++ tests/test_shadbala_mean_motion_continuity.py | 30 +++++++++++ tests/test_shadbala_precise_context.py | 9 +++- 6 files changed, 148 insertions(+), 17 deletions(-) create mode 100644 tests/test_chesta_circular_midpoint.py create mode 100644 tests/test_shadbala_ayana_wrap.py create mode 100644 tests/test_shadbala_hora_civil_weekday.py create mode 100644 tests/test_shadbala_mean_motion_continuity.py diff --git a/scripts/shadbala.py b/scripts/shadbala.py index 4d19cd1a..c71d88ab 100644 --- a/scripts/shadbala.py +++ b/scripts/shadbala.py @@ -24,6 +24,7 @@ import swisseph as swe import sys import os import json +from datetime import date from typing import Dict, Tuple # v6.1.10: 导入实际Varga计算器(支持脚本和包两种调用方式) @@ -351,7 +352,7 @@ def _lagrange_interpolate(xs: tuple[float, ...], ys: tuple[float, ...], value: f def _ayana_bala(pname: str, longitude: float, ayanamsa: float) -> tuple[float, float]: raw_tropical = longitude + ayanamsa tropical = raw_tropical % 360.0 - north = raw_tropical < 180.0 + north = tropical < 180.0 folded = tropical if 90.0 < tropical < 180.0: folded = 180.0 - tropical @@ -397,12 +398,14 @@ def _calendar_lords(context: Dict) -> Dict[str, str]: vaara_ahargana -= 1 vaara = _ABDA_WEEKDAY_PLANETS[int(vaara_ahargana) % 7] - civil_weekday = int(math.ceil(local_jd + 1.0) % 7) + # Civil weekday must not change at astronomical Julian noon. Sunday=0. + civil_weekday = (date(year, month, day).weekday() + 1) % 7 local_hour = context["local_hour"] if local_hour < context["sunrise_hour"]: civil_weekday = (civil_weekday - 1) % 7 local_hour += 24.0 - hora_index = (int(local_hour - context["sunrise_hour"]) + civil_weekday + 1) % 7 + weekday_lord = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")[civil_weekday] + hora_index = (int(local_hour - context["sunrise_hour"]) + _HORA_ORDER.index(weekday_lord)) % 7 return {"abda": abda, "masa": masa, "vaara": vaara, "hora": _HORA_ORDER[hora_index]} @@ -452,15 +455,38 @@ def calc_kala_bala_precise(pname: str, all_planets: Dict, context: Dict) -> Dict def _surya_mean_longitude(pname: str, context: Dict) -> float: - """Surya Siddhanta mean motion with desantara correction from Ujjain.""" - local_jd = context["jd_local"] - kali_days = int(local_jd - 588_465.0) - weekday = int(math.ceil(local_jd + 1.0) % 7) - kali_days += weekday - 5 - kali_days % 7 + """Surya Siddhanta mean motion at the Ujjain mean-time meridian. + + Civil clock time is not local mean solar time. Convert through UTC so + changing a time-zone representation cannot change a geocentric mean. + """ + if "jd_ut" in context: + jd_ut = float(context["jd_ut"]) + else: + # Legacy callers must explicitly identify their civil-clock offset. + jd_ut = float(context["jd_local"]) - float(context["timezone"]) / 24.0 + ujjain_jd = jd_ut + _UJJAIN_LONGITUDE / 360.0 + # Mean motion is continuous elapsed time. Re-aligning this count to a + # weekday introduced seven-day jumps; integer truncation lost sub-day + # motion. Preserve the existing epoch/rates rather than tune to an oracle. + kali_days = ujjain_jd - 588_465.0 daily_motion = round(_SURYA_REVOLUTIONS[pname] / _SURYA_CIVIL_DAYS * 360.0, 7) mean_longitude = (kali_days * daily_motion) % 360.0 - desantara = (_UJJAIN_LONGITUDE - context["lon"]) / 360.0 * daily_motion - return (mean_longitude + desantara) % 360.0 + return mean_longitude + + +def chesta_from_longitudes(mean: float, true: float, apogee: float) -> float: + """Bounded Chesta with a coordinate-invariant short-arc midpoint. + + Short-arc continuation across zero is geometric, not an additional rule + explicitly supplied by Raman's non-crossing published example. + """ + separation = (true - mean + 180.0) % 360.0 - 180.0 + if abs(separation) == 180.0: + raise ValueError("Antipodal mean/true longitudes have no unique short-arc midpoint") + midpoint = (mean + separation / 2.0) % 360.0 + angle = (apogee - midpoint + 180.0) % 360.0 - 180.0 + return round(abs(angle) / 3.0, 2) def calc_chesta_bala_precise(pname: str, all_planets: Dict, kala: Dict, context: Dict) -> float: @@ -479,11 +505,7 @@ def calc_chesta_bala_precise(pname: str, all_planets: Dict, kala: Dict, context: seeghrochcha, mean_longitude = planet_mean, sun_mean else: seeghrochcha, mean_longitude = sun_mean, planet_mean - average_longitude = (_planet_longitude(pname, all_planets) + mean_longitude) / 2.0 - chesta_kendra = abs(seeghrochcha - average_longitude) - if chesta_kendra > 180.0: - chesta_kendra = 360.0 - chesta_kendra - return round(max(0.0, chesta_kendra) / 3.0, 2) + return chesta_from_longitudes(mean_longitude, _planet_longitude(pname, all_planets), seeghrochcha) def calc_shadbala(planets: Dict, asc_sign: str, birth_hour: float, diff --git a/tests/test_chesta_circular_midpoint.py b/tests/test_chesta_circular_midpoint.py new file mode 100644 index 00000000..1523e1e9 --- /dev/null +++ b/tests/test_chesta_circular_midpoint.py @@ -0,0 +1,31 @@ +import pytest + +from scripts.shadbala import chesta_from_longitudes as circular_chesta + + +@pytest.mark.parametrize("rotation", range(0, 360, 15)) +def test_strength_does_not_depend_on_choice_of_zodiac_origin(rotation): + # Mean350/true10 must average to0, not180. Apogee180 gives full strength. + angles = [(angle + rotation) % 360 for angle in (350, 10, 180)] + assert circular_chesta(*angles) == 60 + + +def test_midpoint_is_continuous_across_zero_and_full_turns(): + assert circular_chesta(359.9999, 10, 180) == circular_chesta(.0001, 10, 180) + assert circular_chesta(-10, 370, 540) == circular_chesta(350, 10, 180) + + +def test_does_not_guess_an_antipodal_branch(): + with pytest.raises(ValueError, match="Antipodal"): + circular_chesta(0, 180, 90) + + +@pytest.mark.parametrize("mean,true,apogee,expected", [ + (266.34, 229.50, 181.23, 22.23), + (181.23, 181.52, 174.49, 2.29), + (66.91, 84.01, 181.23, 35.26), + (181.23, 171.16, 158.35, 5.95), + (111.23, 124.39, 181.23, 21.14), +]) +def test_published_non_crossing_inputs_keep_their_arithmetic(mean, true, apogee, expected): + assert circular_chesta(mean, true, apogee) == expected diff --git a/tests/test_shadbala_ayana_wrap.py b/tests/test_shadbala_ayana_wrap.py new file mode 100644 index 00000000..0c51fff0 --- /dev/null +++ b/tests/test_shadbala_ayana_wrap.py @@ -0,0 +1,13 @@ +import pytest +from scripts.shadbala import _ayana_bala + + +@pytest.mark.parametrize("planet", ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"]) +def test_ayana_uses_wrapped_tropical_hemisphere(planet): + assert _ayana_bala(planet, 350.0, 22.0) == _ayana_bala(planet, -10.0, 22.0) + + +def test_venus_just_after_tropical_aries_uses_north_addition(): + bala, declination = _ayana_bala("Venus", 350.0, 22.0) + assert declination > 0 + assert bala > 30 diff --git a/tests/test_shadbala_hora_civil_weekday.py b/tests/test_shadbala_hora_civil_weekday.py new file mode 100644 index 00000000..51e0b5a1 --- /dev/null +++ b/tests/test_shadbala_hora_civil_weekday.py @@ -0,0 +1,30 @@ +from datetime import date, timedelta +import swisseph as swe +import pytest +from scripts.shadbala import _calendar_lords + + +def context(day, hour): + return {"year": day.year, "month": day.month, "day": day.day, + "jd_local": swe.julday(day.year, day.month, day.day, hour), + "local_hour": hour, "sunrise_hour": 6.0} + + +@pytest.mark.parametrize("offset,lord", enumerate( + ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"))) +def test_first_hora_is_weekday_lord(offset, lord): + assert _calendar_lords(context(date(2026, 9, 27) + timedelta(days=offset), 6.1))["hora"] == lord + + +def test_no_artificial_weekday_switch_at_julian_noon(): + # Saturday: seventh hour is Moon, eighth Saturn, ninth Jupiter. + day = date(1993, 4, 17) + assert _calendar_lords(context(day, 12.0))["hora"] == "Moon" + assert _calendar_lords(context(day, 12.01))["hora"] == "Moon" + assert _calendar_lords(context(day, 13.01))["hora"] == "Saturn" + assert _calendar_lords(context(day, 14.01))["hora"] == "Jupiter" + + +def test_before_sunrise_uses_previous_weekday(): + # Sunday 05:30 remains Saturday's 24th hora (Mars). + assert _calendar_lords(context(date(2026, 9, 27), 5.5))["hora"] == "Mars" diff --git a/tests/test_shadbala_mean_motion_continuity.py b/tests/test_shadbala_mean_motion_continuity.py new file mode 100644 index 00000000..55bf0e28 --- /dev/null +++ b/tests/test_shadbala_mean_motion_continuity.py @@ -0,0 +1,30 @@ +import pytest + +from scripts.shadbala import _surya_mean_longitude, _SURYA_REVOLUTIONS, _SURYA_CIVIL_DAYS + + +@pytest.mark.parametrize("planet", list(_SURYA_REVOLUTIONS)) +@pytest.mark.parametrize("start", [2451544.49999, 2451544.99999, 2451545.1, 2451546.1, + 2451547.1, 2451548.1, 2451549.1, 2451550.1, 2451551.1]) +def test_mean_motion_has_no_noon_midnight_or_weekday_jumps(planet, start): + rate = round(_SURYA_REVOLUTIONS[planet] / _SURYA_CIVIL_DAYS * 360, 7) + for interval in (1 / 86400, 1.0, 7.0): + a = _surya_mean_longitude(planet, {"jd_ut": start}) + b = _surya_mean_longitude(planet, {"jd_ut": start + interval}) + delta = (b - a + 180) % 360 - 180 + assert delta == pytest.approx(rate * interval, abs=1e-8) + + +@pytest.mark.parametrize("planet", list(_SURYA_REVOLUTIONS)) +def test_same_instant_has_one_mean_independent_of_clock_and_location(planet): + instant = 2451545.0 + expected = _surya_mean_longitude(planet, {"jd_ut": instant}) + for timezone, longitude in [(0, 0), (5.5, 77.5), (8, 120), (-8, -122.4)]: + context = {"jd_local": instant + timezone / 24, + "timezone": timezone, "lon": longitude} + assert _surya_mean_longitude(planet, context) == pytest.approx(expected, abs=1e-8) + + +def test_ambiguous_civil_clock_without_offset_is_rejected(): + with pytest.raises(KeyError, match="timezone"): + _surya_mean_longitude("Mercury", {"jd_local": 2451545.0, "lon": 120}) diff --git a/tests/test_shadbala_precise_context.py b/tests/test_shadbala_precise_context.py index 5bd47c3d..a27584e8 100644 --- a/tests/test_shadbala_precise_context.py +++ b/tests/test_shadbala_precise_context.py @@ -59,13 +59,18 @@ def test_precise_sthana_bala_matches_pyjhora_same_chart() -> None: assert calc_sthana_bala_precise(planet, planets, houses[planet])["total"] == expected -def test_precise_kala_bala_matches_pyjhora_same_chart() -> None: +def test_precise_kala_preserves_archived_comparison_with_corrected_hora() -> None: + # 原值: 每颗星 Kala 总分 == 存档 PyJHora 值(test_precise_kala_bala_matches_pyjhora_same_chart) + # 新值: 月亮 +52.18、金星 −60.0,其余仍等于存档值(同上游 yinduzhanxing 0afa2780 的改法) + # 原因: TASK-upstream-sync4-20261002 T3:时主按民用星期查 _HORA_ORDER(周四此时为月亮时), + # Ayana 南北按取模后的回归黄经判断;存档 PyJHora 值只作对照,不作本地公式的标准答案 planets = _planets() context = build_shadbala_context(2435163.6354166665, 37.7749, -122.4194, "lahiri", -8.0) assert abs(context["sunrise_hour"] - 6.886306) < 0.00001 assert abs(context["sunset_hour"] - 17.890427) < 0.00001 for planet, expected in PYJHORA_KALA.items(): - assert calc_kala_bala_precise(planet, planets, context)["total"] == expected + delta = 52.18 if planet == "Moon" else -60.0 if planet == "Venus" else 0.0 + assert abs(calc_kala_bala_precise(planet, planets, context)["total"] - expected - delta) < 0.001 def test_precise_chesta_uses_classical_bounded_variant() -> None: