Repository: pvlib/pvlib-python

Issue:
expose pvlib.temperature.fuentes in PVSystem and ModelChain
Follow up to #1032 and #1037 


Discussion:

Patch:
diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py
--- a/pvlib/modelchain.py
+++ b/pvlib/modelchain.py
@@ -323,9 +323,9 @@ class ModelChain:
         as the first argument to a user-defined function.
 
     temperature_model: None, str or function, default None
-        Valid strings are 'sapm', 'pvsyst', and 'faiman'. The ModelChain
-        instance will be passed as the first argument to a user-defined
-        function.
+        Valid strings are 'sapm', 'pvsyst', 'faiman', and 'fuentes'.
+        The ModelChain instance will be passed as the first argument to a
+        user-defined function.
 
     losses_model: str or function, default 'no_loss'
         Valid strings are 'pvwatts', 'no_loss'. The ModelChain instance
@@ -866,6 +866,8 @@ def temperature_model(self, model):
                 self._temperature_model = self.pvsyst_temp
             elif model == 'faiman':
                 self._temperature_model = self.faiman_temp
+            elif model == 'fuentes':
+                self._temperature_model = self.fuentes_temp
             else:
                 raise ValueError(model + ' is not a valid temperature model')
             # check system.temperature_model_parameters for consistency
@@ -891,6 +893,8 @@ def infer_temperature_model(self):
             return self.pvsyst_temp
         elif {'u0', 'u1'} <= params:
             return self.faiman_temp
+        elif {'noct_installed'} <= params:
+            return self.fuentes_temp
         else:
             raise ValueError('could not infer temperature model from '
                              'system.temperature_module_parameters {}.'
@@ -914,6 +918,12 @@ def faiman_temp(self):
             self.weather['wind_speed'])
         return self
 
+    def fuentes_temp(self):
+        self.cell_temperature = self.system.fuentes_celltemp(
+            self.total_irrad['poa_global'], self.weather['temp_air'],
+            self.weather['wind_speed'])
+        return self
+
     @property
     def losses_model(self):
         return self._losses_model
diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py
--- a/pvlib/pvsystem.py
+++ b/pvlib/pvsystem.py
@@ -609,6 +609,46 @@ def faiman_celltemp(self, poa_global, temp_air, wind_speed=1.0):
         return temperature.faiman(poa_global, temp_air, wind_speed,
                                   **kwargs)
 
