From 3de8c0caf331a26e3ab91fc19f405e116711c29e Mon Sep 17 00:00:00 2001 From: Leonardo Scappatura <95079472+Leonard013@users.noreply.github.com> Date: Sun, 12 Jul 2026 17:38:27 +0200 Subject: [PATCH] Fix sun_rise_set_transit_spa day-of-interest to local calendar day sun_rise_set_transit_spa normalized timestamps to midnight *after* converting to UTC (introduced in v0.11.1 by #2055). An evening-local timestamp is the next calendar day in UTC, so its day of interest was shifted forward and the function returned the following day's sunrise, sunset, and transit. Use the input's local calendar date as the day of interest, restoring the pre-v0.11.1 behavior. The SPA routine requires 00:00 UTC input, so the local date is re-labeled (not converted) to UTC. The timezone label is stripped before normalize() so truncation runs on a naive index: normalizing a tz-aware index would build local midnight, which does not exist (spring-forward) or is ambiguous (fall-back) on DST-transition days and would raise. This yields unixtimes byte-for-byte identical to the v0.11.0 implementation for all valid inputs. Adds an inline comment explaining the timezone handling (requested in the issue) and a regression test covering negative and positive offsets and a midnight-DST zone. Fixes #2238 This change was drafted with AI assistance (Claude); all AI-generated material has been vetted for accuracy and is license-compliant. Co-authored-by: Claude --- docs/sphinx/source/whatsnew/v0.15.3.rst | 6 +++ pvlib/solarposition.py | 17 +++++++- tests/test_solarposition.py | 53 +++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/docs/sphinx/source/whatsnew/v0.15.3.rst b/docs/sphinx/source/whatsnew/v0.15.3.rst index b79b9aed66..70dd05d975 100644 --- a/docs/sphinx/source/whatsnew/v0.15.3.rst +++ b/docs/sphinx/source/whatsnew/v0.15.3.rst @@ -14,6 +14,11 @@ Deprecations Bug fixes ~~~~~~~~~ +* :py:func:`~pvlib.solarposition.sun_rise_set_transit_spa` now returns + sunrise, sunset, and transit for the *local* calendar day of each input + timestamp, restoring the behavior from before v0.11.1. Evening-local + timestamps, which fall on the next calendar day in UTC, previously + returned the following day's values. (:issue:`2238`) Enhancements @@ -54,3 +59,4 @@ Contributors * Mathias Aschwanden (:ghuser:`maschwanden`) * Yonry Zhu (:ghuser:`yonryzhu`) * Darshan Gowda (:ghuser:`dgowdaan-cmyk`) +* Leonardo Scappatura (:ghuser:`Leonard013`) diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py index 6faedbb9c7..8f5a50be8b 100644 --- a/pvlib/solarposition.py +++ b/pvlib/solarposition.py @@ -446,9 +446,22 @@ def sun_rise_set_transit_spa(times, latitude, longitude, how='numpy', else: raise ValueError('times must be localized') - # must convert to midnight UTC on day of interest times_utc = times.tz_convert('UTC') - unixtime = _datetime_to_unixtime(times_utc.normalize()) + + # The SPA sunrise/sunset routine (spa.transit_sunrise_sunset) requires + # its input to be midnight UTC (00:00+00:00) on the day of interest, + # where the "day of interest" is the *local* calendar day of each input + # timestamp. Drop the timezone label *first* so ``normalize`` truncates + # a naive index to the local date: normalizing a tz-aware index would + # instead construct local midnight, which does not exist (spring-forward) + # or is ambiguous (fall-back) on DST-transition days and would raise. + # Then re-label that midnight as UTC -- a relabel, not ``tz_convert``, + # because the SPA routine requires 00:00 UTC. Note we deliberately do + # not normalize after converting to UTC: an evening-local timestamp + # falls on the following calendar day in UTC, so that would return the + # next day's sunrise/sunset (the v0.11.1 regression, see GH #2238). + unixtime = _datetime_to_unixtime( + times.tz_localize(None).normalize().tz_localize('UTC')) spa = _spa_python_import(how) diff --git a/tests/test_solarposition.py b/tests/test_solarposition.py index 904062523e..9dc0488fe5 100644 --- a/tests/test_solarposition.py +++ b/tests/test_solarposition.py @@ -183,6 +183,59 @@ def test_sun_rise_set_transit_spa(expected_rise_set_spa, golden, delta_t): ) +def test_sun_rise_set_transit_spa_local_day(): + # GH #2238: the "day of interest" is the *local* calendar day of each + # input timestamp. Evening-local timestamps fall on the next day in + # UTC; in v0.11.1 they incorrectly returned the following day's + # sunrise/sunset/transit. All timestamps on the same local day must + # give identical results, matching pvlib <= v0.11.0. + times = pd.to_datetime( + ['2000-01-01 18:00', '2000-01-01 19:00']).tz_localize('Etc/GMT+5') + result = solarposition.sun_rise_set_transit_spa(times, 40, -80) + + # every result falls on the local calendar day of the input (2000-01-01) + for col in ['sunrise', 'sunset', 'transit']: + assert (result[col].dt.date == datetime.date(2000, 1, 1)).all() + + # both inputs share the same local day, so their results are identical + for col in ['sunrise', 'sunset', 'transit']: + assert result[col].iloc[0] == result[col].iloc[1] + + # known-good values from pvlib v0.11.0 (NLR SPA), rounded to the second + expected = pd.DataFrame( + {'sunrise': pd.DatetimeIndex(['2000-01-01 07:41:51'] * 2), + 'sunset': pd.DatetimeIndex(['2000-01-01 17:05:04'] * 2), + 'transit': pd.DatetimeIndex(['2000-01-01 12:23:23'] * 2)}, + index=times, + ) + for col in expected.columns: + expected[col] = expected[col].dt.tz_localize('Etc/GMT+5') + + result_rounded = pd.DataFrame(index=result.index) + for col, data in result.items(): + result_rounded[col] = data.dt.round('1s') + + assert_frame_equal(result_rounded, expected, check_dtype=False) + + # positive-offset zone: early-morning-local timestamps are the *previous* + # calendar day in UTC, the mirror of the case above + times = pd.to_datetime( + ['2000-07-01 05:00', '2000-07-01 06:00']).tz_localize('Etc/GMT-9') + result = solarposition.sun_rise_set_transit_spa(times, 35, 139) + for col in ['sunrise', 'sunset', 'transit']: + assert (result[col].dt.date == datetime.date(2000, 7, 1)).all() + assert result[col].iloc[0] == result[col].iloc[1] + + # midnight-DST zone: local midnight does not exist in America/Santiago on + # its 2019-09-08 spring-forward day, so normalizing a tz-aware index would + # raise. The day of interest must still be the local day and not error. + times = pd.DatetimeIndex( + ['2019-09-08 20:00']).tz_localize('America/Santiago') + result = solarposition.sun_rise_set_transit_spa(times, -33.45, -70.66) + for col in ['sunrise', 'sunset', 'transit']: + assert (result[col].dt.date == datetime.date(2019, 9, 8)).all() + + @requires_ephem def test_sun_rise_set_transit_ephem(expected_rise_set_ephem, golden): # test for Golden, CO compare to USNO, using local midnight