Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add "logwright" SDE solver functions #1884

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
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
2 changes: 2 additions & 0 deletions docs/sphinx/source/user_guide/singlediode.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ Then the module current can be solved using the Lambert W-function,
- \frac{n Ns V_{th}}{R_s} W \left(z \right)


TODO

Bishop's Algorithm
------------------
The function :func:`pvlib.singlediode.bishop88` uses an explicit solution [4]
Expand Down
21 changes: 14 additions & 7 deletions pvlib/pvsystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -2405,7 +2405,8 @@

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'``, ``'logwright'``, ``'newton'``, or
``'brentq'``.

Returns
-------
Expand Down Expand Up @@ -2441,6 +2442,8 @@
implicit diode equation utilizes the Lambert W function to obtain an
explicit function of :math:`V=f(I)` and :math:`I=f(V)` as shown in [2]_.

If the method is ``'logwright'`` TODO

If the method is ``'newton'`` then the root-finding Newton-Raphson method
is used. It should be safe for well behaved IV-curves, but the ``'brentq'``
method is recommended for reliability.
Expand Down Expand Up @@ -2482,8 +2485,8 @@
resistance_shunt, nNsVth) # collect args
# Calculate points on the IV curve using the LambertW solution to the
# single diode equation
if method.lower() == 'lambertw':
out = _singlediode._lambertw(*args, ivcurve_pnts)
if method.lower() in ['lambertw', 'logwright']:
out = _singlediode._lambertw(*args, ivcurve_pnts, how=method)
points = out[:7]
if ivcurve_pnts:
ivcurve_i, ivcurve_v = out[7:]
Expand Down Expand Up @@ -2651,8 +2654,8 @@
0 < nNsVth

method : str
Method to use: ``'lambertw'``, ``'newton'``, or ``'brentq'``. *Note*:
``'brentq'`` is limited to 1st quadrant only.
Method to use: ``'lambertw'``, ``'logwright'``, ``'newton'``,
or ``'brentq'``. *Note*: ``'brentq'`` is limited to 1st quadrant only.

Returns
-------
Expand All @@ -2668,6 +2671,8 @@
resistance_series, resistance_shunt, nNsVth)
if method.lower() == 'lambertw':
return _singlediode._lambertw_v_from_i(*args)
elif method.lower() == 'logwright':
return _singlediode._logwright_v_from_i(*args)

Check warning on line 2675 in pvlib/pvsystem.py

View check run for this annotation

Codecov / codecov/patch

pvlib/pvsystem.py#L2675

Added line #L2675 was not covered by tests
else:
# Calculate points on the IV curve using either 'newton' or 'brentq'
# methods. Voltages are determined by first solving the single diode
Expand Down Expand Up @@ -2733,8 +2738,8 @@
0 < nNsVth

method : str
Method to use: ``'lambertw'``, ``'newton'``, or ``'brentq'``. *Note*:
``'brentq'`` is limited to 1st quadrant only.
Method to use: ``'lambertw'``, ``'logwright'``, ``'newton'``,
or ``'brentq'``. *Note*: ``'brentq'`` is limited to 1st quadrant only.

Returns
-------
Expand All @@ -2750,6 +2755,8 @@
resistance_series, resistance_shunt, nNsVth)
if method.lower() == 'lambertw':
return _singlediode._lambertw_i_from_v(*args)
elif method.lower() == 'logwright':
return _singlediode._logwright_i_from_v(*args)

Check warning on line 2759 in pvlib/pvsystem.py

View check run for this annotation

Codecov / codecov/patch

pvlib/pvsystem.py#L2759

