Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
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
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
- [ ] Closes #xxxx
- [ ] I am familiar with the [contributing guidelines](https://pvlib-python.readthedocs.io/en/latest/contributing.html)
- [ ] Tests added
- [ ] Updates entries to [`docs/sphinx/source/api.rst`](https://github.com/pvlib/pvlib-python/blob/master/docs/sphinx/source/api.rst) for API changes.
- [ ] Updates entries in [`docs/sphinx/source/reference`](https://github.com/pvlib/pvlib-python/blob/master/docs/sphinx/source/reference) for API changes.
- [ ] 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` ``).
- [ ] New code is fully documented. Includes [numpydoc](https://numpydoc.readthedocs.io/en/latest/format.html) compliant docstrings, examples, and comments where necessary.
- [ ] Pull request is nearly complete and ready for detailed review.
Expand Down
1 change: 1 addition & 0 deletions docs/sphinx/source/reference/pv_modeling.rst
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ PV temperature models
temperature.fuentes
temperature.ross
temperature.noct_sam
temperature.prilliman
pvsystem.PVSystem.get_cell_temperature

Temperature Model Parameters
Expand Down
2 changes: 2 additions & 0 deletions docs/sphinx/source/whatsnew/v0.9.1.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Deprecations

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

Bug fixes
~~~~~~~~~
Expand Down
102 changes: 102 additions & 0 deletions pvlib/temperature.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
import pandas as pd
from pvlib.tools import sind
from pvlib._deprecation import warn_deprecated
from pvlib.clearsky import _get_sample_intervals
import scipy
import warnings


TEMPERATURE_MODEL_PARAMETERS = {
'sapm': {
Expand Down Expand Up @@ -821,3 +825,101 @@ def noct_sam(poa_global, temp_air, wind_speed, noct, module_efficiency,
heat_loss = 1 - module_efficiency / tau_alpha
wind_loss = 9.5 / (5.7 + 3.8 * wind_adj)
return temp_air + cell_temp_init * heat_loss * wind_loss


def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None):
"""
Smooth short-term cell temperature transients using the Prilliman model.

The Prilliman et al. model [1]_ applies a weighted moving average to
the output of a steady-state cell temperature model to account for
a module's thermal inertia by smoothing the cell temperature's
response to changing weather conditions.

.. warning::
This implementation requires the time series inputs to be regularly
sampled in time with frequency less than 20 minutes. Data with
irregular time steps should be resampled prior to using this function.

Parameters
----------
temp_cell : pandas Series with DatetimeIndex
Cell temperature modeled with steady-state assumptions. [C]

wind_speed : pandas Series
Wind speed, adjusted to correspond to array height [m/s]

unit_mass : float, default 11.1
Total mass of module divided by its one-sided surface area [kg/m^2]

coefficients : 4-element list-like, optional
Values for coefficients a_0 through a_3, see Eq. 9 of [1]_

Returns
-------
temp_cell : pandas Series
Smoothed version of the input cell temperature [C]

Copy link
Member

Choose a reason for hiding this comment

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

Add here that the original data is returned if too coarsely sampled.

Notes
-----
This smoothing model was developed and validated using the SAPM
cell temperature model for the steady-state input.

References
Copy link
Member

Choose a reason for hiding this comment

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

One more suggestion: explain in Notes how nan in inputs corresponds to nan in output. It makes sense (to me) that output at k is nan if windspeed[k] is nan. As a user, I may be surprised non-nan at position k in output when input temp_cell[k] is nan. But that also makes sense, because the non-nan value can be produced when window of temp_cell prior to k has data.

Output ``temp_cell[k]`` is NaN when input ``wind_speed[k]`` is NaN, or when no non-NaN data are
in the input temperature for the 20 minute window preceding index ``k``.

----------
.. [1] M. Prilliman, J. S. Stein, D. Riley and G. Tamizhmani,
"Transient Weighted Moving-Average Model of Photovoltaic Module
Back-Surface Temperature," IEEE Journal of Photovoltaics, 2020.
:doi:`10.1109/JPHOTOV.2020.2992351`
"""

time_step, window = _get_sample_intervals(temp_cell.index, 20)

if time_step >= 20:
warnings.warn("temperature.prilliman only applies smoothing when "
"the sampling interval is shorter than 20 minutes "
f"(input sampling interval: {time_step} minutes)")
# too coarsely sampled for smoothing to be relevant
Copy link
Member

Choose a reason for hiding this comment

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

I think a warning here would be appropriate

Copy link
Member

Choose a reason for hiding this comment

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

Maybe extend the message to say that the return values are the original temperature?

return temp_cell

window = min(window, # time series > 20 minutes total
len(temp_cell)) # time series < 20 minutes total

# prefix with NaNs so that the rolling window is "full",
# even for the first actual value:
prefix = np.full(window, np.nan)
temp_cell_prefixed = np.append(prefix, temp_cell.values)

# get one row per 20-minute window
H = scipy.linalg.hankel(np.arange(window),
np.arange(window - 1, len(temp_cell_prefixed)))
subsets = temp_cell_prefixed[H].T

# calculate weights for the values in each window
if coefficients is not None:
a = coefficients
else:
# values from [1], Table II
a = [0.0046, 0.00046, -0.00023, -1.6e-5]

wind_speed = wind_speed.values
P = a[0] + a[1]*wind_speed + a[2]*unit_mass + a[3]*wind_speed*unit_mass
timedeltas = np.arange(window, 0, -1) * (time_step*60) # s to min
weights = np.exp(-P[:, np.newaxis] * timedeltas)

# set weights corresponding to the prefix values to zero; otherwise the
# denominator of the weighted average below would be wrong
mask_idx = np.triu_indices(window)
np.fliplr(weights)[mask_idx] = 0

# change the first row of weights from zero to nan -- this is a
# trick to prevent div by zero warning when dividing by summed weights
weights[0, :] = np.nan

# finally, take the weighted average of each window
numerator = np.nansum(subsets[:-1] * weights, axis=1)
denominator = np.sum(weights, axis=1)
smoothed = numerator / denominator
Copy link
Member

Choose a reason for hiding this comment

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

@kanderso-nrel this line emitted a warning despite the comment on lines 967-969 that suggested otherwise. Is this a version-specific issue? My versions are below...

INSTALLED VERSIONS
------------------
commit           : 66e3805b8cabe977f40c05259cc3fcf7ead5687d
python           : 3.9.7.final.0
python-bits      : 64
OS               : Windows
OS-release       : 10
Version          : 10.0.18363
machine          : AMD64
processor        : Intel64 Family 6 Model 140 Stepping 1, GenuineIntel
byteorder        : little
LC_ALL           : None
LANG             : None
LOCALE           : English_United States.1252

pandas           : 1.3.5
numpy            : 1.20.3

Copy link
Member Author

Choose a reason for hiding this comment

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

Hmm, the only way I can reproduce this is if there is a stretch of NaN in the input lasting longer than 20 minutes -- is that true for your case? That comment is referring to a div by zero warning cause by a quirk of this implementation (no weights for the very first value), not a div by zero warning caused by nans in the input.

Copy link
Member

Choose a reason for hiding this comment

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

Ah, thanks. Yes, I had 38 minutes of NaN temperatures. I filled only the middle point with a valid number and the warning disappeared.

smoothed[0] = temp_cell.values[0]
smoothed = pd.Series(smoothed, index=temp_cell.index)
return smoothed
58 changes: 58 additions & 0 deletions pvlib/tests/test_temperature.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from pvlib import temperature, tools
from pvlib._deprecation import pvlibDeprecationWarning

import re


@pytest.fixture
def sapm_default():
Expand Down Expand Up @@ -293,3 +295,59 @@ def test_noct_sam_options():
def test_noct_sam_errors():
with pytest.raises(ValueError):
temperature.noct_sam(1000., 25., 1., 34., 0.2, array_height=3)


def test_prilliman():
# test against values calculated using pvl_MAmodel_2, see pvlib #1081
times = pd.date_range('2019-01-01', freq='5min', periods=8)
cell_temperature = pd.Series([0, 1, 3, 6, 10, 15, 21, 27], index=times)
wind_speed = pd.Series([0, 1, 2, 3, 2, 1, 2, 3])

# default coeffs
expected = pd.Series([0, 0, 0.7047457, 2.21176412, 4.45584299, 7.63635512,
12.26808265, 18.00305776], index=times)
actual = temperature.prilliman(cell_temperature, wind_speed, unit_mass=10)
assert_series_equal(expected, actual)

# custom coeffs
coefficients = [0.0046, 4.5537e-4, -2.2586e-4, -1.5661e-5]
expected = pd.Series([0, 0, 0.70716941, 2.2199537, 4.47537694, 7.6676931,
12.30423167, 18.04215198], index=times)
actual = temperature.prilliman(cell_temperature, wind_speed, unit_mass=10,
coefficients=coefficients)
assert_series_equal(expected, actual)

# even very short inputs < 20 minutes total still work
times = pd.date_range('2019-01-01', freq='1min', periods=8)
cell_temperature = pd.Series([0, 1, 3, 6, 10, 15, 21, 27], index=times)
wind_speed = pd.Series([0, 1, 2, 3, 2, 1, 2, 3])
expected = pd.Series([0, 0, 0.53557976, 1.49270094, 2.85940173,
4.63914366, 7.09641845, 10.24899272], index=times)
actual = temperature.prilliman(cell_temperature, wind_speed, unit_mass=12)
assert_series_equal(expected, actual)


def test_prilliman_coarse():
# if the input series time step is >= 20 min, input is returned unchanged,
# and a warning is emitted
times = pd.date_range('2019-01-01', freq='30min', periods=3)
cell_temperature = pd.Series([0, 1, 3], index=times)
wind_speed = pd.Series([0, 1, 2])
msg = re.escape("temperature.prilliman only applies smoothing when the "
"sampling interval is shorter than 20 minutes (input "
"sampling interval: 30.0 minutes)")
with pytest.warns(UserWarning, match=msg):
actual = temperature.prilliman(cell_temperature, wind_speed)
assert_series_equal(cell_temperature, actual)


def test_prilliman_nans():
# nans in inputs are handled appropriately; nans in input tcell
# are ignored but nans in wind speed cause nan in output
times = pd.date_range('2019-01-01', freq='1min', periods=8)
cell_temperature = pd.Series([0, 1, 3, 6, 10, np.nan, 21, 27], index=times)
wind_speed = pd.Series([0, 1, 2, 3, 2, 1, np.nan, 3])
actual = temperature.prilliman(cell_temperature, wind_speed)
expected = pd.Series([True, True, True, True, True, True, False, True],
index=times)
assert_series_equal(actual.notnull(), expected)