diff --git a/docs/sphinx/source/whatsnew/v0.11.3.rst b/docs/sphinx/source/whatsnew/v0.11.3.rst index 0c4c55ad9..a8eddd751 100644 --- a/docs/sphinx/source/whatsnew/v0.11.3.rst +++ b/docs/sphinx/source/whatsnew/v0.11.3.rst @@ -12,12 +12,24 @@ Enhancements ~~~~~~~~~~~~ +Bug Fixes +~~~~~~~~~ +* Ensure proper tz and pytz types in pvlib.location.Location. To ensure that + the time zone in pvlib.location.Location remains internally consistent + if/when the time zone is updated, the tz attribute is now the single source + of time-zone truth, is the single time-zone setter interface, and its getter + returns an IANA string. (:issue:`2340`, :pull:`2341`) + + Documentation ~~~~~~~~~~~~~ Testing ~~~~~~~ +* Add tests for timezone types in pvlib.location.Location. + (:issue:`2340`, :pull:`2341`) +* Add tests for time conversions in pvlib.tools. (:issue:`2340`, :pull:`2341`) Requirements @@ -31,6 +43,11 @@ Maintenance * asv 0.4.2 upgraded to asv 0.6.4 to fix CI failure due to pinned older conda. (:pull:`2352`) +Breaking Changes +~~~~~~~~~~~~~~~~ +* The pytz attribute in pvlib.location.Location is now read only. pytz time + zones can still be passed to tz in the object initializer and in the tz + setter after object creation. (:issue:`2340`, :pull:`2341`) Contributors ~~~~~~~~~~~~ diff --git a/pvlib/location.py b/pvlib/location.py index 9259f410f..8adc7acaa 100644 --- a/pvlib/location.py +++ b/pvlib/location.py @@ -6,6 +6,7 @@ import pathlib import datetime +import zoneinfo import pandas as pd import pytz @@ -18,13 +19,16 @@ class Location: """ Location objects are convenient containers for latitude, longitude, - timezone, and altitude data associated with a particular - geographic location. You can also assign a name to a location object. + 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 timezone attributes: + Location objects have two time-zone attributes: - * ``tz`` is a IANA timezone string. - * ``pytz`` is a pytz timezone object. + * ``tz`` is an IANA time-zone string. + * ``pytz`` is a pytz-based time-zone object (read only). + + The read-only ``pytz`` attribute will stay in sync with any changes made + using ``tz``. Location objects support the print method. @@ -38,12 +42,15 @@ class Location: Positive is east of the prime meridian. Use decimal degrees notation. - tz : str, int, float, or pytz.timezone, default 'UTC'. - See - http://en.wikipedia.org/wiki/List_of_tz_database_time_zones - for a list of valid time zones. - pytz.timezone objects will be converted to strings. - ints and floats must be in hours from UTC. + tz : time zone as str, int, float, or datetime.tzinfo, default 'UTC'. + This value represents as a valid IANA time zone name string. See + http://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a + list of valid name strings, any of which may be passed directly here. + ints and floats must be whole-number hour offsets from UTC, which + are converted to the IANA-suppored '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 + directly here, as they are subclasses of datetime.tzinfo. altitude : float, optional Altitude from sea level in meters. @@ -54,43 +61,75 @@ class Location: name : string, optional Sets the name attribute of the Location object. + Raises + ------ + ValueError + when the time-zone ``tz`` input cannot be converted. + + zoneinfo.ZoneInfoNotFoundError + when the time zone ``tz`` is not recognizable as an IANA time zone by + the ``zoneinfo.ZoneInfo`` initializer used for internal time-zone + representation. + See also -------- pvlib.pvsystem.PVSystem """ - def __init__(self, latitude, longitude, tz='UTC', altitude=None, - name=None): - + def __init__( + self, latitude, longitude, tz='UTC', altitude=None, name=None + ): self.latitude = latitude self.longitude = longitude - - if isinstance(tz, str): - self.tz = tz - self.pytz = pytz.timezone(tz) - elif isinstance(tz, datetime.timezone): - self.tz = 'UTC' - self.pytz = pytz.UTC - elif isinstance(tz, datetime.tzinfo): - self.tz = tz.zone - self.pytz = tz - elif isinstance(tz, (int, float)): - self.tz = tz - self.pytz = pytz.FixedOffset(tz*60) - else: - raise TypeError('Invalid tz specification') + self.tz = tz if altitude is None: altitude = lookup_altitude(latitude, longitude) self.altitude = altitude - self.name = name def __repr__(self): attrs = ['name', 'latitude', 'longitude', 'altitude', 'tz'] + # Use None as getattr default in case __repr__ is called during + # initialization before all attributes have been assigned. return ('Location: \n ' + '\n '.join( - f'{attr}: {getattr(self, attr)}' for attr in attrs)) + f'{attr}: {getattr(self, attr, None)}' for attr in attrs)) + + @property + def tz(self): + """The location's IANA time-zone string.""" + return str(self._zoneinfo) + + @tz.setter + def tz(self, tz_): + # self._zoneinfo holds single source of time-zone truth as IANA name. + if isinstance(tz_, str): + self._zoneinfo = zoneinfo.ZoneInfo(tz_) + elif isinstance(tz_, int): + self._zoneinfo = zoneinfo.ZoneInfo(f"Etc/GMT{-tz_:+d}") + 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}") + elif isinstance(tz_, datetime.tzinfo): + # Includes time zones generated by pytz and zoneinfo packages. + self._zoneinfo = zoneinfo.ZoneInfo(str(tz_)) + else: + raise TypeError( + f"invalid tz specification: {tz_}, must be an IANA time zone " + "string, a whole-number int/float UTC offset, or a " + "datetime.tzinfo object (including subclasses)" + ) + + @property + def pytz(self): + """The location's pytz time zone (read only).""" + return pytz.timezone(str(self._zoneinfo)) @classmethod def from_tmy(cls, tmy_metadata, tmy_data=None, **kwargs): diff --git a/pvlib/tests/test_location.py b/pvlib/tests/test_location.py index e04b10ab4..6d06f0f4a 100644 --- a/pvlib/tests/test_location.py +++ b/pvlib/tests/test_location.py @@ -1,5 +1,6 @@ import datetime from unittest.mock import ANY +import zoneinfo import numpy as np from numpy import nan @@ -9,7 +10,6 @@ import pytest import pytz -from pytz.exceptions import UnknownTimeZoneError import pvlib from pvlib import location @@ -27,22 +27,63 @@ def test_location_all(): Location(32.2, -111, 'US/Arizona', 700, 'Tucson') -@pytest.mark.parametrize('tz', [ - pytz.timezone('US/Arizona'), 'America/Phoenix', -7, -7.0, - datetime.timezone.utc -]) -def test_location_tz(tz): - Location(32.2, -111, tz) - - -def test_location_invalid_tz(): - with pytest.raises(UnknownTimeZoneError): - Location(32.2, -111, 'invalid') - - -def test_location_invalid_tz_type(): +@pytest.mark.parametrize( + 'tz,tz_expected', [ + pytest.param('UTC', 'UTC'), + pytest.param('Etc/GMT+5', 'Etc/GMT+5'), + pytest.param('US/Mountain', 'US/Mountain'), + pytest.param('America/Phoenix', 'America/Phoenix'), + pytest.param('Asia/Kathmandu', 'Asia/Kathmandu'), + 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(-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 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( + 'tz', [ + 'invalid', + 'Etc/GMT+20', # offset too large. + 20, # offset too large. + ] +) +def test_location_invalid_tz(tz): + with pytest.raises(zoneinfo.ZoneInfoNotFoundError): + Location(32.2, -111, tz) + + +@pytest.mark.parametrize( + 'tz', [ + -9.5, # float with non-zero fractional part. + b"bytes not str", + [5], + ] +) +def test_location_invalid_tz_type(tz): with pytest.raises(TypeError): - Location(32.2, -111, [5]) + Location(32.2, -111, tz) def test_location_print_all(): diff --git a/pvlib/tests/test_tools.py b/pvlib/tests/test_tools.py index eb9e65c89..013716549 100644 --- a/pvlib/tests/test_tools.py +++ b/pvlib/tests/test_tools.py @@ -1,9 +1,12 @@ -import pytest +from datetime import datetime +from zoneinfo import ZoneInfo -from pvlib import tools import numpy as np -import pandas as pd from numpy.testing import assert_allclose +import pandas as pd +import pytest + +from pvlib import location, tools @pytest.mark.parametrize('keys, input_dict, expected', [ @@ -144,3 +147,108 @@ def test_get_pandas_index(args, args_idx): def test_normalize_max2one(data_in, expected): result = tools.normalize_max2one(data_in) assert_allclose(result, expected) + + +def test_localize_to_utc(): + lat, lon = 43.2, -77.6 + tz = "Etc/GMT+5" + loc = location.Location(lat, lon, tz=tz) + year, month, day, hour, minute, second = 1974, 6, 22, 18, 30, 15 + hour_utc = hour + 5 + + # Test all combinations of supported inputs. + dt_time_aware_utc = datetime( + year, month, day, hour_utc, minute, second, tzinfo=ZoneInfo("UTC") + ) + dt_time_aware = datetime( + year, month, day, hour, minute, second, tzinfo=ZoneInfo(tz) + ) + assert tools.localize_to_utc(dt_time_aware, None) == dt_time_aware_utc + dt_time_naive = datetime(year, month, day, hour, minute, second) + assert tools.localize_to_utc(dt_time_naive, loc) == dt_time_aware_utc + + # FIXME Derive timestamp strings from above variables. + dt_index_aware_utc = pd.DatetimeIndex( + [dt_time_aware_utc.strftime("%Y-%m-%dT%H:%M:%S")], tz=ZoneInfo("UTC") + ) + dt_index_aware = pd.DatetimeIndex( + [dt_time_aware.strftime("%Y-%m-%dT%H:%M:%S")], tz=ZoneInfo(tz) + ) + assert tools.localize_to_utc(dt_index_aware, None) == dt_index_aware_utc + dt_index_naive = pd.DatetimeIndex( + [dt_time_naive.strftime("%Y-%m-%dT%H:%M:%S")] + ) + assert tools.localize_to_utc(dt_index_naive, loc) == dt_index_aware_utc + + # Older pandas versions have wonky dtype equality check on timestamp + # index, so check the values as numpy.ndarray and indices one by one. + series_time_aware_utc_expected = pd.Series([24.42], dt_index_aware_utc) + series_time_aware = pd.Series([24.42], index=dt_index_aware) + series_time_aware_utc_got = tools.localize_to_utc(series_time_aware, None) + np.testing.assert_array_equal( + series_time_aware_utc_got.to_numpy(), + series_time_aware_utc_expected.to_numpy(), + ) + + for index_got, index_expected in zip( + series_time_aware_utc_got.index, series_time_aware_utc_expected.index + ): + assert index_got == index_expected + + series_time_naive = pd.Series([24.42], index=dt_index_naive) + series_time_naive_utc_got = tools.localize_to_utc(series_time_naive, loc) + np.testing.assert_array_equal( + series_time_naive_utc_got.to_numpy(), + series_time_aware_utc_expected.to_numpy(), + ) + + for index_got, index_expected in zip( + series_time_naive_utc_got.index, series_time_aware_utc_expected.index + ): + assert index_got == index_expected + + # Older pandas versions have wonky dtype equality check on timestamp + # index, so check the values as numpy.ndarray and indices one by one. + df_time_aware_utc_expected = pd.DataFrame([[24.42]], dt_index_aware) + df_time_naive = pd.DataFrame([[24.42]], index=dt_index_naive) + df_time_naive_utc_got = tools.localize_to_utc(df_time_naive, loc) + np.testing.assert_array_equal( + df_time_naive_utc_got.to_numpy(), + df_time_aware_utc_expected.to_numpy(), + ) + + for index_got, index_expected in zip( + df_time_naive_utc_got.index, df_time_aware_utc_expected.index + ): + assert index_got == index_expected + + df_time_aware = pd.DataFrame([[24.42]], index=dt_index_aware) + df_time_aware_utc_got = tools.localize_to_utc(df_time_aware, None) + np.testing.assert_array_equal( + df_time_aware_utc_got.to_numpy(), + df_time_aware_utc_expected.to_numpy(), + ) + + for index_got, index_expected in zip( + df_time_aware_utc_got.index, df_time_aware_utc_expected.index + ): + assert index_got == index_expected + + +def test_datetime_to_djd(): + expected = 27201.47934027778 + dt_aware = datetime(1974, 6, 22, 18, 30, 15, tzinfo=ZoneInfo("Etc/GMT+5")) + assert tools.datetime_to_djd(dt_aware) == expected + dt_naive_utc = datetime(1974, 6, 22, 23, 30, 15) + assert tools.datetime_to_djd(dt_naive_utc) == expected + + +def test_djd_to_datetime(): + djd = 27201.47934027778 + tz = "Etc/GMT+5" + + expected = datetime(1974, 6, 22, 18, 30, 15, tzinfo=ZoneInfo(tz)) + assert tools.djd_to_datetime(djd, tz) == expected + + expected = datetime(1974, 6, 22, 23, 30, 15, tzinfo=ZoneInfo("UTC")) + assert tools.djd_to_datetime(djd) == expected diff --git a/pvlib/tools.py b/pvlib/tools.py index c8d4d6e30..b08d06167 100644 --- a/pvlib/tools.py +++ b/pvlib/tools.py @@ -3,10 +3,11 @@ """ import datetime as dt +import warnings + import numpy as np import pandas as pd import pytz -import warnings def cosd(angle): @@ -119,21 +120,21 @@ def atand(number): def localize_to_utc(time, location): """ - Converts or localizes a time series to UTC. + Converts ``time`` to UTC, localizing if necessary using location. Parameters ---------- time : datetime.datetime, pandas.DatetimeIndex, or pandas.Series/DataFrame with a DatetimeIndex. - location : pvlib.Location object + location : pvlib.Location object (unused if ``time`` is localized) Returns ------- - pandas object localized to UTC. + datetime.datetime or pandas object localized to UTC. """ if isinstance(time, dt.datetime): if time.tzinfo is None: - time = pytz.timezone(location.tz).localize(time) + time = location.pytz.localize(time) time_utc = time.astimezone(pytz.utc) else: try: