Skip to content

Commit 74ded02

Browse files
kandersolarechedey-lscwhanse
authored
Add scipy.optimize.elementwise.find_root (method='chandrupatla') to bishop88 functions (#2498)
* add method='chandrupatla' for bishop88 functions * update tests * whatsnew * doc tweaks * test tweak * try out skipping chandrupatla on py3.9 * fix py3.9 skips * Apply suggestions from code review Co-authored-by: Echedey Luis <80125792+echedey-ls@users.noreply.github.com> * more edits from review * docs fixes * Apply suggestions from code review Co-authored-by: Cliff Hansen <cwhanse@sandia.gov> * move whatsnew entry to v0.13.2 * fix test and lint issues --------- Co-authored-by: Echedey Luis <80125792+echedey-ls@users.noreply.github.com> Co-authored-by: Cliff Hansen <cwhanse@sandia.gov>
1 parent 4089dd9 commit 74ded02

File tree

6 files changed

+237
-97
lines changed

6 files changed

+237
-97
lines changed

docs/sphinx/source/whatsnew/v0.13.2.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ Bug fixes
1818

1919
Enhancements
2020
~~~~~~~~~~~~
21+
* Add ``method='chandrupatla'`` (faster than ``brentq`` and slower than ``newton``,
22+
but convergence is guaranteed) as an option for
23+
:py:func:`pvlib.pvsystem.singlediode`,
24+
:py:func:`~pvlib.pvsystem.i_from_v`,
25+
:py:func:`~pvlib.pvsystem.v_from_i`,
26+
:py:func:`~pvlib.pvsystem.max_power_point`,
27+
:py:func:`~pvlib.singlediode.bishop88_mpp`,
28+
:py:func:`~pvlib.singlediode.bishop88_v_from_i`, and
29+
:py:func:`~pvlib.singlediode.bishop88_i_from_v`. (:issue:`2497`, :pull:`2498`)
30+
2131

2232

2333
Documentation

pvlib/pvsystem.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2498,7 +2498,11 @@ def singlediode(photocurrent, saturation_current, resistance_series,
24982498
24992499
method : str, default 'lambertw'
25002500
Determines the method used to calculate points on the IV curve. The
2501-
options are ``'lambertw'``, ``'newton'``, or ``'brentq'``.
2501+
options are ``'lambertw'``, ``'newton'``, ``'brentq'``, or
2502+
``'chandrupatla'``.
2503+
2504+
.. note::
2505+
``'chandrupatla'`` requires scipy 1.15 or greater.
25022506
25032507
Returns
25042508
-------
@@ -2630,7 +2634,11 @@ def max_power_point(photocurrent, saturation_current, resistance_series,
26302634
cells ``Ns`` and the builtin voltage ``Vbi`` of the intrinsic layer.
26312635
[V].
26322636
method : str
2633-
either ``'newton'`` or ``'brentq'``
2637+
either ``'newton'``, ``'brentq'``, or ``'chandrupatla'``.
2638+
2639+
.. note::
2640+
``'chandrupatla'`` requires scipy 1.15 or greater.
2641+
26342642
26352643
Returns
26362644
-------
@@ -2713,8 +2721,13 @@ def v_from_i(current, photocurrent, saturation_current, resistance_series,
27132721
0 < nNsVth
27142722
27152723
method : str
2716-
Method to use: ``'lambertw'``, ``'newton'``, or ``'brentq'``. *Note*:
2717-
``'brentq'`` is limited to 1st quadrant only.
2724+
Method to use: ``'lambertw'``, ``'newton'``, ``'brentq'``, or
2725+
``'chandrupatla'``. *Note*: ``'brentq'`` is limited to
2726+
non-negative current.
2727+
2728+
.. note::
2729+
``'chandrupatla'`` requires scipy 1.15 or greater.
2730+
27182731
27192732
Returns
27202733
-------
@@ -2795,8 +2808,13 @@ def i_from_v(voltage, photocurrent, saturation_current, resistance_series,
27952808
0 < nNsVth
27962809
27972810
method : str
2798-
Method to use: ``'lambertw'``, ``'newton'``, or ``'brentq'``. *Note*:
2799-
``'brentq'`` is limited to 1st quadrant only.
2811+
Method to use: ``'lambertw'``, ``'newton'``, ``'brentq'``, or
2812+
``'chandrupatla'``. *Note*: ``'brentq'`` is limited to
2813+
non-negative current.
2814+
2815+
.. note::
2816+
``'chandrupatla'`` requires scipy 1.15 or greater.
2817+
28002818
28012819
Returns
28022820
-------

pvlib/singlediode.py

Lines changed: 127 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,13 @@ def bishop88(diode_voltage, photocurrent, saturation_current,
109109
(a-Si) modules that is the product of the PV module number of series
110110
cells :math:`N_{s}` and the builtin voltage :math:`V_{bi}` of the
111111
intrinsic layer. [V].
112-
breakdown_factor : float, default 0
112+
breakdown_factor : numeric, default 0
113113
fraction of ohmic current involved in avalanche breakdown :math:`a`.
114114
Default of 0 excludes the reverse bias term from the model. [unitless]
115-
breakdown_voltage : float, default -5.5
115+
breakdown_voltage : numeric, default -5.5
116116
reverse breakdown voltage of the photovoltaic junction :math:`V_{br}`
117117
[V]
118-
breakdown_exp : float, default 3.28
118+
breakdown_exp : numeric, default 3.28
119119
avalanche breakdown exponent :math:`m` [unitless]
120120
gradients : bool
121121
False returns only I, V, and P. True also returns gradients
@@ -162,12 +162,11 @@ def bishop88(diode_voltage, photocurrent, saturation_current,
162162
# calculate temporary values to simplify calculations
163163
v_star = diode_voltage / nNsVth # non-dimensional diode voltage
164164
g_sh = 1.0 / resistance_shunt # conductance
165-
if breakdown_factor > 0: # reverse bias is considered
166-
brk_term = 1 - diode_voltage / breakdown_voltage
167-
brk_pwr = np.power(brk_term, -breakdown_exp)
168-
i_breakdown = breakdown_factor * diode_voltage * g_sh * brk_pwr
169-
else:
170-
i_breakdown = 0.
165+
166+
brk_term = 1 - diode_voltage / breakdown_voltage
167+
brk_pwr = np.power(brk_term, -breakdown_exp)
168+
i_breakdown = breakdown_factor * diode_voltage * g_sh * brk_pwr
169+
171170
i = (photocurrent - saturation_current * np.expm1(v_star) # noqa: W503
172171
- diode_voltage * g_sh - i_recomb - i_breakdown) # noqa: W503
173172
v = diode_voltage - i * resistance_series
@@ -177,18 +176,14 @@ def bishop88(diode_voltage, photocurrent, saturation_current,
177176
grad_i_recomb = np.where(is_recomb, i_recomb / v_recomb, 0)
178177
grad_2i_recomb = np.where(is_recomb, 2 * grad_i_recomb / v_recomb, 0)
179178
g_diode = saturation_current * np.exp(v_star) / nNsVth # conductance
180-
if breakdown_factor > 0: # reverse bias is considered
181-
brk_pwr_1 = np.power(brk_term, -breakdown_exp - 1)
182-
brk_pwr_2 = np.power(brk_term, -breakdown_exp - 2)
183-
brk_fctr = breakdown_factor * g_sh
184-
grad_i_brk = brk_fctr * (brk_pwr + diode_voltage *
185-
-breakdown_exp * brk_pwr_1)
186-
grad2i_brk = (brk_fctr * -breakdown_exp # noqa: W503
187-
* (2 * brk_pwr_1 + diode_voltage # noqa: W503
188-
* (-breakdown_exp - 1) * brk_pwr_2)) # noqa: W503
189-
else:
190-
grad_i_brk = 0.
191-
grad2i_brk = 0.
179+
brk_pwr_1 = np.power(brk_term, -breakdown_exp - 1)
180+
brk_pwr_2 = np.power(brk_term, -breakdown_exp - 2)
181+
brk_fctr = breakdown_factor * g_sh
182+
grad_i_brk = brk_fctr * (brk_pwr + diode_voltage *
183+
-breakdown_exp * brk_pwr_1)
184+
grad2i_brk = (brk_fctr * -breakdown_exp # noqa: W503
185+
* (2 * brk_pwr_1 + diode_voltage # noqa: W503
186+
* (-breakdown_exp - 1) * brk_pwr_2)) # noqa: W503
192187
grad_i = -g_diode - g_sh - grad_i_recomb - grad_i_brk # di/dvd
193188
grad_v = 1.0 - grad_i * resistance_series # dv/dvd
194189
# dp/dv = d(iv)/dv = v * di/dv + i
@@ -247,12 +242,19 @@ def bishop88_i_from_v(voltage, photocurrent, saturation_current,
247242
breakdown_exp : float, default 3.28
248243
avalanche breakdown exponent :math:`m` [unitless]
249244
method : str, default 'newton'
250-
Either ``'newton'`` or ``'brentq'``. ''method'' must be ``'newton'``
251-
if ``breakdown_factor`` is not 0.
245+
Either ``'newton'``, ``'brentq'``, or ``'chandrupatla'``.
246+
''method'' must be ``'newton'`` if ``breakdown_factor`` is not 0.
247+
248+
.. note::
249+
``'chandrupatla'`` requires scipy 1.15 or greater.
250+
252251
method_kwargs : dict, optional
253-
Keyword arguments passed to root finder method. See
254-
:py:func:`scipy:scipy.optimize.brentq` and
255-
:py:func:`scipy:scipy.optimize.newton` parameters.
252+
Keyword arguments passed to the root finder. For options, see:
253+
254+
* ``method='brentq'``: :py:func:`scipy:scipy.optimize.brentq`
255+
* ``method='newton'``: :py:func:`scipy:scipy.optimize.newton`
256+
* ``method='chandrupatla'``: :py:func:`scipy:scipy.optimize.elementwise.find_root`
257+
256258
``'full_output': True`` is allowed, and ``optimizer_output`` would be
257259
returned. See examples section.
258260
@@ -291,7 +293,7 @@ def bishop88_i_from_v(voltage, photocurrent, saturation_current,
291293
.. [1] "Computer simulation of the effects of electrical mismatches in
292294
photovoltaic cell interconnection circuits" JW Bishop, Solar Cell (1988)
293295
:doi:`10.1016/0379-6787(88)90059-2`
294-
"""
296+
""" # noqa: E501
295297
# collect args
296298
args = (photocurrent, saturation_current,
297299
resistance_series, resistance_shunt, nNsVth, d2mutau, NsVbi,
@@ -333,6 +335,30 @@ def vd_from_brent(voc, v, iph, isat, rs, rsh, gamma, d2mutau, NsVbi,
333335
vd = newton(func=lambda x, *a: fv(x, voltage, *a), x0=x0,
334336
fprime=lambda x, *a: bishop88(x, *a, gradients=True)[4],
335337
args=args, **method_kwargs)
338+
elif method == 'chandrupatla':
339+
try:
340+
from scipy.optimize.elementwise import find_root
341+
except ModuleNotFoundError as e:
342+
# TODO remove this when our minimum scipy version is >=1.15
343+
msg = (
344+
"method='chandrupatla' requires scipy v1.15 or greater "
345+
"(available for Python 3.10+). "
346+
"Select another method, or update your version of scipy."
347+
)
348+
raise ImportError(msg) from e
349+
350+
voc_est = estimate_voc(photocurrent, saturation_current, nNsVth)
351+
shape = _shape_of_max_size(voltage, voc_est)
352+
vlo = np.zeros(shape)
353+
vhi = np.full(shape, voc_est)
354+
bounds = (vlo, vhi)
355+
kwargs_trimmed = method_kwargs.copy()
356+
kwargs_trimmed.pop("full_output", None) # not valid for find_root
357+
358+
result = find_root(fv, bounds, args=(voltage, *args), **kwargs_trimmed)
359+
vd = result.x
360+
if method_kwargs.get('full_output'):
361+
vd = (vd, result) # mimic the other methods
336362
else:
337363
raise NotImplementedError("Method '%s' isn't implemented" % method)
338364

@@ -388,12 +414,19 @@ def bishop88_v_from_i(current, photocurrent, saturation_current,
388414
breakdown_exp : float, default 3.28
389415
avalanche breakdown exponent :math:`m` [unitless]
390416
method : str, default 'newton'
391-
Either ``'newton'`` or ``'brentq'``. ''method'' must be ``'newton'``
392-
if ``breakdown_factor`` is not 0.
417+
Either ``'newton'``, ``'brentq'``, or ``'chandrupatla'``.
418+
''method'' must be ``'newton'`` if ``breakdown_factor`` is not 0.
419+
420+
.. note::
421+
``'chandrupatla'`` requires scipy 1.15 or greater.
422+
393423
method_kwargs : dict, optional
394-
Keyword arguments passed to root finder method. See
395-
:py:func:`scipy:scipy.optimize.brentq` and
396-
:py:func:`scipy:scipy.optimize.newton` parameters.
424+
Keyword arguments passed to the root finder. For options, see:
425+
426+
* ``method='brentq'``: :py:func:`scipy:scipy.optimize.brentq`
427+
* ``method='newton'``: :py:func:`scipy:scipy.optimize.newton`
428+
* ``method='chandrupatla'``: :py:func:`scipy:scipy.optimize.elementwise.find_root`
429+
397430
``'full_output': True`` is allowed, and ``optimizer_output`` would be
398431
returned. See examples section.
399432
@@ -432,7 +465,7 @@ def bishop88_v_from_i(current, photocurrent, saturation_current,
432465
.. [1] "Computer simulation of the effects of electrical mismatches in
433466
photovoltaic cell interconnection circuits" JW Bishop, Solar Cell (1988)
434467
:doi:`10.1016/0379-6787(88)90059-2`
435-
"""
468+
""" # noqa: E501
436469
# collect args
437470
args = (photocurrent, saturation_current,
438471
resistance_series, resistance_shunt, nNsVth, d2mutau, NsVbi,
@@ -474,6 +507,29 @@ def vd_from_brent(voc, i, iph, isat, rs, rsh, gamma, d2mutau, NsVbi,
474507
vd = newton(func=lambda x, *a: fi(x, current, *a), x0=x0,
475508
fprime=lambda x, *a: bishop88(x, *a, gradients=True)[3],
476509
args=args, **method_kwargs)
510+
elif method == 'chandrupatla':
511+
try:
512+
from scipy.optimize.elementwise import find_root
513+
except ModuleNotFoundError as e:
514+
# TODO remove this when our minimum scipy version is >=1.15
515+
msg = (
516+
"method='chandrupatla' requires scipy v1.15 or greater "
517+
"(available for Python 3.10+). "
518+
"Select another method, or update your version of scipy."
519+
)
520+
raise ImportError(msg) from e
521+
522+
shape = _shape_of_max_size(current, voc_est)
523+
vlo = np.zeros(shape)
524+
vhi = np.full(shape, voc_est)
525+
bounds = (vlo, vhi)
526+
kwargs_trimmed = method_kwargs.copy()
527+
kwargs_trimmed.pop("full_output", None) # not valid for find_root
528+
529+
result = find_root(fi, bounds, args=(current, *args), **kwargs_trimmed)
530+
vd = result.x
531+
if method_kwargs.get('full_output'):
532+
vd = (vd, result) # mimic the other methods
477533
else:
478534
raise NotImplementedError("Method '%s' isn't implemented" % method)
479535

@@ -526,12 +582,19 @@ def bishop88_mpp(photocurrent, saturation_current, resistance_series,
526582
breakdown_exp : numeric, default 3.28
527583
avalanche breakdown exponent :math:`m` [unitless]
528584
method : str, default 'newton'
529-
Either ``'newton'`` or ``'brentq'``. ''method'' must be ``'newton'``
530-
if ``breakdown_factor`` is not 0.
585+
Either ``'newton'``, ``'brentq'``, or ``'chandrupatla'``.
586+
''method'' must be ``'newton'`` if ``breakdown_factor`` is not 0.
587+
588+
.. note::
589+
``'chandrupatla'`` requires scipy 1.15 or greater.
590+
531591
method_kwargs : dict, optional
532-
Keyword arguments passed to root finder method. See
533-
:py:func:`scipy:scipy.optimize.brentq` and
534-
:py:func:`scipy:scipy.optimize.newton` parameters.
592+
Keyword arguments passed to the root finder. For options, see:
593+
594+
* ``method='brentq'``: :py:func:`scipy:scipy.optimize.brentq`
595+
* ``method='newton'``: :py:func:`scipy:scipy.optimize.newton`
596+
* ``method='chandrupatla'``: :py:func:`scipy:scipy.optimize.elementwise.find_root`
597+
535598
``'full_output': True`` is allowed, and ``optimizer_output`` would be
536599
returned. See examples section.
537600
@@ -571,7 +634,7 @@ def bishop88_mpp(photocurrent, saturation_current, resistance_series,
571634
.. [1] "Computer simulation of the effects of electrical mismatches in
572635
photovoltaic cell interconnection circuits" JW Bishop, Solar Cell (1988)
573636
:doi:`10.1016/0379-6787(88)90059-2`
574-
"""
637+
""" # noqa: E501
575638
# collect args
576639
args = (photocurrent, saturation_current,
577640
resistance_series, resistance_shunt, nNsVth, d2mutau, NsVbi,
@@ -611,6 +674,31 @@ def fmpp(x, *a):
611674
vd = newton(func=fmpp, x0=x0,
612675
fprime=lambda x, *a: bishop88(x, *a, gradients=True)[7],
613676
args=args, **method_kwargs)
677+
elif method == 'chandrupatla':
678+
try:
679+
from scipy.optimize.elementwise import find_root
680+
except ModuleNotFoundError as e:
681+
# TODO remove this when our minimum scipy version is >=1.15
682+
msg = (
683+
"method='chandrupatla' requires scipy v1.15 or greater "
684+
"(available for Python 3.10+). "
685+
"Select another method, or update your version of scipy."
686+
)
687+
raise ImportError(msg) from e
688+
689+
vlo = np.zeros_like(photocurrent)
690+
vhi = np.full_like(photocurrent, voc_est)
691+
kwargs_trimmed = method_kwargs.copy()
692+
kwargs_trimmed.pop("full_output", None) # not valid for find_root
693+
694+
result = find_root(fmpp,
695+
(vlo, vhi),
696+
args=args,
697+
**kwargs_trimmed)
698+
vd = result.x
699+
if method_kwargs.get('full_output'):
700+
vd = (vd, result) # mimic the other methods
701+
614702
else:
615703
raise NotImplementedError("Method '%s' isn't implemented" % method)
616704

tests/conftest.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import warnings
44

55
import pandas as pd
6+
import scipy
67
import os
78
from packaging.version import Version
89
import pytest
@@ -194,6 +195,17 @@ def has_spa_c():
194195
reason="requires pandas>=2.0.0")
195196

196197

198+
# single-diode equation functions have method=='chandrupatla', which relies
199+
# on scipy.optimize.elementwise.find_root, which is only available in
200+
# scipy>=1.15.
201+
# TODO remove this when our minimum scipy is >=1.15
202+
chandrupatla_available = Version(scipy.__version__) >= Version("1.15.0")
203+
chandrupatla = pytest.param(
204+
"chandrupatla", marks=pytest.mark.skipif(not chandrupatla_available,
205+
reason="needs scipy 1.15")
206+
)
207+
208+
197209
@pytest.fixture()
198210
def golden():
199211
return Location(39.742476, -105.1786, 'America/Denver', 1830.14)

0 commit comments

Comments
 (0)