diff --git a/docs/sphinx/source/user_guide/modeling_topics/timetimezones.rst b/docs/sphinx/source/user_guide/modeling_topics/timetimezones.rst
index cb19dd764e..9219f1952d 100644
--- a/docs/sphinx/source/user_guide/modeling_topics/timetimezones.rst
+++ b/docs/sphinx/source/user_guide/modeling_topics/timetimezones.rst
@@ -5,7 +5,7 @@ Time and time zones
Dealing with time and time zones can be a frustrating experience in any
programming language and for any application. pvlib-python relies on
-:py:mod:`pandas` and `pytz `_ to handle
+:py:mod:`pandas` and the builtin :py:mod:`python:zoneinfo` to handle
time and time zones. Therefore, the vast majority of the information in
this document applies to any time series analysis using pandas and is
not specific to pvlib-python.
@@ -27,39 +27,41 @@ time and time zone functionality in python and pvlib.
.. ipython:: python
import datetime
+ import zoneinfo
import pandas as pd
- import pytz
Finding a time zone
*******************
-pytz is based on the Olson time zone database. You can obtain a list of
-all valid time zone strings with ``pytz.all_timezones``. It's a long
-list, so we only print every 20th time zone.
+``zoneinfo`` is based on the IANA (Olson) time zone database. You can obtain
+a set of all valid time zone strings with ``zoneinfo.available_timezones()``.
+It's a large set, so we only print every 20th time zone (sorted for
+consistency).
.. ipython:: python
- len(pytz.all_timezones)
- pytz.all_timezones[::20]
+ len(zoneinfo.available_timezones())
+ sorted(zoneinfo.available_timezones())[::20]
Wikipedia's `List of tz database time zones
`_ is also
good reference.
-The ``pytz.country_timezones`` function is useful, too.
+You can filter the available time zones using Python's built-in
+:py:func:`python:filter` function.
.. ipython:: python
- pytz.country_timezones('US')
+ sorted(filter(lambda x: 'America' in x, zoneinfo.available_timezones()))
-And don't forget about Python's :py:func:`python:filter` function.
+And you can also filter for GMT-based fixed offset zones:
.. ipython:: python
- list(filter(lambda x: 'GMT' in x, pytz.all_timezones))
+ sorted(filter(lambda x: 'GMT' in x, zoneinfo.available_timezones()))
-Note that while pytz has ``'EST'`` and ``'MST'``, it does not have
+Note that while the IANA database has ``'EST'`` and ``'MST'``, it does not have
``'PST'``. Use ``'Etc/GMT+8'`` instead, or see :ref:`fixedoffsets`.
Timestamps
@@ -125,7 +127,7 @@ vs. the UTC offset in summer...
pd.Timestamp('2015-6-1 00:00').tz_localize('US/Mountain')
pd.Timestamp('2015-6-1 00:00').tz_localize('Etc/GMT+7')
-pandas and pytz make this time zone handling possible because pandas
+pandas makes this time zone handling possible because pandas
stores all times as integer nanoseconds since January 1, 1970.
Here is the pandas time representation of the integers 1 and 1e9.
@@ -174,12 +176,13 @@ specifications, but watch out for the counter-intuitive sign convention.
pd.Timestamp('2015-1-1 00:00', tz='Etc/GMT-2')
-Fixed offset time zones can also be specified as offset minutes
-from UTC using ``pytz.FixedOffset``.
+Fixed offset time zones can also be specified using
+:py:class:`python:datetime.timezone` with a
+:py:class:`python:datetime.timedelta` offset.
.. ipython:: python
- pd.Timestamp('2015-1-1 00:00', tz=pytz.FixedOffset(120))
+ pd.Timestamp('2015-1-1 00:00', tz=datetime.timezone(datetime.timedelta(hours=2)))
You can also specify the fixed offset directly in the ``tz_localize``
method, however, be aware that this is not documented and that the
@@ -216,8 +219,8 @@ expected.
# tz naive pandas Timestamp object
pd.Timestamp(naive_python_dt)
- # tz aware python datetime.datetime object
- aware_python_dt = pytz.timezone('US/Mountain').localize(naive_python_dt)
+ # tz aware python datetime.datetime object using zoneinfo
+ aware_python_dt = naive_python_dt.replace(tzinfo=zoneinfo.ZoneInfo('US/Mountain'))
# tz aware pandas Timestamp object
pd.Timestamp(aware_python_dt)
@@ -234,13 +237,14 @@ passed to ``Timestamp``.
# tz naive pandas Timestamp object (time=midnight)
pd.Timestamp(naive_python_date)
-You cannot localize a native Python date object.
+You cannot localize a native Python date object directly; you must first
+convert it to a :py:class:`python:datetime.datetime`.
.. ipython:: python
:okexcept:
- # fail
- pytz.timezone('US/Mountain').localize(naive_python_date)
+ # fail: datetime.date has no tzinfo support via replace
+ naive_python_date.replace(tzinfo=zoneinfo.ZoneInfo('US/Mountain'))
pvlib-specific functionality
@@ -348,7 +352,7 @@ UTC, and then convert it to the desired time zone.
.. ipython:: python
- fixed_tz = pytz.FixedOffset(tmy3_metadata['TZ'] * 60)
+ fixed_tz = datetime.timezone(datetime.timedelta(hours=tmy3_metadata['TZ']))
solar_position_hack = solar_position_notz.tz_localize('UTC').tz_convert(fixed_tz)
solar_position_hack.index
diff --git a/docs/sphinx/source/whatsnew/v0.15.2.rst b/docs/sphinx/source/whatsnew/v0.15.2.rst
index 1f4524d893..af23808bbd 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
~~~~~~~~~~~~
+* :py:attr:`pvlib.location.Location.pytz` is deprecated and will be removed in a future release. Use :py:attr:`~pvlib.location.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/docs/tutorials/solarposition.ipynb b/docs/tutorials/solarposition.ipynb
index bd3245445d..bc4d079275 100644
--- a/docs/tutorials/solarposition.ipynb
+++ b/docs/tutorials/solarposition.ipynb
@@ -121,7 +121,7 @@
"outputs": [],
"source": [
"times = pd.date_range(start=datetime.datetime(2014,6,23), end=datetime.datetime(2014,6,24), freq='1Min')\n",
- "times_loc = times.tz_localize(tus.pytz)"
+ "times_loc = times.tz_localize(tus.tz)"
]
},
{
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..e339e752f0 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.
@@ -44,11 +43,11 @@ class Location:
tz : time zone as str, int, float, or datetime.tzinfo, default 'UTC'.
See http://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a
- 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.
+ 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
+ ``zoneinfo`` packages may also be passed.
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..cd835e41a1 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 ``pandas<3``, ``ValueError`` in ``pandas>=3``
+ 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)