Skip to content
10 changes: 10 additions & 0 deletions docs/sphinx/source/whatsnew/v0.13.2.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ Bug fixes

Enhancements
~~~~~~~~~~~~
* Add ``method='chandrupatla'`` (faster than ``brentq`` and slower than ``newton``,
but convergence is guaranteed) as an option for
:py:func:`pvlib.pvsystem.singlediode`,
:py:func:`~pvlib.pvsystem.i_from_v`,
:py:func:`~pvlib.pvsystem.v_from_i`,
:py:func:`~pvlib.pvsystem.max_power_point`,
:py:func:`~pvlib.singlediode.bishop88_mpp`,
:py:func:`~pvlib.singlediode.bishop88_v_from_i`, and
:py:func:`~pvlib.singlediode.bishop88_i_from_v`. (:issue:`2497`, :pull:`2498`)



Documentation
Expand Down
30 changes: 24 additions & 6 deletions pvlib/pvsystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -2498,7 +2498,11 @@ def singlediode(photocurrent, saturation_current, resistance_series,

method : str, default 'lambertw'
Determines the method used to calculate points on the IV curve. The
options are ``'lambertw'``, ``'newton'``, or ``'brentq'``.
options are ``'lambertw'``, ``'newton'``, ``'brentq'``, or
``'chandrupatla'``.

.. note::
``'chandrupatla'`` requires scipy 1.15 or greater.

Returns
-------
Expand Down Expand Up @@ -2630,7 +2634,11 @@ def max_power_point(photocurrent, saturation_current, resistance_series,
cells ``Ns`` and the builtin voltage ``Vbi`` of the intrinsic layer.
[V].
method : str
either ``'newton'`` or ``'brentq'``
either ``'newton'``, ``'brentq'``, or ``'chandrupatla'``.

.. note::
``'chandrupatla'`` requires scipy 1.15 or greater.


Returns
-------
Expand Down Expand Up @@ -2713,8 +2721,13 @@ def v_from_i(current, photocurrent, saturation_current, resistance_series,
0 < nNsVth

method : str
Method to use: ``'lambertw'``, ``'newton'``, or ``'brentq'``. *Note*:
``'brentq'`` is limited to 1st quadrant only.
Method to use: ``'lambertw'``, ``'newton'``, ``'brentq'``, or
``'chandrupatla'``. *Note*: ``'brentq'`` is limited to
non-negative current.

.. note::
``'chandrupatla'`` requires scipy 1.15 or greater.


Returns
-------
Expand Down Expand Up @@ -2795,8 +2808,13 @@ def i_from_v(voltage, photocurrent, saturation_current, resistance_series,
0 < nNsVth

method : str
Method to use: ``'lambertw'``, ``'newton'``, or ``'brentq'``. *Note*:
``'brentq'`` is limited to 1st quadrant only.
Method to use: ``'lambertw'``, ``'newton'``, ``'brentq'``, or
``'chandrupatla'``. *Note*: ``'brentq'`` is limited to
non-negative current.

.. note::
``'chandrupatla'`` requires scipy 1.15 or greater.


Returns
-------
Expand Down
166 changes: 127 additions & 39 deletions pvlib/singlediode.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,13 @@ def bishop88(diode_voltage, photocurrent, saturation_current,
(a-Si) modules that is the product of the PV module number of series
cells :math:`N_{s}` and the builtin voltage :math:`V_{bi}` of the
intrinsic layer. [V].
breakdown_factor : float, default 0
breakdown_factor : numeric, default 0
fraction of ohmic current involved in avalanche breakdown :math:`a`.
Default of 0 excludes the reverse bias term from the model. [unitless]
breakdown_voltage : float, default -5.5
breakdown_voltage : numeric, default -5.5
reverse breakdown voltage of the photovoltaic junction :math:`V_{br}`
[V]
breakdown_exp : float, default 3.28
breakdown_exp : numeric, default 3.28
avalanche breakdown exponent :math:`m` [unitless]
gradients : bool
False returns only I, V, and P. True also returns gradients
Expand Down Expand Up @@ -162,12 +162,11 @@ def bishop88(diode_voltage, photocurrent, saturation_current,
# calculate temporary values to simplify calculations
v_star = diode_voltage / nNsVth # non-dimensional diode voltage
g_sh = 1.0 / resistance_shunt # conductance
if breakdown_factor > 0: # reverse bias is considered
brk_term = 1 - diode_voltage / breakdown_voltage
brk_pwr = np.power(brk_term, -breakdown_exp)
i_breakdown = breakdown_factor * diode_voltage * g_sh * brk_pwr
else:
i_breakdown = 0.

brk_term = 1 - diode_voltage / breakdown_voltage
brk_pwr = np.power(brk_term, -breakdown_exp)
i_breakdown = breakdown_factor * diode_voltage * g_sh * brk_pwr

i = (photocurrent - saturation_current * np.expm1(v_star) # noqa: W503
- diode_voltage * g_sh - i_recomb - i_breakdown) # noqa: W503
v = diode_voltage - i * resistance_series
Expand All @@ -177,18 +176,14 @@ def bishop88(diode_voltage, photocurrent, saturation_current,
grad_i_recomb = np.where(is_recomb, i_recomb / v_recomb, 0)
grad_2i_recomb = np.where(is_recomb, 2 * grad_i_recomb / v_recomb, 0)
g_diode = saturation_current * np.exp(v_star) / nNsVth # conductance
if breakdown_factor > 0: # reverse bias is considered
brk_pwr_1 = np.power(brk_term, -breakdown_exp - 1)
brk_pwr_2 = np.power(brk_term, -breakdown_exp - 2)
brk_fctr = breakdown_factor * g_sh
grad_i_brk = brk_fctr * (brk_pwr + diode_voltage *
-breakdown_exp * brk_pwr_1)
grad2i_brk = (brk_fctr * -breakdown_exp # noqa: W503
* (2 * brk_pwr_1 + diode_voltage # noqa: W503
* (-breakdown_exp - 1) * brk_pwr_2)) # noqa: W503
else:
grad_i_brk = 0.
grad2i_brk = 0.
brk_pwr_1 = np.power(brk_term, -breakdown_exp - 1)
brk_pwr_2 = np.power(brk_term, -breakdown_exp - 2)
brk_fctr = breakdown_factor * g_sh
grad_i_brk = brk_fctr * (brk_pwr + diode_voltage *
-breakdown_exp * brk_pwr_1)
grad2i_brk = (brk_fctr * -breakdown_exp # noqa: W503
* (2 * brk_pwr_1 + diode_voltage # noqa: W503
* (-breakdown_exp - 1) * brk_pwr_2)) # noqa: W503
grad_i = -g_diode - g_sh - grad_i_recomb - grad_i_brk # di/dvd
grad_v = 1.0 - grad_i * resistance_series # dv/dvd
# dp/dv = d(iv)/dv = v * di/dv + i
Expand Down Expand Up @@ -247,12 +242,19 @@ def bishop88_i_from_v(voltage, photocurrent, saturation_current,
breakdown_exp : float, default 3.28
avalanche breakdown exponent :math:`m` [unitless]
method : str, default 'newton'
Either ``'newton'`` or ``'brentq'``. ''method'' must be ``'newton'``
if ``breakdown_factor`` is not 0.
Either ``'newton'``, ``'brentq'``, or ``'chandrupatla'``.
''method'' must be ``'newton'`` if ``breakdown_factor`` is not 0.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this requirement for 'newton'? The arguments passed when method='brentq' or method='chandrupatla' include all the reverse bias parameters.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do not know the answer to this. I see in #948 you wrote this:

The Lambert W technique cannot solve the single diode equation with the term for current at reverse bias. The Bishop '88 methods can solve the equation. Tested with method='newton', not clear if method='brentq' is reliable for this application.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My apologies for asking you to explain something I added 5 years ago :). I didn't pick up that all you did was reformat a bit of documentation.

I think that comment was added because the tests didn't confirm that 'brentq' works. I can't think of a reason why it wouldn't work. I suspect there was a vectorization issue that I didn't see how to fix at the time. It may be gone with removal of the if breakdown > 0: statement.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could remove that constraint requiring method='newton' with breakdown_factor>0. But in a follow-on PR.


.. note::
``'chandrupatla'`` requires scipy 1.15 or greater.

method_kwargs : dict, optional
Keyword arguments passed to root finder method. See
:py:func:`scipy:scipy.optimize.brentq` and
:py:func:`scipy:scipy.optimize.newton` parameters.
Keyword arguments passed to the root finder. For options, see:

* ``method='brentq'``: :py:func:`scipy:scipy.optimize.brentq`
* ``method='newton'``: :py:func:`scipy:scipy.optimize.newton`
* ``method='chandrupatla'``: :py:func:`scipy:scipy.optimize.elementwise.find_root`

``'full_output': True`` is allowed, and ``optimizer_output`` would be
returned. See examples section.

Expand Down Expand Up @@ -291,7 +293,7 @@ def bishop88_i_from_v(voltage, photocurrent, saturation_current,
.. [1] "Computer simulation of the effects of electrical mismatches in
photovoltaic cell interconnection circuits" JW Bishop, Solar Cell (1988)
:doi:`10.1016/0379-6787(88)90059-2`
"""
""" # noqa: E501
# collect args
args = (photocurrent, saturation_current,
resistance_series, resistance_shunt, nNsVth, d2mutau, NsVbi,
Expand Down Expand Up @@ -333,6 +335,30 @@ def vd_from_brent(voc, v, iph, isat, rs, rsh, gamma, d2mutau, NsVbi,
vd = newton(func=lambda x, *a: fv(x, voltage, *a), x0=x0,
fprime=lambda x, *a: bishop88(x, *a, gradients=True)[4],
args=args, **method_kwargs)
elif method == 'chandrupatla':
try:
from scipy.optimize.elementwise import find_root
except ModuleNotFoundError as e:
# TODO remove this when our minimum scipy version is >=1.15
msg = (
"method='chandrupatla' requires scipy v1.15 or greater "
"(available for Python 3.10+). "
"Select another method, or update your version of scipy."
)
raise ImportError(msg) from e

voc_est = estimate_voc(photocurrent, saturation_current, nNsVth)
shape = _shape_of_max_size(voltage, voc_est)
vlo = np.zeros(shape)
vhi = np.full(shape, voc_est)
bounds = (vlo, vhi)
kwargs_trimmed = method_kwargs.copy()
kwargs_trimmed.pop("full_output", None) # not valid for find_root

result = find_root(fv, bounds, args=(voltage, *args), **kwargs_trimmed)
vd = result.x
if method_kwargs.get('full_output'):
vd = (vd, result) # mimic the other methods
else:
raise NotImplementedError("Method '%s' isn't implemented" % method)

Expand Down Expand Up @@ -388,12 +414,19 @@ def bishop88_v_from_i(current, photocurrent, saturation_current,
breakdown_exp : float, default 3.28
avalanche breakdown exponent :math:`m` [unitless]
method : str, default 'newton'
Either ``'newton'`` or ``'brentq'``. ''method'' must be ``'newton'``
if ``breakdown_factor`` is not 0.
Either ``'newton'``, ``'brentq'``, or ``'chandrupatla'``.
''method'' must be ``'newton'`` if ``breakdown_factor`` is not 0.

.. note::
``'chandrupatla'`` requires scipy 1.15 or greater.

method_kwargs : dict, optional
Keyword arguments passed to root finder method. See
:py:func:`scipy:scipy.optimize.brentq` and
:py:func:`scipy:scipy.optimize.newton` parameters.
Keyword arguments passed to the root finder. For options, see:

* ``method='brentq'``: :py:func:`scipy:scipy.optimize.brentq`
* ``method='newton'``: :py:func:`scipy:scipy.optimize.newton`
* ``method='chandrupatla'``: :py:func:`scipy:scipy.optimize.elementwise.find_root`

``'full_output': True`` is allowed, and ``optimizer_output`` would be
returned. See examples section.

Expand Down Expand Up @@ -432,7 +465,7 @@ def bishop88_v_from_i(current, photocurrent, saturation_current,
.. [1] "Computer simulation of the effects of electrical mismatches in
photovoltaic cell interconnection circuits" JW Bishop, Solar Cell (1988)
:doi:`10.1016/0379-6787(88)90059-2`
"""
""" # noqa: E501
# collect args
args = (photocurrent, saturation_current,
resistance_series, resistance_shunt, nNsVth, d2mutau, NsVbi,
Expand Down Expand Up @@ -474,6 +507,29 @@ def vd_from_brent(voc, i, iph, isat, rs, rsh, gamma, d2mutau, NsVbi,
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)
elif method == 'chandrupatla':
try:
from scipy.optimize.elementwise import find_root
except ModuleNotFoundError as e:
# TODO remove this when our minimum scipy version is >=1.15
msg = (
"method='chandrupatla' requires scipy v1.15 or greater "
"(available for Python 3.10+). "
"Select another method, or update your version of scipy."
)
raise ImportError(msg) from e

shape = _shape_of_max_size(current, voc_est)
vlo = np.zeros(shape)
vhi = np.full(shape, voc_est)
bounds = (vlo, vhi)
kwargs_trimmed = method_kwargs.copy()
kwargs_trimmed.pop("full_output", None) # not valid for find_root

result = find_root(fi, bounds, args=(current, *args), **kwargs_trimmed)
vd = result.x
if method_kwargs.get('full_output'):
vd = (vd, result) # mimic the other methods
else:
raise NotImplementedError("Method '%s' isn't implemented" % method)

Expand Down Expand Up @@ -526,12 +582,19 @@ def bishop88_mpp(photocurrent, saturation_current, resistance_series,
breakdown_exp : numeric, default 3.28
avalanche breakdown exponent :math:`m` [unitless]
method : str, default 'newton'
Either ``'newton'`` or ``'brentq'``. ''method'' must be ``'newton'``
if ``breakdown_factor`` is not 0.
Either ``'newton'``, ``'brentq'``, or ``'chandrupatla'``.
''method'' must be ``'newton'`` if ``breakdown_factor`` is not 0.

.. note::
``'chandrupatla'`` requires scipy 1.15 or greater.

method_kwargs : dict, optional
Keyword arguments passed to root finder method. See
:py:func:`scipy:scipy.optimize.brentq` and
:py:func:`scipy:scipy.optimize.newton` parameters.
Keyword arguments passed to the root finder. For options, see:

* ``method='brentq'``: :py:func:`scipy:scipy.optimize.brentq`
* ``method='newton'``: :py:func:`scipy:scipy.optimize.newton`
* ``method='chandrupatla'``: :py:func:`scipy:scipy.optimize.elementwise.find_root`

``'full_output': True`` is allowed, and ``optimizer_output`` would be
returned. See examples section.

Expand Down Expand Up @@ -571,7 +634,7 @@ def bishop88_mpp(photocurrent, saturation_current, resistance_series,
.. [1] "Computer simulation of the effects of electrical mismatches in
photovoltaic cell interconnection circuits" JW Bishop, Solar Cell (1988)
:doi:`10.1016/0379-6787(88)90059-2`
"""
""" # noqa: E501
# collect args
args = (photocurrent, saturation_current,
resistance_series, resistance_shunt, nNsVth, d2mutau, NsVbi,
Expand Down Expand Up @@ -611,6 +674,31 @@ def fmpp(x, *a):
vd = newton(func=fmpp, x0=x0,
fprime=lambda x, *a: bishop88(x, *a, gradients=True)[7],
args=args, **method_kwargs)
elif method == 'chandrupatla':
try:
from scipy.optimize.elementwise import find_root
except ModuleNotFoundError as e:
# TODO remove this when our minimum scipy version is >=1.15
msg = (
"method='chandrupatla' requires scipy v1.15 or greater "
"(available for Python 3.10+). "
"Select another method, or update your version of scipy."
)
raise ImportError(msg) from e

vlo = np.zeros_like(photocurrent)
vhi = np.full_like(photocurrent, voc_est)
kwargs_trimmed = method_kwargs.copy()
kwargs_trimmed.pop("full_output", None) # not valid for find_root

result = find_root(fmpp,
(vlo, vhi),
args=args,
**kwargs_trimmed)
vd = result.x
if method_kwargs.get('full_output'):
vd = (vd, result) # mimic the other methods

else:
raise NotImplementedError("Method '%s' isn't implemented" % method)

Expand Down
12 changes: 12 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import warnings

import pandas as pd
import scipy
import os
from packaging.version import Version
import pytest
Expand Down Expand Up @@ -194,6 +195,17 @@ def has_spa_c():
reason="requires pandas>=2.0.0")


# single-diode equation functions have method=='chandrupatla', which relies
# on scipy.optimize.elementwise.find_root, which is only available in
# scipy>=1.15.
# TODO remove this when our minimum scipy is >=1.15
chandrupatla_available = Version(scipy.__version__) >= Version("1.15.0")
chandrupatla = pytest.param(
"chandrupatla", marks=pytest.mark.skipif(not chandrupatla_available,
reason="needs scipy 1.15")
)


@pytest.fixture()
def golden():
return Location(39.742476, -105.1786, 'America/Denver', 1830.14)
Expand Down
Loading