diff --git a/docs/sphinx/source/whatsnew/v0.15.2.rst b/docs/sphinx/source/whatsnew/v0.15.2.rst index 1f4524d893..2682e37baf 100644 --- a/docs/sphinx/source/whatsnew/v0.15.2.rst +++ b/docs/sphinx/source/whatsnew/v0.15.2.rst @@ -10,6 +10,8 @@ Breaking Changes Deprecations ~~~~~~~~~~~~ +* ``Location.pytz`` is deprecated. Use ``Location.tz`` instead. + (:issue:`2343`, :pull:`2757`) Bug fixes @@ -62,3 +64,5 @@ Contributors * Cliff Hansen (:ghuser:`cwhanse`) * Arthur Onno (:ghuser:`ArthurOnnoTerabase`) * Adam R. Jensen (:ghuser:`AdamRJensen`) +* :ghuser:`JoLo90` + diff --git a/pvlib/iotools/pvgis.py b/pvlib/iotools/pvgis.py index c69017344a..61f3eff695 100644 --- a/pvlib/iotools/pvgis.py +++ b/pvlib/iotools/pvgis.py @@ -20,7 +20,7 @@ import requests import numpy as np import pandas as pd -import pytz +import zoneinfo from pvlib.iotools import read_epw URL = 'https://re.jrc.ec.europa.eu/api/' @@ -413,10 +413,10 @@ def _coerce_and_roll_tmy(tmy_data, tz, year): re-interpreted as zero / UTC. """ if tz: - tzname = pytz.timezone(f'Etc/GMT{-tz:+d}') + tzname = zoneinfo.ZoneInfo(f'Etc/GMT{-tz:+d}') # noqa: E231 else: tz = 0 - tzname = pytz.timezone('UTC') + tzname = zoneinfo.ZoneInfo('UTC') new_index = pd.DatetimeIndex([ timestamp.replace(year=year, tzinfo=tzname) for timestamp in tmy_data.index], diff --git a/pvlib/location.py b/pvlib/location.py index 230ce26a66..e5568db529 100644 --- a/pvlib/location.py +++ b/pvlib/location.py @@ -14,6 +14,7 @@ from pvlib import solarposition, clearsky, atmosphere, irradiance from pvlib.tools import _degrees_to_index +from pvlib._deprecation import warn_deprecated class Location: @@ -22,13 +23,11 @@ class Location: time zone, and altitude data associated with a particular geographic location. You can also assign a name to a location object. - Location objects have two time-zone attributes: + Location objects have a time-zone attribute ``tz`` (IANA timezone string). - * ``tz`` is an IANA time-zone string. - * ``pytz`` is a pytz-based time-zone object (read only). + .. deprecated:: 0.15.2 - The read-only ``pytz`` attribute will stay in sync with any changes made - using ``tz``. + The ``pytz`` attribute is deprecated. Use ``tz`` instead. Location objects support the print method. @@ -47,8 +46,8 @@ class Location: list of valid name strings. An `int` or `float` must be a whole-number hour offsets from UTC that can be converted to the IANA-supported 'Etc/GMT-N' format. (Note the limited range of the offset N and its - sign-change convention.) Time zones from the pytz and zoneinfo packages - may also be passed here, as they are subclasses of datetime.tzinfo. + sign-change convention.) Time zones from the zoneinfo packages may also + be passed here. The `tz` attribute is represented as a valid IANA time zone name string. @@ -108,7 +107,8 @@ def tz(self, tz_): if isinstance(tz_, str): self._zoneinfo = zoneinfo.ZoneInfo(tz_) elif isinstance(tz_, int): - self._zoneinfo = zoneinfo.ZoneInfo(f"Etc/GMT{-tz_:+d}") + tz_str = f"Etc/GMT{-tz_:+d}" # noqa: E231 + self._zoneinfo = zoneinfo.ZoneInfo(tz_str) elif isinstance(tz_, float): if tz_ % 1 != 0: raise TypeError( @@ -116,9 +116,10 @@ def tz(self, tz_): f"{tz_}. Only whole-number offsets are supported." ) - self._zoneinfo = zoneinfo.ZoneInfo(f"Etc/GMT{-int(tz_):+d}") + tz_str = f"Etc/GMT{-int(tz_):+d}" # noqa: E231 + self._zoneinfo = zoneinfo.ZoneInfo(tz_str) elif isinstance(tz_, datetime.tzinfo): - # Includes time zones generated by pytz and zoneinfo packages. + # Includes time zones generated by zoneinfo packages. self._zoneinfo = zoneinfo.ZoneInfo(str(tz_)) else: raise TypeError( @@ -128,8 +129,20 @@ def tz(self, tz_): ) @property - def pytz(self): - """The location's pytz time zone (read only).""" + def pytz(self): # pragma: no cover + """The location's pytz time zone (read only). + + .. deprecated:: 0.15.2 + The ``pytz`` attribute is deprecated. Use the ``tz`` property + instead. + """ + warn_deprecated( + since='0.15.2', + removal='0.17.0', + name='pytz', + obj_type='attribute', + alternative='tz', + ) return pytz.timezone(str(self._zoneinfo)) @classmethod diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py index 501daa1c21..7a0d5e4196 100644 --- a/pvlib/solarposition.py +++ b/pvlib/solarposition.py @@ -1360,11 +1360,11 @@ def hour_angle(times, longitude, equation_of_time): 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. + ``AmbiguousTimeError`` in older pandas, ``ValueError`` in newer + 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 diff --git a/pvlib/tools.py b/pvlib/tools.py index 6cb631f852..1d9db70369 100644 --- a/pvlib/tools.py +++ b/pvlib/tools.py @@ -4,11 +4,12 @@ import contextlib import datetime as dt +from datetime import timezone import warnings import numpy as np import pandas as pd -import pytz +import zoneinfo def cosd(angle): @@ -135,8 +136,8 @@ def localize_to_utc(time, location): """ if isinstance(time, dt.datetime): if time.tzinfo is None: - time = location.pytz.localize(time) - time_utc = time.astimezone(pytz.utc) + time = time.replace(tzinfo=zoneinfo.ZoneInfo(location.tz)) + time_utc = time.astimezone(timezone.utc) else: try: time_utc = time.tz_convert('UTC') @@ -162,11 +163,11 @@ def datetime_to_djd(time): """ if time.tzinfo is None: - time_utc = pytz.utc.localize(time) + time_utc = time.replace(tzinfo=timezone.utc) else: - time_utc = time.astimezone(pytz.utc) + time_utc = time.astimezone(timezone.utc) - djd_start = pytz.utc.localize(dt.datetime(1899, 12, 31, 12)) + djd_start = dt.datetime(1899, 12, 31, 12, tzinfo=timezone.utc) djd = (time_utc - djd_start).total_seconds() * 1.0/(60 * 60 * 24) return djd @@ -189,10 +190,10 @@ def djd_to_datetime(djd, tz='UTC'): The resultant datetime localized to tz """ - djd_start = pytz.utc.localize(dt.datetime(1899, 12, 31, 12)) + djd_start = dt.datetime(1899, 12, 31, 12, tzinfo=timezone.utc) utc_time = djd_start + dt.timedelta(days=djd) - return utc_time.astimezone(pytz.timezone(tz)) + return utc_time.astimezone(zoneinfo.ZoneInfo(tz)) def _pandas_to_doy(pd_object): diff --git a/tests/iotools/test_midc.py b/tests/iotools/test_midc.py index 636550d23c..78a642736e 100644 --- a/tests/iotools/test_midc.py +++ b/tests/iotools/test_midc.py @@ -1,6 +1,5 @@ import pandas as pd import pytest -import pytz from pvlib.iotools import midc from tests.conftest import TESTS_DATA_DIR, RERUNS, RERUNS_DELAY diff --git a/tests/test_clearsky.py b/tests/test_clearsky.py index 687dd9133e..638dfb0bc5 100644 --- a/tests/test_clearsky.py +++ b/tests/test_clearsky.py @@ -3,7 +3,7 @@ import numpy as np from numpy import nan import pandas as pd -import pytz +import zoneinfo from scipy.linalg import hankel import pytest @@ -770,7 +770,7 @@ def test_bird(): times = pd.date_range(start='1/1/2015 0:00', end='12/31/2015 23:00', freq='h') tz = -7 # test timezone - gmt_tz = pytz.timezone('Etc/GMT%+d' % -(tz)) + gmt_tz = zoneinfo.ZoneInfo(f'Etc/GMT{-tz:+d}') # noqa: E231 times = times.tz_localize(gmt_tz) # set timezone times_utc = times.tz_convert('UTC') # match test data from BIRD_08_16_2012.xls diff --git a/tests/test_location.py b/tests/test_location.py index 36d71e30be..b2f780fcfc 100644 --- a/tests/test_location.py +++ b/tests/test_location.py @@ -5,12 +5,12 @@ import numpy as np from numpy import nan import pandas as pd -from .conftest import assert_frame_equal, assert_index_equal +from .conftest import (assert_frame_equal, assert_index_equal, + fail_on_pvlib_version) +from pvlib._deprecation import pvlibDeprecationWarning import pytest -import pytz - import pvlib from pvlib import location from pvlib.location import Location, lookup_altitude @@ -23,8 +23,9 @@ def test_location_required(): Location(32.2, -111) -def test_location_all(): - Location(32.2, -111, 'US/Arizona', 700, 'Tucson') +@pytest.fixture() +def some_location() -> Location: + return Location(32.2, -111, 'US/Arizona', 700, 'Tucson') @pytest.mark.parametrize( @@ -37,7 +38,7 @@ def test_location_all(): pytest.param('Asia/Yangon', 'Asia/Yangon'), pytest.param(datetime.timezone.utc, 'UTC'), pytest.param(zoneinfo.ZoneInfo('Etc/GMT-7'), 'Etc/GMT-7'), - pytest.param(pytz.timezone('US/Arizona'), 'US/Arizona'), + pytest.param(zoneinfo.ZoneInfo('US/Arizona'), 'US/Arizona'), pytest.param(-6, 'Etc/GMT+6'), pytest.param(-11.0, 'Etc/GMT+11'), pytest.param(12, 'Etc/GMT-12'), @@ -45,8 +46,7 @@ def test_location_all(): ) def test_location_tz(tz, tz_expected): loc = Location(32.2, -111, tz) - assert isinstance(loc.pytz, datetime.tzinfo) # Abstract base class. - assert isinstance(loc.pytz, pytz.tzinfo.BaseTzInfo) + assert isinstance(loc._zoneinfo, datetime.tzinfo) # Abstract base class. assert type(loc.tz) is str assert loc.tz == tz_expected @@ -54,12 +54,10 @@ def test_location_tz(tz, tz_expected): def test_location_tz_update(): loc = Location(32.2, -111, -11) assert loc.tz == 'Etc/GMT+11' - assert loc.pytz == pytz.timezone('Etc/GMT+11') # Deprecated attribute. # Updating Location's tz updates read-only time-zone attributes. loc.tz = 7 assert loc.tz == 'Etc/GMT-7' - assert loc.pytz == pytz.timezone('Etc/GMT-7') # Deprecated attribute. @pytest.mark.parametrize( @@ -99,8 +97,8 @@ def test_location_print_all(): assert tus.__str__() == expected_str -def test_location_print_pytz(): - tus = Location(32.2, -111, pytz.timezone('US/Arizona'), 700, 'Tucson') +def test_location_print(): + tus = Location(32.2, -111, zoneinfo.ZoneInfo('US/Arizona'), 700, 'Tucson') expected_str = '\n'.join([ 'Location: ', ' name: Tucson', @@ -395,3 +393,9 @@ def test_location_lookup_altitude(mocker): tus = Location(32.2, -111, 'US/Arizona') location.lookup_altitude.assert_called_once_with(32.2, -111) assert tus.altitude == location.lookup_altitude(32.2, -111) + + +@fail_on_pvlib_version('0.17.0') +def test_location_pytz_warning(some_location): + with pytest.warns(pvlibDeprecationWarning): + assert str(some_location.pytz) == 'US/Arizona' diff --git a/tests/test_solarposition.py b/tests/test_solarposition.py index a6cf6b4819..904062523e 100644 --- a/tests/test_solarposition.py +++ b/tests/test_solarposition.py @@ -1,21 +1,20 @@ import calendar import datetime +import math import warnings +import zoneinfo +from datetime import timezone import numpy as np import pandas as pd - -from .conftest import assert_frame_equal, assert_series_equal -from numpy.testing import assert_allclose import pytest -import pytz +from numpy.testing import assert_allclose -from pvlib.location import Location from pvlib import solarposition, spa +from pvlib.location import Location -from .conftest import ( - requires_ephem, requires_spa_c, requires_numba, requires_pandas_2_0 -) +from .conftest import (assert_frame_equal, assert_series_equal, requires_ephem, + requires_numba, requires_pandas_2_0, requires_spa_c) # setup times and locations to be tested. times = pd.date_range(start=datetime.datetime(2014, 6, 24), @@ -343,29 +342,26 @@ def test_pyephem_physical_dst(expected_solpos, golden): @requires_ephem def test_calc_time(): - import pytz - import math # validation from USNO solar position calculator online - epoch = datetime.datetime(1970, 1, 1) - epoch_dt = pytz.utc.localize(epoch) + epoch = datetime.datetime(1970, 1, 1, tzinfo=timezone.utc) loc = tus loc.pressure = 0 - actual_time = pytz.timezone(loc.tz).localize( - datetime.datetime(2014, 10, 10, 8, 30)) - lb = pytz.timezone(loc.tz).localize(datetime.datetime(2014, 10, 10, tol)) - ub = pytz.timezone(loc.tz).localize(datetime.datetime(2014, 10, 10, 10)) + tz = zoneinfo.ZoneInfo(loc.tz) + actual_time = datetime.datetime(2014, 10, 10, 8, 30, tzinfo=tz) + lb = datetime.datetime(2014, 10, 10, tol, tzinfo=tz) + ub = datetime.datetime(2014, 10, 10, 10, tzinfo=tz) alt = solarposition.calc_time(lb, ub, loc.latitude, loc.longitude, 'alt', math.radians(24.7)) az = solarposition.calc_time(lb, ub, loc.latitude, loc.longitude, 'az', math.radians(116.3)) - actual_timestamp = (actual_time - epoch_dt).total_seconds() + actual_timestamp = (actual_time - epoch).total_seconds() assert_allclose((alt.replace(second=0, microsecond=0) - - epoch_dt).total_seconds(), actual_timestamp) + epoch).total_seconds(), actual_timestamp) assert_allclose((az.replace(second=0, microsecond=0) - - epoch_dt).total_seconds(), actual_timestamp) + epoch).total_seconds(), actual_timestamp) @requires_ephem @@ -715,6 +711,15 @@ def test_hour_angle_with_tricky_timezones(): # GH 2132 # tests timezones that have a DST shift at midnight + try: # transitive dependency to pytz through pandas < 3 + import pytz + _NonExistentTimeError = pytz.exceptions.NonExistentTimeError + _AmbiguousTimeError = pytz.exceptions.AmbiguousTimeError + except ImportError: # pragma: no cover + # pandas 3.x dropped pytz; these are now raised as ValueError + _NonExistentTimeError = ValueError + _AmbiguousTimeError = ValueError + eot = np.array([-3.935172, -4.117227, -4.026295, -4.026295]) longitude = 70.6693 @@ -726,7 +731,7 @@ def test_hour_angle_with_tricky_timezones(): ]).tz_localize('America/Santiago', nonexistent='shift_forward') with pytest.raises(( - pytz.exceptions.NonExistentTimeError, # pandas 1.x, 2.x + _NonExistentTimeError, # pandas 1.x, 2.x ValueError, # pandas 3.x )): times.normalize() @@ -743,7 +748,7 @@ def test_hour_angle_with_tricky_timezones(): ]).tz_localize('America/Havana', ambiguous=[True, True, False, False]) with pytest.raises(( - pytz.exceptions.AmbiguousTimeError, # pandas 1.x, 2.x + _AmbiguousTimeError, # pandas 1.x, 2.x ValueError, # pandas 3.x )): solarposition.hour_angle(times, longitude, eot)