Repository: pvlib/pvlib-python

Issue:
Add interp method for modelchain aoi model.
I would like to simulate the effect of different IAM functions on performance. Pvlib already has an `interp` method for the iam_loss function. However, it is not possible to use `interp` within model chain. Can we add this feature?


Discussion:

Patch:
diff --git a/pvlib/iam.py b/pvlib/iam.py
--- 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
--- 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
--- 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')
 


Test patch:
diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py
--- 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
--- 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():