Skip to content

Commit 113917b

Browse files
mikofskicwhansewholmgren
authored
fix read_tmy3 with year coerced not monotonic, breaks soiling (#910)
* fix soiling_hsu for tmy not monotonic * if tmy is coerced to year, then the last hour is in the same year, before the other timestamps, so timestamps are no monotonic * closes #889 * add underscore to is_tmy * add is_tmy to soiling_hsu * add private _fix_tmy_monotonicity * also fix reference footnote in first line of docstring, since used in api.rst, causes doc warning * don't use mutable input args * don't create pd.timedelta object every single time soiling_hsu() called * always create soiling pd.Series for consistency and robust to future edits * fix rain_index is not a tz-aware pd.DatetimeIndex series, so rename as rain_index_vals and use rainfall.index in Soiling * add new test for gh889, check soiling ratio for 24 hours, and check timestamp of first and last hours, and last day * update what's new with fix for TMY3 in soiling_hsu * also change 1st line of kimber soiling docstring, to say calculates energy loss, not soiling ratio, since kimber is 1-hsu, they're opposite! * move fix_tmy_monotonicity to tmy in iotools * and remove it from losses, remove is_tmy args, and remove calls to fix tmy monotonicity * test to make sure if coerced year, last record is Jan 1 of next year * add fix_tmy3_coerce_year_monotonicity to iotools api * revert changes to soiling_hsu, don't use private constants for rain accumulation period or deposition velocity * update kimber soiling example in gallery - to use fix_tmy3_coerce_year_monotonicity - and not use conftest, instead build path - also better comments * remove test for gh889 in test_losses * apply fix to greensboro rain data fixture and remove is_tmy args from kimber tests * add needs_pandas_0_22 to soiling_hsu tests to show that it is required, since requies_scipy masks the requirement * fix path to data in kimber example * add test for HSU soiling defaults for coverage * fix iotools api stickler warning * remove unused pytz import in test_losses * update what's new and api.rst with fix_tmy3_coerce_year_monotonicity * parameters spelling typo in fix_tmy3_monotonicity docstring * also move reference in kimber soiling example to comment in first code cell * add monotonic_index kwarg to read_tmy3 * change name to tmy3_monotonic_index * set monotonic_index default to false but warn that this will change to True in v0.8 * improve tmy3_monotonic_index docstring with reviewer comments, and add note to call only after read_tmy3 with coerce year, to use monotonic_index=True to combine calls, and that there's no validation that input TMY3 has been coerced or if it has already been fixed * also if only used for TMY3 no need to calculate timestep interval, it's always 1-hour * that's all folks * update what's new with monotonic_index kwarg * Update pvlib/tests/iotools/test_tmy.py * change test name to `test_tmy3_monotonic_index` Co-Authored-By: Cliff Hansen <[email protected]> * fix bug in read_tmy3 when year coerced - so the indices are monotonically increasing - add note to API changes and update bug fix in what's new - update docstring in read_tmy3 * explain in coerce_year parmaeter that all of the indices except the last one will have this, value, but the last index will be the value+1 - update the warnings to explain that if the year is coerced that the last index will be the next year - do coerce year on date_ymd which is a Series of Timestamps, not an index so mutable (can assign by integer index) which makes it trivial to change the last index to be the next year - revert most of the changes from the previous commits in this PR * remove `tmy3_monotonic_index` from `api.rst`, `iotools/__init__.py`, and from `tmy.py` * remove `monotonic_index` kwarg from read_tmy3 * remove use of `tmy3_monotonic_index` from sphinx gallery example, test_losses, and test_tmy * remove test for tmy3_monotonic_index and the monotonic_index kwarg * fix test_tmy test to treat last index differently, except for the leap year test which now has a constant diff, yay! * stickler: rm unused imports from tmy, test * suggested changes in read_tmy3 for monotonic index * sed s/data/index/g * wordsmithing docstring: coerce_year kwarg * wordsmithing what's new bug fixes * also consistently use periods for bullets in what's new * Apply suggestions from code review to test last hour in read_tmy3 * and minor wordsmithing in what's new Co-Authored-By: Will Holmgren <[email protected]> Co-authored-by: Cliff Hansen <[email protected]> Co-authored-by: Will Holmgren <[email protected]>
1 parent e545f7e commit 113917b

File tree

7 files changed

+112
-78
lines changed

7 files changed

+112
-78
lines changed

docs/examples/plot_greensboro_kimber_soiling.py

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,20 @@
22
Kimber Soiling Model
33
====================
44
5-
Examples of soiling using the Kimber model [1]_.
6-
7-
References
8-
----------
9-
.. [1] "The Effect of Soiling on Large Grid-Connected Photovoltaic Systems
10-
in California and the Southwest Region of the United States," Adrianne
11-
Kimber, et al., IEEE 4th World Conference on Photovoltaic Energy
12-
Conference, 2006, :doi:`10.1109/WCPEC.2006.279690`
5+
Examples of soiling using the Kimber model.
136
"""
147

158
# %%
16-
# This example shows basic usage of pvlib's Kimber Soiling model with
9+
# This example shows basic usage of pvlib's Kimber Soiling model [1]_ with
1710
# :py:meth:`pvlib.losses.soiling_kimber`.
1811
#
12+
# References
13+
# ----------
14+
# .. [1] "The Effect of Soiling on Large Grid-Connected Photovoltaic Systems
15+
# in California and the Southwest Region of the United States," Adrianne
16+
# Kimber, et al., IEEE 4th World Conference on Photovoltaic Energy
17+
# Conference, 2006, :doi:`10.1109/WCPEC.2006.279690`
18+
#
1919
# The Kimber Soiling model assumes that soiling builds up at a constant rate
2020
# until cleaned either manually or by rain. The rain must reach a threshold to
2121
# clean the panels. When rains exceeds the threshold, it's assumed the earth is
@@ -30,19 +30,22 @@
3030
# step.
3131

3232
from datetime import datetime
33+
import pathlib
3334
from matplotlib import pyplot as plt
3435
from pvlib.iotools import read_tmy3
3536
from pvlib.losses import soiling_kimber
36-
from pvlib.tests.conftest import DATA_DIR
37+
import pvlib
38+
39+
# get full path to the data directory
40+
DATA_DIR = pathlib.Path(pvlib.__file__).parent / 'data'
3741

3842
# get TMY3 data with rain
39-
greensboro = read_tmy3(DATA_DIR / '723170TYA.CSV', coerce_year=1990)
40-
# NOTE: can't use Sand Point, AK b/c Lprecipdepth is -9900, ie: missing
41-
greensboro_rain = greensboro[0].Lprecipdepth
42-
# calculate soiling with no wash dates
43+
greensboro, _ = read_tmy3(DATA_DIR / '723170TYA.CSV', coerce_year=1990)
44+
# get the rain data
45+
greensboro_rain = greensboro.Lprecipdepth
46+
# calculate soiling with no wash dates and cleaning threshold of 25-mm of rain
4347
THRESHOLD = 25.0
44-
soiling_no_wash = soiling_kimber(
45-
greensboro_rain, cleaning_threshold=THRESHOLD, istmy=True)
48+
soiling_no_wash = soiling_kimber(greensboro_rain, cleaning_threshold=THRESHOLD)
4649
soiling_no_wash.name = 'soiling'
4750
# daily rain totals
4851
daily_rain = greensboro_rain.iloc[:-1].resample('D').sum()

docs/sphinx/source/whatsnew/v0.7.2.rst

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,42 +7,47 @@ API Changes
77
~~~~~~~~~~~
88
* :py:class:`pvlib.forecast.ForecastModel` now requires ``start`` and ``end``
99
arguments to be tz-localized. (:issue:`877`, :pull:`879`)
10+
* :py:func:`pvlib.iotools.read_tmy3` when coerced to a single year now returns
11+
indices that are monotonically increasing. Therefore, the last index will be
12+
January 1, 00:00 of the *next* year. (:pull:`910`)
1013

1114
Enhancements
1215
~~~~~~~~~~~~
1316
* TMY3 dataframe returned by :py:func:`~pvlib.iotools.read_tmy3` now contains
1417
the original ``Date (MM/DD/YYYY)`` and ``Time (HH:MM)`` columns that the
15-
indices were parsed from (:pull:`866`)
18+
indices were parsed from. (:pull:`866`)
1619
* Add :py:func:`~pvlib.pvsystem.PVSystem.faiman` and added
1720
``temperature_model='faiman'`` option to :py:class:`~pvlib.modelchain.ModelChain`
1821
(:pull:`897`) (:issue:`836`).
19-
* Add Kimber soiling model :py:func:`pvlib.losses.soiling_kimber` (:pull:`860`)
22+
* Add Kimber soiling model :py:func:`pvlib.losses.soiling_kimber`. (:pull:`860`)
2023

2124
Bug fixes
2225
~~~~~~~~~
2326
* Fix :py:func:`~pvlib.iotools.read_tmy3` parsing when February contains
24-
a leap year (:pull:`866`)
27+
a leap year. (:pull:`866`)
2528
* Implement NREL Developer Network API key for consistent success with API
26-
calls in :py:mod:`pvlib.tests.iotools.test_psm3` (:pull:`873`)
29+
calls in :py:mod:`pvlib.tests.iotools.test_psm3`. (:pull:`873`)
2730
* Fix issue with :py:class:`pvlib.location.Location` creation when
28-
passing ``tz=datetime.timezone.utc`` (:pull:`879`)
31+
passing ``tz=datetime.timezone.utc``. (:pull:`879`)
2932
* Fix documentation homepage title to "pvlib python" based on first heading on
3033
the page. (:pull:`890`) (:issue:`888`)
3134
* Implement `pytest-remotedata <https://github.com/astropy/pytest-remotedata>`_
3235
to increase test suite speed. Requires ``--remote-data`` pytest flag to
33-
execute data retrieval tests over a network.(:issue:`882`)(:pull:`896`)
36+
execute data retrieval tests over a network. (:issue:`882`)(:pull:`896`)
3437
* Fix missing
3538
`0.7.0 what's new <https://pvlib-python.readthedocs.io/en/stable/whatsnew.html#v0-7-0-december-18-2019>`_
3639
entries about changes to ``PVSystem.pvwatts_ac``. Delete unreleased
3740
0.6.4 what's new file. (:issue:`898`)
3841
* Compatibility with cftime 1.1. (:issue:`895`)
39-
* Add Python3.8 to Azure Pipelines CI (:issue:`903`)(:pull:`904`)
40-
* Add documentation build test to Azure Pipelines CI (:pull:`909`)
42+
* Add Python3.8 to Azure Pipelines CI. (:issue:`903`)(:pull:`904`)
43+
* Add documentation build test to Azure Pipelines CI. (:pull:`909`)
4144
* Minor implemention changes to avoid runtime and deprecation warnings in
4245
:py:func:`~pvlib.clearsky.detect_clearsky`,
4346
:py:func:`~pvlib.iam.martin_ruiz_diffuse`,
4447
:py:func:`~pvlib.losses.soiling_hsu`,
4548
and various test functions.
49+
* Fix :py:func:`~pvlib.iotools.read_tmy3` so that when coerced to a single year
50+
the TMY3 index will be monotonically increasing. (:pull:`910`)
4651

4752
Testing
4853
~~~~~~~

pvlib/iotools/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
from pvlib.iotools.tmy import read_tmy2 # noqa: F401
2-
from pvlib.iotools.tmy import read_tmy3 # noqa: F401
1+
from pvlib.iotools.tmy import read_tmy2, read_tmy3 # noqa: F401
32
from pvlib.iotools.epw import read_epw, parse_epw # noqa: F401
43
from pvlib.iotools.srml import read_srml # noqa: F401
54
from pvlib.iotools.srml import read_srml_month_from_solardat # noqa: F401

pvlib/iotools/tmy.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@ def read_tmy3(filename=None, coerce_year=None, recolumn=True):
2828
a relative file path, absolute file path, or url.
2929
3030
coerce_year : None or int, default None
31-
If supplied, the year of the data will be set to this value.
31+
If supplied, the year of the index will be set to `coerce_year`, except
32+
for the last index value which will be set to the *next* year so that
33+
the index increases monotonically.
3234
3335
recolumn : bool, default True
34-
If True, apply standard names to TMY3 columns. Typically this
36+
If ``True``, apply standard names to TMY3 columns. Typically this
3537
results in stripping the units from the column name.
3638
3739
Returns
@@ -49,7 +51,6 @@ def read_tmy3(filename=None, coerce_year=None, recolumn=True):
4951
5052
Notes
5153
-----
52-
5354
The returned structures have the following fields.
5455
5556
=============== ====== ===================
@@ -139,15 +140,16 @@ def read_tmy3(filename=None, coerce_year=None, recolumn=True):
139140
TMYData.PresWthUncertainty Present weather code uncertainty, see [2]_.
140141
============================= ======================================================================================================================================================
141142
142-
.. warning:: TMY3 irradiance data corresponds to the previous hour, so the
143-
first hour is 1AM, corresponding to the net irradiance from midnite to
144-
1AM, and the last hour is midnite of the *next* year, unless the year
145-
has been coerced. EG: if TMY3 was 1988-12-31 24:00:00 this becomes
146-
1989-01-01 00:00:00
143+
.. warning:: TMY3 irradiance data corresponds to the *previous* hour, so
144+
the first index is 1AM, corresponding to the irradiance from midnight
145+
to 1AM, and the last index is midnight of the *next* year. For example,
146+
if the last index in the TMY3 file was 1988-12-31 24:00:00 this becomes
147+
1989-01-01 00:00:00 after calling :func:`~pvlib.iotools.read_tmy3`.
147148
148149
.. warning:: When coercing the year, the last index in the dataframe will
149-
be the first hour of the same year, EG: if TMY3 was 1988-12-31 24:00:00
150-
and year is coerced to 1990 this becomes 1990-01-01
150+
become midnight of the *next* year. For example, if the last index in
151+
the TMY3 was 1988-12-31 24:00:00, and year is coerced to 1990 then this
152+
becomes 1991-01-01 00:00:00.
151153
152154
References
153155
----------
@@ -214,11 +216,12 @@ def read_tmy3(filename=None, coerce_year=None, recolumn=True):
214216
data_ymd[leapday] += datetime.timedelta(days=1)
215217
# shifted_hour is a pd.Series, so use pd.to_timedelta to get a pd.Series of
216218
# timedeltas
219+
if coerce_year is not None:
220+
data_ymd = data_ymd.map(lambda dt: dt.replace(year=coerce_year))
221+
data_ymd.iloc[-1] = data_ymd.iloc[-1].replace(year=coerce_year+1)
217222
# NOTE: as of pvlib-0.6.3, min req is pandas-0.18.1, so pd.to_timedelta
218223
# unit must be in (D,h,m,s,ms,us,ns), but pandas>=0.24 allows unit='hour'
219224
data.index = data_ymd + pd.to_timedelta(shifted_hour, unit='h')
220-
if coerce_year is not None:
221-
data.index = data.index.map(lambda dt: dt.replace(year=coerce_year))
222225

223226
if recolumn:
224227
data = _recolumn(data) # rename to standard column names

pvlib/losses.py

Lines changed: 21 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,14 @@
1111

1212

1313
def soiling_hsu(rainfall, cleaning_threshold, tilt, pm2_5, pm10,
14-
depo_veloc={'2_5': 0.004, '10': 0.0009},
15-
rain_accum_period=pd.Timedelta('1h')):
14+
depo_veloc=None, rain_accum_period=pd.Timedelta('1h')):
1615
"""
1716
Calculates soiling ratio given particulate and rain data using the model
18-
from Humboldt State University [1]_.
17+
from Humboldt State University (HSU).
18+
19+
The HSU soiling model [1]_ returns the soiling ratio, a value between zero
20+
and one which is equivalent to (1 - transmission loss). Therefore a soiling
21+
ratio of 1.0 is equivalent to zero transmission loss.
1922
2023
Parameters
2124
----------
@@ -65,6 +68,10 @@ def soiling_hsu(rainfall, cleaning_threshold, tilt, pm2_5, pm10,
6568
except ImportError:
6669
raise ImportError("The soiling_hsu function requires scipy.")
6770

71+
# never use mutable input arguments
72+
if depo_veloc is None:
73+
depo_veloc = {'2_5': 0.004, '10': 0.0009}
74+
6875
# accumulate rainfall into periods for comparison with threshold
6976
accum_rain = rainfall.rolling(rain_accum_period, closed='right').sum()
7077
# cleaning is True for intervals with rainfall greater than threshold
@@ -91,12 +98,12 @@ def soiling_hsu(rainfall, cleaning_threshold, tilt, pm2_5, pm10,
9198

9299
def soiling_kimber(rainfall, cleaning_threshold=6, soiling_loss_rate=0.0015,
93100
grace_period=14, max_soiling=0.3, manual_wash_dates=None,
94-
initial_soiling=0, rain_accum_period=24, istmy=False):
101+
initial_soiling=0, rain_accum_period=24):
95102
"""
96-
Calculate soiling ratio with rainfall data and a daily soiling rate using
97-
the Kimber soiling model [1]_.
103+
Calculates fraction of energy lost due to soiling given rainfall data and
104+
daily loss rate using the Kimber model.
98105
99-
Kimber soiling model assumes soiling builds up at a daily rate unless
106+
Kimber soiling model [1]_ assumes soiling builds up at a daily rate unless
100107
the daily rainfall is greater than a threshold. The model also assumes that
101108
if daily rainfall has exceeded the threshold within a grace period, then
102109
the ground is too damp to cause soiling build-up. The model also assumes
@@ -127,8 +134,6 @@ def soiling_kimber(rainfall, cleaning_threshold=6, soiling_loss_rate=0.0015,
127134
rain_accum_period : int, default 24
128135
Period for accumulating rainfall to check against `cleaning_threshold`.
129136
The Kimber model defines this period as one day. [hours]
130-
istmy : bool, default False
131-
Fix last timestep in TMY so that it is monotonically increasing.
132137
133138
Returns
134139
-------
@@ -166,23 +171,12 @@ def soiling_kimber(rainfall, cleaning_threshold=6, soiling_loss_rate=0.0015,
166171
# convert grace_period to timedelta
167172
grace_period = datetime.timedelta(days=grace_period)
168173

169-
# get rainfall timezone, timestep as timedelta64, and timestep in int days
170-
rain_tz = rainfall.index.tz
171-
rain_index = rainfall.index.values
172-
timestep_interval = (rain_index[1] - rain_index[0])
174+
# get indices as numpy datetime64, calculate timestep as numpy timedelta64,
175+
# and convert timestep to fraction of days
176+
rain_index_vals = rainfall.index.values
177+
timestep_interval = (rain_index_vals[1] - rain_index_vals[0])
173178
day_fraction = timestep_interval / np.timedelta64(24, 'h')
174179

175-
# if TMY fix to be monotonically increasing by rolling index by 1 interval
176-
# and then adding 1 interval, while the values stay the same
177-
if istmy:
178-
rain_index = np.roll(rain_index, 1) + timestep_interval
179-
# NOTE: numpy datetim64[ns] has no timezone
180-
# convert to datetimeindex at UTC and convert to original timezone
181-
rain_index = pd.DatetimeIndex(rain_index, tz='UTC').tz_convert(rain_tz)
182-
# fixed rainfall timeseries with monotonically increasing index
183-
rainfall = pd.Series(
184-
rainfall.values, index=rain_index, name=rainfall.name)
185-
186180
# accumulate rainfall
187181
accumulated_rainfall = rainfall.rolling(
188182
rain_accum_period, closed='right').sum()
@@ -191,6 +185,7 @@ def soiling_kimber(rainfall, cleaning_threshold=6, soiling_loss_rate=0.0015,
191185
soiling = np.ones_like(rainfall.values) * soiling_loss_rate * day_fraction
192186
soiling[0] = initial_soiling
193187
soiling = np.cumsum(soiling)
188+
soiling = pd.Series(soiling, index=rainfall.index, name='soiling')
194189

195190
# rainfall events that clean the panels
196191
rain_events = accumulated_rainfall > cleaning_threshold
@@ -205,8 +200,9 @@ def soiling_kimber(rainfall, cleaning_threshold=6, soiling_loss_rate=0.0015,
205200

206201
# manual wash dates
207202
if manual_wash_dates is not None:
203+
rain_tz = rainfall.index.tz
204+
# convert manual wash dates to datetime index in the timezone of rain
208205
manual_wash_dates = pd.DatetimeIndex(manual_wash_dates, tz=rain_tz)
209-
soiling = pd.Series(soiling, index=rain_index, name='soiling')
210206
cleaning[manual_wash_dates] = soiling[manual_wash_dates]
211207

212208
# remove soiling by foward filling cleaning where NaN

pvlib/tests/iotools/test_tmy.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pandas as pd
44
import pytest
55
from pvlib.iotools import tmy
6+
from pvlib.iotools import read_tmy3
67
from conftest import DATA_DIR
78

89
# test the API works
@@ -30,21 +31,23 @@ def test_read_tmy3_recolumn():
3031

3132

3233
def test_read_tmy3_norecolumn():
33-
data, meta = tmy.read_tmy3(TMY3_TESTFILE, recolumn=False)
34+
data, _ = tmy.read_tmy3(TMY3_TESTFILE, recolumn=False)
3435
assert 'GHI source' in data.columns
3536

3637

3738
def test_read_tmy3_coerce_year():
3839
coerce_year = 1987
39-
data, meta = tmy.read_tmy3(TMY3_TESTFILE, coerce_year=coerce_year)
40-
assert (data.index.year == 1987).all()
40+
data, _ = tmy.read_tmy3(TMY3_TESTFILE, coerce_year=coerce_year)
41+
assert (data.index[:-1].year == 1987).all()
42+
assert data.index[-1].year == 1988
4143

4244

4345
def test_read_tmy3_no_coerce_year():
4446
coerce_year = None
45-
data, meta = tmy.read_tmy3(TMY3_TESTFILE, coerce_year=coerce_year)
47+
data, _ = tmy.read_tmy3(TMY3_TESTFILE, coerce_year=coerce_year)
4648
assert 1997 and 1999 in data.index.year
47-
49+
assert data.index[-2] == pd.Timestamp('1998-12-31 23:00:00-09:00')
50+
assert data.index[-1] == pd.Timestamp('1999-01-01 00:00:00-09:00')
4851

4952
def test_read_tmy2():
5053
tmy.read_tmy2(TMY2_TESTFILE)
@@ -70,9 +73,10 @@ def test_gh865_read_tmy3_feb_leapyear_hr24():
7073
# now check if it parses correctly when we try to coerce the year
7174
data, _ = read_tmy3(TMY3_FEB_LEAPYEAR, coerce_year=1990)
7275
# if get's here w/o an error, then gh865 is fixed, but let's check anyway
73-
assert all(data.index.year == 1990)
76+
assert all(data.index[:-1].year == 1990)
77+
assert data.index[-1].year == 1991
7478
# let's do a quick sanity check, are the indices monotonically increasing?
75-
assert all(np.diff(data.index[:-1].astype(int)) == 3600000000000)
79+
assert all(np.diff(data.index.astype(int)) == 3600000000000)
7680
# according to the TMY3 manual, each record corresponds to the previous
7781
# hour so check that the 1st hour is 1AM and the last hour is midnite
7882
assert data.index[0].hour == 1

0 commit comments

Comments
 (0)