fix(ondemand): beyond_next_return compares UTC with UTC (#175 acceptance, BUG-1818)
Independent Staging Quality Gate / validate-frontend (push) Successful in 5m19s
Independent Staging Quality Gate / validate-python (push) Successful in 6m44s
Independent Staging Quality Gate / publish (push) Successful in 3m6s

Round 2 read coverage.end.local, but _parse_moment cannot parse the
+hh:mm suffix, so that branch never ran and the comparison always fell
back to utc + profile.tz. Convert each Mudda end to UTC with the
solar-return instant's own offset (tajika_mudda.TIME_BASIS) and compare
it with coverage.end.utc. Adds tests for a UTC+8 birth and a New York
birth (±3 h around the next return, plus real rows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyn7Hj3LCW3BUs5bZ8
This commit is contained in:
2026-11-09 19:33:24 +08:00
co-authored by Claude Opus 5.5
parent d46df6a42a
commit c9f2f76f6a
2 changed files with 65 additions and 10 deletions
+12 -10
View File
@@ -250,15 +250,16 @@ def run(body: Dict[str, Any]) -> Dict[str, Any]:
elif abs(float(dasha_year['days']) - NOMINAL_YEAR_DAYS) > 1e-9:
gaps.append({'field': 'nominal_year', 'reason': 'dasha_year_days_not_365_25'})
# 原值:用 coverage.end 的 utc 去比(mudda 段是返照地点的本地时刻,两边差一个时区偏移)
# 新值:两边都用本地时刻(coverage.end.local 与 raw_periods 同一地点、同一时区)
# 原因:验收退回(BUG-1818):beyond_next_return 的时区基准必须一致
coverage_end = _parse_moment((coverage_interval.get('end') or {}).get('local'))
if coverage_end is None:
# 没有 local 时才退回 utc + 返照地点偏移,仍在同一基准上比较。
coverage_end_utc = _parse_moment((coverage_interval.get('end') or {}).get('utc'))
if coverage_end_utc is not None:
coverage_end = coverage_end_utc + timedelta(hours=float(profile['tz']))
# 原值:mudda 段终点(返照地点本地时刻)直接与 coverage.end.utc 比,差一个时区偏移;
# 第 2 轮改成读 coverage.end.local,但带 ±hh:mm 的串 _parse_moment 解析不了,实际总走 utc + profile.tz 的回退。
# 新值:两边都换成 UTC 再比:段终点减去返照时刻的本地偏移(tajika_mudda.TIME_BASIS:
# “naive local wall time in the UTC offset of the solar-return instant”),对 coverage.end.utc。
# 原因:验收退回(BUG-1818):beyond_next_return 必须同一时间基准(UTC 对 UTC),夏令时两端不同也不受影响。
coverage_end_utc = _parse_moment((coverage_interval.get('end') or {}).get('utc'))
if isinstance(moment_local, datetime) and isinstance(moment_utc, datetime):
mudda_offset = moment_local.replace(tzinfo=None) - moment_utc.replace(tzinfo=None)
else:
mudda_offset = timedelta(hours=float(profile['tz']))
raw_periods: List[Dict[str, Any]] = []
for item in (mudda.get('raw_periods') or []):
if not isinstance(item, dict):
@@ -266,7 +267,8 @@ def run(body: Dict[str, Any]) -> Dict[str, Any]:
period = dict(item)
period_end = _parse_moment(period.get('end'))
period['beyond_next_return'] = bool(
coverage_end is not None and period_end is not None and period_end > coverage_end
coverage_end_utc is not None and period_end is not None
and period_end - mudda_offset > coverage_end_utc
)
raw_periods.append(period)
@@ -248,3 +248,56 @@ def test_beyond_next_return_uses_the_same_local_time_basis_in_the_west(monkeypat
envelope = ondemand_calc_api(_body(return_location={'mode': 'current_location', **NEW_YORK}))
assert envelope['calculation_status'] in {'executed', 'partial'}
assert envelope['raw']['mudda_raw'][0]['beyond_next_return'] is beyond
# 验收补充(Claude,2026-11-09):东八区出生与西半球出生各一组,越界标记用 UTC 对 UTC。
# 段终点按「返照时刻的本地偏移」写成本地时刻(tajika_mudda.TIME_BASIS),再挪 ±3 小时。
NEW_YORK_BIRTH = {
'year': 1990, 'month': 7, 'day': 4, 'hour': 9, 'minute': 30,
'lat': 40.7128, 'lon': -74.0060, 'tz': -4.0, 'timezone_id': 'America/New_York',
}
def _patch_engine_utc_based(monkeypatch, shift_hours: float) -> None:
real = solar_return._solar_return_module()
real_report = real.solar_return_full_report
def patched(*args, **kwargs):
result = real_report(*args, **kwargs)
moment = result['solar_return']
offset = moment['dt_local'].replace(tzinfo=None) - moment['dt_ut'].replace(tzinfo=None)
end_utc = _moment(result['annual_interval']['solar_return_interval']['end']['utc'])
mudda = dict(result.get('mudda_dasha') or {})
mudda['raw_periods'] = [{
'order': 1, 'lord': 'Rahu', 'start': '2026-01-01 00:00:00',
'end': (end_utc + offset + timedelta(hours=shift_hours)).strftime('%Y-%m-%d %H:%M:%S'),
}]
mudda['periods'] = []
return dict(result, mudda_dasha=mudda)
monkeypatch.setattr(real, 'solar_return_full_report', patched)
@pytest.mark.parametrize('shift_hours,beyond', [(3.0, True), (-3.0, False)])
def test_utc_plus_8_birth_marks_beyond_on_utc(monkeypatch, shift_hours, beyond):
assert float(NEUTRAL_TZ) == 8.0
_patch_engine_utc_based(monkeypatch, shift_hours)
envelope = ondemand_calc_api(_body())
assert envelope['raw']['solar_return']['utc_offset_hours'] == 8.0
assert envelope['raw']['mudda_raw'][0]['beyond_next_return'] is beyond
@pytest.mark.parametrize('shift_hours,beyond', [(3.0, True), (-3.0, False)])
def test_western_hemisphere_birth_marks_beyond_on_utc(monkeypatch, shift_hours, beyond):
_patch_engine_utc_based(monkeypatch, shift_hours)
envelope = ondemand_calc_api(_body(birth=dict(NEW_YORK_BIRTH)))
assert envelope['calculation_status'] in {'executed', 'partial'}
assert envelope['raw']['solar_return']['utc_offset_hours'] < 0
assert envelope['raw']['mudda_raw'][0]['beyond_next_return'] is beyond
def test_western_hemisphere_birth_real_rows_only_last_is_beyond():
envelope = ondemand_calc_api(_body(birth=dict(NEW_YORK_BIRTH)))
flags = [row['beyond_next_return'] for row in envelope['raw']['mudda_raw']]
assert flags[-1] is True
assert not any(flags[:-1])