Skip to content

Commit

Permalink
Add tests for time conversions in tools package (#2341)
Browse files Browse the repository at this point in the history
* Add tests for tools.localize_to_utc

* Add tests for datetime_to_djd and djd_to_datetime

* Update what's new

* Appease the linter

* Fix pandas equality tests for Python 3.9

* Fix pandas equality tests for Python 3.9 more

* Fix pandas equality tests for Python 3.9 more more

* Bump miniimum pandas to fix bad test failure

* Try alternative pandas test fix

* Revert change in minimum pandas version

* Fix test

* Type Location's tz and pytz attributes as advertised

* Add timezone type checks to Location init test

* Don't parameterize repetitive tests

* Update whatsnew for Location bugfix

* Update docstring

* Improve whatsnew formatting

* Support non-fractional int and float and pytz and zoneinfo time zones

* Appease the linter

* Use zoneinfo as single source of truth and tz as interface point

* Add zoneinfo asserts in tests

* Try to fix asv ci

* See if newer asv works with newer conda

* Remove comments no longer needed

* Remove addition of zoneinfo attribute

* Revise whatsnew bugfix

* Revise whatsnew bugfix more

* Spell my name correctly

* The linter strikes back again

* Fix whatsnew after main merge

* Address Cliff's comment

* Adjust Location documentation

* Fix indent

* More docstring tweaks

* Try to fix bad parens

* Rearrange docstring

* Appease the linter

* Document pytz attribute as read only

* Consistent read only

* Update pvlib/location.py per review comment

Co-authored-by: Cliff Hansen <[email protected]>

* Add breaking change to whatsnew and fix linting

* Clarify breaking change in whatsnew

* Update whatsnew ordering

* Implement review comments on documentation

* Missed saving changes and appease the linter

* Apply suggestions from code review

---------

Co-authored-by: Cliff Hansen <[email protected]>
  • Loading branch information
markcampanelli and cwhanse authored Mar 9, 2025
1 parent afc90f6 commit 72ced3c
Show file tree
Hide file tree
Showing 5 changed files with 259 additions and 56 deletions.
15 changes: 14 additions & 1 deletion docs/sphinx/source/whatsnew/v0.11.3.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ Bug fixes
* :py:class:`~pvlib.modelchain.ModelChain` now requires only a minimal set of
parameters to run the SAPM electrical model. (:issue:`2369`, :pull:`2393`)
* Correct keys for First Solar modules in `~pvlib.spectrum.spectral_factor_pvspec` (:issue:`2398`, :pull:`2400`)
* 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`)


Deprecations
Expand Down Expand Up @@ -40,6 +45,9 @@ Testing
* Moved tests folder to `/tests` and data exclusively used for testing to `/tests/data`.
(:issue:`2271`, :pull:`2277`)
* Added Python 3.13 to test suite. (:pull:`2258`)
* Add tests for all input types for the pvlib.location.Location.tz attribute.
(:issue:`2340`, :pull:`2341`)
* Add tests for time-conversion functions in pvlib.tools. (:issue:`2340`, :pull:`2341`)


Requirements
Expand All @@ -53,15 +61,20 @@ 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 pvlib.location.Location.pytz attribute is now read only. The
pytz attribute is now set internally to be consistent with the
pvlib.location.Location.tz attribute. (:issue:`2340`, :pull:`2341`)

Contributors
~~~~~~~~~~~~
* Rajiv Daxini (:ghuser:`RDaxini`)
* Mark Campanelli (:ghuser:`markcampanelli`)
* Cliff Hansen (:ghuser:`cwhanse`)
* Jason Lun Leung (:ghuser:`jason-rpkt`)
* Manoj K S (:ghuser:`manojks1999`)
* Kurt Rhee (:ghuser:`kurt-rhee`)
* Ayush jariyal (:ghuser:`ayushjariyal`)
* Kevin Anderson (:ghuser:`kandersolar`)
* Echedey Luis (:ghuser:`echedey-ls`)
* Mark Campanelli (:ghuser:`markcampanelli`)
102 changes: 71 additions & 31 deletions pvlib/location.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import pathlib
import datetime
import zoneinfo

import pandas as pd
import pytz
Expand All @@ -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.
Expand All @@ -38,12 +42,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, 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.
The `tz` attribute is represented as a valid IANA time zone name
string.
altitude : float, optional
Altitude from sea level in meters.
Expand All @@ -54,43 +62,75 @@ class Location:
name : string, optional
Sets the name attribute of the Location object.
Raises
------
ValueError
when the time zone ``tz`` 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):
Expand Down
11 changes: 6 additions & 5 deletions pvlib/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
73 changes: 57 additions & 16 deletions 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 All @@ -9,7 +10,6 @@
import pytest

import pytz
from pytz.exceptions import UnknownTimeZoneError

import pvlib
from pvlib import location
Expand All @@ -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():
Expand Down
Loading

0 comments on commit 72ced3c

Please sign in to comment.