Repository: pvlib/pvlib-python
Pull request #1137 (2021-01-20 21:11:57)

add testpaths to setup.cfg pytest section

 - [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.


I had been running into odd behavior with vscode no longer able to automatically discover pvlib tests and with strange errors when trying to run `pytest` from the root pvlib directory - sometimes I'd get numpy import errors in good environments and sometimes I'd see errors with old pvlib version numbers. But `pytest pvlib` worked just fine. It turns out that the presence of conda environments in `benchmarks/` led to bad numpy imports and the presence of old pvlib versions in `dist/` led to other issues. Adding these directories to the `setup.cfg` pytest section solved the issues, and I added a bunch of extra directories for safety.

wholmgren: @kanderso-nrel @mikofski can you merge this if you have no concerns with it? It feels like just a bit more than I should do without a second opinion.

Patch:
diff --git a/docs/sphinx/source/whatsnew/v0.9.0.rst b/docs/sphinx/source/whatsnew/v0.9.0.rst
index 8c65f43..5b82f40 100644
--- a/docs/sphinx/source/whatsnew/v0.9.0.rst
+++ b/docs/sphinx/source/whatsnew/v0.9.0.rst
@@ -83,9 +83,16 @@ Enhancements
   automatically switch to using ``'effective_irradiance'`` (if available) for
   cell temperature models, when ``'poa_global'`` is not provided in input
   weather or calculated from input weather data.
+* :py:meth:`~pvlib.modelchain.ModelChain.pvwatts_dc` now scales the DC power
+  by ``pvsystem.PVSystem.modules_per_strings`` and
+  ``pvsystem.PVSystem.strings_per_inverter``. Note that both attributes still
+  default to 1. (:pull:`1138`)
 
 Bug fixes
 ~~~~~~~~~
+* Pass weather data to solar position calculations in
+  :ref:meth:`~pvlib.modelchain.ModelChain.prepare_inputs_from_poa`.
+  (:issue:`1065`, :pull:`1140`)
 
 Testing
 ~~~~~~~
diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py
index e4facd9..c9c578b 100644
--- a/pvlib/modelchain.py
+++ b/pvlib/modelchain.py
@@ -730,8 +730,33 @@ class ModelChain:
         return self._singlediode(self.system.calcparams_pvsyst)
 
     def pvwatts_dc(self):
+        """Calculate DC power using the PVWatts model.
+
+        Results are stored in ModelChain.results.dc. DC power is computed
+        from PVSystem.module_parameters['pdc0'] and then scaled by
+        PVSystem.modules_per_string and PVSystem.strings_per_inverter.
+
+        Returns
+        -------
+        self
+
+        See also
+        --------
+        pvlib.pvsystem.PVSystem.pvwatts_dc
+        pvlib.pvsystem.PVSystem.scale_voltage_current_power
+        """
         self.results.dc = self.system.pvwatts_dc(
             self.results.effective_irradiance, self.results.cell_temperature)
+        if isinstance(self.results.dc, tuple):
+            temp = tuple(
+                pd.DataFrame(s, columns=['p_mp']) for s in self.results.dc)
+        else:
+            temp = pd.DataFrame(self.results.dc, columns=['p_mp'])
+        scaled = self.system.scale_voltage_current_power(temp)
+        if isinstance(scaled, tuple):
+            self.results.dc = tuple(s['p_mp'] for s in scaled)
+        else:
+            self.results.dc = scaled['p_mp']
         return self
 
     @property
@@ -1185,10 +1210,19 @@ class ModelChain:
                 weather.ghi - weather.dni *
                 tools.cosd(self.results.solar_position.zenith))
 
-    def _prep_inputs_solar_pos(self, kwargs={}):
+    def _prep_inputs_solar_pos(self, weather):
         """
         Assign solar position
         """
+        # build weather kwargs for solar position calculation
+        kwargs = _build_kwargs(['pressure', 'temp_air'],
+                               weather[0] if isinstance(weather, tuple)
+                               else weather)
+        try:
+            kwargs['temperature'] = kwargs.pop('temp_air')
+        except KeyError:
+            pass
+
         self.results.solar_position = self.location.get_solarposition(
             self.times, method=self.solar_position_method,
             **kwargs)
@@ -1338,16 +1372,7 @@ class ModelChain:
         self._assign_weather(weather)
         self._assign_times()
 
