Repository: pvlib/pvlib-python

Issue:
Apparent numerical instability in I_mp calculation using PVsyst model
**Describe the bug**

I used these parameters in `pvlib.pvsystem.calcparams_pvsyst()` in order to calculate `I_mp` vs. `T` using `pvlib.pvsystem.singlediode()` with `effective_irradiance` fixed at 1000 W/m^2 and `temp_cell` having 1001 values ranging from 15 to 50 degC:

`{'alpha_sc': 0.006, 'gamma_ref': 1.009, 'mu_gamma': -0.0005, 'I_L_ref': 13.429, 'I_o_ref': 3.719506010004821e-11, 'R_sh_ref': 800.0, 'R_sh_0': 3200.0, 'R_s': 0.187, 'cells_in_series': 72, 'R_sh_exp': 5.5, 'EgRef': 1.121, 'irrad_ref': 1000, 'temp_ref': 25}`

My purpose was to investigate the temperature coefficient of `I_mp`, and I got the following result, which appears to suffer from a numeric instability:

![image](https://user-images.githubusercontent.com/1125363/98264917-ab2d2880-1f45-11eb-83a2-e146774abf44.png)

For comparison, the corresponding `V_mp` vs. `T` plot:

![image](https://user-images.githubusercontent.com/1125363/98264984-bc763500-1f45-11eb-9012-7c29efa25e1e.png)

**To Reproduce**

Run the above calculations using the parameters provided.

**Expected behavior**

Better numerical stability in `I_mp` vs. `T`.

**Screenshots**

See above.

**Versions:**

 - ``pvlib.__version__``: 0.8.0
 - ``numpy.__version__``: 1.19.2
 - ``scipy.__version__``: 1.5.2
 - ``pandas.__version__``: 1.1.3
 - python: 3.8.5

**Additional context**

I was going to attempt a numerical computation of the temperature coefficient of `I_mp` for a model translation to the SAPM. I have seen reports from CFV in which this coefficient is actually negative, and I have computed it alternately using the `P_mp` and `V_mp` temperature coefficients, and gotten a negative value for this particular PV module. Despite the apparent numerical instability in the above plot, it still suggests that the coefficient should be positive, not negative. Perhaps I am missing something here?

Also, I have not dug deep enough to figure out if the underlying issue is in `pvlib.pvsystem.singlediode()`.


Discussion:
Confirmed. Looks like it's a lack of convergence in `pvlib.singlediode._lambertw`.  Some time ago a similar issue was reported for PVLib Matlab, I think we bumped up the iteration count to fix it.

If you set `pvlib.singlediode(..., method='brentq',...)` the noise smooths away.
@cwhanse Thanks. `method='brentq'` fixed the issue for me too. :)

Since you're from Sandia, do have any insight about a "typical" sign of the T-coef for `I_mp` in the SAPM? It seems like the sign is positive for the PVsyst model and negative for the SAPM (~both~ in SOME CFV reports ~and by taking the derivative of `P_mp` = `I_mp` * `V_mp` w.r.t. `T` and solving for `dI_mp/ dT`~).

UPDATE: With the `I_mp` calculation correction, it appears that I get a consistent + sign for the `I_mp` temperature coefficient in both computational methods. However, I do see this reported as a negative value in some places.
I don't know that theory predicts a sign for this temperature coefficient.

For SAPM, when Sandia fits this model using outdoor measurements, a value is determined by fitting a line to Imp vs. cell temperature (backed out of Isc, Voc values). I've argued that given the resolution of measurements, the technique for determining cell temperature and maximum power point, and the regression method, that the value is usually statistically indistinguishable from zero.

For CFV-produced Pvsyst parameters, my guess is that CFV is determining alpha_imp by fitting a line to the Imp vs. cell temperature data (IEC61853 matrix test results using an indoor flasher with temperature variation control), which is the same method used by Sandia for the SAPM. Happy to connect you with CFV and you can ask them.
Thanks for sharing your understanding @cwhanse. I think I will have a convo with Daniel C. Zirzow at CFV at some point in the near future.

Should I close this issue, or should it become a PR to change defaults to better ensure convergence?
Leave it open, there's a pvlib issue to be addressed.

I tracked down the source of the oscillatory behavior to [this line](https://github.com/pvlib/pvlib-python/blob/3e25627e34bfd5aadea041da85a30626322b3a99/pvlib/singlediode.py#L611). 

```
        argW = Rs[idx_p] * I0[idx_p] / (
                    a[idx_p] * (Rs[idx_p] * Gsh[idx_p] + 1.)) * \
               np.exp((Rs[idx_p] * (IL[idx_p] + I0[idx_p]) + V[idx_p]) /
                      (a[idx_p] * (Rs[idx_p] * Gsh[idx_p] + 1.)))
```

It's a product of two terms:
constant factor : Rs * I0 / (a (Rs Gsh + 1))  which is very small (~1E-10) and
exponential factor: np.exp( (Rs * (IL + I0) + V) / (a * (Rs * Gsh + 1)) ) which is quite large (~1E+10), since the argument is ~20.

The constant factor increases smoothly with cell temperature, as expected. 

The argument of the exponential term decreases with temperature but not smoothly. The cause seems to be that Vmp (`V` in that line of code) decreases but not smoothly. Vmp is calculated using `pvlib.tools._golden_sect_DataFrame`. I suspect the convergence of this root finder is the culprit.

@cwhanse An incidental colleague of mine (Ken Roberts) figured out a way around the numerical convergence issues with using the LambertW function for solving the single diode equation. I have never had the time to do it full justice in code (I use simple newton in PVfit), but this "Log Wright Omega" function might be worth looking into for pvlib. Here are some references he shared with me. He was so pleasant to work with and I found his exposition very approachable.

- https://arxiv.org/pdf/1504.01964.pdf
- https://www.researchgate.net/publication/305991463

Patch:
diff --git a/pvlib/ivtools/sdm.py b/pvlib/ivtools/sdm.py
--- a/pvlib/ivtools/sdm.py
+++ b/pvlib/ivtools/sdm.py
@@ -979,7 +979,8 @@ def _filter_params(ee, isc, io, rs, rsh):
     negrs = rs < 0.
     badrs = np.logical_or(rs > rsh, np.isnan(rs))
     imagrs = ~(np.isreal(rs))
-    badio = np.logical_or(~(np.isreal(rs)), io <= 0)
+    badio = np.logical_or(np.logical_or(~(np.isreal(rs)), io <= 0),
+                          np.isnan(io))
     goodr = np.logical_and(~badrsh, ~imagrs)
     goodr = np.logical_and(goodr, ~negrs)
     goodr = np.logical_and(goodr, ~badrs)
diff --git a/pvlib/tools.py b/pvlib/tools.py
--- a/pvlib/tools.py
+++ b/pvlib/tools.py
@@ -277,51 +277,61 @@ def _build_args(keys, input_dict, dict_name):
 
 # Created April,2014
 # Author: Rob Andrews, Calama Consulting
-
-def _golden_sect_DataFrame(params, VL, VH, func):
+# Modified: November, 2020 by C. W. Hansen, to add atol and change exit
+# criteria
+def _golden_sect_DataFrame(params, lower, upper, func, atol=1e-8):
     """
-    Vectorized golden section search for finding MPP from a dataframe
-    timeseries.
+    Vectorized golden section search for finding maximum of a function of a
+    single variable.
 
     Parameters
     ----------
-    params : dict
-        Dictionary containing scalars or arrays
-        of inputs to the function to be optimized.
-        Each row should represent an independent optimization.
+    params : dict or Dataframe
+        Parameters to be passed to `func`.
 
-    VL: float
-        Lower bound of the optimization
+    lower: numeric
+        Lower bound for the optimization
 
-    VH: float
-        Upper bound of the optimization
+    upper: numeric
+        Upper bound for the optimization
 
     func: function
-        Function to be optimized must be in the form f(array-like, x)
+        Function to be optimized. Must be in the form
+        result = f(dict or DataFrame, str), where result is a dict or DataFrame
+        that also contains the function output, and str is the key
+        corresponding to the function's input variable.
 
     Returns
     -------
-    func(df,'V1') : DataFrame
-        function evaluated at the optimal point
+    numeric
+        function evaluated at the optimal points
 
-    df['V1']: Dataframe
-        Dataframe of optimal points
+    numeric
+        optimal points
 
     Notes
     -----
-    This function will find the MAXIMUM of a function
+    This function will find the points where the function is maximized.
+
+    See also
+    --------
+    pvlib.singlediode._pwr_optfcn
     """
 
+    phim1 = (np.sqrt(5) - 1) / 2
+
     df = params
-    df['VH'] = VH
-    df['VL'] = VL
+    df['VH'] = upper
+    df['VL'] = lower
 
-    errflag = True
+    converged = False
     iterations = 0
+    iterlimit = 1 + np.max(
+        np.trunc(np.log(atol / (df['VH'] - df['VL'])) / np.log(phim1)))
 
-    while errflag:
+    while not converged and (iterations < iterlimit):
 
-        phi = (np.sqrt(5)-1)/2*(df['VH']-df['VL'])
+        phi = phim1 * (df['VH'] - df['VL'])
         df['V1'] = df['VL'] + phi
         df['V2'] = df['VH'] - phi
 
@@ -332,15 +342,15 @@ def _golden_sect_DataFrame(params, VL, VH, func):
         df['VL'] = df['V2']*df['SW_Flag'] + df['VL']*(~df['SW_Flag'])
         df['VH'] = df['V1']*~df['SW_Flag'] + df['VH']*(df['SW_Flag'])
 
-        err = df['V1'] - df['V2']
-        try:
-            errflag = (abs(err) > .01).any()
-        except ValueError:
-            errflag = (abs(err) > .01)
+        err = abs(df['V2'] - df['V1'])
 
+        # works with single value because err is np.float64
+        converged = (err < atol).all()
+        # err will be less than atol before iterations hit the limit
+        # but just to be safe
         iterations += 1
 
-        if iterations > 50:
-            raise Exception("EXCEPTION:iterations exceeded maximum (50)")
+    if iterations > iterlimit:
+        raise Exception("iterations exceeded maximum")  # pragma: no cover
 
     return func(df, 'V1'), df['V1']


Test patch:
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
@@ -1313,32 +1313,35 @@ def test_singlediode_array():
                               resistance_series, resistance_shunt, nNsVth,
                               method='lambertw')
 
-    expected = np.array([
-        0.        ,  0.54538398,  1.43273966,  2.36328163,  3.29255606,
-        4.23101358,  5.16177031,  6.09368251,  7.02197553,  7.96846051,
-        8.88220557])
-
-    assert_allclose(sd['i_mp'], expected, atol=0.01)
+    expected_i = np.array([
+        0., 0.54614798740338, 1.435026463529, 2.3621366610078, 3.2953968319952,
+        4.2303869378787, 5.1655276691892, 6.1000269648604, 7.0333996177802,
+        7.9653036915959, 8.8954716265647])
+    expected_v = np.array([
+        0., 7.0966259059555, 7.9961986643428, 8.2222496810656, 8.3255927555753,
+        8.3766915453915, 8.3988872440242, 8.4027948807891, 8.3941399580559,
+        8.3763655188855, 8.3517057522791])
+
+    assert_allclose(sd['i_mp'], expected_i, atol=1e-8)
+    assert_allclose(sd['v_mp'], expected_v, atol=1e-8)
 
     sd = pvsystem.singlediode(photocurrent, saturation_current,
                               resistance_series, resistance_shunt, nNsVth)
-
     expected = pvsystem.i_from_v(resistance_shunt, resistance_series, nNsVth,
                                  sd['v_mp'], saturation_current, photocurrent,
                                  method='lambertw')
-
-    assert_allclose(sd['i_mp'], expected, atol=0.01)
+    assert_allclose(sd['i_mp'], expected, atol=1e-8)
 
 
 def test_singlediode_floats():
-    out = pvsystem.singlediode(7, 6e-7, .1, 20, .5, method='lambertw')
-    expected = {'i_xx': 4.2498,
-                'i_mp': 6.1275,
-                'v_oc': 8.1063,
-                'p_mp': 38.1937,
-                'i_x': 6.7558,
-                'i_sc': 6.9651,
-                'v_mp': 6.2331,
+    out = pvsystem.singlediode(7., 6.e-7, .1, 20., .5, method='lambertw')
+    expected = {'i_xx': 4.264060478,
+                'i_mp': 6.136267360,
+                'v_oc': 8.106300147,
+                'p_mp': 38.19421055,
+                'i_x': 6.7558815684,
+                'i_sc': 6.965172322,
+                'v_mp': 6.224339375,
                 'i': None,
                 'v': None}
     assert isinstance(out, dict)
@@ -1346,23 +1349,26 @@ def test_singlediode_floats():
         if k in ['i', 'v']:
             assert v is None
         else:
-            assert_allclose(v, expected[k], atol=1e-3)
+            assert_allclose(v, expected[k], atol=1e-6)
 
 
 def test_singlediode_floats_ivcurve():
-    out = pvsystem.singlediode(7, 6e-7, .1, 20, .5, ivcurve_pnts=3, method='lambertw')
-    expected = {'i_xx': 4.2498,
-                'i_mp': 6.1275,
-                'v_oc': 8.1063,
-                'p_mp': 38.1937,
-                'i_x': 6.7558,
-                'i_sc': 6.9651,
-                'v_mp': 6.2331,
-                'i': np.array([6.965172e+00, 6.755882e+00, 2.575717e-14]),
-                'v': np.array([0., 4.05315, 8.1063])}
+    out = pvsystem.singlediode(7., 6e-7, .1, 20., .5, ivcurve_pnts=3,
+                               method='lambertw')
+    expected = {'i_xx': 4.264060478,
+                'i_mp': 6.136267360,
+                'v_oc': 8.106300147,
+                'p_mp': 38.19421055,
+                'i_x': 6.7558815684,
+                'i_sc': 6.965172322,
+                'v_mp': 6.224339375,
+                'i': np.array([
+                    6.965172322, 6.755881568, 2.664535259e-14]),
+                'v': np.array([
+                    0., 4.053150073, 8.106300147])}
     assert isinstance(out, dict)
     for k, v in out.items():
-        assert_allclose(v, expected[k], atol=1e-3)
+        assert_allclose(v, expected[k], atol=1e-6)
 
 
 def test_singlediode_series_ivcurve(cec_module_params):
@@ -1383,21 +1389,20 @@ def test_singlediode_series_ivcurve(cec_module_params):
     out = pvsystem.singlediode(IL, I0, Rs, Rsh, nNsVth, ivcurve_pnts=3,
                                method='lambertw')
 
-    expected = OrderedDict([('i_sc', array([0., 3.01054475, 6.00675648])),
-                            ('v_oc', array([0., 9.96886962, 10.29530483])),
-                            ('i_mp', array([0., 2.65191983, 5.28594672])),
-                            ('v_mp', array([0., 8.33392491, 8.4159707])),
-                            ('p_mp', array([0., 22.10090078, 44.48637274])),
-                            ('i_x', array([0., 2.88414114, 5.74622046])),
-                            ('i_xx', array([0., 2.04340914, 3.90007956])),
+    expected = OrderedDict([('i_sc', array([0., 3.01079860, 6.00726296])),
+                            ('v_oc', array([0., 9.96959733, 10.29603253])),
+                            ('i_mp', array([0., 2.656285960, 5.290525645])),
+                            ('v_mp', array([0., 8.321092255, 8.409413795])),
+                            ('p_mp', array([0., 22.10320053, 44.49021934])),
+                            ('i_x', array([0., 2.884132006, 5.746202281])),
+                            ('i_xx', array([0., 2.052691562, 3.909673879])),
                             ('v', array([[0., 0., 0.],
-                                         [0., 4.98443481, 9.96886962],
-                                         [0., 5.14765242, 10.29530483]])),
+                                         [0., 4.984798663, 9.969597327],
+                                         [0., 5.148016266, 10.29603253]])),
                             ('i', array([[0., 0., 0.],
-                                         [3.01079860e+00, 2.88414114e+00,
-                                          3.10862447e-14],
-                                         [6.00726296e+00, 5.74622046e+00,
-                                          0.00000000e+00]]))])
+                                         [3.0107985972, 2.8841320056, 0.],
+                                         [6.0072629615, 5.7462022810, 0.]]))])
+
 
     for k, v in out.items():
         assert_allclose(v, expected[k], atol=1e-2)
