diff --git a/docs/sphinx/source/whatsnew/v0.10.2.rst b/docs/sphinx/source/whatsnew/v0.10.2.rst index e1bf89dcd3..6288b077fc 100644 --- a/docs/sphinx/source/whatsnew/v0.10.2.rst +++ b/docs/sphinx/source/whatsnew/v0.10.2.rst @@ -19,12 +19,17 @@ Enhancements :py:func:`pvlib.clearsky.detect_clearsky` (:issue:`1808`, :pull:`1784`) * Added a continuous version of the Erbs diffuse-fraction/decomposition model. :py:func:`pvlib.irradiance.erbs_driesse` (:issue:`1755`, :pull:`1834`) - +* Added :py:func:`~pvlib.iam.interp` option as AOI losses model in + :py:class:`pvlib.modelchain.ModelChain` and + :py:class:`pvlib.pvsystem.PVSystem`. (:issue:`1742`, :pull:`1832`) Bug fixes ~~~~~~~~~ * :py:func:`~pvlib.iotools.get_psm3` no longer incorrectly returns clear-sky DHI instead of clear-sky GHI when requesting ``ghi_clear``. (:pull:`1819`) +* :py:class:`pvlib.pvsystem.PVSystem` now correctly passes ``n_ar`` module + parameter to :py:func:`pvlib.iam.physical` when this IAM model is specified + or inferred. (:pull:`1832`) Testing ~~~~~~~ @@ -53,6 +58,8 @@ Contributors * Adam R. Jensen (:ghuser:`AdamRJensen`) * Abigail Jones (:ghuser:`ajonesr`) * Taos Transue (:ghuser:`reepoi`) +* Echedey Luis (:ghuser:`echedey-ls`) +* Todd Karin (:ghuser:`toddkarin`) * NativeSci (:ghuser:`nativesci`) * Anton Driesse (:ghuser:`adriesse`) * Lukas Grossar (:ghuser:`tongpu`) diff --git a/pvlib/iam.py b/pvlib/iam.py index b02d20989b..d72866e1e9 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -20,7 +20,7 @@ 'physical': {'n', 'K', 'L'}, 'martin_ruiz': {'a_r'}, 'sapm': {'B0', 'B1', 'B2', 'B3', 'B4', 'B5'}, - 'interp': set() + 'interp': {'theta_ref', 'iam_ref'} } diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 3d2404689f..389ca68a54 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -14,10 +14,9 @@ from typing import Union, Tuple, Optional, TypeVar from pvlib import (atmosphere, clearsky, inverter, pvsystem, solarposition, - temperature) + temperature, iam) import pvlib.irradiance # avoid name conflict with full import from pvlib.pvsystem import _DC_MODEL_PARAMS -from pvlib._deprecation import pvlibDeprecationWarning from pvlib.tools import _build_kwargs from pvlib._deprecation import deprecated @@ -279,7 +278,7 @@ def _mcr_repr(obj): # scalar, None, other? return repr(obj) - + # Type for fields that vary between arrays T = TypeVar('T') @@ -490,7 +489,7 @@ class ModelChain: If None, the model will be inferred from the parameters that are common to all of system.arrays[i].module_parameters. Valid strings are 'physical', 'ashrae', 'sapm', 'martin_ruiz', - 'no_loss'. The ModelChain instance will be passed as the + 'interp' and 'no_loss'. The ModelChain instance will be passed as the first argument to a user-defined function. spectral_model: None, str, or function, default None @@ -917,6 +916,8 @@ def aoi_model(self, model): self._aoi_model = self.sapm_aoi_loss elif model == 'martin_ruiz': self._aoi_model = self.martin_ruiz_aoi_loss + elif model == 'interp': + self._aoi_model = self.interp_aoi_loss elif model == 'no_loss': self._aoi_model = self.no_aoi_loss else: @@ -928,22 +929,24 @@ def infer_aoi_model(self): module_parameters = tuple( array.module_parameters for array in self.system.arrays) params = _common_keys(module_parameters) - if {'K', 'L', 'n'} <= params: + if iam._IAM_MODEL_PARAMS['physical'] <= params: return self.physical_aoi_loss - elif {'B5', 'B4', 'B3', 'B2', 'B1', 'B0'} <= params: + elif iam._IAM_MODEL_PARAMS['sapm'] <= params: return self.sapm_aoi_loss - elif {'b'} <= params: + elif iam._IAM_MODEL_PARAMS['ashrae'] <= params: return self.ashrae_aoi_loss - elif {'a_r'} <= params: + elif iam._IAM_MODEL_PARAMS['martin_ruiz'] <= params: return self.martin_ruiz_aoi_loss + elif iam._IAM_MODEL_PARAMS['interp'] <= params: + return self.interp_aoi_loss else: raise ValueError('could not infer AOI model from ' 'system.arrays[i].module_parameters. Check that ' 'the module_parameters for all Arrays in ' - 'system.arrays contain parameters for ' - 'the physical, aoi, ashrae or martin_ruiz model; ' - 'explicitly set the model with the aoi_model ' - 'kwarg; or set aoi_model="no_loss".') + 'system.arrays contain parameters for the ' + 'physical, aoi, ashrae, martin_ruiz or interp ' + 'model; explicitly set the model with the ' + 'aoi_model kwarg; or set aoi_model="no_loss".') def ashrae_aoi_loss(self): self.results.aoi_modifier = self.system.get_iam( @@ -972,6 +975,13 @@ def martin_ruiz_aoi_loss(self): ) return self + def interp_aoi_loss(self): + self.results.aoi_modifier = self.system.get_iam( + self.results.aoi, + iam_model='interp' + ) + return self + def no_aoi_loss(self): if self.system.num_arrays == 1: self.results.aoi_modifier = 1.0 diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index 9de1a4d807..2dcf8f5ee3 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -8,6 +8,7 @@ import io import itertools import os +import inspect from urllib.request import urlopen import numpy as np from scipy import constants @@ -388,7 +389,7 @@ def get_iam(self, aoi, iam_model='physical'): aoi_model : string, default 'physical' The IAM model to be used. Valid strings are 'physical', 'ashrae', - 'martin_ruiz' and 'sapm'. + 'martin_ruiz', 'sapm' and 'interp'. Returns ------- iam : numeric or tuple of numeric @@ -1151,7 +1152,7 @@ def get_iam(self, aoi, iam_model='physical'): aoi_model : string, default 'physical' The IAM model to be used. Valid strings are 'physical', 'ashrae', - 'martin_ruiz' and 'sapm'. + 'martin_ruiz', 'sapm' and 'interp'. Returns ------- @@ -1164,16 +1165,16 @@ def get_iam(self, aoi, iam_model='physical'): if `iam_model` is not a valid model name. """ model = iam_model.lower() - if model in ['ashrae', 'physical', 'martin_ruiz']: - param_names = iam._IAM_MODEL_PARAMS[model] - kwargs = _build_kwargs(param_names, self.module_parameters) - func = getattr(iam, model) + if model in ['ashrae', 'physical', 'martin_ruiz', 'interp']: + func = getattr(iam, model) # get function at pvlib.iam + # get all parameters from function signature to retrieve them from + # module_parameters if present + params = set(inspect.signature(func).parameters.keys()) + params.discard('aoi') # exclude aoi so it can't be repeated + kwargs = _build_kwargs(params, self.module_parameters) return func(aoi, **kwargs) elif model == 'sapm': return iam.sapm(aoi, self.module_parameters) - elif model == 'interp': - raise ValueError(model + ' is not implemented as an IAM model ' - 'option for Array') else: raise ValueError(model + ' is not a valid IAM model') diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py index 7f7c94cb0e..edda3b7e0e 100644 --- a/pvlib/tests/test_modelchain.py +++ b/pvlib/tests/test_modelchain.py @@ -1455,6 +1455,27 @@ def test_aoi_model_no_loss(sapm_dc_snl_ac_system, location, weather): assert mc.results.ac[1] < 1 +def test_aoi_model_interp(sapm_dc_snl_ac_system, location, weather, mocker): + # similar to test_aoi_models but requires arguments to work, so we + # add 'interp' aoi losses model arguments to module + iam_ref = (1., 0.85) + theta_ref = (0., 80.) + sapm_dc_snl_ac_system.arrays[0].module_parameters['iam_ref'] = iam_ref + sapm_dc_snl_ac_system.arrays[0].module_parameters['theta_ref'] = theta_ref + mc = ModelChain(sapm_dc_snl_ac_system, location, + dc_model='sapm', aoi_model='interp', + spectral_model='no_loss') + m = mocker.spy(iam, 'interp') + mc.run_model(weather=weather) + # only test kwargs + assert m.call_args[1]['iam_ref'] == iam_ref + assert m.call_args[1]['theta_ref'] == theta_ref + assert isinstance(mc.results.ac, pd.Series) + assert not mc.results.ac.empty + assert mc.results.ac[0] > 150 and mc.results.ac[0] < 200 + assert mc.results.ac[1] < 1 + + def test_aoi_model_user_func(sapm_dc_snl_ac_system, location, weather, mocker): m = mocker.spy(sys.modules[__name__], 'constant_aoi_loss') mc = ModelChain(sapm_dc_snl_ac_system, location, dc_model='sapm', @@ -1468,7 +1489,7 @@ def test_aoi_model_user_func(sapm_dc_snl_ac_system, location, weather, mocker): @pytest.mark.parametrize('aoi_model', [ - 'sapm', 'ashrae', 'physical', 'martin_ruiz' + 'sapm', 'ashrae', 'physical', 'martin_ruiz', 'interp' ]) def test_infer_aoi_model(location, system_no_aoi, aoi_model): for k in iam._IAM_MODEL_PARAMS[aoi_model]: @@ -1477,6 +1498,26 @@ def test_infer_aoi_model(location, system_no_aoi, aoi_model): assert isinstance(mc, ModelChain) +@pytest.mark.parametrize('aoi_model,model_kwargs', [ + # model_kwargs has both required and optional kwargs; test all + ('physical', + {'n': 1.526, 'K': 4.0, 'L': 0.002, # required + 'n_ar': 1.8}), # extra + ('interp', + {'theta_ref': (0, 75, 85, 90), 'iam_ref': (1, 0.8, 0.42, 0), # required + 'method': 'cubic', 'normalize': False})]) # extra +def test_infer_aoi_model_with_extra_params(location, system_no_aoi, aoi_model, + model_kwargs, weather, mocker): + # test extra parameters not defined at iam._IAM_MODEL_PARAMS are passed + m = mocker.spy(iam, aoi_model) + system_no_aoi.arrays[0].module_parameters.update(**model_kwargs) + mc = ModelChain(system_no_aoi, location, spectral_model='no_loss') + assert isinstance(mc, ModelChain) + mc.run_model(weather=weather) + _, call_kwargs = m.call_args + assert call_kwargs == model_kwargs + + def test_infer_aoi_model_invalid(location, system_no_aoi): exc_text = 'could not infer AOI model' with pytest.raises(ValueError, match=exc_text): diff --git a/pvlib/tests/test_pvsystem.py b/pvlib/tests/test_pvsystem.py index 754c906c21..b379dd41bc 100644 --- a/pvlib/tests/test_pvsystem.py +++ b/pvlib/tests/test_pvsystem.py @@ -64,10 +64,15 @@ def test_PVSystem_get_iam_sapm(sapm_module_params, mocker): assert_allclose(out, 1.0, atol=0.01) -def test_PVSystem_get_iam_interp(sapm_module_params, mocker): - system = pvsystem.PVSystem(module_parameters=sapm_module_params) - with pytest.raises(ValueError): - system.get_iam(45, iam_model='interp') +def test_PVSystem_get_iam_interp(mocker): + interp_module_params = {'iam_ref': (1., 0.8), 'theta_ref': (0., 80.)} + system = pvsystem.PVSystem(module_parameters=interp_module_params) + spy = mocker.spy(_iam, 'interp') + aoi = ((0., 40., 80.),) + expected = (1., 0.9, 0.8) + out = system.get_iam(aoi, iam_model='interp') + assert_allclose(out, expected) + spy.assert_called_once_with(aoi[0], **interp_module_params) def test__normalize_sam_product_names():