+    def fuentes_celltemp(self, poa_global, temp_air, wind_speed):
+        """
+        Use :py:func:`temperature.fuentes` to calculate cell temperature.
+
+        Parameters
+        ----------
+        poa_global : pandas Series
+            Total incident irradiance [W/m^2]
+
+        temp_air : pandas Series
+            Ambient dry bulb temperature [C]
+
+        wind_speed : pandas Series
+            Wind speed [m/s]
+
+        Returns
+        -------
+        temperature_cell : pandas Series
+            The modeled cell temperature [C]
+
+        Notes
+        -----
+        The Fuentes thermal model uses the module surface tilt for convection
+        modeling. The SAM implementation of PVWatts hardcodes the surface tilt
+        value at 30 degrees, ignoring whatever value is used for irradiance
+        transposition. This method defaults to using ``self.surface_tilt``, but
+        if you want to match the PVWatts behavior, you can override it by
+        including a ``surface_tilt`` value in ``temperature_model_parameters``.
+        """
+        # default to using the PVSystem attribute, but allow user to
+        # override with a custom surface_tilt value
+        kwargs = {'surface_tilt': self.surface_tilt}
+        temp_model_kwargs = _build_kwargs([
+            'noct_installed', 'module_height', 'wind_height', 'emissivity',
+            'absorption', 'surface_tilt', 'module_width', 'module_length'],
+            self.temperature_model_parameters)
+        kwargs.update(temp_model_kwargs)
+        return temperature.fuentes(poa_global, temp_air, wind_speed,
+                                   **kwargs)
+
     def first_solar_spectral_loss(self, pw, airmass_absolute):
 
         """


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
@@ -138,6 +138,18 @@ def pvwatts_dc_pvwatts_ac_pvsyst_temp_system():
     return system
 
 
+@pytest.fixture(scope="function")
+def pvwatts_dc_pvwatts_ac_fuentes_temp_system():
+    module_parameters = {'pdc0': 220, 'gamma_pdc': -0.003}
+    temp_model_params = {'noct_installed': 45}
+    inverter_parameters = {'pdc0': 220, 'eta_inv_nom': 0.95}
+    system = PVSystem(surface_tilt=32.2, surface_azimuth=180,
+                      module_parameters=module_parameters,
+                      temperature_model_parameters=temp_model_params,
+                      inverter_parameters=inverter_parameters)
+    return system
+
+
 @pytest.fixture(scope="function")
 def system_no_aoi(cec_module_cs5p_220m, sapm_temperature_cs5p_220m,
                   cec_inverter_parameters):
@@ -317,6 +329,23 @@ def test_run_model_with_weather_faiman_temp(sapm_dc_snl_ac_system, location,
     assert not mc.ac.empty
 
 
+def test_run_model_with_weather_fuentes_temp(sapm_dc_snl_ac_system, location,
+                                             weather, mocker):
+    weather['wind_speed'] = 5
+    weather['temp_air'] = 10
+    sapm_dc_snl_ac_system.temperature_model_parameters = {
+        'noct_installed': 45
+    }
+    mc = ModelChain(sapm_dc_snl_ac_system, location)
+    mc.temperature_model = 'fuentes'
+    m_fuentes = mocker.spy(sapm_dc_snl_ac_system, 'fuentes_celltemp')
+    mc.run_model(weather)
+    assert m_fuentes.call_count == 1
+    assert_series_equal(m_fuentes.call_args[0][1], weather['temp_air'])
+    assert_series_equal(m_fuentes.call_args[0][2], weather['wind_speed'])
+    assert not mc.ac.empty
+
+
 def test_run_model_tracker(sapm_dc_snl_ac_system, location, weather, mocker):
     system = SingleAxisTracker(
         module_parameters=sapm_dc_snl_ac_system.module_parameters,
@@ -479,14 +508,16 @@ def test_infer_spectral_model(location, sapm_dc_snl_ac_system,
 
 
 @pytest.mark.parametrize('temp_model', [
-    'sapm_temp', 'faiman_temp', 'pvsyst_temp'])
+    'sapm_temp', 'faiman_temp', 'pvsyst_temp', 'fuentes_temp'])
 def test_infer_temp_model(location, sapm_dc_snl_ac_system,
                           pvwatts_dc_pvwatts_ac_pvsyst_temp_system,
                           pvwatts_dc_pvwatts_ac_faiman_temp_system,
+                          pvwatts_dc_pvwatts_ac_fuentes_temp_system,
                           temp_model):
     dc_systems = {'sapm_temp': sapm_dc_snl_ac_system,
                   'pvsyst_temp': pvwatts_dc_pvwatts_ac_pvsyst_temp_system,
-                  'faiman_temp': pvwatts_dc_pvwatts_ac_faiman_temp_system}
+                  'faiman_temp': pvwatts_dc_pvwatts_ac_faiman_temp_system,
+                  'fuentes_temp': pvwatts_dc_pvwatts_ac_fuentes_temp_system}
     system = dc_systems[temp_model]
     mc = ModelChain(system, location,
                     orientation_strategy='None', aoi_model='physical',
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
@@ -360,6 +360,50 @@ def test_PVSystem_faiman_celltemp(mocker):
     assert_allclose(out, 56.4, atol=1)
 
 
+def test_PVSystem_fuentes_celltemp(mocker):
+    noct_installed = 45
+    temp_model_params = {'noct_installed': noct_installed}
+    system = pvsystem.PVSystem(temperature_model_parameters=temp_model_params)
+    spy = mocker.spy(temperature, 'fuentes')
+    index = pd.date_range('2019-01-01 11:00', freq='h', periods=3)
+    temps = pd.Series(25, index)
+    irrads = pd.Series(1000, index)
+    winds = pd.Series(1, index)
+    out = system.fuentes_celltemp(irrads, temps, winds)
+    assert_series_equal(spy.call_args[0][0], irrads)
+    assert_series_equal(spy.call_args[0][1], temps)
+    assert_series_equal(spy.call_args[0][2], winds)
+    assert spy.call_args[1]['noct_installed'] == noct_installed
+    assert_series_equal(out, pd.Series([52.85, 55.85, 55.85], index,
+                                       name='tmod'))
+
+
+def test_PVSystem_fuentes_celltemp_override(mocker):
+    # test that the surface_tilt value in the cell temp calculation can be
+    # overridden but defaults to the surface_tilt attribute of the PVSystem
+    spy = mocker.spy(temperature, 'fuentes')
+
+    noct_installed = 45
+    index = pd.date_range('2019-01-01 11:00', freq='h', periods=3)
+    temps = pd.Series(25, index)
+    irrads = pd.Series(1000, index)
+    winds = pd.Series(1, index)
+
+    # uses default value
+    temp_model_params = {'noct_installed': noct_installed}
+    system = pvsystem.PVSystem(temperature_model_parameters=temp_model_params,
+                               surface_tilt=20)
+    system.fuentes_celltemp(irrads, temps, winds)
+    assert spy.call_args[1]['surface_tilt'] == 20
+
+    # can be overridden
+    temp_model_params = {'noct_installed': noct_installed, 'surface_tilt': 30}
+    system = pvsystem.PVSystem(temperature_model_parameters=temp_model_params,
+                               surface_tilt=20)
+    system.fuentes_celltemp(irrads, temps, winds)
+    assert spy.call_args[1]['surface_tilt'] == 30
+
+
 def test__infer_temperature_model_params():
     system = pvsystem.PVSystem(module_parameters={},
                                racking_model='open_rack',