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