fix(gulika): end the night segment at the real next Hindu sunrise
Upstream 9f9f16b3 and 872eaecf. Saham, Gulika, and Shayanadi opt into Hindu rising. The default window used by rectification and Panchanga stays the apparent-disk one. BUG-1388. Co-Authored-By: Grok (agent loop) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Grok (agent loop)
Cursor
parent
2f8263b6ac
commit
0bd254cf18
@@ -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`。
|
||||
|
||||
+4
-8
@@ -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
|
||||
|
||||
+62
-13
@@ -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.",
|
||||
}
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user