Skip to content
Open
51 changes: 28 additions & 23 deletions docs/sphinx/source/user_guide/modeling_topics/timetimezones.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <http://pythonhosted.org/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.
Expand All @@ -16,7 +16,8 @@ General functionality
pvlib makes extensive use of pandas due to its excellent time series
functionality. Take the time to become familiar with pandas' `Time
Series / Date functionality page
<http://pandas.pydata.org/pandas-docs/version/0.18.0/timeseries.html>`_.
functionality. Take the time to become familiar with pandas'
:external+pandas:doc:`Time Series / Date functionality page <user_guide/timeseries>`.
It is also worthwhile to become familiar with pure Python's
:py:mod:`python:datetime` module, although we usually recommend
using the corresponding pandas functionality where possible.
Expand All @@ -27,39 +28,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
<https://en.wikipedia.org/wiki/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
Expand Down Expand Up @@ -125,7 +128,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.

Expand Down Expand Up @@ -174,12 +177,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
Expand Down Expand Up @@ -216,8 +220,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)
Expand All @@ -234,13 +238,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
Expand Down Expand Up @@ -348,7 +353,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
Expand Down
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`

2 changes: 1 addition & 1 deletion docs/tutorials/solarposition.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
]
},
{
Expand Down
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
43 changes: 28 additions & 15 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
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.
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
Loading
Loading