Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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