Repository: pvlib/pvlib-python

Issue:
Bishop88 functions sometimes return incorrect results or errors when using `d2mutau` and `NsVbi` parameters
**Describe the bug**
The bishop88 functions can sometimes fail, or return incorrect results, when using the `d2mutau` and `NsVbi` parameters.

**To Reproduce**

Set-up:

```python
import pvlib
import numpy as np
import matplotlib.pyplot as plt

sdm_params = {
    'alpha_sc': 0.001, 'gamma_ref': 1.409, 'mu_gamma': 0.001, 'I_o_ref': 3.96e-11,
    'R_sh_ref': 4900, 'R_sh_0': 8800, 'R_sh_exp': 5.5,
    'R_s': 6.76, 'cells_in_series': 264, 'EgRef': 1.121, 'I_L_ref': 2.278883384981941
}
d2mutau = 1.5
NsVbi = 237.6

G = np.linspace(0, 1200)
T = 25
sde_params = pvlib.pvsystem.calcparams_pvsyst(G, T, **sdm_params)
```

`method='brentq'` errors with `ValueError: f(a) and f(b) must have different signs`.

<details>
  <summary>Click to show traceback</summary>

```
Traceback (most recent call last):

  Cell In[16], line 17
    mpp = pvlib.singlediode.bishop88_mpp(*sde_params, d2mutau=d2mutau, NsVbi=NsVbi, method='brentq')

  File ~\software\miniconda3\envs\dev\lib\site-packages\pvlib\singlediode.py:577 in bishop88_mpp
    vd = vec_fun(voc_est, *args)

  File ~\software\miniconda3\envs\dev\lib\site-packages\numpy\lib\function_base.py:2372 in __call__
    return self._call_as_normal(*args, **kwargs)

  File ~\software\miniconda3\envs\dev\lib\site-packages\numpy\lib\function_base.py:2365 in _call_as_normal
    return self._vectorize_call(func=func, args=vargs)

  File ~\software\miniconda3\envs\dev\lib\site-packages\numpy\lib\function_base.py:2450 in _vectorize_call
    ufunc, otypes = self._get_ufunc_and_otypes(func=func, args=args)

  File ~\software\miniconda3\envs\dev\lib\site-packages\numpy\lib\function_base.py:2410 in _get_ufunc_and_otypes
    outputs = func(*inputs)

  File ~\software\miniconda3\envs\dev\lib\site-packages\pvlib\singlediode.py:572 in <lambda>
    vbr_exp: brentq(fmpp, 0.0, voc,

  File ~\software\miniconda3\envs\dev\lib\site-packages\scipy\optimize\_zeros_py.py:809 in brentq
    r = _zeros._brentq(f, a, b, xtol, rtol, maxiter, args, full_output, disp)

ValueError: f(a) and f(b) must have different signs
```

</details>

