Skip to content

Commit f86d71d

Browse files
kandersolarcwhanseadriessewholmgren
authored
Add Prilliman et al transience model to pvlib.temperature (#1391)
* create temperature.prilliman * api.rst * whatsnew * Update PULL_REQUEST_TEMPLATE.md * fix comment * stickler * a bit of cleanup * tests * fix whatsnew * Apply suggestions from code review Co-authored-by: Cliff Hansen <[email protected]> * stickler * use _get_sample_intervals; add warning * Update pvlib/temperature.py Co-authored-by: Anton Driesse <[email protected]> * Apply suggestions from code review Co-authored-by: Will Holmgren <[email protected]> * move _get_sample_intervals to pvlib.tools * update temperature.prilliman from review * Update pvlib/temperature.py Co-authored-by: Cliff Hansen <[email protected]> * fix weights bug for nans; simplify * add Note about nans * update docstring and warning text Co-authored-by: Cliff Hansen <[email protected]> Co-authored-by: Anton Driesse <[email protected]> Co-authored-by: Will Holmgren <[email protected]>
1 parent 291631a commit f86d71d

File tree

7 files changed

+249
-20
lines changed

7 files changed

+249
-20
lines changed

.github/PULL_REQUEST_TEMPLATE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
- [ ] Closes #xxxx
44
- [ ] I am familiar with the [contributing guidelines](https://pvlib-python.readthedocs.io/en/latest/contributing.html)
55
- [ ] Tests added
6-
- [ ] Updates entries to [`docs/sphinx/source/api.rst`](https://github.com/pvlib/pvlib-python/blob/master/docs/sphinx/source/api.rst) for API changes.
6+
- [ ] Updates entries in [`docs/sphinx/source/reference`](https://github.com/pvlib/pvlib-python/blob/master/docs/sphinx/source/reference) for API changes.
77
- [ ] Adds description and name entries in the appropriate "what's new" file in [`docs/sphinx/source/whatsnew`](https://github.com/pvlib/pvlib-python/tree/master/docs/sphinx/source/whatsnew) for all changes. Includes link to the GitHub Issue with `` :issue:`num` `` or this Pull Request with `` :pull:`num` ``. Includes contributor name and/or GitHub username (link with `` :ghuser:`user` ``).
88
- [ ] New code is fully documented. Includes [numpydoc](https://numpydoc.readthedocs.io/en/latest/format.html) compliant docstrings, examples, and comments where necessary.
99
- [ ] Pull request is nearly complete and ready for detailed review.

docs/sphinx/source/reference/pv_modeling.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ PV temperature models
4343
temperature.fuentes
4444
temperature.ross
4545
temperature.noct_sam
46+
temperature.prilliman
4647
pvsystem.PVSystem.get_cell_temperature
4748

4849
Temperature Model Parameters

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ Deprecations
1111

1212
Enhancements
1313
~~~~~~~~~~~~
14+
* Added :py:func:`pvlib.temperature.prilliman` for modeling cell temperature
15+
at short time steps (:issue:`1081`, :pull:`1391`)
1416

1517
Bug fixes
1618
~~~~~~~~~

pvlib/clearsky.py

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -679,23 +679,6 @@ def _to_centered_series(vals, idx, samples_per_window):
679679
return pd.Series(index=idx, data=vals).shift(shift)
680680

681681

682-
def _get_sample_intervals(times, win_length):
683-
""" Calculates time interval and samples per window for Reno-style clear
684-
sky detection functions
685-
"""
686-
deltas = np.diff(times.values) / np.timedelta64(1, '60s')
687-
688-
# determine if we can proceed
689-
if times.inferred_freq and len(np.unique(deltas)) == 1:
690-
sample_interval = times[1] - times[0]
691-
sample_interval = sample_interval.seconds / 60 # in minutes
692-
samples_per_window = int(win_length / sample_interval)
693-
return sample_interval, samples_per_window
694-
else:
695-
raise NotImplementedError('algorithm does not yet support unequal '
696-
'times. consider resampling your data.')
697-
698-
699682
def _clear_sample_index(clear_windows, samples_per_window, align, H):
700683
"""
701684
Returns indices of clear samples in clear windows
@@ -849,8 +832,8 @@ def detect_clearsky(measured, clearsky, times=None, window_length=10,
849832
else:
850833
clear = clearsky
851834

852-
sample_interval, samples_per_window = _get_sample_intervals(times,
853-
window_length)
835+
sample_interval, samples_per_window = \
836+
tools._get_sample_intervals(times, window_length)
854837

855838
# generate matrix of integers for creating windows with indexing
856839
H = hankel(np.arange(samples_per_window),

pvlib/temperature.py

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
import pandas as pd
88
from pvlib.tools import sind
99
from pvlib._deprecation import warn_deprecated
10+
from pvlib.tools import _get_sample_intervals
11+
import scipy
12+
import warnings
13+
1014

1115
TEMPERATURE_MODEL_PARAMETERS = {
1216
'sapm': {
@@ -821,3 +825,155 @@ def noct_sam(poa_global, temp_air, wind_speed, noct, module_efficiency,
821825
heat_loss = 1 - module_efficiency / tau_alpha
822826
wind_loss = 9.5 / (5.7 + 3.8 * wind_adj)
823827
return temp_air + cell_temp_init * heat_loss * wind_loss
828+
829+
830+
def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None):
831+
"""
832+
Smooth short-term cell temperature transients using the Prilliman model.
833+
834+
The Prilliman et al. model [1]_ applies a weighted moving average to
835+
the output of a steady-state cell temperature model to account for
836+
a module's thermal inertia by smoothing the cell temperature's
837+
response to changing weather conditions.
838+
839+
.. warning::
840+
This implementation requires the time series inputs to be regularly
841+
sampled in time with frequency less than 20 minutes. Data with
842+
irregular time steps should be resampled prior to using this function.
843+
844+
Parameters
845+
----------
846+
temp_cell : pandas.Series with DatetimeIndex
847+
Cell temperature modeled with steady-state assumptions. [C]
848+
849+
wind_speed : pandas.Series
850+
Wind speed, adjusted to correspond to array height [m/s]
851+
852+
unit_mass : float, default 11.1
853+
Total mass of module divided by its one-sided surface area [kg/m^2]
854+
855+
coefficients : 4-element list-like, optional
856+
Values for coefficients a_0 through a_3, see Eq. 9 of [1]_
857+
858+
Returns
859+
-------
860+
temp_cell : pandas.Series
861+
Smoothed version of the input cell temperature. Input temperature
862+
with sampling interval >= 20 minutes is returned unchanged. [C]
863+
864+
Notes
865+
-----
866+
This smoothing model was developed and validated using the SAPM
867+
cell temperature model for the steady-state input.
868+
869+
Smoothing is done using the 20 minute window behind each temperature
870+
value. At the beginning of the series where a full 20 minute window is not
871+
possible, partial windows are used instead.
872+
873+
Output ``temp_cell[k]`` is NaN when input ``wind_speed[k]`` is NaN, or
874+
when no non-NaN data are in the input temperature for the 20 minute window
875+
preceding index ``k``.
876+
877+
References
878+
----------
879+
.. [1] M. Prilliman, J. S. Stein, D. Riley and G. Tamizhmani,
880+
"Transient Weighted Moving-Average Model of Photovoltaic Module
881+
Back-Surface Temperature," IEEE Journal of Photovoltaics, 2020.
882+
:doi:`10.1109/JPHOTOV.2020.2992351`
883+
"""
884+
885+
# `sample_interval` in minutes:
886+
sample_interval, samples_per_window = \
887+
_get_sample_intervals(times=temp_cell.index, win_length=20)
888+
889+
if sample_interval >= 20:
890+
warnings.warn("temperature.prilliman only applies smoothing when "
891+
"the sampling interval is shorter than 20 minutes "
892+
f"(input sampling interval: {sample_interval} minutes);"
893+
" returning input temperature series unchanged")
894+
# too coarsely sampled for smoothing to be relevant
895+
return temp_cell
896+
897+
# handle cases where the time series is shorter than 20 minutes total
898+
samples_per_window = min(samples_per_window, len(temp_cell))
899+
900+
# prefix with NaNs so that the rolling window is "full",
901+
# even for the first actual value:
902+
prefix = np.full(samples_per_window, np.nan)
903+
temp_cell_prefixed = np.append(prefix, temp_cell.values)
904+
905+
# generate matrix of integers for creating windows with indexing
906+
H = scipy.linalg.hankel(np.arange(samples_per_window),
907+
np.arange(samples_per_window - 1,
908+
len(temp_cell_prefixed) - 1))
909+
# each row of `subsets` is the values in one window
910+
subsets = temp_cell_prefixed[H].T
911+
912+
# `subsets` now looks like this (for 5-minute data, so 4 samples/window)
913+
# where "1." is a stand-in for the actual temperature values
914+
# [[nan, nan, nan, nan],
915+
# [nan, nan, nan, 1.],
916+
# [nan, nan, 1., 1.],
917+
# [nan, 1., 1., 1.],
918+
# [ 1., 1., 1., 1.],
919+
# [ 1., 1., 1., 1.],
920+
# [ 1., 1., 1., 1.],
921+
# ...
922+
923+
# calculate weights for the values in each window
924+
if coefficients is not None:
925+
a = coefficients
926+
else:
927+
# values from [1], Table II
928+
a = [0.0046, 0.00046, -0.00023, -1.6e-5]
929+
930+
wind_speed = wind_speed.values
931+
p = a[0] + a[1]*wind_speed + a[2]*unit_mass + a[3]*wind_speed*unit_mass
932+
# calculate the time lag for each sample in the window, paying attention
933+
# to units (seconds for `timedeltas`, minutes for `sample_interval`)
934+
timedeltas = np.arange(samples_per_window, 0, -1) * sample_interval * 60
935+
weights = np.exp(-p[:, np.newaxis] * timedeltas)
936+
937+
# Set weights corresponding to the prefix values to zero; otherwise the
938+
# denominator of the weighted average below would be wrong.
939+
# Weights corresponding to (non-prefix) NaN values must be zero too
940+
# for the same reason.
941+
942+
# Right now `weights` is something like this
943+
# (using 5-minute inputs, so 4 samples per window -> 4 values per row):
944+
# [[0.0611, 0.1229, 0.2472, 0.4972],
945+
# [0.0611, 0.1229, 0.2472, 0.4972],
946+
# [0.0611, 0.1229, 0.2472, 0.4972],
947+
# [0.0611, 0.1229, 0.2472, 0.4972],
948+
# [0.0611, 0.1229, 0.2472, 0.4972],
949+
# [0.0611, 0.1229, 0.2472, 0.4972],
950+
# [0.0611, 0.1229, 0.2472, 0.4972],
951+
# ...
952+
953+
# After the next line, the NaNs in `subsets` will be zeros in `weights`,
954+
# like this (with more zeros for any NaNs in the input temperature):
955+
956+
# [[0. , 0. , 0. , 0. ],
957+
# [0. , 0. , 0. , 0.4972],
958+
# [0. , 0. , 0.2472, 0.4972],
959+
# [0. , 0.1229, 0.2472, 0.4972],
960+
# [0.0611, 0.1229, 0.2472, 0.4972],
961+
# [0.0611, 0.1229, 0.2472, 0.4972],
962+
# [0.0611, 0.1229, 0.2472, 0.4972],
963+
# ...
964+
965+
weights[np.isnan(subsets)] = 0
966+
967+
# change the first row of weights from zero to nan -- this is a
968+
# trick to prevent div by zero warning when dividing by summed weights
969+
weights[0, :] = np.nan
970+
971+
# finally, take the weighted average of each window:
972+
# use np.nansum for numerator to ignore nans in input temperature, but
973+
# np.sum for denominator to propagate nans in input wind speed.
974+
numerator = np.nansum(subsets * weights, axis=1)
975+
denominator = np.sum(weights, axis=1)
976+
smoothed = numerator / denominator
977+
smoothed[0] = temp_cell.values[0]
978+
smoothed = pd.Series(smoothed, index=temp_cell.index)
979+
return smoothed

pvlib/tests/test_temperature.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from pvlib import temperature, tools
99
from pvlib._deprecation import pvlibDeprecationWarning
1010

11+
import re
12+
1113

1214
@pytest.fixture
1315
def sapm_default():
@@ -293,3 +295,71 @@ def test_noct_sam_options():
293295
def test_noct_sam_errors():
294296
with pytest.raises(ValueError):
295297
temperature.noct_sam(1000., 25., 1., 34., 0.2, array_height=3)
298+
299+
300+
def test_prilliman():
301+
# test against values calculated using pvl_MAmodel_2, see pvlib #1081
302+
times = pd.date_range('2019-01-01', freq='5min', periods=8)
303+
cell_temperature = pd.Series([0, 1, 3, 6, 10, 15, 21, 27], index=times)
304+
wind_speed = pd.Series([0, 1, 2, 3, 2, 1, 2, 3])
305+
306+
# default coeffs
307+
expected = pd.Series([0, 0, 0.7047457, 2.21176412, 4.45584299, 7.63635512,
308+
12.26808265, 18.00305776], index=times)
309+
actual = temperature.prilliman(cell_temperature, wind_speed, unit_mass=10)
310+
assert_series_equal(expected, actual)
311+
312+
# custom coeffs
313+
coefficients = [0.0046, 4.5537e-4, -2.2586e-4, -1.5661e-5]
314+
expected = pd.Series([0, 0, 0.70716941, 2.2199537, 4.47537694, 7.6676931,
315+
12.30423167, 18.04215198], index=times)
316+
actual = temperature.prilliman(cell_temperature, wind_speed, unit_mass=10,
317+
coefficients=coefficients)
318+
assert_series_equal(expected, actual)
319+
320+
# even very short inputs < 20 minutes total still work
321+
times = pd.date_range('2019-01-01', freq='1min', periods=8)
322+
cell_temperature = pd.Series([0, 1, 3, 6, 10, 15, 21, 27], index=times)
323+
wind_speed = pd.Series([0, 1, 2, 3, 2, 1, 2, 3])
324+
expected = pd.Series([0, 0, 0.53557976, 1.49270094, 2.85940173,
325+
4.63914366, 7.09641845, 10.24899272], index=times)
326+
actual = temperature.prilliman(cell_temperature, wind_speed, unit_mass=12)
327+
assert_series_equal(expected, actual)
328+
329+
330+
def test_prilliman_coarse():
331+
# if the input series time step is >= 20 min, input is returned unchanged,
332+
# and a warning is emitted
333+
times = pd.date_range('2019-01-01', freq='30min', periods=3)
334+
cell_temperature = pd.Series([0, 1, 3], index=times)
335+
wind_speed = pd.Series([0, 1, 2])
336+
msg = re.escape("temperature.prilliman only applies smoothing when the "
337+
"sampling interval is shorter than 20 minutes (input "
338+
"sampling interval: 30.0 minutes); returning "
339+
"input temperature series unchanged")
340+
with pytest.warns(UserWarning, match=msg):
341+
actual = temperature.prilliman(cell_temperature, wind_speed)
342+
assert_series_equal(cell_temperature, actual)
343+
344+
345+
def test_prilliman_nans():
346+
# nans in inputs are handled appropriately; nans in input tcell
347+
# are ignored but nans in wind speed cause nan in output
348+
times = pd.date_range('2019-01-01', freq='1min', periods=8)
349+
cell_temperature = pd.Series([0, 1, 3, 6, 10, np.nan, 21, 27], index=times)
350+
wind_speed = pd.Series([0, 1, 2, 3, 2, 1, np.nan, 3])
351+
actual = temperature.prilliman(cell_temperature, wind_speed)
352+
expected = pd.Series([True, True, True, True, True, True, False, True],
353+
index=times)
354+
assert_series_equal(actual.notnull(), expected)
355+
356+
# check that nan temperatures do not mess up the weighted average;
357+
# the original implementation did not set weight=0 for nan values,
358+
# so the numerator of the weighted average ignored nans but the
359+
# denominator (total weight) still included the weight for the nan.
360+
cell_temperature = pd.Series([1, 1, 1, 1, 1, np.nan, 1, 1], index=times)
361+
wind_speed = pd.Series(1, index=times)
362+
actual = temperature.prilliman(cell_temperature, wind_speed)
363+
# original implementation would return some values < 1 here
364+
expected = pd.Series(1., index=times)
365+
assert_series_equal(actual, expected)

pvlib/tools.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,3 +344,20 @@ def _golden_sect_DataFrame(params, VL, VH, func):
344344
raise Exception("EXCEPTION:iterations exceeded maximum (50)")
345345

346346
return func(df, 'V1'), df['V1']
347+
348+
349+
def _get_sample_intervals(times, win_length):
350+
""" Calculates time interval and samples per window for Reno-style clear
351+
sky detection functions
352+
"""
353+
deltas = np.diff(times.values) / np.timedelta64(1, '60s')
354+
355+
# determine if we can proceed
356+
if times.inferred_freq and len(np.unique(deltas)) == 1:
357+
sample_interval = times[1] - times[0]
358+
sample_interval = sample_interval.seconds / 60 # in minutes
359+
samples_per_window = int(win_length / sample_interval)
360+
return sample_interval, samples_per_window
361+
else:
362+
raise NotImplementedError('algorithm does not yet support unequal '
363+
'times. consider resampling your data.')

0 commit comments

Comments
 (0)