From 7619d048f56cba519a6cdd78103c2f2b52d23c45 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 24 Jan 2022 21:28:11 -0500 Subject: [PATCH 01/20] create temperature.prilliman --- pvlib/temperature.py | 97 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/pvlib/temperature.py b/pvlib/temperature.py index 422270b8d6..d850c996a7 100644 --- a/pvlib/temperature.py +++ b/pvlib/temperature.py @@ -7,6 +7,8 @@ import pandas as pd from pvlib.tools import sind from pvlib._deprecation import warn_deprecated +import scipy + TEMPERATURE_MODEL_PARAMETERS = { 'sapm': { @@ -821,3 +823,98 @@ 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 out short-term model transience using the Prilliman model [1]_. + + The Prilliman et al. model applies an exponential moving average to + the output of a steady-state cell temperature model to account for a + module's thermal inertia and smooth out the cell temperature's response + to changing weather conditions. + + .. warning:: + This implementation requires the time series inputs to be regularly + sampled in time. Data with irregular time steps should be resampled + prior to using this function. + + Parameters + ---------- + temp_cell : pandas Series + 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–a_3 from [1]_ + + Returns + ------- + temp_cell : pandas Series + Smoothed version of the input cell temperature [C] + + Notes + ----- + This smoothing model was developed and validated using the SAPM + model for the steady-state input. + + References + ---------- + .. [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` + """ + + # TODO: check inputs to ensure regular spacing? + + wind_speed = wind_speed.values + time_step = (temp_cell.index[1] - temp_cell.index[0]).total_seconds() + if time_step >= 1200: + # too coarsely sampled for smoothing to be relevant + return temp_cell + + window = int(1200 / time_step) + + # 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 None: + a = coefficients + else: + # values from [1], Table II + a = [0.0046, 0.00046, -0.00023, -1.6e-5] + + 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 + 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 on the next line + 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 + smoothed[0] = temp_cell.values[0] + smoothed = pd.Series(smoothed, index=temp_cell.index) + return smoothed From ff2c006eeb89fae4d6e5aa44026f208c5c725aa8 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 24 Jan 2022 21:29:30 -0500 Subject: [PATCH 02/20] api.rst --- docs/sphinx/source/reference/pv_modeling.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/sphinx/source/reference/pv_modeling.rst b/docs/sphinx/source/reference/pv_modeling.rst index dc2f8f1099..d1ae5a6559 100644 --- a/docs/sphinx/source/reference/pv_modeling.rst +++ b/docs/sphinx/source/reference/pv_modeling.rst @@ -43,6 +43,7 @@ PV temperature models temperature.fuentes temperature.ross temperature.noct_sam + temperature.prilliman pvsystem.PVSystem.get_cell_temperature Temperature Model Parameters From 95bff1320f26af4355e386c25b3b995c2d3b34e0 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 24 Jan 2022 21:34:08 -0500 Subject: [PATCH 03/20] whatsnew --- docs/sphinx/source/whatsnew/v0.9.1.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/sphinx/source/whatsnew/v0.9.1.rst b/docs/sphinx/source/whatsnew/v0.9.1.rst index 4ddcb9bd51..7eab518727 100644 --- a/docs/sphinx/source/whatsnew/v0.9.1.rst +++ b/docs/sphinx/source/whatsnew/v0.9.1.rst @@ -11,6 +11,8 @@ Deprecations Enhancements ~~~~~~~~~~~~ +* Added :py:func:`pvlib.temperature.prilliman` for modeling cell temperature + at short time steps (:issue:`1081`, :pull:`1389`) Bug fixes ~~~~~~~~~ From e5f83db55a2acfe1fb104b3b3c2cd8caea4c3d48 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 24 Jan 2022 21:36:10 -0500 Subject: [PATCH 04/20] Update PULL_REQUEST_TEMPLATE.md --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a9138ca588..f716995416 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -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. From f92e73add54c218ad484bce866267b032c0c7fd1 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 24 Jan 2022 21:41:43 -0500 Subject: [PATCH 05/20] fix comment --- pvlib/temperature.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pvlib/temperature.py b/pvlib/temperature.py index d850c996a7..ee7157f0a2 100644 --- a/pvlib/temperature.py +++ b/pvlib/temperature.py @@ -908,7 +908,7 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): 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 on the next line + # trick to prevent div by zero warning when dividing by summed weights weights[0, :] = np.nan # finally, take the weighted average of each window From 92afe495dc2858c0e3d001f77574fe19c720ccde Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 24 Jan 2022 21:45:09 -0500 Subject: [PATCH 06/20] stickler --- pvlib/temperature.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pvlib/temperature.py b/pvlib/temperature.py index ee7157f0a2..4b71102b09 100644 --- a/pvlib/temperature.py +++ b/pvlib/temperature.py @@ -878,7 +878,7 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): if time_step >= 1200: # too coarsely sampled for smoothing to be relevant return temp_cell - + window = int(1200 / time_step) # prefix with NaNs so that the rolling window is "full", From 56519e24af69cc57ced91b8c02aaf98fcc2f4534 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Tue, 25 Jan 2022 20:58:49 -0500 Subject: [PATCH 07/20] a bit of cleanup --- pvlib/temperature.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pvlib/temperature.py b/pvlib/temperature.py index 4b71102b09..1fdceea1b9 100644 --- a/pvlib/temperature.py +++ b/pvlib/temperature.py @@ -873,13 +873,13 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): # TODO: check inputs to ensure regular spacing? - wind_speed = wind_speed.values time_step = (temp_cell.index[1] - temp_cell.index[0]).total_seconds() if time_step >= 1200: # too coarsely sampled for smoothing to be relevant return temp_cell - window = int(1200 / time_step) + window = min(int(1200 / time_step), # time series > 20 minutes + len(temp_cell)) # time series < 20 minutes # prefix with NaNs so that the rolling window is "full", # even for the first actual value: @@ -892,12 +892,13 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): subsets = temp_cell_prefixed[H].T # calculate weights for the values in each window - if coefficients is None: + 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 weights = np.exp(-P[:, np.newaxis] * timedeltas) From c384415d7cca3d85b4b2d5f1e48cfc5e088d43d4 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Tue, 25 Jan 2022 20:59:41 -0500 Subject: [PATCH 08/20] tests --- pvlib/tests/test_temperature.py | 51 +++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/pvlib/tests/test_temperature.py b/pvlib/tests/test_temperature.py index 40d0ec2d6f..1c2885e985 100644 --- a/pvlib/tests/test_temperature.py +++ b/pvlib/tests/test_temperature.py @@ -293,3 +293,54 @@ 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: + 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]) + 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) From d92250ca48a113b06c525e795e944d73178273a5 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Tue, 25 Jan 2022 21:01:28 -0500 Subject: [PATCH 09/20] fix whatsnew --- docs/sphinx/source/whatsnew/v0.9.1.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sphinx/source/whatsnew/v0.9.1.rst b/docs/sphinx/source/whatsnew/v0.9.1.rst index 7eab518727..c23f50496c 100644 --- a/docs/sphinx/source/whatsnew/v0.9.1.rst +++ b/docs/sphinx/source/whatsnew/v0.9.1.rst @@ -12,7 +12,7 @@ Deprecations Enhancements ~~~~~~~~~~~~ * Added :py:func:`pvlib.temperature.prilliman` for modeling cell temperature - at short time steps (:issue:`1081`, :pull:`1389`) + at short time steps (:issue:`1081`, :pull:`1391`) Bug fixes ~~~~~~~~~ From 985baba87a9b950a74a4c9231a389701a36532c6 Mon Sep 17 00:00:00 2001 From: Kevin Anderson <57452607+kanderso-nrel@users.noreply.github.com> Date: Sat, 29 Jan 2022 10:43:47 -0700 Subject: [PATCH 10/20] Apply suggestions from code review Co-authored-by: Cliff Hansen --- pvlib/temperature.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pvlib/temperature.py b/pvlib/temperature.py index 1fdceea1b9..6c4897de04 100644 --- a/pvlib/temperature.py +++ b/pvlib/temperature.py @@ -827,22 +827,22 @@ def noct_sam(poa_global, temp_air, wind_speed, noct, module_efficiency, def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): """ - Smooth out short-term model transience using the Prilliman model [1]_. + Smooth short-term cell temperature transients using the Prilliman model. - The Prilliman et al. model applies an exponential moving average to - the output of a steady-state cell temperature model to account for a - module's thermal inertia and smooth out the cell temperature's response - to changing weather conditions. + 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. Data with irregular time steps should be resampled + 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 - Cell temperature modeled with steady-state assumptions [C] + 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] @@ -851,7 +851,7 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): 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–a_3 from [1]_ + Values for coefficients a_0 through a_3, see Eq. 9 of [1]_ Returns ------- @@ -861,7 +861,7 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): Notes ----- This smoothing model was developed and validated using the SAPM - model for the steady-state input. + cell temperature model for the steady-state input. References ---------- From 01c4afc10685bb32d4c993bd3a1fdf6107fd9d8e Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Sat, 29 Jan 2022 12:44:50 -0500 Subject: [PATCH 11/20] stickler --- pvlib/temperature.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pvlib/temperature.py b/pvlib/temperature.py index 6c4897de04..ad5bed3cc5 100644 --- a/pvlib/temperature.py +++ b/pvlib/temperature.py @@ -836,8 +836,8 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): .. 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. + sampled in time with frequency less than 20 minutes. Data with + irregular time steps should be resampled prior to using this function. Parameters ---------- From 188e3df08b97f87c2f7732ef6474265ab4cf3261 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Sat, 29 Jan 2022 13:19:56 -0500 Subject: [PATCH 12/20] use _get_sample_intervals; add warning --- pvlib/temperature.py | 16 ++++++++++------ pvlib/tests/test_temperature.py | 11 +++++++++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/pvlib/temperature.py b/pvlib/temperature.py index ad5bed3cc5..91234ad82e 100644 --- a/pvlib/temperature.py +++ b/pvlib/temperature.py @@ -7,7 +7,9 @@ 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 = { @@ -871,15 +873,17 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): :doi:`10.1109/JPHOTOV.2020.2992351` """ - # TODO: check inputs to ensure regular spacing? + time_step, window = _get_sample_intervals(temp_cell.index, 20) - time_step = (temp_cell.index[1] - temp_cell.index[0]).total_seconds() - if time_step >= 1200: + 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 return temp_cell - window = min(int(1200 / time_step), # time series > 20 minutes - len(temp_cell)) # time series < 20 minutes + 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: @@ -900,7 +904,7 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): 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 + 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 diff --git a/pvlib/tests/test_temperature.py b/pvlib/tests/test_temperature.py index 1c2885e985..10773f4381 100644 --- a/pvlib/tests/test_temperature.py +++ b/pvlib/tests/test_temperature.py @@ -8,6 +8,8 @@ from pvlib import temperature, tools from pvlib._deprecation import pvlibDeprecationWarning +import re + @pytest.fixture def sapm_default(): @@ -326,11 +328,16 @@ def test_prilliman(): def test_prilliman_coarse(): - # if the input series time step is >= 20 min, input is returned unchanged: + # 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]) - actual = temperature.prilliman(cell_temperature, wind_speed) + 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) From be1704e7289aa756e4f921c71d880a0f08b77094 Mon Sep 17 00:00:00 2001 From: Kevin Anderson <57452607+kanderso-nrel@users.noreply.github.com> Date: Thu, 3 Feb 2022 07:44:12 -0700 Subject: [PATCH 13/20] Update pvlib/temperature.py Co-authored-by: Anton Driesse --- pvlib/temperature.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pvlib/temperature.py b/pvlib/temperature.py index 91234ad82e..71a303c239 100644 --- a/pvlib/temperature.py +++ b/pvlib/temperature.py @@ -904,7 +904,7 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): 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 + timedeltas = np.arange(window, 0, -1) * (time_step*60) # min to s weights = np.exp(-P[:, np.newaxis] * timedeltas) # set weights corresponding to the prefix values to zero; otherwise the From 5e2c6dacd850942c937be5b9745d7fa6a7cff4e0 Mon Sep 17 00:00:00 2001 From: Kevin Anderson <57452607+kanderso-nrel@users.noreply.github.com> Date: Thu, 3 Feb 2022 14:06:48 -0700 Subject: [PATCH 14/20] Apply suggestions from code review Co-authored-by: Will Holmgren --- pvlib/temperature.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pvlib/temperature.py b/pvlib/temperature.py index 71a303c239..236648e372 100644 --- a/pvlib/temperature.py +++ b/pvlib/temperature.py @@ -843,10 +843,10 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): Parameters ---------- - temp_cell : pandas Series with DatetimeIndex + temp_cell : pandas.Series with DatetimeIndex Cell temperature modeled with steady-state assumptions. [C] - wind_speed : pandas Series + wind_speed : pandas.Series Wind speed, adjusted to correspond to array height [m/s] unit_mass : float, default 11.1 @@ -857,7 +857,7 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): Returns ------- - temp_cell : pandas Series + temp_cell : pandas.Series Smoothed version of the input cell temperature [C] Notes @@ -903,9 +903,9 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): 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 + 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) # min to s - weights = np.exp(-P[:, np.newaxis] * timedeltas) + 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 From 2197f3cfbc7eed7a2cce9a2e8e9d3b7774f6b06e Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 3 Feb 2022 16:41:33 -0500 Subject: [PATCH 15/20] move _get_sample_intervals to pvlib.tools --- pvlib/clearsky.py | 21 ++------------------- pvlib/temperature.py | 2 +- pvlib/tools.py | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/pvlib/clearsky.py b/pvlib/clearsky.py index d54f57767b..9f355669d0 100644 --- a/pvlib/clearsky.py +++ b/pvlib/clearsky.py @@ -679,23 +679,6 @@ def _to_centered_series(vals, idx, samples_per_window): return pd.Series(index=idx, data=vals).shift(shift) -def _get_sample_intervals(times, win_length): - """ Calculates time interval and samples per window for Reno-style clear - sky detection functions - """ - deltas = np.diff(times.values) / np.timedelta64(1, '60s') - - # determine if we can proceed - if times.inferred_freq and len(np.unique(deltas)) == 1: - sample_interval = times[1] - times[0] - sample_interval = sample_interval.seconds / 60 # in minutes - samples_per_window = int(win_length / sample_interval) - return sample_interval, samples_per_window - else: - raise NotImplementedError('algorithm does not yet support unequal ' - 'times. consider resampling your data.') - - def _clear_sample_index(clear_windows, samples_per_window, align, H): """ Returns indices of clear samples in clear windows @@ -849,8 +832,8 @@ def detect_clearsky(measured, clearsky, times=None, window_length=10, else: clear = clearsky - sample_interval, samples_per_window = _get_sample_intervals(times, - window_length) + sample_interval, samples_per_window = \ + tools._get_sample_intervals(times, window_length) # generate matrix of integers for creating windows with indexing H = hankel(np.arange(samples_per_window), diff --git a/pvlib/temperature.py b/pvlib/temperature.py index 236648e372..94c72b26d7 100644 --- a/pvlib/temperature.py +++ b/pvlib/temperature.py @@ -7,7 +7,7 @@ import pandas as pd from pvlib.tools import sind from pvlib._deprecation import warn_deprecated -from pvlib.clearsky import _get_sample_intervals +from pvlib.tools import _get_sample_intervals import scipy import warnings diff --git a/pvlib/tools.py b/pvlib/tools.py index eef80a3b37..94bd042afe 100644 --- a/pvlib/tools.py +++ b/pvlib/tools.py @@ -344,3 +344,20 @@ def _golden_sect_DataFrame(params, VL, VH, func): raise Exception("EXCEPTION:iterations exceeded maximum (50)") return func(df, 'V1'), df['V1'] + + +def _get_sample_intervals(times, win_length): + """ Calculates time interval and samples per window for Reno-style clear + sky detection functions + """ + deltas = np.diff(times.values) / np.timedelta64(1, '60s') + + # determine if we can proceed + if times.inferred_freq and len(np.unique(deltas)) == 1: + sample_interval = times[1] - times[0] + sample_interval = sample_interval.seconds / 60 # in minutes + samples_per_window = int(win_length / sample_interval) + return sample_interval, samples_per_window + else: + raise NotImplementedError('algorithm does not yet support unequal ' + 'times. consider resampling your data.') From 3095afb43fc059f5d1a32dbac23e0ccaca562b0c Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 3 Feb 2022 16:46:49 -0500 Subject: [PATCH 16/20] update temperature.prilliman from review --- pvlib/temperature.py | 73 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 60 insertions(+), 13 deletions(-) diff --git a/pvlib/temperature.py b/pvlib/temperature.py index 94c72b26d7..c10d358842 100644 --- a/pvlib/temperature.py +++ b/pvlib/temperature.py @@ -865,6 +865,10 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): This smoothing model was developed and validated using the SAPM cell temperature model for the steady-state input. + At the beginning of the series where a full 20 minute window is not + possible, "partial" windows including whatever values are available + is used instead. + References ---------- .. [1] M. Prilliman, J. S. Stein, D. Riley and G. Tamizhmani, @@ -873,28 +877,43 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): :doi:`10.1109/JPHOTOV.2020.2992351` """ - time_step, window = _get_sample_intervals(temp_cell.index, 20) + # `sample_interval` in minutes: + sample_interval, samples_per_window = \ + _get_sample_intervals(times=temp_cell.index, win_length=20) - if time_step >= 20: + if sample_interval >= 20: warnings.warn("temperature.prilliman only applies smoothing when " "the sampling interval is shorter than 20 minutes " - f"(input sampling interval: {time_step} minutes)") + f"(input sampling interval: {sample_interval} minutes)") # too coarsely sampled for smoothing to be relevant return temp_cell - window = min(window, # time series > 20 minutes total - len(temp_cell)) # time series < 20 minutes total + # handle cases where the time series is shorter than 20 minutes total + samples_per_window = min(samples_per_window, len(temp_cell)) # prefix with NaNs so that the rolling window is "full", # even for the first actual value: - prefix = np.full(window, np.nan) + prefix = np.full(samples_per_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))) + # generate matrix of integers for creating windows with indexing + H = scipy.linalg.hankel(np.arange(samples_per_window), + np.arange(samples_per_window - 1, + len(temp_cell_prefixed))) + # each row of `subsets` is the values in one window subsets = temp_cell_prefixed[H].T + # `subsets` now looks like this (for 5-minute data, so 4 samples/window) + # where "1." is a stand-in for the actual temperature values + # [[nan, nan, nan, nan], + # [nan, nan, nan, 1.], + # [nan, nan, 1., 1.], + # [nan, 1., 1., 1.], + # [ 1., 1., 1., 1.], + # [ 1., 1., 1., 1.], + # [ 1., 1., 1., 1.], + # ... + # calculate weights for the values in each window if coefficients is not None: a = coefficients @@ -904,19 +923,47 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): 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) # min to s + # calculate the time lag for each sample in the window, paying attention + # to units (seconds for `timedeltas`, minutes for `sample_interval`) + timedeltas = np.arange(samples_per_window, 0, -1) * sample_interval * 60 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) + # denominator of the weighted average below would be wrong. + + # The following arcane magic turns `weights` from something like this + # (using 5-minute inputs, so 4 samples per window -> 4 values per row): + # [[0.0611, 0.1229, 0.2472, 0.4972], + # [0.0611, 0.1229, 0.2472, 0.4972], + # [0.0611, 0.1229, 0.2472, 0.4972], + # [0.0611, 0.1229, 0.2472, 0.4972], + # [0.0611, 0.1229, 0.2472, 0.4972], + # [0.0611, 0.1229, 0.2472, 0.4972], + # [0.0611, 0.1229, 0.2472, 0.4972]] + # ... + + # to this: + # [[0. , 0. , 0. , 0. ], + # [0. , 0. , 0. , 0.4972], + # [0. , 0. , 0.2472, 0.4972], + # [0. , 0.1229, 0.2472, 0.4972], + # [0.0611, 0.1229, 0.2472, 0.4972], + # [0.0611, 0.1229, 0.2472, 0.4972], + # [0.0611, 0.1229, 0.2472, 0.4972]] + # ... + + # note that the triangle of zeros here corresponds to nans in `subsets`. + # it is a bit opaque, but it is fast! + mask_idx = np.triu_indices(samples_per_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 + # finally, take the weighted average of each window: + # use np.nansum for numerator to ignore nans in input temperature, but + # np.sum for denominator to propagate nans in input wind speed. numerator = np.nansum(subsets[:-1] * weights, axis=1) denominator = np.sum(weights, axis=1) smoothed = numerator / denominator From 7cecbbe3b5edd0959bb7a03ca471be3d89b78239 Mon Sep 17 00:00:00 2001 From: Kevin Anderson <57452607+kanderso-nrel@users.noreply.github.com> Date: Thu, 3 Feb 2022 17:09:40 -0700 Subject: [PATCH 17/20] Update pvlib/temperature.py Co-authored-by: Cliff Hansen --- pvlib/temperature.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pvlib/temperature.py b/pvlib/temperature.py index c10d358842..3a14b3c0f9 100644 --- a/pvlib/temperature.py +++ b/pvlib/temperature.py @@ -865,9 +865,9 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): This smoothing model was developed and validated using the SAPM cell temperature model for the steady-state input. - At the beginning of the series where a full 20 minute window is not - possible, "partial" windows including whatever values are available - is used instead. + Smoothing is done using the 20 minute window behind each temperature + value. At the beginning of the series where a full 20 minute window is not + possible, partial windows are used instead. References ---------- From 5fda72029d86a4de51ec1ba8d479d174adb2fbaa Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 3 Feb 2022 20:02:44 -0500 Subject: [PATCH 18/20] fix weights bug for nans; simplify --- pvlib/temperature.py | 23 ++++++++++++----------- pvlib/tests/test_temperature.py | 11 +++++++++++ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/pvlib/temperature.py b/pvlib/temperature.py index 3a14b3c0f9..0f1f24a54e 100644 --- a/pvlib/temperature.py +++ b/pvlib/temperature.py @@ -899,7 +899,7 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): # generate matrix of integers for creating windows with indexing H = scipy.linalg.hankel(np.arange(samples_per_window), np.arange(samples_per_window - 1, - len(temp_cell_prefixed))) + len(temp_cell_prefixed) - 1)) # each row of `subsets` is the values in one window subsets = temp_cell_prefixed[H].T @@ -928,10 +928,12 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): timedeltas = np.arange(samples_per_window, 0, -1) * sample_interval * 60 weights = np.exp(-p[:, np.newaxis] * timedeltas) - # set weights corresponding to the prefix values to zero; otherwise the + # Set weights corresponding to the prefix values to zero; otherwise the # denominator of the weighted average below would be wrong. + # Weights corresponding to (non-prefix) NaN values must be zero too + # for the same reason. - # The following arcane magic turns `weights` from something like this + # Right now `weights` is something like this # (using 5-minute inputs, so 4 samples per window -> 4 values per row): # [[0.0611, 0.1229, 0.2472, 0.4972], # [0.0611, 0.1229, 0.2472, 0.4972], @@ -939,23 +941,22 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): # [0.0611, 0.1229, 0.2472, 0.4972], # [0.0611, 0.1229, 0.2472, 0.4972], # [0.0611, 0.1229, 0.2472, 0.4972], - # [0.0611, 0.1229, 0.2472, 0.4972]] + # [0.0611, 0.1229, 0.2472, 0.4972], # ... - # to this: + # After the next line, the NaNs in `subsets` will be zeros in `weights`, + # like this (with more zeros for any NaNs in the input temperature): + # [[0. , 0. , 0. , 0. ], # [0. , 0. , 0. , 0.4972], # [0. , 0. , 0.2472, 0.4972], # [0. , 0.1229, 0.2472, 0.4972], # [0.0611, 0.1229, 0.2472, 0.4972], # [0.0611, 0.1229, 0.2472, 0.4972], - # [0.0611, 0.1229, 0.2472, 0.4972]] + # [0.0611, 0.1229, 0.2472, 0.4972], # ... - # note that the triangle of zeros here corresponds to nans in `subsets`. - # it is a bit opaque, but it is fast! - mask_idx = np.triu_indices(samples_per_window) - np.fliplr(weights)[mask_idx] = 0 + weights[np.isnan(subsets)] = 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 @@ -964,7 +965,7 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): # finally, take the weighted average of each window: # use np.nansum for numerator to ignore nans in input temperature, but # np.sum for denominator to propagate nans in input wind speed. - numerator = np.nansum(subsets[:-1] * weights, axis=1) + numerator = np.nansum(subsets * weights, axis=1) denominator = np.sum(weights, axis=1) smoothed = numerator / denominator smoothed[0] = temp_cell.values[0] diff --git a/pvlib/tests/test_temperature.py b/pvlib/tests/test_temperature.py index 10773f4381..0d5066cd1e 100644 --- a/pvlib/tests/test_temperature.py +++ b/pvlib/tests/test_temperature.py @@ -351,3 +351,14 @@ def test_prilliman_nans(): expected = pd.Series([True, True, True, True, True, True, False, True], index=times) assert_series_equal(actual.notnull(), expected) + + # check that nan temperatures do not mess up the weighted average; + # the original implementation did not set weight=0 for nan values, + # so the numerator of the weighted average ignored nans but the + # denominator (total weight) still included the weight for the nan. + cell_temperature = pd.Series([1, 1, 1, 1, 1, np.nan, 1, 1], index=times) + wind_speed = pd.Series(1, index=times) + actual = temperature.prilliman(cell_temperature, wind_speed) + # original implementation would return some values < 1 here + expected = pd.Series(1., index=times) + assert_series_equal(actual, expected) From b8d5479e906687a1bfdeaf8448d53da14a3a7670 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 3 Feb 2022 20:42:53 -0500 Subject: [PATCH 19/20] add Note about nans --- pvlib/temperature.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pvlib/temperature.py b/pvlib/temperature.py index 0f1f24a54e..8fde141de7 100644 --- a/pvlib/temperature.py +++ b/pvlib/temperature.py @@ -869,6 +869,10 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): value. At the beginning of the series where a full 20 minute window is not possible, partial windows are used instead. + 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``. + References ---------- .. [1] M. Prilliman, J. S. Stein, D. Riley and G. Tamizhmani, From 0a0a1f39356b4fa0e5906f5daf82a6c5b57b9f8e Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Fri, 4 Feb 2022 10:10:09 -0500 Subject: [PATCH 20/20] update docstring and warning text --- pvlib/temperature.py | 6 ++++-- pvlib/tests/test_temperature.py | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pvlib/temperature.py b/pvlib/temperature.py index 8fde141de7..eb960701bd 100644 --- a/pvlib/temperature.py +++ b/pvlib/temperature.py @@ -858,7 +858,8 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): Returns ------- temp_cell : pandas.Series - Smoothed version of the input cell temperature [C] + Smoothed version of the input cell temperature. Input temperature + with sampling interval >= 20 minutes is returned unchanged. [C] Notes ----- @@ -888,7 +889,8 @@ def prilliman(temp_cell, wind_speed, unit_mass=11.1, coefficients=None): if sample_interval >= 20: warnings.warn("temperature.prilliman only applies smoothing when " "the sampling interval is shorter than 20 minutes " - f"(input sampling interval: {sample_interval} minutes)") + f"(input sampling interval: {sample_interval} minutes);" + " returning input temperature series unchanged") # too coarsely sampled for smoothing to be relevant return temp_cell diff --git a/pvlib/tests/test_temperature.py b/pvlib/tests/test_temperature.py index 0d5066cd1e..5630f441e5 100644 --- a/pvlib/tests/test_temperature.py +++ b/pvlib/tests/test_temperature.py @@ -335,7 +335,8 @@ def test_prilliman_coarse(): 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)") + "sampling interval: 30.0 minutes); returning " + "input temperature series unchanged") with pytest.warns(UserWarning, match=msg): actual = temperature.prilliman(cell_temperature, wind_speed) assert_series_equal(cell_temperature, actual)