`method='newton'` gives incorrect results for some conditions:
```python
mpp_newton = pvlib.singlediode.bishop88_mpp(*sde_params, d2mutau=d2mutau, NsVbi=NsVbi, method='newton')
plt.plot(G, mpp_newton[2])
plt.ylabel('$P_{mp}$ [W]')
plt.xlabel('Irradiance [W/m2]')
```
![image](https://github.com/pvlib/pvlib-python/assets/57452607/8647ba33-b3fd-4ddc-a9f9-472daa429ca2)

**Versions:**
 - ``pvlib.__version__``: 0.10.4
 - ``scipy.__version__``: 1.12.0

**Additional context**
I think the problem has something to do with the following:
1. The [SDE with the thin film recombination current term](https://pvlib-python.readthedocs.io/en/stable/reference/generated/pvlib.singlediode.bishop88.html) has a singularity at `NsVbi = Vd`, and our code only returns valid results for `Vd < NsVbi` (ref https://github.com/pvlib/pvlib-python/issues/163#issuecomment-404238561)
2. `estimate_voc` can sometimes return a value on the other side of the singularity (`Vd > NsVbi`), where our bishop88-style calculations are not valid. 
3. `bishop88_mpp` uses `estimate_voc` to calculate either an upper bound for the bracketed optimization (with `method='brentq'`) or the initial guess (with `method='newton'`), meaning we are sometimes providing invalid starting points to the root finder.

Here is an example of the estimated Voc being greater than NsVbi:

```python

import pvlib

sdm_params = {
    'alpha_sc': 0.001, 'gamma_ref': 1.409, 'mu_gamma': 0.001, 'I_o_ref': 3.96e-11,
    'R_sh_ref': 4900, 'R_sh_0': 8800, 'R_sh_exp': 5.5,
    'R_s': 6.76, 'cells_in_series': 264, 'EgRef': 1.121, 'I_L_ref': 2.278883384981941
}
d2mutau = 1.5
NsVbi = 237.6

G = 1100
T = 25

sde_params = pvlib.pvsystem.calcparams_pvsyst(G, T, **sdm_params)
voc_est = pvlib.singlediode.estimate_voc(sde_params[0], sde_params[1], sde_params[-1])
print(voc_est)  # 237.6945087345138, greater than NsVbi=237.6
```

cc @leliadeville


Discussion:
Thank you for bringing this issue up.  This is something I've noticed recently as well.  I have tested a fair amount of CdTe modules with `bishop88` and have seen "discontinuities" like the one you pointed out above - in the form of extremely high or low MPPs, or just getting `nan` values returned at those points.

A minor question regarding the example you gave - was that module supposed to CdTe?  If so, shouldn't the energy bandgap be 1.5eV?
> was that module supposed to CdTe? If so, shouldn't the energy bandgap be 1.5eV?

Yes, good point.  I guess my brain and fingers were on autopilot when putting together an example set of parameters.  Interestingly, updating the parameters to use 1.5 eV doesn't seem to change the simulation results at all... am I overlooking something?

Also @smmeredith, if you can, it would be great if you would assemble a collection of parameter sets that you have observed to produce incorrect simulations.  Whenever someone fixes this bug, the fix could be evaluated on that collection.  
> updating the parameters to use 1.5 eV doesn't seem to change the simulation results at all
> 

I think this is because only saturation current uses it, which has no irradiance dependence.

> it would be great if you would assemble a collection of parameter sets that you have observed to produce incorrect simulations
> 

No problem, just let me know how I can share it.

So I've done a little more investigating on the 25 CdTe modules that I've been testing with `bishop88`, and I think you're right about the problem being related to the Voc estimate.  For each module, I:

1.  Calculated the estimated Voc using `singlediode.estimate_voc`
2.  Ran the module through the `singlediode.bishop_mpp` model (using the `newton` method) at a range of temperatures from -25 to +74 C in steps of 1 C, and a range of irradiances from 20 to 1100 W/m2 in steps of 20 W/m2.
3.  Calculated the efficiency based on the output `p_mp` and identified problematic outputs where the efficiency was either `nan`, less than zero, or greater than 30%.
4.  Calculated `NsVbi - Voc`

**For every module that I looked at, whenever a problematic output occurred,** `NsVbi - Voc` **was negative**.  This is not to say that ALL negative `NsVbi - Voc` caused a problematic output - in fact there were many outputs where this expression was negative and the output seemed reasonable.
Attaching a JSON of parameters that were used in my analysis.

I removed seven parameter sets that came from a third-party lab, because my single-diode model does not match PVsyst as well for those modules - and that is a separate issue that needs to be resolved by me.

[sd_params.json](https://github.com/pvlib/pvlib-python/files/15081291/sd_params.json)

Modifying `bishop88_mpp` [here](https://github.com/pvlib/pvlib-python/blob/1d854e5a83543de34337a63356efb9c8dd3d859a/pvlib/singlediode.py#L581) from

```
        x0, args, method_kwargs = \
            _prepare_newton_inputs(voc_est, args, method_kwargs)
```

to 

```
        xp = np.where(voc_est < NsVbi, voc_est, 0.99*NsVbi)
        x0, args, method_kwargs = \
            _prepare_newton_inputs(xp, args, method_kwargs)
```

fixes all `np.isnan(p_mp) | (p_mp < 0)` in the modeling cases described by @smmeredith (irradiance 20W - 1100W by 20W, cell temp. -25C to +74C, parameters from the posted sd_params.json file). I don't have the module areas so can't tell if the high efficiency values are also fixed, but I suspect yes.

We want to start the Newton iteration a little less than NsVbi, where the derivative dP/dV should be negative (and large in magnitude), and then I think Newton's should be reliable (as reliable as Newton's can be). Illustration of the power vs. diode voltage curve in the neighborhood of the asymptote at Vd=NsVbi, we're trying to find the max power point (red square)
![bishop88_negs_example](https://github.com/pvlib/pvlib-python/assets/5393711/c0b24cdc-0682-4f58-b40a-e77e57564ed1)
.


@cwhanse how about with `method='brentq'`?
> @cwhanse how about with `method='brentq'`?

Same change to the right end point also fixes the failures using `method='brentq'`.

Edit: ...and prevents a number of warnings that are currently emitted.



Patch:
diff --git a/docs/sphinx/source/whatsnew/v0.10.5.rst b/docs/sphinx/source/whatsnew/v0.10.5.rst
index 7ee3be7613..0751366b59 100644
--- a/docs/sphinx/source/whatsnew/v0.10.5.rst
+++ b/docs/sphinx/source/whatsnew/v0.10.5.rst
@@ -15,6 +15,10 @@ Enhancements
 
 Bug fixes
 ~~~~~~~~~
+* Improved reliability of :py:func:`pvlib.singlediode.bishop88_mpp`,
+  :py:func:`pvlib.singlediode.bishop88_i_from_v` and
+  :py:func:`pvlib.singlediode.bishop88_v_from_i` by improving the initial
+  guess for the newton and brentq algorithms. (:issue:`2013`, :pull:`2032`)
 * Corrected equation for Ixx0 in :py:func:`pvlib.pvsystem.sapm` (:issue:`2016`, :pull:`2019`)
 * Fixed :py:func:`pvlib.pvsystem.retrieve_sam` silently ignoring the `path` parameter
   when `name` was provided. Now an exception is raised requesting to only provide one
diff --git a/pvlib/singlediode.py b/pvlib/singlediode.py
index 6ee3593aa6..a1261f3f64 100644
--- a/pvlib/singlediode.py
+++ b/pvlib/singlediode.py
@@ -310,6 +310,9 @@ def fv(x, v, *a):
     if method == 'brentq':
         # first bound the search using voc
         voc_est = estimate_voc(photocurrent, saturation_current, nNsVth)
+        # start iteration slightly less than NsVbi when voc_est > NsVbi, to
+        # avoid the asymptote at NsVbi
+        xp = np.where(voc_est < NsVbi, voc_est, 0.9999*NsVbi)
 
         # brentq only works with scalar inputs, so we need a set up function
         # and np.vectorize to repeatedly call the optimizer with the right
@@ -323,7 +326,7 @@ def vd_from_brent(voc, v, iph, isat, rs, rsh, gamma, d2mutau, NsVbi,
                           **method_kwargs)
 
         vd_from_brent_vectorized = np.vectorize(vd_from_brent)
-        vd = vd_from_brent_vectorized(voc_est, voltage, *args)
+        vd = vd_from_brent_vectorized(xp, voltage, *args)
     elif method == 'newton':
         x0, (voltage, *args), method_kwargs = \
             _prepare_newton_inputs(voltage, (voltage, *args), method_kwargs)
@@ -443,6 +446,9 @@ def bishop88_v_from_i(current, photocurrent, saturation_current,
 
     # first bound the search using voc
     voc_est = estimate_voc(photocurrent, saturation_current, nNsVth)
+    # start iteration slightly less than NsVbi when voc_est > NsVbi, to avoid
+    # the asymptote at NsVbi
+    xp = np.where(voc_est < NsVbi, voc_est, 0.9999*NsVbi)
 
     def fi(x, i, *a):
         # calculate current residual given diode voltage "x"
@@ -461,10 +467,10 @@ def vd_from_brent(voc, i, iph, isat, rs, rsh, gamma, d2mutau, NsVbi,
                           **method_kwargs)
 
         vd_from_brent_vectorized = np.vectorize(vd_from_brent)
-        vd = vd_from_brent_vectorized(voc_est, current, *args)
+        vd = vd_from_brent_vectorized(xp, current, *args)
     elif method == 'newton':
         x0, (current, *args), method_kwargs = \
-            _prepare_newton_inputs(voc_est, (current, *args), method_kwargs)
+            _prepare_newton_inputs(xp, (current, *args), method_kwargs)
         vd = newton(func=lambda x, *a: fi(x, current, *a), x0=x0,
                     fprime=lambda x, *a: bishop88(x, *a, gradients=True)[3],
                     args=args, **method_kwargs)
@@ -579,6 +585,9 @@ def bishop88_mpp(photocurrent, saturation_current, resistance_series,
 
     # first bound the search using voc
     voc_est = estimate_voc(photocurrent, saturation_current, nNsVth)
+    # start iteration slightly less than NsVbi when voc_est > NsVbi, to avoid
+    # the asymptote at NsVbi
+    xp = np.where(voc_est < NsVbi, voc_est, 0.9999*NsVbi)
 
     def fmpp(x, *a):
         return bishop88(x, *a, gradients=True)[6]
@@ -592,12 +601,13 @@ def fmpp(x, *a):
                                   vbr_a, vbr, vbr_exp),
                             **method_kwargs)
         )
-        vd = vec_fun(voc_est, *args)
+        vd = vec_fun(xp, *args)
     elif method == 'newton':
         # make sure all args are numpy arrays if max size > 1
         # if voc_est is an array, then make a copy to use for initial guess, v0
+
         x0, args, method_kwargs = \
-            _prepare_newton_inputs(voc_est, args, method_kwargs)
+            _prepare_newton_inputs(xp, args, method_kwargs)
         vd = newton(func=fmpp, x0=x0,
                     fprime=lambda x, *a: bishop88(x, *a, gradients=True)[7],
                     args=args, **method_kwargs)


Test patch:
diff --git a/pvlib/tests/test_singlediode.py b/pvlib/tests/test_singlediode.py
index 9089820db0..3cf98dd2d5 100644
--- a/pvlib/tests/test_singlediode.py
+++ b/pvlib/tests/test_singlediode.py
@@ -575,3 +575,53 @@ def test_bishop88_pdSeries_len_one(method, bishop88_arguments):
     bishop88_i_from_v(pd.Series([0]), **bishop88_arguments, method=method)
     bishop88_v_from_i(pd.Series([0]), **bishop88_arguments, method=method)
     bishop88_mpp(**bishop88_arguments, method=method)
+
+
+def _sde_check_solution(i, v, il, io, rs, rsh, a, d2mutau=0., NsVbi=np.inf):
+    vd = v + rs * i
+    return il - io*np.expm1(vd/a) - vd/rsh - il*d2mutau/(NsVbi - vd) - i
+
+
+@pytest.mark.parametrize('method', ['newton', 'brentq'])
+def test_bishop88_init_cond(method):
+    # GH 2013
+    p = {'alpha_sc': 0.0012256,
+         'gamma_ref': 1.2916241612804187,
+         'mu_gamma': 0.00047308959960937403,
+         'I_L_ref': 3.068717040806731,
+         'I_o_ref': 2.2691248021217617e-11,
+         'R_sh_ref': 7000,
+         'R_sh_0': 7000,
+         'R_s': 4.602,
+         'cells_in_series': 268,
+         'R_sh_exp': 5.5,
+         'EgRef': 1.5}
+    NsVbi = 268 * 0.9
+    d2mutau = 1.4
+    irrad = np.arange(20, 1100, 20)
+    tc = np.arange(-25, 74, 1)
+    weather = np.array(np.meshgrid(irrad, tc)).T.reshape(-1, 2)
+    # with the above parameters and weather conditions, a few combinations
+    # result in voc_est > NsVbi, which causes failure of brentq and newton
+    # when the recombination parameters NsVbi and d2mutau are used.
+    sde_params = pvsystem.calcparams_pvsyst(weather[:, 0], weather[:, 1], **p)
+    # test _mpp
+    result = bishop88_mpp(*sde_params, d2mutau=d2mutau, NsVbi=NsVbi)
+    imp, vmp, pmp = result
+    err = np.abs(_sde_check_solution(
+        imp, vmp, sde_params[0], sde_params[1], sde_params[2], sde_params[3],
+        sde_params[4], d2mutau=d2mutau, NsVbi=NsVbi))
+    bad_results = np.isnan(pmp) | (pmp < 0) | (err > 0.00001)  # 0.01mA error
+    assert not bad_results.any()
+    # test v_from_i
+    vmp2 = bishop88_v_from_i(imp, *sde_params, d2mutau=d2mutau, NsVbi=NsVbi)
+    err = np.abs(_sde_check_solution(imp, vmp2, *sde_params, d2mutau=d2mutau,
+                                     NsVbi=NsVbi))
+    bad_results = np.isnan(vmp2) | (vmp2 < 0) | (err > 0.00001)
+    assert not bad_results.any()
+    # test v_from_i
+    imp2 = bishop88_i_from_v(vmp, *sde_params, d2mutau=d2mutau, NsVbi=NsVbi)
+    err = np.abs(_sde_check_solution(imp2, vmp, *sde_params, d2mutau=d2mutau,
+                                     NsVbi=NsVbi))
+    bad_results = np.isnan(imp2) | (imp2 < 0) | (err > 0.00001)
+    assert not bad_results.any()