diff --git a/docs/sphinx/source/whatsnew/v0.11.1.rst b/docs/sphinx/source/whatsnew/v0.11.1.rst index 832e1a7221..7ed06de86a 100644 --- a/docs/sphinx/source/whatsnew/v0.11.1.rst +++ b/docs/sphinx/source/whatsnew/v0.11.1.rst @@ -33,7 +33,8 @@ Enhancements Bug fixes ~~~~~~~~~ - +* Handle DST transitions that happen at midnight in :py:func:`pvlib.solarposition.hour_angle` + (:issue:`2132` :pull:`2133`) Testing ~~~~~~~ @@ -66,6 +67,7 @@ Contributors * Leonardo Micheli (:ghuser:`lmicheli`) * Echedey Luis (:ghuser:`echedey-ls`) * Rajiv Daxini (:ghuser:`RDaxini`) +* Scott Nelson (:ghuser:`scttnlsn`) * Mark A. Mikofski (:ghuser:`mikofski`) * Ben Pierce (:ghuser:`bgpierc`) * Jose Meza (:ghuser:`JoseMezaMendieta`) diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py index 4b76204063..42ca357c5d 100644 --- a/pvlib/solarposition.py +++ b/pvlib/solarposition.py @@ -1360,6 +1360,11 @@ 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 (i.e. GMT-4) before calling this function. longitude : numeric Longitude in degrees equation_of_time : numeric @@ -1391,7 +1396,17 @@ def hour_angle(times, longitude, equation_of_time): times = times.tz_localize('utc') tzs = np.array([ts.utcoffset().total_seconds() for ts in times]) / 3600 - hrs_minus_tzs = (times - times.normalize()) / pd.Timedelta('1h') - tzs + # 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_minus_tzs = (times - normalized_times) / pd.Timedelta('1h') - tzs # ensure array return instead of a version-dependent pandas Index return np.asarray( diff --git a/pvlib/tests/test_solarposition.py b/pvlib/tests/test_solarposition.py index 472383acce..4e9b38cdd6 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 @@ -673,6 +674,38 @@ def test_hour_angle(): assert np.allclose(hours, expected) +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