Repository: pvlib/pvlib-python
Pull request #1177 (2021-02-25 22:32:29)

NOCT cell temperature function
 - ~~[ ] Closes #xxxx~~
 - [x] I am familiar with the [contributing guidelines](https://pvlib-python.readthedocs.io/en/latest/contributing.html)
 - [x] Tests added
 - [x] Updates entries to [`docs/sphinx/source/api.rst`](https://github.com/pvlib/pvlib-python/blob/master/docs/sphinx/source/api.rst) for API changes.
 - [x] 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` ``).
 - [x] New code is fully documented. Includes [numpydoc](https://numpydoc.readthedocs.io/en/latest/format.html) compliant docstrings, examples, and comments where necessary.
 - [x] Pull request is nearly complete and ready for detailed review.
 - [x] Maintainer: Appropriate GitHub Labels and Milestone are assigned to the Pull Request and linked Issue.

Implement cell temperature function that is the default in SAM when the CEC module model is chosen.



Patch:
diff --git a/docs/sphinx/source/api.rst b/docs/sphinx/source/api.rst
index 8805d19..a82471c 100644
--- a/docs/sphinx/source/api.rst
+++ b/docs/sphinx/source/api.rst
@@ -238,6 +238,7 @@ PV temperature models
    temperature.faiman
    temperature.fuentes
    temperature.ross
+   temperature.noct_sam
    pvsystem.PVSystem.sapm_celltemp
    pvsystem.PVSystem.pvsyst_celltemp
    pvsystem.PVSystem.faiman_celltemp
@@ -609,7 +610,6 @@ ModelChain properties that are aliases for your specific modeling functions.
 .. autosummary::
    :toctree: generated/
 
-   modelchain.ModelChain.orientation_strategy
    modelchain.ModelChain.dc_model
    modelchain.ModelChain.ac_model
    modelchain.ModelChain.aoi_model
diff --git a/docs/sphinx/source/introtutorial.rst b/docs/sphinx/source/introtutorial.rst
index c45260a..495b0c6 100644
--- a/docs/sphinx/source/introtutorial.rst
+++ b/docs/sphinx/source/introtutorial.rst
@@ -190,12 +190,6 @@ by examining the parameters defined for the module.
     from pvlib.location import Location
     from pvlib.modelchain import ModelChain
 
-    system = PVSystem(
-        module_parameters=module,
-        inverter_parameters=inverter,
-        temperature_model_parameters=temperature_model_parameters,
-    )
-
     energies = {}
     for location, weather in zip(coordinates, tmys):
         latitude, longitude, name, altitude, timezone = location
@@ -206,11 +200,15 @@ by examining the parameters defined for the module.
             altitude=altitude,
             tz=timezone,
         )
-        mc = ModelChain(
-            system,
-            location,
-            orientation_strategy='south_at_latitude_tilt',
+        system = PVSystem(
+            surface_tilt=latitude,
+            surface_azimuth=180,
+            module_parameters=module,
+            inverter_parameters=inverter,
+            temperature_model_parameters=temperature_model_parameters,
         )
+
+        mc = ModelChain(system, location)
         results = mc.run_model(weather)
         annual_energy = results.ac.sum()
         energies[name] = annual_energy
diff --git a/docs/sphinx/source/whatsnew/v0.9.0.rst b/docs/sphinx/source/whatsnew/v0.9.0.rst
index 81e7a0c..cb8a347 100644
--- a/docs/sphinx/source/whatsnew/v0.9.0.rst
+++ b/docs/sphinx/source/whatsnew/v0.9.0.rst
@@ -40,6 +40,11 @@ Breaking changes
   ``ModelChain.adrinverter`` changed to ``ModelChain.adr_inverter``.
   (:pull:`1150`)
 
+* The ``orientation_strategy`` parameter has been removed from the various
+  :py:class:`pvlib.modelchain.ModelChain` constructors and ``surface_tilt``,
+  ``surface_azimuth`` are now required parameters for
+  :py:func:`pvlib.modelchain.basic_chain` (:issue:`1028`, :pull:`1181`)
+
 
 Deprecations
 ~~~~~~~~~~~~
@@ -96,6 +101,8 @@ Enhancements
 * :py:meth:`~pvlib.pvsystem.PVSystem.get_ac` is added to calculate AC power
   from DC power. Use parameter ``model`` to specify which inverter model to use.
   (:pull:`1147`, :issue:`998`, :pull:`1150`)
+* Added :py:func:`~pvlib.temperature.noct_sam`, a cell temperature model
+  implemented in SAM (:pull:`1177`)
 
 Bug fixes
 ~~~~~~~~~
diff --git a/docs/tutorials/forecast.ipynb b/docs/tutorials/forecast.ipynb
index 58c3cfb..be40e3d 100644
--- a/docs/tutorials/forecast.ipynb
+++ b/docs/tutorials/forecast.ipynb
@@ -6664,7 +6664,6 @@
       "text/plain": [
        "ModelChain: \n",
        "  name: None\n",
-       "  orientation_strategy: south_at_latitude_tilt\n",
        "  clearsky_model: ineichen\n",
        "  transposition_model: haydavies\n",
        "  solar_position_method: nrel_numpy\n",
@@ -6692,15 +6691,16 @@
     "inverter = sapm_inverters['ABB__MICRO_0_25_I_OUTD_US_208__208V_']\n",
     "\n",
     "system = PVSystem(module_parameters=module,\n",
-    "                  inverter_parameters=inverter)\n",
+    "                  inverter_parameters=inverter,\n",
+    "                  surface_tilt=latitude,\n",
+    "                  surface_azimuth=180)\n",
     "\n",
     "# fx is a common abbreviation for forecast\n",
     "fx_model = GFS()\n",
     "fx_data = fx_model.get_processed_data(latitude, longitude, start, end)\n",
     "\n",
     "# use a ModelChain object to calculate modeling intermediates\n",
-    "mc = ModelChain(system, fx_model.location,\n",
-    "                orientation_strategy='south_at_latitude_tilt')\n",
+    "mc = ModelChain(system, fx_model.location)\n",
     "\n",
     "# extract relevant data for model chain\n",
     "mc.run_model(weather=fx_data)"
diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py
index 1c07857..885353a 100644
--- a/pvlib/modelchain.py
+++ b/pvlib/modelchain.py
@@ -63,11 +63,10 @@ SAPM_CONFIG = dict(
 
 
 def basic_chain(times, latitude, longitude,
+                surface_tilt, surface_azimuth,
                 module_parameters, temperature_model_parameters,
                 inverter_parameters,
                 irradiance=None, weather=None,
-                surface_tilt=None, surface_azimuth=None,
-                orientation_strategy=None,
                 transposition_model='haydavies',
                 solar_position_method='nrel_numpy',
                 airmass_model='kastenyoung1989',
@@ -91,6 +90,17 @@ def basic_chain(times, latitude, longitude,
         Positive is east of the prime meridian.
         Use decimal degrees notation.
 
+    surface_tilt : numeric
+        Surface tilt angles in decimal degrees.
+        The tilt angle is defined as degrees from horizontal
+        (e.g. surface facing up = 0, surface facing horizon = 90)
+
+    surface_azimuth : numeric
+        Surface azimuth angles in decimal degrees.
+        The azimuth convention is defined
+        as degrees east of north
+        (North=0, South=180, East=90, West=270).
+
     module_parameters : None, dict or Series
         Module parameters as defined by the SAPM. See pvsystem.sapm for
         details.
@@ -112,23 +122,6 @@ def basic_chain(times, latitude, longitude,
         wind speed is 0 m/s.
         Columns must be 'wind_speed', 'temp_air'.
 
-    surface_tilt : None, float or Series, default None
-        Surface tilt angles in decimal degrees.
-        The tilt angle is defined as degrees from horizontal
-        (e.g. surface facing up = 0, surface facing horizon = 90)
-
-    surface_azimuth : None, float or Series, default None
-        Surface azimuth angles in decimal degrees.
-        The azimuth convention is defined
-        as degrees east of north
-        (North=0, South=180, East=90, West=270).
-
-    orientation_strategy : None or str, default None
-        The strategy for aligning the modules.
-        If not None, sets the ``surface_azimuth`` and ``surface_tilt``
-        properties of the ``system``. Allowed strategies include 'flat',
-        'south_at_latitude_tilt'. Ignored for SingleAxisTracker systems.
-
     transposition_model : str, default 'haydavies'
         Passed to system.get_irradiance.
 
@@ -157,17 +150,6 @@ def basic_chain(times, latitude, longitude,
         power (Series).
     """
 
-    # use surface_tilt and surface_azimuth if provided,
-    # otherwise set them using the orientation_strategy
-    if surface_tilt is not None and surface_azimuth is not None:
-        pass
-    elif orientation_strategy is not None:
-        surface_tilt, surface_azimuth = \
-            get_orientation(orientation_strategy, latitude=latitude)
-    else:
-        raise ValueError('orientation_strategy or surface_tilt and '
-                         'surface_azimuth must be provided')
-
     if altitude is None and pressure is None:
         altitude = 0.
         pressure = 101325.
@@ -332,12 +314,6 @@ class ModelChain:
         A :py:class:`~pvlib.location.Location` object that represents
         the physical location at which to evaluate the model.
 
-    orientation_strategy : None or str, default None
-        The strategy for aligning the modules. If not None, sets the
-        ``surface_azimuth`` and ``surface_tilt`` properties of the
-        ``system``. Allowed strategies include 'flat',
-        'south_at_latitude_tilt'. Ignored for SingleAxisTracker systems.
-
     clearsky_model : str, default 'ineichen'
         Passed to location.get_clearsky.
 
@@ -395,7 +371,6 @@ class ModelChain:
                          'dc', 'ac', 'diode_params', 'tracking']
 
     def __init__(self, system, location,
-                 orientation_strategy=None,
                  clearsky_model='ineichen',
                  transposition_model='haydavies',
                  solar_position_method='nrel_numpy',
@@ -421,7 +396,6 @@ class ModelChain:
         self.temperature_model = temperature_model
 
         self.losses_model = losses_model
-        self.orientation_strategy = orientation_strategy
 
         self.weather = None
         self.times = None
@@ -451,7 +425,6 @@ class ModelChain:
 
     @classmethod
     def with_pvwatts(cls, system, location,
-                     orientation_strategy=None,
                      clearsky_model='ineichen',
                      airmass_model='kastenyoung1989',
                      name=None,
@@ -469,12 +442,6 @@ class ModelChain:
             A :py:class:`~pvlib.location.Location` object that represents
             the physical location at which to evaluate the model.
 
-        orientation_strategy : None or str, default None
-            The strategy for aligning the modules. If not None, sets the
-            ``surface_azimuth`` and ``surface_tilt`` properties of the
-            ``system``. Allowed strategies include 'flat',
-            'south_at_latitude_tilt'. Ignored for SingleAxisTracker systems.
-
         clearsky_model : str, default 'ineichen'
             Passed to location.get_clearsky.
 
@@ -502,7 +469,6 @@ class ModelChain:
         >>> ModelChain.with_pvwatts(system, location)
         ModelChain:
           name: None
-          orientation_strategy: None
           clearsky_model: ineichen
           transposition_model: perez
           solar_position_method: nrel_numpy
@@ -518,7 +484,6 @@ class ModelChain:
         config.update(kwargs)
         return ModelChain(
             system, location,
-            orientation_strategy=orientation_strategy,
             clearsky_model=clearsky_model,
             airmass_model=airmass_model,
             name=name,
@@ -527,7 +492,6 @@ class ModelChain:
 
     @classmethod
     def with_sapm(cls, system, location,
-                  orientation_strategy=None,
                   clearsky_model='ineichen',
                   transposition_model='haydavies',
                   solar_position_method='nrel_numpy',
@@ -548,12 +512,6 @@ class ModelChain:
             A :py:class:`~pvlib.location.Location` object that represents
             the physical location at which to evaluate the model.
 
-        orientation_strategy : None or str, default None
-            The strategy for aligning the modules. If not None, sets the
-            ``surface_azimuth`` and ``surface_tilt`` properties of the
-            ``system``. Allowed strategies include 'flat',
-            'south_at_latitude_tilt'. Ignored for SingleAxisTracker systems.
-
         clearsky_model : str, default 'ineichen'
             Passed to location.get_clearsky.
 
@@ -589,7 +547,6 @@ class ModelChain:
         >>> ModelChain.with_sapm(system, location)
         ModelChain:
           name: None
-          orientation_strategy: None
           clearsky_model: ineichen
           transposition_model: haydavies
           solar_position_method: nrel_numpy
@@ -605,7 +562,6 @@ class ModelChain:
         config.update(kwargs)
         return ModelChain(
             system, location,
-            orientation_strategy=orientation_strategy,
             clearsky_model=clearsky_model,
             transposition_model=transposition_model,
             solar_position_method=solar_position_method,
@@ -616,7 +572,7 @@ class ModelChain:
 
     def __repr__(self):
         attrs = [
-            'name', 'orientation_strategy', 'clearsky_model',
+            'name', 'clearsky_model',
             'transposition_model', 'solar_position_method',
             'airmass_model', 'dc_model', 'ac_model', 'aoi_model',
             'spectral_model', 'temperature_model', 'losses_model'
@@ -634,21 +590,6 @@ class ModelChain:
         return ('ModelChain: \n  ' + '\n  '.join(
             f'{attr}: {getmcattr(self, attr)}' for attr in attrs))
 
-    @property
-    def orientation_strategy(self):
-        return self._orientation_strategy
-
-    @orientation_strategy.setter
-    def orientation_strategy(self, strategy):
-        if strategy == 'None':
-            strategy = None
-
-        if strategy is not None:
-            self.system.surface_tilt, self.system.surface_azimuth = \
-                get_orientation(strategy, latitude=self.location.latitude)
-
-        self._orientation_strategy = strategy
-
     @property
     def dc_model(self):
         return self._dc_model
diff --git a/pvlib/temperature.py b/pvlib/temperature.py
index 0387114..5797eed 100644
--- a/pvlib/temperature.py
+++ b/pvlib/temperature.py
@@ -316,7 +316,8 @@ def pvsyst_cell(poa_global, temp_air, wind_speed=1.0, u_c=29.0, u_v=0.0,
         :math:`\left[ \frac{\text{W}/\text{m}^2}{\text{C}\ \left( \text{m/s} \right)} \right]`
 
     eta_m : numeric, default 0.1
-        Module external efficiency as a fraction, i.e., DC power / poa_global.
+        Module external efficiency as a fraction, i.e.,
+        :math:`DC\ power / (POA\ irradiance \times module\ area)`.
         Parameter :math:`\eta_{m}` in :eq:`pvsyst`.
 
     alpha_absorption : numeric, default 0.9
@@ -706,3 +707,109 @@ def fuentes(poa_global, temp_air, wind_speed, noct_installed, module_height=5,
         sun0 = sun
 
     return pd.Series(tmod_array - 273.15, index=poa_global.index, name='tmod')
+
+
+def _adj_for_mounting_standoff(x):
+    # supports noct cell temperature function. Except for x > 3.5, the SAM code
+    # and documentation aren't clear on the precise intervals. The choice of
+    # < or <= here is pvlib's.
+    return np.piecewise(x, [x <= 0, (x > 0) & (x < 0.5),
+                            (x >= 0.5) & (x < 1.5), (x >= 1.5) & (x < 2.5),
+                            (x >= 2.5) & (x <= 3.5), x > 3.5],
+                        [0., 18., 11., 6., 2., 0.])
+
+
+def noct_sam(poa_global, temp_air, wind_speed, noct, eta_m_ref,
+             effective_irradiance=None, transmittance_absorptance=0.9,
+             array_height=1, mount_standoff=4):
+    r'''
+    Cell temperature model from the System Advisor Model (SAM).
+
+    The model is described in [1]_, Section 10.6.
+
+    Parameters
+    ----------
+    poa_global : numeric
+        Total incident irradiance. [W/m^2]
+
+    temp_air : numeric
+        Ambient dry bulb temperature. [C]
+
+    wind_speed : numeric
+        Wind speed in m/s measured at the same height for which the wind loss
+        factor was determined.  The default value 1.0 m/s is the wind
+        speed at module height used to determine NOCT. [m/s]
+
+    noct : float
+        Nominal operating cell temperature [C], determined at conditions of
+        800 W/m^2 irradiance, 20 C ambient air temperature and 1 m/s wind.
+
+    eta_m_ref : float
+        Module external efficiency [unitless] at reference conditions of
+        1000 W/m^2 and 20C. Calculate as
+        :math:`\eta_{m} = \frac{V_{mp} I_{mp}}{A \times 1000 W/m^2}`
+        where A is module area [m^2].
+
+    effective_irradiance : numeric, default None.
+        The irradiance that is converted to photocurrent. If None,
+        assumed equal to poa_global. [W/m^2]
+
+    transmittance_absorptance : numeric, default 0.9
+        Coefficient for combined transmittance and absorptance effects.
+        [unitless]
+
+    array_height : int, default 1
+        Height of array above ground in stories (one story is about 3m). Must
+        be either 1 or 2. For systems elevated less than one story, use 1.
+        If system is elevated more than two stories, use 2.
+
+    mount_standoff : numeric, default 4
+        Distance between array mounting and mounting surface. Use default
+        if system is ground-mounted. [inches]
+
+    Returns
+    -------
+    cell_temperature : numeric
+        Cell temperature. [C]
+
+    Raises
+    ------
+    ValueError
+        If array_height is an invalid value (must be 1 or 2).
+
+    References
+    ----------
+    .. [1] Gilman, P., Dobos, A., DiOrio, N., Freeman, J., Janzou, S.,
+           Ryberg, D., 2018, "SAM Photovoltaic Model Technical Reference
+           Update", National Renewable Energy Laboratory Report
+           NREL/TP-6A20-67399.
+    '''
+    # in [1] the denominator for irr_ratio isn't precisely clear. From
+    # reproducing output of the SAM function noct_celltemp_t, we determined
+    # that:
+    #  - G_total (SAM) is broadband plane-of-array irradiance before
+    #    reflections. Equivalent to pvlib variable poa_global
+    #  - Geff_total (SAM) is POA irradiance after reflections and
+    #    adjustment for spectrum. Equivalent to effective_irradiance
+    if effective_irradiance is None:
+        irr_ratio = 1.
+    else:
+        irr_ratio = effective_irradiance / poa_global
+
+    if array_height == 1:
+        wind_adj = 0.51 * wind_speed
+    elif array_height == 2:
+        wind_adj = 0.61 * wind_speed
+    else:
+        raise ValueError(
+            f'array_height must be 1 or 2, {array_height} was given')
+
+    noct_adj = noct + _adj_for_mounting_standoff(mount_standoff)
+    tau_alpha = transmittance_absorptance * irr_ratio
+
+    # [1] Eq. 10.37 isn't clear on exactly what "G" is. SAM SSC code uses
+    # poa_global where G appears
+    cell_temp_init = poa_global / 800. * (noct_adj - 20.)
+    heat_loss = 1 - eta_m_ref / tau_alpha
+    wind_loss = 9.5 / (5.7 + 3.8 * wind_adj)
+    return temp_air + cell_temp_init * heat_loss * wind_loss


Test patch:
diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py
index c45fd50..7b96b40 100644
--- a/pvlib/tests/test_modelchain.py
+++ b/pvlib/tests/test_modelchain.py
@@ -333,22 +333,6 @@ def test_with_pvwatts(pvwatts_dc_pvwatts_ac_system, location, weather):
     mc.run_model(weather)
 
 
-@pytest.mark.parametrize('strategy, expected', [
-    (None, (32.2, 180)), ('None', (32.2, 180)), ('flat', (0, 180)),
-    ('south_at_latitude_tilt', (32.2, 180))
-])
-def test_orientation_strategy(strategy, expected, sapm_dc_snl_ac_system,
-                              location):
-    mc = ModelChain(sapm_dc_snl_ac_system, location,
-                    orientation_strategy=strategy)
-
-    # the || accounts for the coercion of 'None' to None
-    assert (mc.orientation_strategy == strategy or
-            mc.orientation_strategy is None)
-    assert sapm_dc_snl_ac_system.surface_tilt == expected[0]
-    assert sapm_dc_snl_ac_system.surface_azimuth == expected[1]
-
-
 def test_run_model_with_irradiance(sapm_dc_snl_ac_system, location):
     mc = ModelChain(sapm_dc_snl_ac_system, location)
     times = pd.date_range('20160101 1200-0700', periods=2, freq='6H')
@@ -1235,8 +1219,7 @@ def test_infer_spectral_model(location, sapm_dc_snl_ac_system,
                   'cec': cec_dc_snl_ac_system,
                   'cec_native': cec_dc_native_snl_ac_system}
     system = dc_systems[dc_model]
-    mc = ModelChain(system, location,
-                    orientation_strategy='None', aoi_model='physical')
+    mc = ModelChain(system, location, aoi_model='physical')
     assert isinstance(mc, ModelChain)
 
 
@@ -1252,8 +1235,7 @@ def test_infer_temp_model(location, sapm_dc_snl_ac_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',
+    mc = ModelChain(system, location, aoi_model='physical',
                     spectral_model='no_loss')
     assert temp_model == mc.temperature_model.__name__
     assert isinstance(mc, ModelChain)
@@ -1263,14 +1245,12 @@ def test_infer_temp_model_invalid(location, sapm_dc_snl_ac_system):
     sapm_dc_snl_ac_system.temperature_model_parameters.pop('a')
     with pytest.raises(ValueError):
         ModelChain(sapm_dc_snl_ac_system, location,
-                   orientation_strategy='None', aoi_model='physical',
-                   spectral_model='no_loss')
+                   aoi_model='physical', spectral_model='no_loss')
 
 
 def test_temperature_model_inconsistent(location, sapm_dc_snl_ac_system):
     with pytest.raises(ValueError):
-        ModelChain(sapm_dc_snl_ac_system, location,
-                   orientation_strategy='None', aoi_model='physical',
+        ModelChain(sapm_dc_snl_ac_system, location, aoi_model='physical',
                    spectral_model='no_loss', temperature_model='pvsyst')
 
 
@@ -1441,17 +1421,14 @@ def test_aoi_model_user_func(sapm_dc_snl_ac_system, location, weather, mocker):
 def test_infer_aoi_model(location, system_no_aoi, aoi_model):
     for k in iam._IAM_MODEL_PARAMS[aoi_model]:
         system_no_aoi.module_parameters.update({k: 1.0})
-    mc = ModelChain(system_no_aoi, location,
-                    orientation_strategy='None',
-                    spectral_model='no_loss')
+    mc = ModelChain(system_no_aoi, location, spectral_model='no_loss')
     assert isinstance(mc, ModelChain)
 
 
 def test_infer_aoi_model_invalid(location, system_no_aoi):
     exc_text = 'could not infer AOI model'
     with pytest.raises(ValueError, match=exc_text):
-        ModelChain(system_no_aoi, location, orientation_strategy='None',
-                   spectral_model='no_loss')
+        ModelChain(system_no_aoi, location, spectral_model='no_loss')
 
 
 def constant_spectral_loss(mc):
@@ -1623,23 +1600,6 @@ def test_ModelChain_attributes_deprecated_10(sapm_dc_snl_ac_system, location):
         mc.aoi = 5
 
 
-def test_basic_chain_required(sam_data, cec_inverter_parameters,
-                              sapm_temperature_cs5p_220m):
-    times = pd.date_range(start='20160101 1200-0700',
-                          end='20160101 1800-0700', freq='6H')
-    latitude = 32
-    longitude = -111
-    altitude = 700
-    modules = sam_data['sandiamod']
-    module_parameters = modules['Canadian_Solar_CS5P_220M___2009_']
-    temp_model_params = sapm_temperature_cs5p_220m.copy()
-    with pytest.raises(ValueError):
-        dc, ac = modelchain.basic_chain(
-            times, latitude, longitude, module_parameters, temp_model_params,
-            cec_inverter_parameters, altitude=altitude
-        )
-
-
 @requires_tables
 def test_basic_chain_alt_az(sam_data, cec_inverter_parameters,
                             sapm_temperature_cs5p_220m):
@@ -1653,37 +1613,15 @@ def test_basic_chain_alt_az(sam_data, cec_inverter_parameters,
     module_parameters = modules['Canadian_Solar_CS5P_220M___2009_']
     temp_model_params = sapm_temperature_cs5p_220m.copy()
     dc, ac = modelchain.basic_chain(times, latitude, longitude,
-                                    module_parameters,  temp_model_params,
-                                    cec_inverter_parameters,
-                                    surface_tilt=surface_tilt,
-                                    surface_azimuth=surface_azimuth)
+                                    surface_tilt, surface_azimuth,
+                                    module_parameters, temp_model_params,
+                                    cec_inverter_parameters)
 
     expected = pd.Series(np.array([111.621405, -2.00000000e-02]),
                          index=times)
     assert_series_equal(ac, expected)
 
 
-@requires_tables
-def test_basic_chain_strategy(sam_data, cec_inverter_parameters,
-                              sapm_temperature_cs5p_220m):
-    times = pd.date_range(start='20160101 1200-0700',
-                          end='20160101 1800-0700', freq='6H')
-    latitude = 32.2
-    longitude = -111
-    altitude = 700
-    modules = sam_data['sandiamod']
-    module_parameters = modules['Canadian_Solar_CS5P_220M___2009_']
-    temp_model_params = sapm_temperature_cs5p_220m.copy()
-    dc, ac = modelchain.basic_chain(
-        times, latitude, longitude, module_parameters, temp_model_params,
-        cec_inverter_parameters, orientation_strategy='south_at_latitude_tilt',
-        altitude=altitude)
-
-    expected = pd.Series(np.array([178.382754, -2.00000000e-02]),
-                         index=times)
-    assert_series_equal(ac, expected)
-
-
 @requires_tables
 def test_basic_chain_altitude_pressure(sam_data, cec_inverter_parameters,
                                        sapm_temperature_cs5p_220m):
@@ -1698,10 +1636,9 @@ def test_basic_chain_altitude_pressure(sam_data, cec_inverter_parameters,
     module_parameters = modules['Canadian_Solar_CS5P_220M___2009_']
     temp_model_params = sapm_temperature_cs5p_220m.copy()
     dc, ac = modelchain.basic_chain(times, latitude, longitude,
+                                    surface_tilt, surface_azimuth,
                                     module_parameters, temp_model_params,
                                     cec_inverter_parameters,
-                                    surface_tilt=surface_tilt,
-                                    surface_azimuth=surface_azimuth,
                                     pressure=93194)
 
     expected = pd.Series(np.array([113.190045, -2.00000000e-02]),
@@ -1709,10 +1646,9 @@ def test_basic_chain_altitude_pressure(sam_data, cec_inverter_parameters,
     assert_series_equal(ac, expected)
 
     dc, ac = modelchain.basic_chain(times, latitude, longitude,
+                                    surface_tilt, surface_azimuth,
                                     module_parameters, temp_model_params,
                                     cec_inverter_parameters,
-                                    surface_tilt=surface_tilt,
-                                    surface_azimuth=surface_azimuth,
                                     altitude=altitude)
 
     expected = pd.Series(np.array([113.189814, -2.00000000e-02]),
@@ -1720,34 +1656,6 @@ def test_basic_chain_altitude_pressure(sam_data, cec_inverter_parameters,
     assert_series_equal(ac, expected)
 
 
-@pytest.mark.parametrize('strategy, strategy_str', [
-    ('south_at_latitude_tilt', 'south_at_latitude_tilt'),
-    (None, 'None')])  # GitHub issue 352
-def test_ModelChain___repr__(sapm_dc_snl_ac_system, location, strategy,
-                             strategy_str):
-
-    mc = ModelChain(sapm_dc_snl_ac_system, location,
-                    orientation_strategy=strategy, name='my mc')
-
-    expected = '\n'.join([
-        'ModelChain: ',
-        '  name: my mc',
-        '  orientation_strategy: ' + strategy_str,
-        '  clearsky_model: ineichen',
-        '  transposition_model: haydavies',
-        '  solar_position_method: nrel_numpy',
-        '  airmass_model: kastenyoung1989',
-        '  dc_model: sapm',
-        '  ac_model: sandia_inverter',
-        '  aoi_model: sapm_aoi_loss',
-        '  spectral_model: sapm_spectral_loss',
-        '  temperature_model: sapm_temp',
-        '  losses_model: no_extra_losses'
-    ])
-
-    assert mc.__repr__() == expected
-
-
 def test_complete_irradiance_clean_run(sapm_dc_snl_ac_system, location):
     """The DataFrame should not change if all columns are passed"""
     mc = ModelChain(sapm_dc_snl_ac_system, location)
diff --git a/pvlib/tests/test_temperature.py b/pvlib/tests/test_temperature.py
index f8ea3a8..1ce2e69 100644
--- a/pvlib/tests/test_temperature.py
+++ b/pvlib/tests/test_temperature.py
@@ -5,7 +5,7 @@ import pytest
 from conftest import DATA_DIR, assert_series_equal
 from numpy.testing import assert_allclose
 
-from pvlib import temperature
+from pvlib import temperature, tools
 
 
 @pytest.fixture
@@ -212,3 +212,76 @@ def test_fuentes_timezone(tz):
 
     assert_series_equal(out, pd.Series([47.85, 50.85, 50.85], index=index,
                                        name='tmod'))
+
+
+def test_noct_sam():
+    poa_global, temp_air, wind_speed, noct, eta_m_ref = (1000., 25., 1., 45.,
+                                                         0.2)
+    expected = 55.230790492
+    result = temperature.noct_sam(poa_global, temp_air, wind_speed, noct,
+                                  eta_m_ref)
+    assert_allclose(result, expected)
+    # test with different types
+    result = temperature.noct_sam(np.array(poa_global), np.array(temp_air),
+                                  np.array(wind_speed), np.array(noct),
+                                  np.array(eta_m_ref))
+    assert_allclose(result, expected)
+    dr = pd.date_range(start='2020-01-01 12:00:00', end='2020-01-01 13:00:00',
+                       freq='1H')
+    result = temperature.noct_sam(pd.Series(index=dr, data=poa_global),
+                                  pd.Series(index=dr, data=temp_air),
+                                  pd.Series(index=dr, data=wind_speed),
+                                  pd.Series(index=dr, data=noct),
+                                  eta_m_ref)
+    assert_series_equal(result, pd.Series(index=dr, data=expected))
+
+
+def test_noct_sam_against_sam():
+    # test is constructed to reproduce output from SAM v2020.11.29.
+    # SAM calculation is the default Detailed PV System model (CEC diode model,
+    # NOCT cell temperature model), with the only change being the soiling
+    # loss is set to 0. Weather input is TMY3 for Phoenix AZ.
+    # Values are taken from the Jan 1 12:00:00 timestamp.
+    poa_total, temp_air, wind_speed, noct, eta_m_ref = (
+        860.673, 25, 3, 46.4, 0.20551)
+    poa_total_after_refl = 851.458  # from SAM output
+    # compute effective irradiance
+    # spectral loss coefficients fixed in lib_cec6par.cpp
+    a = np.flipud([0.918093, 0.086257, -0.024459, 0.002816, -0.000126])
+    # reproduce SAM air mass calculation
+    zen = 56.4284
+    elev = 358
+    air_mass = 1. / (tools.cosd(zen) + 0.5057 * (96.080 - zen)**-1.634)
+    air_mass *= np.exp(-0.0001184 * elev)
+    f1 = np.polyval(a, air_mass)
+    effective_irradiance = f1 * poa_total_after_refl
+    transmittance_absorptance = 0.9
+    array_height = 1
+    mount_standoff = 4.0
+    result = temperature.noct_sam(poa_total, temp_air, wind_speed, noct,
+                                  eta_m_ref, effective_irradiance,
+                                  transmittance_absorptance, array_height,
+                                  mount_standoff)
+    expected = 43.0655
+    # rtol from limited SAM output precision
+    assert_allclose(result, expected, rtol=1e-5)
+
+
+def test_noct_sam_options():
+    poa_global, temp_air, wind_speed, noct, eta_m_ref = (1000., 25., 1., 45.,
+                                                         0.2)
+    effective_irradiance = 1100.
+    transmittance_absorbtance = 0.8
+    array_height = 2
+    mount_standoff = 2.0
+    result = temperature.noct_sam(poa_global, temp_air, wind_speed, noct,
+                                  eta_m_ref, effective_irradiance,
+                                  transmittance_absorbtance, array_height,
+                                  mount_standoff)
+    expected = 60.477703576
+    assert_allclose(result, expected)
+
+
+def test_noct_sam_errors():
+    with pytest.raises(ValueError):
+        temperature.noct_sam(1000., 25., 1., 34., 0.2, array_height=3)