-        # build kwargs for solar position calculation
-        try:
-            press_temp = _build_kwargs(['pressure', 'temp_air'],
-                                       weather[0] if isinstance(weather, tuple)
-                                       else weather)
-            press_temp['temperature'] = press_temp.pop('temp_air')
-        except KeyError:
-            pass
-
-        self._prep_inputs_solar_pos(press_temp)
+        self._prep_inputs_solar_pos(weather)
         self._prep_inputs_airmass()
 
         # PVSystem.get_irradiance and SingleAxisTracker.get_irradiance
@@ -1445,7 +1470,7 @@ class ModelChain:
                                         'poa_diffuse'])
         self._assign_total_irrad(data)
 
-        self._prep_inputs_solar_pos()
+        self._prep_inputs_solar_pos(data)
         self._prep_inputs_airmass()
 
         if isinstance(self.system, SingleAxisTracker):
diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py
index b2370d4..afc1c19 100644
--- a/pvlib/pvsystem.py
+++ b/pvlib/pvsystem.py
@@ -891,7 +891,7 @@ class PVSystem:
         Parameters
         ----------
         data: DataFrame or tuple of DataFrame
-            Must contain columns `'v_mp', 'v_oc', 'i_mp' ,'i_x', 'i_xx',
+            May contain columns `'v_mp', 'v_oc', 'i_mp' ,'i_x', 'i_xx',
             'i_sc', 'p_mp'`.
 
         Returns
@@ -2626,13 +2626,13 @@ def i_from_v(resistance_shunt, resistance_series, nNsVth, voltage,
 
 def scale_voltage_current_power(data, voltage=1, current=1):
     """
-    Scales the voltage, current, and power of the DataFrames
-    returned by :py:func:`singlediode` and :py:func:`sapm`.
+    Scales the voltage, current, and power in data by the voltage
+    and current factors.
 
     Parameters
     ----------
     data: DataFrame
-        Must contain columns `'v_mp', 'v_oc', 'i_mp' ,'i_x', 'i_xx',
+        May contain columns `'v_mp', 'v_oc', 'i_mp' ,'i_x', 'i_xx',
         'i_sc', 'p_mp'`.
     voltage: numeric, default 1
         The amount by which to multiply the voltages.
@@ -2648,14 +2648,15 @@ def scale_voltage_current_power(data, voltage=1, current=1):
 
     # as written, only works with a DataFrame
     # could make it work with a dict, but it would be more verbose
-    data = data.copy()
-    voltages = ['v_mp', 'v_oc']
-    currents = ['i_mp', 'i_x', 'i_xx', 'i_sc']
-    data[voltages] *= voltage
-    data[currents] *= current
-    data['p_mp'] *= voltage * current
-
-    return data
+    voltage_keys = ['v_mp', 'v_oc']
+    current_keys = ['i_mp', 'i_x', 'i_xx', 'i_sc']
+    power_keys = ['p_mp']
+    voltage_df = data.filter(voltage_keys, axis=1) * voltage
+    current_df = data.filter(current_keys, axis=1) * current
+    power_df = data.filter(power_keys, axis=1) * voltage * current
+    df = pd.concat([voltage_df, current_df, power_df], axis=1)
+    df_sorted = df[data.columns]  # retain original column order
+    return df_sorted
 
 
 def pvwatts_dc(g_poa_effective, temp_cell, pdc0, gamma_pdc, temp_ref=25.):
@@ -2675,20 +2676,20 @@ def pvwatts_dc(g_poa_effective, temp_cell, pdc0, gamma_pdc, temp_ref=25.):
     Parameters
     ----------
     g_poa_effective: numeric
-        Irradiance transmitted to the PV cells in units of W/m**2. To be
+        Irradiance transmitted to the PV cells. To be
         fully consistent with PVWatts, the user must have already
         applied angle of incidence losses, but not soiling, spectral,
-        etc.
+        etc. [W/m^2]
     temp_cell: numeric
-        Cell temperature in degrees C.
+        Cell temperature [C].
     pdc0: numeric
-        Power of the modules at 1000 W/m2 and cell reference temperature.
+        Power of the modules at 1000 W/m^2 and cell reference temperature. [W]
     gamma_pdc: numeric
-        The temperature coefficient in units of 1/C. Typically -0.002 to
-        -0.005 per degree C.
+        The temperature coefficient of power. Typically -0.002 to
+        -0.005 per degree C. [1/C]
     temp_ref: numeric, default 25.0
         Cell reference temperature. PVWatts defines it to be 25 C and
