diff --git a/docs/sphinx/source/whatsnew/v0.11.2.rst b/docs/sphinx/source/whatsnew/v0.11.2.rst index 49a1121c17..8fb16ed0a9 100644 --- a/docs/sphinx/source/whatsnew/v0.11.2.rst +++ b/docs/sphinx/source/whatsnew/v0.11.2.rst @@ -17,6 +17,8 @@ Bug Fixes ~~~~~~~~~ * :py:meth:`~pvlib.pvsystem.PVSystem.get_irradiance` accepts float inputs. (:issue:`1338`, :pull:`2227`) +* Handle DST transitions that happen at midnight in :py:func:`pvlib.solarposition.hour_angle` + (:issue:`2132` :pull:`2133`) Bug fixes ~~~~~~~~~ @@ -74,3 +76,4 @@ Contributors * matsuobasho (:ghuser:`matsuobasho`) * Echedey Luis (:ghuser:`echedey-ls`) * Kevin Anderson (:ghuser:`kandersolar`) +* Scott Nelson (:ghuser:`scttnlsn`) diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py index 46fa01fbe5..b667ac04e0 100644 --- a/pvlib/solarposition.py +++ b/pvlib/solarposition.py @@ -1349,6 +1349,12 @@ def hour_angle(times, longitude, equation_of_time): times : :class:`pandas.DatetimeIndex` Corresponding timestamps, must be localized to the timezone for the ``longitude``. + + A `pytz.exceptions.AmbiguousTimeError` will be raised if any of the + given times are on a day when the local daylight savings transition + happens at midnight. If you're working with such a timezone, + consider converting to a non-DST timezone (e.g. GMT-4) before + calling this function. longitude : numeric Longitude in degrees equation_of_time : numeric @@ -1421,7 +1427,17 @@ def _times_to_hours_after_local_midnight(times): if not times.tz: raise ValueError('times must be localized') - hrs = (times - times.normalize()) / pd.Timedelta('1h') + # Some timezones have a DST shift at midnight: + # 11:59pm -> 1:00am - results in a nonexistent midnight + # 12:59am -> 12:00am - results in an ambiguous midnight + # We remove the timezone before normalizing for this reason. + naive_normalized_times = times.tz_localize(None).normalize() + + # Use Pandas functionality for shifting nonexistent times forward + normalized_times = naive_normalized_times.tz_localize( + times.tz, nonexistent='shift_forward', ambiguous='raise') + + hrs = (times - normalized_times) / pd.Timedelta('1h') # ensure array return instead of a version-dependent pandas Index return np.array(hrs) diff --git a/pvlib/tests/test_solarposition.py b/pvlib/tests/test_solarposition.py index 9a69673d6c..88093e05f9 100644 --- a/pvlib/tests/test_solarposition.py +++ b/pvlib/tests/test_solarposition.py @@ -8,6 +8,7 @@ from .conftest import assert_frame_equal, assert_series_equal from numpy.testing import assert_allclose import pytest +import pytz from pvlib.location import Location from pvlib import solarposition, spa @@ -711,6 +712,38 @@ def test_hour_angle(): solarposition._local_times_from_hours_since_midnight(times, hours) +def test_hour_angle_with_tricky_timezones(): + # GH 2132 + # tests timezones that have a DST shift at midnight + + eot = np.array([-3.935172, -4.117227, -4.026295, -4.026295]) + + longitude = 70.6693 + times = pd.DatetimeIndex([ + '2014-09-06 23:00:00', + '2014-09-07 00:00:00', + '2014-09-07 01:00:00', + '2014-09-07 02:00:00', + ]).tz_localize('America/Santiago', nonexistent='shift_forward') + + with pytest.raises(pytz.exceptions.NonExistentTimeError): + times.normalize() + + # should not raise `pytz.exceptions.NonExistentTimeError` + solarposition.hour_angle(times, longitude, eot) + + longitude = 82.3666 + times = pd.DatetimeIndex([ + '2014-11-01 23:00:00', + '2014-11-02 00:00:00', + '2014-11-02 01:00:00', + '2014-11-02 02:00:00', + ]).tz_localize('America/Havana', ambiguous=[True, True, False, False]) + + with pytest.raises(pytz.exceptions.AmbiguousTimeError): + solarposition.hour_angle(times, longitude, eot) + + def test_sun_rise_set_transit_geometric(expected_rise_set_spa, golden_mst): """Test geometric calculations for sunrise, sunset, and transit times""" times = expected_rise_set_spa.index