Added line #L2759 was not covered by tests
else:
# Calculate points on the IV curve using either 'newton' or 'brentq'
# methods. Voltages are determined by first solving the single diode
Expand Down
90 changes: 75 additions & 15 deletions pvlib/singlediode.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"""

import numpy as np
from pvlib.tools import _golden_sect_DataFrame
from pvlib.tools import _golden_sect_DataFrame, _logwrightomega

from scipy.optimize import brentq, newton
from scipy.special import lambertw
Expand Down Expand Up @@ -770,18 +770,27 @@


def _lambertw(photocurrent, saturation_current, resistance_series,
resistance_shunt, nNsVth, ivcurve_pnts=None):
resistance_shunt, nNsVth, ivcurve_pnts=None, how='lambertw'):
if how == 'lambertw':
f_i_from_v = _lambertw_i_from_v
f_v_from_i = _lambertw_v_from_i
elif how == 'logwright':
f_i_from_v = _logwright_i_from_v
f_v_from_i = _logwright_v_from_i

Check warning on line 779 in pvlib/singlediode.py

View check run for this annotation

Codecov / codecov/patch

pvlib/singlediode.py#L777-L779

Added lines #L777 - L779 were not covered by tests
else:
raise ValueError(f'invalid method: {how}')

Check warning on line 781 in pvlib/singlediode.py

View check run for this annotation

Codecov / codecov/patch

pvlib/singlediode.py#L781

Added line #L781 was not covered by tests

# collect args
params = {'photocurrent': photocurrent,
'saturation_current': saturation_current,
'resistance_series': resistance_series,
'resistance_shunt': resistance_shunt, 'nNsVth': nNsVth}

# Compute short circuit current
i_sc = _lambertw_i_from_v(0., **params)
i_sc = f_i_from_v(0., **params)

# Compute open circuit voltage
v_oc = _lambertw_v_from_i(0., **params)
v_oc = f_v_from_i(0., **params)

# Set small elements <0 in v_oc to 0
if isinstance(v_oc, np.ndarray):
Expand All @@ -792,15 +801,16 @@

# Find the voltage, v_mp, where the power is maximized.
# Start the golden section search at v_oc * 1.14
p_mp, v_mp = _golden_sect_DataFrame(params, 0., v_oc * 1.14, _pwr_optfcn)
p_mp, v_mp = _golden_sect_DataFrame(params, 0., v_oc * 1.14,
_make_pwr_optfcn(f_i_from_v))

# Find Imp using Lambert W
i_mp = _lambertw_i_from_v(v_mp, **params)
i_mp = f_i_from_v(v_mp, **params)

# Find Ix and Ixx using Lambert W
i_x = _lambertw_i_from_v(0.5 * v_oc, **params)
i_x = f_i_from_v(0.5 * v_oc, **params)

i_xx = _lambertw_i_from_v(0.5 * (v_oc + v_mp), **params)
i_xx = f_i_from_v(0.5 * (v_oc + v_mp), **params)

out = (i_sc, v_oc, i_mp, v_mp, p_mp, i_x, i_xx)

Expand All @@ -809,21 +819,71 @@
ivcurve_v = (np.asarray(v_oc)[..., np.newaxis] *
np.linspace(0, 1, ivcurve_pnts))

ivcurve_i = _lambertw_i_from_v(ivcurve_v.T, **params).T
ivcurve_i = f_i_from_v(ivcurve_v.T, **params).T

out += (ivcurve_i, ivcurve_v)

return out


def _pwr_optfcn(df, loc):
def _make_pwr_optfcn(i_from_v):
'''
Function to find power from ``i_from_v``.
'''
def _pwr_optfcn(df, loc):
current = i_from_v(df[loc], df['photocurrent'],
df['saturation_current'],
df['resistance_series'],
df['resistance_shunt'], df['nNsVth'])

Check failure on line 838 in pvlib/singlediode.py

View workflow job for this annotation

GitHub Actions / flake8-linter

W293 blank line contains whitespace
return current * df[loc]
return _pwr_optfcn


def _logwright_v_from_i(current, photocurrent, saturation_current,
resistance_series, resistance_shunt, nNsVth):
# Record if inputs were all scalar
output_is_scalar = all(map(np.isscalar,

Check warning on line 846 in pvlib/singlediode.py

View check run for this annotation

Codecov / codecov/patch

pvlib/singlediode.py#L846

Added line #L846 was not covered by tests
(current, photocurrent, saturation_current,
resistance_series, resistance_shunt, nNsVth)))

# Ensure that we are working with read-only views of numpy arrays
# Turns Series into arrays so that we don't have to worry about
# multidimensional broadcasting failing
I, IL, I0, Rs, Rsh, a = \

Check warning on line 853 in pvlib/singlediode.py

View check run for this annotation

Codecov / codecov/patch

pvlib/singlediode.py#L853

Added line #L853 was not covered by tests
np.broadcast_arrays(current, photocurrent, saturation_current,
resistance_series, resistance_shunt, nNsVth)

log_I0_Rsh_a = np.log(I0 * Rsh / a)
x = log_I0_Rsh_a + Rsh * (-I + IL + I0) / a
V = a * (_logwrightomega(x) - log_I0_Rsh_a) - I * Rs

Check warning on line 859 in pvlib/singlediode.py

View check run for this annotation

Codecov / codecov/patch

pvlib/singlediode.py#L857-L859

Added lines #L857 - L859 were not covered by tests

if output_is_scalar:
return V.item()

Check warning on line 862 in pvlib/singlediode.py

View check run for this annotation

Codecov / codecov/patch

pvlib/singlediode.py#L861-L862

Added lines #L861 - L862 were not covered by tests
else:
return V

Check warning on line 864 in pvlib/singlediode.py

View check run for this annotation

Codecov / codecov/patch

pvlib/singlediode.py#L864

Added line #L864 was not covered by tests

current = _lambertw_i_from_v(df[loc], df['photocurrent'],
df['saturation_current'],
df['resistance_series'],
df['resistance_shunt'], df['nNsVth'])

return current * df[loc]
def _logwright_i_from_v(voltage, photocurrent, saturation_current,
resistance_series, resistance_shunt, nNsVth):
# Record if inputs were all scalar
output_is_scalar = all(map(np.isscalar,

Check warning on line 870 in pvlib/singlediode.py

View check run for this annotation

Codecov / codecov/patch

pvlib/singlediode.py#L870

Added line #L870 was not covered by tests
(voltage, photocurrent, saturation_current,
resistance_series, resistance_shunt, nNsVth)))

# Ensure that we are working with read-only views of numpy arrays
# Turns Series into arrays so that we don't have to worry about
# multidimensional broadcasting failing
V, IL, I0, Rs, Rsh, a = \

Check warning on line 877 in pvlib/singlediode.py

View check run for this annotation

Codecov / codecov/patch

pvlib/singlediode.py#L877

Added line #L877 was not covered by tests
np.broadcast_arrays(voltage, photocurrent, saturation_current,
resistance_series, resistance_shunt, nNsVth)

log_term = np.log(I0 * Rs * Rsh / (a * (Rs + Rsh)))
x = log_term + (Rsh / (Rs + Rsh)) * (Rs * (IL + I0) + V) / a
I = (a * (_logwrightomega(x) - log_term) - V) / Rs

Check failure on line 883 in pvlib/singlediode.py

View workflow job for this annotation

GitHub Actions / flake8-linter

E741 ambiguous variable name 'I'

Check warning on line 883 in pvlib/singlediode.py

View check run for this annotation

Codecov / codecov/patch

pvlib/singlediode.py#L881-L883

Added lines #L881 - L883 were not covered by tests

if output_is_scalar:
return I.item()

Check warning on line 886 in pvlib/singlediode.py

View check run for this annotation

Codecov / codecov/patch

pvlib/singlediode.py#L885-L886

Added lines #L885 - L886 were not covered by tests
else:
return I

Check warning on line 888 in pvlib/singlediode.py

View check run for this annotation

Codecov / codecov/patch

pvlib/singlediode.py#L888

Added line #L888 was not covered by tests

Check failure on line 889 in pvlib/singlediode.py

View workflow job for this annotation

GitHub Actions / flake8-linter

W391 blank line at end of file
16 changes: 16 additions & 0 deletions pvlib/tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from pvlib import tools
import numpy as np
import pandas as pd
import scipy


@pytest.mark.parametrize('keys, input_dict, expected', [
Expand Down Expand Up @@ -120,3 +121,18 @@ def test_get_pandas_index(args, args_idx):
assert index is None
else:
pd.testing.assert_index_equal(args[args_idx].index, index)


def test__logwrightomega():
x = np.concatenate([-(10.**np.arange(100, -100, -0.1)),
10.**np.arange(-100, 100, 0.1)])
with np.errstate(divide='ignore'):
expected = np.log(scipy.special.wrightomega(x))

expected[x < -750] = x[x < -750]

actual = tools._logwrightomega(x, n=2)
np.testing.assert_allclose(actual, expected, atol=2e-11, rtol=2e-11)

actual = tools._logwrightomega(x, n=3)
np.testing.assert_allclose(actual, expected, atol=2e-15, rtol=4e-16)
21 changes: 21 additions & 0 deletions pvlib/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,3 +494,24 @@
(a.index for a in args if isinstance(a, (pd.DataFrame, pd.Series))),
None
)


def _logwrightomega(x, n=3):
# a faster alternative to np.log(scipy.special.wrightomega(x)).
# this calculation also more robust in that it avoids underflow
# problems that np.log(scipy.specia.wrightomega()) experiences
# for x < ~-745.

# TODO see ref X

y = np.where(x <= -np.e, x, 1)
y = np.where((-np.e < x) & (x < np.e), -np.e + (1 + np.e) * (x + np.e) / (2 * np.e), y)

Check failure on line 508 in pvlib/tools.py

View workflow job for this annotation

GitHub Actions / flake8-linter

E501 line too long (91 > 79 characters)
np.log(x, out=y, where=x >= np.e)

for _ in range(n):
ey = np.exp(y)
ey_plus_one = 1 + ey
y_ey_x = y + ey - x
y = y - 2 * (y_ey_x) * (ey_plus_one) / ((2 * (ey_plus_one)**2 - (y_ey_x)*ey))

Check failure on line 515 in pvlib/tools.py

View workflow job for this annotation

GitHub Actions / flake8-linter

E501 line too long (85 > 79 characters)

return y
Loading