Skip to content

Commit

Permalink
Support non-fractional int and float and pytz and zoneinfo time zones
Browse files Browse the repository at this point in the history
  • Loading branch information
markcampanelli committed Jan 9, 2025
1 parent a3c3e03 commit 5f59417
Show file tree
Hide file tree
Showing 2 changed files with 106 additions and 44 deletions.
81 changes: 50 additions & 31 deletions pvlib/location.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Check failure on line 50 in pvlib/location.py

View workflow job for this annotation

GitHub Actions / flake8-linter

W291 trailing whitespace
pytz.exceptions.UnknownTimeZoneError when (stringified) time zone is
not recognized by pytz.timezone.
altitude : float, optional
Altitude from sea level in meters.
Expand All @@ -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):
Expand All @@ -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):
"""
Expand Down
69 changes: 56 additions & 13 deletions pvlib/tests/test_location.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime
from unittest.mock import ANY
import zoneinfo

import numpy as np
from numpy import nan
Expand Down Expand Up @@ -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'),

Check failure on line 36 in pvlib/tests/test_location.py

View workflow job for this annotation

GitHub Actions / flake8-linter

E231 missing whitespace after ','
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():
Expand Down

0 comments on commit 5f59417

Please sign in to comment.