Skip to content
Open
4 changes: 4 additions & 0 deletions docs/sphinx/source/whatsnew/v0.15.2.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -62,3 +64,5 @@ Contributors
* Cliff Hansen (:ghuser:`cwhanse`)
Comment thread
JoLo90 marked this conversation as resolved.
* Arthur Onno (:ghuser:`ArthurOnnoTerabase`)
* Adam R. Jensen (:ghuser:`AdamRJensen`)
* :ghuser:`JoLo90`

6 changes: 3 additions & 3 deletions pvlib/iotools/pvgis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/'
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't see the commit history, did you add # noqa: E231 because flake8 was complaining? I may be wrong, but sintax in this line shouldn't raise any flake8 warning. I can't reproduce locally in a reasonable amount of time (flake8 5.0.4 is tech debt at this point...).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@echedey-ls yes it was a false positive, flake8 insisted on putting a space after the :

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],
Expand Down
39 changes: 26 additions & 13 deletions pvlib/location.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Comment thread
JoLo90 marked this conversation as resolved.

Location objects support the print method.

Expand All @@ -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
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.

The `tz` attribute is represented as a valid IANA time zone name
string.
Expand Down Expand Up @@ -108,17 +107,19 @@ 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(
"Floating-point tz has non-zero fractional part: "
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(
Expand All @@ -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
Expand Down
10 changes: 5 additions & 5 deletions pvlib/solarposition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 9 additions & 8 deletions pvlib/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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')
Expand All @@ -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
Expand All @@ -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):
Expand Down
1 change: 0 additions & 1 deletion tests/iotools/test_midc.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions tests/test_clearsky.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
28 changes: 16 additions & 12 deletions tests/test_location.py
Comment thread
JoLo90 marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -37,29 +38,26 @@ 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'),
],
)
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


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(
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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'
47 changes: 26 additions & 21 deletions tests/test_solarposition.py
Original file line number Diff line number Diff line change
@@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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)
Expand Down