@@ -1414,7 +1419,7 @@ def test_singlediode_series_ivcurve(cec_module_params):
                                          method='lambertw').T
 
     for k, v in out.items():
-        assert_allclose(v, expected[k], atol=1e-2)
+        assert_allclose(v, expected[k], atol=1e-6)
 
 
 def test_scale_voltage_current_power():
diff --git a/pvlib/tests/test_tools.py b/pvlib/tests/test_tools.py
--- a/pvlib/tests/test_tools.py
+++ b/pvlib/tests/test_tools.py
@@ -1,6 +1,7 @@
 import pytest
 
 from pvlib import tools
+import numpy as np
 
 
 @pytest.mark.parametrize('keys, input_dict, expected', [
@@ -12,3 +13,35 @@
 def test_build_kwargs(keys, input_dict, expected):
     kwargs = tools._build_kwargs(keys, input_dict)
     assert kwargs == expected
+
+
+def _obj_test_golden_sect(params, loc):
+    return params[loc] * (1. - params['c'] * params[loc]**params['n'])
+
+
+@pytest.mark.parametrize('params, lb, ub, expected, func', [
+    ({'c': 1., 'n': 1.}, 0., 1., 0.5, _obj_test_golden_sect),
+    ({'c': 1e6, 'n': 6.}, 0., 1., 0.07230200263994839, _obj_test_golden_sect),
+    ({'c': 0.2, 'n': 0.3}, 0., 100., 89.14332727531685, _obj_test_golden_sect)
+])
+def test__golden_sect_DataFrame(params, lb, ub, expected, func):
+    v, x = tools._golden_sect_DataFrame(params, lb, ub, func)
+    assert np.isclose(x, expected, atol=1e-8)
+
+
+def test__golden_sect_DataFrame_atol():
+    params = {'c': 0.2, 'n': 0.3}
+    expected = 89.14332727531685
+    v, x = tools._golden_sect_DataFrame(
+        params, 0., 100., _obj_test_golden_sect, atol=1e-12)
+    assert np.isclose(x, expected, atol=1e-12)
+
+
+def test__golden_sect_DataFrame_vector():
+    params = {'c': np.array([1., 2.]), 'n': np.array([1., 1.])}
+    lower = np.array([0., 0.001])
+    upper = np.array([1.1, 1.2])
+    expected = np.array([0.5, 0.25])
+    v, x = tools._golden_sect_DataFrame(params, lower, upper,
+                                        _obj_test_golden_sect)
+    assert np.allclose(x, expected, atol=1e-8)