diff --git a/pvlib/location.py b/pvlib/location.py index 34da92da6..730f90160 100644 --- a/pvlib/location.py +++ b/pvlib/location.py @@ -18,13 +18,15 @@ 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, either of which can be + individually changed after the Location object has been instantiated and + the other will stay in sync: - * ``tz`` is a IANA timezone string. - * ``pytz`` is a pytz timezone object. + * ``tz`` is a IANA time-zone string. + * ``pytz`` is a pytz time-zone object. Location objects support the print method. @@ -38,12 +40,16 @@ 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 (inc. subclasses + from the pytz and zoneinfo packages), default 'UTC'. + See http://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a + list of valid name strings for IANA time zones. + ints and floats must be non-fractional N-hour offsets from UTC, which + are converted to the 'Etc/GMT-N' format (note limited range of N and + its conventional sign change). + Raises TypeError for time zone conversion issues or + pytz.exceptions.UnknownTimeZoneError when (stringified) time zone is + not recognized by pytz.timezone. altitude : float, optional Altitude from sea level in meters. @@ -59,33 +65,17 @@ class Location: 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): - # This includes pytz timezones. - self.tz = tz.zone - self.pytz = pytz.timezone(tz.zone) - elif isinstance(tz, (int, float)): - self.tz = f"Etc/GMT{int(-tz):+d}" - self.pytz = pytz.timezone(self.tz) - 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): @@ -95,6 +85,35 @@ def __repr__(self): return ('Location: \n ' + '\n '.join( f'{attr}: {getattr(self, attr, None)}' for attr in attrs)) + @property + def tz(self): + # self.pytz holds the single source of time-zone truth. + return self.pytz.zone + + @tz.setter + def tz(self, tz_): + if isinstance(tz_, str): + self.pytz = pytz.timezone(tz_) + elif isinstance(tz_, int): + self.pytz = pytz.timezone(f"Etc/GMT{-tz_:+d}") + elif isinstance(tz_, float): + if tz_ % 1 != 0: + raise TypeError( + "floating point tz does not have zero fractional part: " + f"{tz_}" + ) + + self.pytz = pytz.timezone(f"Etc/GMT{-int(tz_):+d}") + elif isinstance(tz_, datetime.tzinfo): + # Includes time zones generated by pytz and zoneinfo packages. + self.pytz = pytz.timezone(str(tz_)) + else: + raise TypeError( + f"invalid tz specification: {tz_}, must be an IANA time zone " + "string, a non-fractional int/float UTC offset, or a " + "datetime.tzinfo object (including subclasses)" + ) + @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 13fc6c9cc..6371dbcb5 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 @@ -27,27 +28,69 @@ def test_location_all(): Location(32.2, -111, 'US/Arizona', 700, 'Tucson') -@pytest.mark.parametrize('tz', [ - 'America/Phoenix', - datetime.timezone.utc, - pytz.timezone('US/Arizona'), - -7, - -7.0, -]) -def test_location_tz(tz): +@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 type(loc.tz) is str + 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') + + # Updating tz updates pytz. + loc.tz = 7 + assert loc.tz == 'Etc/GMT-7' + assert loc.pytz == pytz.timezone('Etc/GMT-7') + + # Updating pytz updates tz. + loc.pytz = pytz.timezone('US/Arizona') + assert loc.tz == 'US/Arizona' + assert loc.pytz == pytz.timezone('US/Arizona') -def test_location_invalid_tz(): +@pytest.mark.parametrize( + 'tz', [ + 'invalid', + 'Etc/GMT+20', # offset too large. + 20, # offset too large. + ] +) +def test_location_invalid_tz(tz): with pytest.raises(UnknownTimeZoneError): - Location(32.2, -111, 'invalid') + Location(32.2, -111, tz) -def test_location_invalid_tz_type(): +@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():