diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index cc627d5f..df2e6192 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -36608,3 +36608,18 @@ - 相关记录:无。 - 复发自:无。 - 修复版本:分支 `codex/upstream-sync8-20261017`。上游提交 `ce9fed15`。 + +## BUG-1388 | Gulika 夜段把日出加一天当成次日日出 + +- 状态:resolved +- 首次发现 / 最近更新:2026-10-17 / 2026-10-18 +- 来源:issue #80,任务书 T10。上游 `9f9f16b3` 只取 `gulika.py`,`872eaecf` 只取 `saham_daynight.py`。检索「next_sunrise」「determine_daytime」,无既有编号。 +- 现象:夜晚 Gulika 的结束时刻是本次日出的儒略日再加 1。高纬度冬季这个值和真实次日日出不一致,夜段八分点跟着偏。 +- 触发条件:`calculate_gulika` 与 `_temporal_upagraha` 在 `is_daytime` 为假时。Saham 昼夜原先也没有真实相邻日窗口。 +- 根因:夜段终点写成 `sunrise_jd_ut + 1`。日窗口用视日出并向前减一天,不是 Hindu rising 的相邻事件。 +- 修复:`determine_daytime` 增加 `rising`。默认 `apparent` 保持校正和 Panchanga 的旧窗口。Saham 昼夜、Gulika、后续 Shayanadi 传 `hindu`:日面中心、不计折射,夜段终点用真实 `next_sunrise_jd_ut`。 +- 验证:`tests/test_saham_daynight.py` 默认调用与改前算法逐字段相同,并覆盖四季四时的 Hindu 窗口。`tests/test_gulika_hindu_sunrise.py` 用北纬 64.1°、2026-12-21 02:00 核对夜段终点等于独立算出的次日 Hindu 日出。`tests/test_gulika.py` 与 `tests/test_gulika_ayanamsa_mode.py` 通过。 +- 防复发:校正调用不传 `rising`,测试锁定默认返回不含 `next_sunrise_jd_ut`。 +- 相关记录:无。 +- 复发自:无。 +- 修复版本:分支 `codex/upstream-sync8-20261017`。上游提交 `9f9f16b3`、`872eaecf`。 diff --git a/scripts/gulika.py b/scripts/gulika.py index 2b389c37..bd3634b5 100644 --- a/scripts/gulika.py +++ b/scripts/gulika.py @@ -89,13 +89,11 @@ def calculate_gulika( ) -> dict[str, Any]: """Return Gulika from local moment/location using Swiss sunrise and sunset.""" ayanamsa_name = normalize_ayanamsa_name(ayanamsa) - daynight = determine_daytime(moment, lat=lat, lon=lon, tz=tz) + daynight = determine_daytime(moment, lat=lat, lon=lon, tz=tz, rising="hindu") is_day = bool(daynight["is_daytime"]) period = "day" if is_day else "night" start_jd = daynight["sunrise_jd_ut"] if is_day else daynight["sunset_jd_ut"] - end_jd = daynight["sunset_jd_ut"] if is_day else daynight["sunrise_jd_ut"] + 1.0 - if end_jd <= start_jd: - end_jd += 1.0 + end_jd = daynight["sunset_jd_ut"] if is_day else daynight["next_sunrise_jd_ut"] start_year, start_month, start_day, _ = swe.revjul(start_jd + float(tz) / 24.0) period_weekday = datetime(int(start_year), int(start_month), int(start_day)).weekday() if method == "saturn_part_start": @@ -143,13 +141,11 @@ def _temporal_upagraha( ayanamsa: str = DEFAULT_AYANAMSA_NAME, ) -> dict[str, Any]: ayanamsa_name = normalize_ayanamsa_name(ayanamsa) - daynight = determine_daytime(moment, lat=lat, lon=lon, tz=tz) + daynight = determine_daytime(moment, lat=lat, lon=lon, tz=tz, rising="hindu") is_day = bool(daynight["is_daytime"]) period = "day" if is_day else "night" start_jd = daynight["sunrise_jd_ut"] if is_day else daynight["sunset_jd_ut"] - end_jd = daynight["sunset_jd_ut"] if is_day else daynight["sunrise_jd_ut"] + 1.0 - if end_jd <= start_jd: - end_jd += 1.0 + end_jd = daynight["sunset_jd_ut"] if is_day else daynight["next_sunrise_jd_ut"] start_year, start_month, start_day, _ = swe.revjul(start_jd + float(tz) / 24.0) weekday = datetime(int(start_year), int(start_month), int(start_day)).weekday() ruler_table = DAY_RULERS if is_day else NIGHT_RULERS diff --git a/scripts/saham_daynight.py b/scripts/saham_daynight.py index 38ebea39..a7c66821 100644 --- a/scripts/saham_daynight.py +++ b/scripts/saham_daynight.py @@ -12,23 +12,71 @@ class SahamDayNightError(ValueError): pass -def determine_daytime(moment: datetime, *, lat: float, lon: float, tz: float) -> dict[str, Any]: +def determine_daytime( + moment: datetime, + *, + lat: float, + lon: float, + tz: float, + rising: str = "apparent", +) -> dict[str, Any]: + """Day/night window. + + ``rising="apparent"`` is the historical disk-edge window. Rectification and + Panchanga rely on that default and must not pick up Hindu rising. + ``rising="hindu"`` is disk-center, no refraction, with the real next + sunrise. Saham day/night, Gulika, and Shayanadi pass it explicitly. + """ + if rising not in ("apparent", "hindu"): + raise SahamDayNightError("unsupported sunrise convention") if not -90 <= float(lat) <= 90 or not -180 <= float(lon) <= 180: raise SahamDayNightError("invalid WGS84 latitude/longitude") local = moment.replace(tzinfo=None) jd = swe.julday(local.year, local.month, local.day, local.hour + local.minute / 60 + local.second / 3600 - float(tz)) geopos = (float(lon), float(lat), 0.0) - rise_status, rise = swe.rise_trans(jd - 1.0, swe.SUN, swe.CALC_RISE, geopos) - set_status, sunset = swe.rise_trans(jd - 1.0, swe.SUN, swe.CALC_SET, geopos) - if rise_status != 0 or set_status != 0: - raise SahamDayNightError("sunrise_or_sunset_unavailable_for_location_date") - sunrise_jd, sunset_jd = rise[0], sunset[0] - # Normalize the next daily events around the queried instant. - while sunrise_jd > jd: - sunrise_jd -= 1.0 - while sunset_jd > jd: - sunset_jd -= 1.0 - is_day = sunrise_jd <= jd < sunset_jd if sunrise_jd < sunset_jd else not (sunset_jd <= jd < sunrise_jd) + if rising == "apparent": + rise_status, rise = swe.rise_trans(jd - 1.0, swe.SUN, swe.CALC_RISE, geopos) + set_status, sunset = swe.rise_trans(jd - 1.0, swe.SUN, swe.CALC_SET, geopos) + if rise_status != 0 or set_status != 0: + raise SahamDayNightError("sunrise_or_sunset_unavailable_for_location_date") + sunrise_jd, sunset_jd = rise[0], sunset[0] + # Normalize the next daily events around the queried instant. + while sunrise_jd > jd: + sunrise_jd -= 1.0 + while sunset_jd > jd: + sunset_jd -= 1.0 + is_day = sunrise_jd <= jd < sunset_jd if sunrise_jd < sunset_jd else not (sunset_jd <= jd < sunrise_jd) + return { + "scope": "saham_daynight_swiss", + "status": "computed", + "is_daytime": is_day, + "julian_day_ut": jd, + "sunrise_jd_ut": sunrise_jd, + "sunset_jd_ut": sunset_jd, + "method": "swisseph.rise_trans", + "boundary": "Formula-specific +30 degree exceptions must be applied by the Saham rule layer, not inferred from house placement.", + } + + flags = swe.FLG_SWIEPH | swe.FLG_TRUEPOS + + def event(after, kind): + status, times = swe.rise_trans(after, swe.SUN, kind | swe.BIT_HINDU_RISING, geopos, flags=flags) + if status != 0: + raise SahamDayNightError("sunrise_or_sunset_unavailable_for_location_date") + return times[0] + + # Consecutive rise/set events. Adding one Julian day to a sunrise is not + # the next sunrise, and it moves the night-segment endpoint. + midnight_jd = swe.julday(local.year, local.month, local.day, -float(tz)) + sunrise_jd = event(midnight_jd, swe.CALC_RISE) + if sunrise_jd > jd: + midnight_jd -= 1.0 + sunrise_jd = event(midnight_jd, swe.CALC_RISE) + next_sunrise_jd = event(midnight_jd + 1.0, swe.CALC_RISE) + sunset_jd = event(midnight_jd, swe.CALC_SET) + if not sunrise_jd <= jd < next_sunrise_jd or not sunrise_jd < sunset_jd < next_sunrise_jd: + raise SahamDayNightError("solar_day_window_unavailable") + is_day = sunrise_jd <= jd < sunset_jd return { "scope": "saham_daynight_swiss", "status": "computed", @@ -36,6 +84,7 @@ def determine_daytime(moment: datetime, *, lat: float, lon: float, tz: float) -> "julian_day_ut": jd, "sunrise_jd_ut": sunrise_jd, "sunset_jd_ut": sunset_jd, - "method": "swisseph.rise_trans", + "next_sunrise_jd_ut": next_sunrise_jd, + "method": "swisseph.rise_trans_hindu_rising", "boundary": "Formula-specific +30 degree exceptions must be applied by the Saham rule layer, not inferred from house placement.", } diff --git a/scripts/tajika.py b/scripts/tajika.py index f99ef5f9..aaad8a6c 100644 --- a/scripts/tajika.py +++ b/scripts/tajika.py @@ -1614,7 +1614,7 @@ def calc_all_sahams(planet_lons: Dict[str, float], 'boundary': 'No solar-house day/night proxy is permitted in production.', } from saham_daynight import determine_daytime - daynight = determine_daytime(birth_dt, lat=float(lat), lon=float(lon), tz=float(tz)) + daynight = determine_daytime(birth_dt, lat=float(lat), lon=float(lon), tz=float(tz), rising="hindu") sun_lon = planet_lons.get('Sun', 0) moon_lon = planet_lons.get('Moon', 0) mars_lon = planet_lons.get('Mars', 0) diff --git a/tests/test_gulika_hindu_sunrise.py b/tests/test_gulika_hindu_sunrise.py new file mode 100644 index 00000000..9bd153ef --- /dev/null +++ b/tests/test_gulika_hindu_sunrise.py @@ -0,0 +1,28 @@ +from datetime import datetime + +import pytest +import swisseph as swe + +from scripts.gulika import calculate_gulika + + +def test_high_latitude_winter_night_ends_at_the_real_next_sunrise(): + moment = datetime(2026, 12, 21, 2, 0) + lat, lon, tz = 64.1, 21.9, 1.0 + result = calculate_gulika(moment, lat=lat, lon=lon, tz=tz) + evidence = result["daynight_evidence"] + assert result["period"] == "night" + flags = swe.FLG_SWIEPH | swe.FLG_TRUEPOS + rise = evidence["sunrise_jd_ut"] + year, month, day, _ = swe.revjul(rise + tz / 24) + midnight = swe.julday(year, month, day, -tz) + status, times = swe.rise_trans( + midnight + 1.0, + swe.SUN, + swe.CALC_RISE | swe.BIT_HINDU_RISING, + (lon, lat, 0.0), + flags=flags, + ) + assert status == 0 + assert evidence["next_sunrise_jd_ut"] == pytest.approx(times[0], abs=1e-8, rel=0) + assert evidence["method"] == "swisseph.rise_trans_hindu_rising" diff --git a/tests/test_saham_daynight.py b/tests/test_saham_daynight.py index 8ad32924..76e6a594 100644 --- a/tests/test_saham_daynight.py +++ b/tests/test_saham_daynight.py @@ -1,5 +1,8 @@ from datetime import datetime +import pytest +import swisseph as swe + from scripts.saham_daynight import determine_daytime @@ -11,3 +14,63 @@ def test_swiss_daynight_does_not_use_solar_house_proxy(): assert noon["is_daytime"] is True assert midnight["is_daytime"] is False assert noon["method"] == "swisseph.rise_trans" + + +def _legacy_apparent_daytime(moment, *, lat, lon, tz): + """The pre-T10 window. Rectification and Panchanga still call this default.""" + local = moment.replace(tzinfo=None) + jd = swe.julday(local.year, local.month, local.day, local.hour + local.minute / 60 + local.second / 3600 - float(tz)) + geopos = (float(lon), float(lat), 0.0) + rise_status, rise = swe.rise_trans(jd - 1.0, swe.SUN, swe.CALC_RISE, geopos) + set_status, sunset = swe.rise_trans(jd - 1.0, swe.SUN, swe.CALC_SET, geopos) + assert rise_status == 0 and set_status == 0 + sunrise_jd, sunset_jd = rise[0], sunset[0] + while sunrise_jd > jd: + sunrise_jd -= 1.0 + while sunset_jd > jd: + sunset_jd -= 1.0 + is_day = sunrise_jd <= jd < sunset_jd if sunrise_jd < sunset_jd else not (sunset_jd <= jd < sunrise_jd) + return { + "scope": "saham_daynight_swiss", + "status": "computed", + "is_daytime": is_day, + "julian_day_ut": jd, + "sunrise_jd_ut": sunrise_jd, + "sunset_jd_ut": sunset_jd, + "method": "swisseph.rise_trans", + "boundary": "Formula-specific +30 degree exceptions must be applied by the Saham rule layer, not inferred from house placement.", + } + + +def test_default_daytime_matches_rectification_apparent_sunrise(): + # active_rectification_event_engine._sunrise_local_naive calls noon with no rising argument. + for date in ("2000-01-01", "2026-06-21", "2026-12-21"): + noon = datetime.fromisoformat(f"{date}T12:00:00") + current = determine_daytime(noon, lat=39.9042, lon=116.4074, tz=8) + previous = _legacy_apparent_daytime(noon, lat=39.9042, lon=116.4074, tz=8) + assert current == previous + assert "next_sunrise_jd_ut" not in current + + +@pytest.mark.parametrize("hour", [0, 4, 12, 23]) +@pytest.mark.parametrize("month,day", [(3, 20), (6, 21), (9, 22), (12, 21)]) +def test_hindu_consecutive_solar_events_are_recomputed_not_shifted(month, day, hour): + moment = datetime(2026, month, day, hour) + result = determine_daytime(moment, lat=39.9, lon=116.4, tz=8, rising="hindu") + rise = result["sunrise_jd_ut"] + flags = swe.FLG_SWIEPH | swe.FLG_TRUEPOS + year, month_num, day_num, _ = swe.revjul(rise + 8 / 24) + midnight = swe.julday(year, month_num, day_num, -8) + + def event(start, kind): + status, times = swe.rise_trans( + start, swe.SUN, kind | swe.BIT_HINDU_RISING, (116.4, 39.9, 0), flags=flags, + ) + assert status == 0 + return times[0] + + assert rise == pytest.approx(event(midnight, swe.CALC_RISE), abs=1e-8, rel=0) + assert result["sunset_jd_ut"] == pytest.approx(event(midnight, swe.CALC_SET), abs=1e-8, rel=0) + assert result["next_sunrise_jd_ut"] == pytest.approx(event(midnight + 1, swe.CALC_RISE), abs=1e-8, rel=0) + assert rise <= result["julian_day_ut"] < result["next_sunrise_jd_ut"] + assert result["method"] == "swisseph.rise_trans_hindu_rising"