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 19 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: 10 additions & 2 deletions docs/sphinx/source/whatsnew/v0.11.3.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,21 @@ Enhancements
~~~~~~~~~~~~


Bug Fixes
~~~~~~~~~
* Ensure proper tz and pytz types in pvlib.location.Location.
(:issue:`2340`, :pull:`2341`)


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 All @@ -26,5 +35,4 @@ Requirements

Contributors
~~~~~~~~~~~~


* Mark Campanellli (:ghuser:`markcampanelli`)
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
* Mark Campanellli (:ghuser:`markcampanelli`)
* Mark Campanelli (:ghuser:`markcampanelli`)

84 changes: 53 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
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
ints and floats must be non-fractional N-hour offsets from UTC, which
ints and floats must be whole number 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.
Expand All @@ -59,38 +65,54 @@ 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):
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):
# 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_}"
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
"floating point tz does not have zero fractional part: "
f"{tz_}"
"floating point tz has non-zero fractional part: "
f"{tz_}. Only whole number offsets are supported."

)

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 "
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
"string, a non-fractional int/float UTC offset, or a "
"string, a whole number int/float UTC offset, or a "

"datetime.tzinfo object (including subclasses)"
)

@classmethod
def from_tmy(cls, tmy_metadata, tmy_data=None, **kwargs):
Expand Down
71 changes: 59 additions & 12 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,22 +28,68 @@ 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():
@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')

# 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')


@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
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
8 changes: 4 additions & 4 deletions pvlib/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,21 +119,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.
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
Converts time to UTC, localizing if necessary using location.
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)
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
location : pvlib.Location object (unused if time is localized)
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
Loading