Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add tests for time conversions in tools package #2341

Open
wants to merge 43 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
9347f12
Add tests for tools.localize_to_utc
markcampanelli Dec 23, 2024
0638773
Add tests for datetime_to_djd and djd_to_datetime
markcampanelli Dec 23, 2024
c1df9a7
Update what's new
markcampanelli Dec 23, 2024
77c0f81
Appease the linter
markcampanelli Dec 23, 2024
6704d06
Fix pandas equality tests for Python 3.9
markcampanelli Dec 23, 2024
dbb1805
Fix pandas equality tests for Python 3.9 more
markcampanelli Dec 23, 2024
6750709
Fix pandas equality tests for Python 3.9 more more
markcampanelli Dec 23, 2024
1144106
Bump miniimum pandas to fix bad test failure
markcampanelli Dec 23, 2024
14715ed
Try alternative pandas test fix
markcampanelli Dec 23, 2024
545c196
Revert change in minimum pandas version
markcampanelli Dec 23, 2024
271fd97
Fix test
markcampanelli Dec 23, 2024
01263c2
Type Location's tz and pytz attributes as advertised
markcampanelli Dec 23, 2024
60a5d94
Add timezone type checks to Location init test
markcampanelli Dec 23, 2024
9ab2ecf
Don't parameterize repetitive tests
markcampanelli Dec 24, 2024
ddef8d1
Update whatsnew for Location bugfix
markcampanelli Dec 24, 2024
4f17f49
Update docstring
markcampanelli Dec 24, 2024
a3c3e03
Improve whatsnew formatting
markcampanelli Dec 24, 2024
5f59417
Support non-fractional int and float and pytz and zoneinfo time zones
markcampanelli Jan 9, 2025
c84801f
Appease the linter
markcampanelli Jan 9, 2025
195efbc
Use zoneinfo as single source of truth and tz as interface point
markcampanelli Jan 10, 2025
1a5efed
Add zoneinfo asserts in tests
markcampanelli Jan 10, 2025
e5af9ae
Try to fix asv ci
markcampanelli Jan 10, 2025
67e9844
See if newer asv works with newer conda
markcampanelli Jan 10, 2025
e35eb42
Remove comments no longer needed
markcampanelli Jan 10, 2025
a1a0261
Remove addition of zoneinfo attribute
markcampanelli Jan 10, 2025
8373ac4
Revise whatsnew bugfix
markcampanelli Jan 10, 2025
eee6f51
Revise whatsnew bugfix more
markcampanelli Jan 10, 2025
9662c1f
Spell my name correctly
markcampanelli Jan 10, 2025
32284ba
The linter strikes back again
markcampanelli Jan 10, 2025
01e4cfc
Merge branch 'main' into add_tools_tests
markcampanelli Jan 27, 2025
c09a328
Fix whatsnew after main merge
markcampanelli Jan 27, 2025
4ef4b69
Address Cliff's comment
markcampanelli Jan 28, 2025
7490792
Adjust Location documentation
markcampanelli Jan 28, 2025
a5f7646
Fix indent
markcampanelli Jan 28, 2025
1382e30
More docstring tweaks
markcampanelli Jan 28, 2025
059e35f
Try to fix bad parens
markcampanelli Jan 28, 2025
f9f07d7
Rearrange docstring
markcampanelli Jan 28, 2025
75db2aa
Appease the linter
markcampanelli Jan 28, 2025
1164c96
Document pytz attribute as read only
markcampanelli Jan 28, 2025
5f6ad14
Consistent read only
markcampanelli Jan 28, 2025
f691bb6
Update pvlib/location.py per review comment
markcampanelli Jan 28, 2025
7cfb170
Add breaking change to whatsnew and fix linting
markcampanelli Jan 28, 2025
ef5c60f
Clarify breaking change in whatsnew
markcampanelli Feb 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/sphinx/source/whatsnew/v0.11.3.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,24 @@ Enhancements
~~~~~~~~~~~~


Bug Fixes
~~~~~~~~~
* Ensure proper tz and pytz types in pvlib.location.Location. tz becomes the
single source of time-zone truth, is the single time-zone setter interface,
and the getter consistently returns an IANA string. datetime.tzinfo
subclasses are fully supported in tz setter. pytz attribute becomes read
only. (:issue:`2340`, :pull:`2341`)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Ensure proper tz and pytz types in pvlib.location.Location. tz becomes the
single source of time-zone truth, is the single time-zone setter interface,
and the getter consistently returns an IANA string. datetime.tzinfo
subclasses are fully supported in tz setter. pytz attribute becomes read
only. (:issue:`2340`, :pull:`2341`)
* Ensure proper tz and pytz types in pvlib.location.Location.

Ensuring types is a bug fix. The remainder describes an API change that should be announced elsewhere. Not really an enhancement, so maybe we put the rest of this note in the "Breaking changes" section, because one can no longer set a value for .pytz.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cwhanse Lmk if my whatsnew change for this looks ok.



Documentation
~~~~~~~~~~~~~


Testing
~~~~~~~
* Add tests for timezone types in pvlib.location.Location.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Add tests for timezone types in pvlib.location.Location.
* Add tests for all input types for the pvlib.location.Location.tz attribute.

(:issue:`2340`, :pull:`2341`)
* Add tests for time conversions in pvlib.tools. (:issue:`2340`, :pull:`2341`)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Add tests for time conversions in pvlib.tools. (:issue:`2340`, :pull:`2341`)
* Add tests for time conversion functions in pvlib.tools. (:issue:`2340`, :pull:`2341`)



Requirements
Expand Down
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,17 @@
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).

As with Location-object initialization, use the ``tz`` attribute to update
the Location's object's time zone after instantiation, and 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 +43,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.
Comment on lines +46 to +53
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.
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, which 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`` 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):
Expand Down
73 changes: 57 additions & 16 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 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
114 changes: 111 additions & 3 deletions pvlib/tests/test_tools.py
Original file line number Diff line number Diff line change
@@ -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', [
Expand Down Expand Up @@ -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
Loading
Loading