-        is included here for flexibility.
+        is included here for flexibility. [C]
 
     Returns
     -------
diff --git a/setup.cfg b/setup.cfg
index 6e46fdc..5074148 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -20,4 +20,5 @@ ignore = E201, E241, E226, W503, W504
 exclude = pvlib/_version.py docs dist
 
 [tool:pytest]
-junit_family=xunit2
\ No newline at end of file
+junit_family=xunit2
+testpaths = pvlib/tests
\ No newline at end of file


Test patch:
diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py
index 407c8df..9085b02 100644
--- a/pvlib/tests/test_modelchain.py
+++ b/pvlib/tests/test_modelchain.py
@@ -886,6 +886,20 @@ def test_temperature_models_arrays_multi_weather(
             != mc.results.cell_temperature[1]).all()
 
 
+def test_run_model_solar_position_weather(
+        pvwatts_dc_pvwatts_ac_system, location, weather, mocker):
+    mc = ModelChain(pvwatts_dc_pvwatts_ac_system, location,
+                    aoi_model='no_loss', spectral_model='no_loss')
+    weather['pressure'] = 90000
+    weather['temp_air'] = 25
+    m = mocker.spy(location, 'get_solarposition')
+    mc.run_model(weather)
+    # assert_called_once_with cannot be used with series, so need to use
+    # assert_series_equal on call_args
+    assert_series_equal(m.call_args[1]['temperature'], weather['temp_air'])
+    assert_series_equal(m.call_args[1]['pressure'], weather['pressure'])
+
+
 def test_run_model_from_poa(sapm_dc_snl_ac_system, location, total_irrad):
     mc = ModelChain(sapm_dc_snl_ac_system, location, aoi_model='no_loss',
                     spectral_model='no_loss')
@@ -909,6 +923,24 @@ def test_run_model_from_poa_arrays(sapm_dc_snl_ac_system_Array, location,
     assert_frame_equal(mc.results.dc[0], mc.results.dc[1])
 
 
+def test_run_model_from_poa_arrays_solar_position_weather(
+        sapm_dc_snl_ac_system_Array, location, weather, total_irrad, mocker):
+    data = weather.copy()
+    data[['poa_global', 'poa_diffuse', 'poa_direct']] = total_irrad
+    data['pressure'] = 90000
+    data['temp_air'] = 25
+    data2 = data.copy()
+    data2['pressure'] = 95000
+    data2['temp_air'] = 30
+    mc = ModelChain(sapm_dc_snl_ac_system_Array, location, aoi_model='no_loss',
+                    spectral_model='no_loss')
+    m = mocker.spy(location, 'get_solarposition')
+    mc.run_model_from_poa((data, data2))
+    # mc uses only the first weather data for solar position corrections
+    assert_series_equal(m.call_args[1]['temperature'], data['temp_air'])
+    assert_series_equal(m.call_args[1]['pressure'], data['pressure'])
+
+
 def test_run_model_from_poa_tracking(sapm_dc_snl_ac_system, location,
                                      total_irrad):
     system = SingleAxisTracker(
@@ -1180,6 +1212,25 @@ def test_dc_model_user_func(pvwatts_dc_pvwatts_ac_system, location, weather,
     assert not mc.results.ac.empty
 
 
+def test_pvwatts_dc_multiple_strings(pvwatts_dc_pvwatts_ac_system, location,
+                                     weather, mocker):
+    system = pvwatts_dc_pvwatts_ac_system
+    m = mocker.spy(system, 'scale_voltage_current_power')
+    mc1 = ModelChain(system, location,
+                     aoi_model='no_loss', spectral_model='no_loss')
+    mc1.run_model(weather)
+    assert m.call_count == 1
+    system.arrays[0].modules_per_string = 2
+    mc2 = ModelChain(system, location,
+                     aoi_model='no_loss', spectral_model='no_loss')
+    mc2.run_model(weather)
+    assert isinstance(mc2.results.ac, (pd.Series, pd.DataFrame))
+    assert not mc2.results.ac.empty
+    expected = pd.Series(data=[2., np.nan], index=mc2.results.dc.index,
+                         name='p_mp')
+    assert_series_equal(mc2.results.dc / mc1.results.dc, expected)
+
+
 def acdc(mc):
     mc.results.ac = mc.results.dc