From 26a942e36087c92335e591adf3622f31a488c3b2 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 24 Sep 2025 13:21:12 -0400 Subject: [PATCH 01/34] Add whatsnew file for v0.13.2 (#2555) --- docs/sphinx/source/whatsnew.rst | 1 + docs/sphinx/source/whatsnew/v0.13.2.rst | 45 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 docs/sphinx/source/whatsnew/v0.13.2.rst diff --git a/docs/sphinx/source/whatsnew.rst b/docs/sphinx/source/whatsnew.rst index 715cb16d49..c38bd96134 100644 --- a/docs/sphinx/source/whatsnew.rst +++ b/docs/sphinx/source/whatsnew.rst @@ -6,6 +6,7 @@ What's New These are new features and improvements of note in each release. +.. include:: whatsnew/v0.13.2.rst .. include:: whatsnew/v0.13.1.rst .. include:: whatsnew/v0.13.0.rst .. include:: whatsnew/v0.12.0.rst diff --git a/docs/sphinx/source/whatsnew/v0.13.2.rst b/docs/sphinx/source/whatsnew/v0.13.2.rst new file mode 100644 index 0000000000..f649a93fd0 --- /dev/null +++ b/docs/sphinx/source/whatsnew/v0.13.2.rst @@ -0,0 +1,45 @@ +.. _whatsnew_0_13_2: + + +v0.13.2 (Anticipated December, 2025) +------------------------------------ + +Breaking Changes +~~~~~~~~~~~~~~~~ + + +Deprecations +~~~~~~~~~~~~ + + +Bug fixes +~~~~~~~~~ + + +Enhancements +~~~~~~~~~~~~ + + +Documentation +~~~~~~~~~~~~~ + + +Testing +~~~~~~~ + + +Benchmarking +~~~~~~~~~~~~ + + +Requirements +~~~~~~~~~~~~ + + +Maintenance +~~~~~~~~~~~ + + +Contributors +~~~~~~~~~~~~ + From 3888d217aa4446265a1628d975eba7022aa213a3 Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Mon, 29 Sep 2025 19:02:54 +0200 Subject: [PATCH 02/34] Add note in Hay-Davies model that horizon component is zero (#2556) --- pvlib/irradiance.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pvlib/irradiance.py b/pvlib/irradiance.py index 5a9051614b..667592834b 100644 --- a/pvlib/irradiance.py +++ b/pvlib/irradiance.py @@ -796,7 +796,7 @@ def haydavies(surface_tilt, surface_azimuth, dhi, dni, dni_extra, * sky_diffuse: Total sky diffuse * isotropic * circumsolar - * horizon + * horizon (always zero, not accounted for by the Hay-Davies model) Notes ------ From 4089dd92446f43be0d29e7d2aad50aa065dad2d3 Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Mon, 29 Sep 2025 19:23:36 +0200 Subject: [PATCH 03/34] Add documentation section on reverse transposition (#2557) --- .../source/reference/irradiance/decomposition.rst | 2 -- docs/sphinx/source/reference/irradiance/index.rst | 1 + .../reference/irradiance/reverse-transposition.rst | 10 ++++++++++ .../source/reference/irradiance/transposition.rst | 1 - 4 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 docs/sphinx/source/reference/irradiance/reverse-transposition.rst diff --git a/docs/sphinx/source/reference/irradiance/decomposition.rst b/docs/sphinx/source/reference/irradiance/decomposition.rst index eede9df089..b51529829e 100644 --- a/docs/sphinx/source/reference/irradiance/decomposition.rst +++ b/docs/sphinx/source/reference/irradiance/decomposition.rst @@ -16,6 +16,4 @@ DNI estimation models irradiance.orgill_hollands irradiance.boland irradiance.campbell_norman - irradiance.gti_dirint irradiance.louche - diff --git a/docs/sphinx/source/reference/irradiance/index.rst b/docs/sphinx/source/reference/irradiance/index.rst index 72064cccbc..bf503f3822 100644 --- a/docs/sphinx/source/reference/irradiance/index.rst +++ b/docs/sphinx/source/reference/irradiance/index.rst @@ -9,6 +9,7 @@ Irradiance class-methods components transposition + reverse-transposition decomposition clearness-index albedo diff --git a/docs/sphinx/source/reference/irradiance/reverse-transposition.rst b/docs/sphinx/source/reference/irradiance/reverse-transposition.rst new file mode 100644 index 0000000000..31ce27bedd --- /dev/null +++ b/docs/sphinx/source/reference/irradiance/reverse-transposition.rst @@ -0,0 +1,10 @@ +.. currentmodule:: pvlib + +Reverse transposition models +---------------------------- + +.. autosummary:: + :toctree: ../generated/ + + irradiance.ghi_from_poa_driesse_2023 + irradiance.gti_dirint diff --git a/docs/sphinx/source/reference/irradiance/transposition.rst b/docs/sphinx/source/reference/irradiance/transposition.rst index 22136f0c58..7b3624e692 100644 --- a/docs/sphinx/source/reference/irradiance/transposition.rst +++ b/docs/sphinx/source/reference/irradiance/transposition.rst @@ -15,4 +15,3 @@ Transposition models irradiance.klucher irradiance.reindl irradiance.king - irradiance.ghi_from_poa_driesse_2023 From 74ded020440f0dca38648d95132a7389e1f6a2ab Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Fri, 3 Oct 2025 09:05:24 -0400 Subject: [PATCH 04/34] 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 * 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 --- docs/sphinx/source/whatsnew/v0.13.2.rst | 10 ++ pvlib/pvsystem.py | 30 ++++- pvlib/singlediode.py | 166 ++++++++++++++++++------ tests/conftest.py | 12 ++ tests/test_pvsystem.py | 77 +++++------ tests/test_singlediode.py | 39 ++++-- 6 files changed, 237 insertions(+), 97 deletions(-) diff --git a/docs/sphinx/source/whatsnew/v0.13.2.rst b/docs/sphinx/source/whatsnew/v0.13.2.rst index f649a93fd0..3177f7a022 100644 --- a/docs/sphinx/source/whatsnew/v0.13.2.rst +++ b/docs/sphinx/source/whatsnew/v0.13.2.rst @@ -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 diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index 23ca1a934a..2b703f3a52 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -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 ------- @@ -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 ------- @@ -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 ------- @@ -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 ------- diff --git a/pvlib/singlediode.py b/pvlib/singlediode.py index ff3b9497a6..43be522437 100644 --- a/pvlib/singlediode.py +++ b/pvlib/singlediode.py @@ -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 @@ -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 @@ -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 @@ -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. + + .. 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. @@ -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, @@ -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) @@ -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. @@ -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, @@ -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) @@ -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. @@ -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, @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py index 28ae973390..0dc957751b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ import warnings import pandas as pd +import scipy import os from packaging.version import Version import pytest @@ -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) diff --git a/tests/test_pvsystem.py b/tests/test_pvsystem.py index b58f9fd9e4..b7d8ba6173 100644 --- a/tests/test_pvsystem.py +++ b/tests/test_pvsystem.py @@ -22,6 +22,8 @@ from tests.test_singlediode import get_pvsyst_fs_495 +from .conftest import chandrupatla, chandrupatla_available + @pytest.mark.parametrize('iam_model,model_params', [ ('ashrae', {'b': 0.05}), @@ -1371,7 +1373,12 @@ def fixture_i_from_v(request): @pytest.mark.parametrize( - 'method, atol', [('lambertw', 1e-11), ('brentq', 1e-11), ('newton', 1e-11)] + 'method, atol', [ + ('lambertw', 1e-11), ('brentq', 1e-11), ('newton', 1e-11), + pytest.param("chandrupatla", 1e-11, + marks=pytest.mark.skipif(not chandrupatla_available, + reason="needs scipy 1.15")), + ] ) def test_i_from_v(fixture_i_from_v, method, atol): # Solution set loaded from fixture @@ -1400,44 +1407,43 @@ def test_PVSystem_i_from_v(mocker): m.assert_called_once_with(*args) -def test_i_from_v_size(): - with pytest.raises(ValueError): - pvsystem.i_from_v([7.5] * 3, 7., 6e-7, [0.1] * 2, 20, 0.5) - with pytest.raises(ValueError): - pvsystem.i_from_v([7.5] * 3, 7., 6e-7, [0.1] * 2, 20, 0.5, - method='brentq') +@pytest.mark.parametrize('method', ['lambertw', 'brentq', 'newton', + chandrupatla]) +def test_i_from_v_size(method): + if method == 'newton': + args = ([7.5] * 3, np.array([7., 7.]), 6e-7, 0.1, 20, 0.5) + else: + args = ([7.5] * 3, 7., 6e-7, [0.1] * 2, 20, 0.5) with pytest.raises(ValueError): - pvsystem.i_from_v([7.5] * 3, np.array([7., 7.]), 6e-7, 0.1, 20, 0.5, - method='newton') + pvsystem.i_from_v(*args, method=method) -def test_v_from_i_size(): - with pytest.raises(ValueError): - pvsystem.v_from_i([3.] * 3, 7., 6e-7, [0.1] * 2, 20, 0.5) - with pytest.raises(ValueError): - pvsystem.v_from_i([3.] * 3, 7., 6e-7, [0.1] * 2, 20, 0.5, - method='brentq') +@pytest.mark.parametrize('method', ['lambertw', 'brentq', 'newton', + chandrupatla]) +def test_v_from_i_size(method): + if method == 'newton': + args = ([3.] * 3, np.array([7., 7.]), 6e-7, [0.1], 20, 0.5) + else: + args = ([3.] * 3, 7., 6e-7, [0.1] * 2, 20, 0.5) with pytest.raises(ValueError): - pvsystem.v_from_i([3.] * 3, np.array([7., 7.]), 6e-7, [0.1], 20, 0.5, - method='newton') + pvsystem.v_from_i(*args, method=method) -def test_mpp_floats(): +@pytest.mark.parametrize('method', ['brentq', 'newton', chandrupatla]) +def test_mpp_floats(method): """test max_power_point""" IL, I0, Rs, Rsh, nNsVth = (7, 6e-7, .1, 20, .5) - out = pvsystem.max_power_point(IL, I0, Rs, Rsh, nNsVth, method='brentq') + out = pvsystem.max_power_point(IL, I0, Rs, Rsh, nNsVth, method=method) expected = {'i_mp': 6.1362673597376753, # 6.1390251797935704, lambertw 'v_mp': 6.2243393757884284, # 6.221535886625464, lambertw 'p_mp': 38.194210547580511} # 38.194165464983037} lambertw assert isinstance(out, dict) for k, v in out.items(): assert np.isclose(v, expected[k]) - out = pvsystem.max_power_point(IL, I0, Rs, Rsh, nNsVth, method='newton') - for k, v in out.items(): - assert np.isclose(v, expected[k]) -def test_mpp_recombination(): +@pytest.mark.parametrize('method', ['brentq', 'newton', chandrupatla]) +def test_mpp_recombination(method): """test max_power_point""" pvsyst_fs_495 = get_pvsyst_fs_495() IL, I0, Rs, Rsh, nNsVth = pvsystem.calcparams_pvsyst( @@ -1455,7 +1461,7 @@ def test_mpp_recombination(): IL, I0, Rs, Rsh, nNsVth, d2mutau=pvsyst_fs_495['d2mutau'], NsVbi=VOLTAGE_BUILTIN*pvsyst_fs_495['cells_in_series'], - method='brentq') + method=method) expected_imp = pvsyst_fs_495['I_mp_ref'] expected_vmp = pvsyst_fs_495['V_mp_ref'] expected_pmp = expected_imp*expected_vmp @@ -1465,36 +1471,28 @@ def test_mpp_recombination(): assert isinstance(out, dict) for k, v in out.items(): assert np.isclose(v, expected[k], 0.01) - out = pvsystem.max_power_point( - IL, I0, Rs, Rsh, nNsVth, - d2mutau=pvsyst_fs_495['d2mutau'], - NsVbi=VOLTAGE_BUILTIN*pvsyst_fs_495['cells_in_series'], - method='newton') - for k, v in out.items(): - assert np.isclose(v, expected[k], 0.01) -def test_mpp_array(): +@pytest.mark.parametrize('method', ['brentq', 'newton', chandrupatla]) +def test_mpp_array(method): """test max_power_point""" IL, I0, Rs, Rsh, nNsVth = (np.array([7, 7]), 6e-7, .1, 20, .5) - out = pvsystem.max_power_point(IL, I0, Rs, Rsh, nNsVth, method='brentq') + out = pvsystem.max_power_point(IL, I0, Rs, Rsh, nNsVth, method=method) expected = {'i_mp': [6.1362673597376753] * 2, 'v_mp': [6.2243393757884284] * 2, 'p_mp': [38.194210547580511] * 2} assert isinstance(out, dict) for k, v in out.items(): assert np.allclose(v, expected[k]) - out = pvsystem.max_power_point(IL, I0, Rs, Rsh, nNsVth, method='newton') - for k, v in out.items(): - assert np.allclose(v, expected[k]) -def test_mpp_series(): +@pytest.mark.parametrize('method', ['brentq', 'newton', chandrupatla]) +def test_mpp_series(method): """test max_power_point""" idx = ['2008-02-17T11:30:00-0800', '2008-02-17T12:30:00-0800'] IL, I0, Rs, Rsh, nNsVth = (np.array([7, 7]), 6e-7, .1, 20, .5) IL = pd.Series(IL, index=idx) - out = pvsystem.max_power_point(IL, I0, Rs, Rsh, nNsVth, method='brentq') + out = pvsystem.max_power_point(IL, I0, Rs, Rsh, nNsVth, method=method) expected = pd.DataFrame({'i_mp': [6.1362673597376753] * 2, 'v_mp': [6.2243393757884284] * 2, 'p_mp': [38.194210547580511] * 2}, @@ -1502,9 +1500,6 @@ def test_mpp_series(): assert isinstance(out, pd.DataFrame) for k, v in out.items(): assert np.allclose(v, expected[k]) - out = pvsystem.max_power_point(IL, I0, Rs, Rsh, nNsVth, method='newton') - for k, v in out.items(): - assert np.allclose(v, expected[k]) def test_singlediode_series(cec_module_params): diff --git a/tests/test_singlediode.py b/tests/test_singlediode.py index efded9ff3c..8f3b4012c3 100644 --- a/tests/test_singlediode.py +++ b/tests/test_singlediode.py @@ -12,11 +12,13 @@ from numpy.testing import assert_array_equal from .conftest import TESTS_DATA_DIR +from .conftest import chandrupatla, chandrupatla_available + POA = 888 TCELL = 55 -@pytest.mark.parametrize('method', ['brentq', 'newton']) +@pytest.mark.parametrize('method', ['brentq', 'newton', chandrupatla]) def test_method_spr_e20_327(method, cec_module_spr_e20_327): """test pvsystem.singlediode with different methods on SPR-E20-327""" spr_e20_327 = cec_module_spr_e20_327 @@ -38,7 +40,7 @@ def test_method_spr_e20_327(method, cec_module_spr_e20_327): assert np.isclose(pvs['i_xx'], out['i_xx']) -@pytest.mark.parametrize('method', ['brentq', 'newton']) +@pytest.mark.parametrize('method', ['brentq', 'newton', chandrupatla]) def test_newton_fs_495(method, cec_module_fs_495): """test pvsystem.singlediode with different methods on FS495""" fs_495 = cec_module_fs_495 @@ -146,7 +148,8 @@ def precise_iv_curves(request): return singlediode_params, pc -@pytest.mark.parametrize('method', ['lambertw', 'brentq', 'newton']) +@pytest.mark.parametrize('method', ['lambertw', 'brentq', 'newton', + chandrupatla]) def test_singlediode_precision(method, precise_iv_curves): """ Tests the accuracy of singlediode. ivcurve_pnts is not tested. @@ -187,7 +190,8 @@ def test_singlediode_lambert_negative_voc(mocker): assert_array_equal(outs["v_oc"], [0, 0]) -@pytest.mark.parametrize('method', ['lambertw', 'brentq', 'newton']) +@pytest.mark.parametrize('method', ['lambertw', 'brentq', 'newton', + chandrupatla]) def test_v_from_i_i_from_v_precision(method, precise_iv_curves): """ Tests the accuracy of pvsystem.v_from_i and pvsystem.i_from_v. @@ -256,7 +260,7 @@ def get_pvsyst_fs_495(): ) ] ) -@pytest.mark.parametrize('method', ['newton', 'brentq']) +@pytest.mark.parametrize('method', ['newton', 'brentq', chandrupatla]) def test_pvsyst_recombination_loss(method, poa, temp_cell, expected, tol): """test PVSst recombination loss""" pvsyst_fs_495 = get_pvsyst_fs_495() @@ -348,7 +352,7 @@ def test_pvsyst_recombination_loss(method, poa, temp_cell, expected, tol): ) ] ) -@pytest.mark.parametrize('method', ['newton', 'brentq']) +@pytest.mark.parametrize('method', ['newton', 'brentq', chandrupatla]) def test_pvsyst_breakdown(method, brk_params, recomb_params, poa, temp_cell, expected, tol): """test PVSyst recombination loss""" @@ -456,7 +460,13 @@ def bishop88_arguments(): 'xtol': 1e-8, 'rtol': 1e-8, 'maxiter': 30, - }) + }), + # can't include chandrupatla since the function is not available to patch + # TODO: add this once chandrupatla becomes non-optional functionality + # ('chandrupatla', { + # 'tolerances ': {'xtol': 1e-8, 'rtol': 1e-8}, + # 'maxiter': 30, + # }), ]) def test_bishop88_kwargs_transfer(method, method_kwargs, mocker, bishop88_arguments): @@ -495,7 +505,14 @@ def test_bishop88_kwargs_transfer(method, method_kwargs, mocker, 'rtol': 1e-4, 'maxiter': 20, '_inexistent_param': "0.01" - }) + }), + pytest.param('chandrupatla', { + 'xtol': 1e-4, + 'rtol': 1e-4, + 'maxiter': 20, + '_inexistent_param': "0.01" + }, marks=pytest.mark.skipif(not chandrupatla_available, + reason="needs scipy 1.15")), ]) def test_bishop88_kwargs_fails(method, method_kwargs, bishop88_arguments): """test invalid method_kwargs passed onto the optimizer fail""" @@ -513,7 +530,7 @@ def test_bishop88_kwargs_fails(method, method_kwargs, bishop88_arguments): method_kwargs=method_kwargs) -@pytest.mark.parametrize('method', ['newton', 'brentq']) +@pytest.mark.parametrize('method', ['newton', 'brentq', chandrupatla]) def test_bishop88_full_output_kwarg(method, bishop88_arguments): """test call to bishop88_.* with full_output=True return values are ok""" method_kwargs = {'full_output': True} @@ -547,7 +564,7 @@ def test_bishop88_full_output_kwarg(method, bishop88_arguments): assert len(ret_val[1]) >= 2 -@pytest.mark.parametrize('method', ['newton', 'brentq']) +@pytest.mark.parametrize('method', ['newton', 'brentq', chandrupatla]) def test_bishop88_pdSeries_len_one(method, bishop88_arguments): for k, v in bishop88_arguments.items(): bishop88_arguments[k] = pd.Series([v]) @@ -563,7 +580,7 @@ def _sde_check_solution(i, v, il, io, rs, rsh, a, d2mutau=0., NsVbi=np.inf): return il - io*np.expm1(vd/a) - vd/rsh - il*d2mutau/(NsVbi - vd) - i -@pytest.mark.parametrize('method', ['newton', 'brentq']) +@pytest.mark.parametrize('method', ['newton', 'brentq', chandrupatla]) def test_bishop88_init_cond(method): # GH 2013 p = {'alpha_sc': 0.0012256, From cf4e06ec3d80e308cd092f563562c91e776a5ffd Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Tue, 7 Oct 2025 14:26:36 -0400 Subject: [PATCH 05/34] Add guidance about SDMs to User Guide (#2565) * initial additions * fix table * revisions * whatsnew * Apply suggestions from code review Co-authored-by: Cliff Hansen * Update docs/sphinx/source/user_guide/modeling_topics/singlediode.rst --------- Co-authored-by: Cliff Hansen --- .../modeling_topics/singlediode.rst | 111 +++++++++++++++++- docs/sphinx/source/whatsnew/v0.13.2.rst | 1 + 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/docs/sphinx/source/user_guide/modeling_topics/singlediode.rst b/docs/sphinx/source/user_guide/modeling_topics/singlediode.rst index 085b0b66cc..0d43966742 100644 --- a/docs/sphinx/source/user_guide/modeling_topics/singlediode.rst +++ b/docs/sphinx/source/user_guide/modeling_topics/singlediode.rst @@ -1,7 +1,110 @@ .. _singlediode: -Single Diode Equation -===================== +Single diode models +=================== + +Single-diode models are a popular means of simulating the electrical output +of a PV module under any given irradiance and temperature conditions. +A single-diode model (SDM) pairs the single-diode equation (SDE) with a set of +auxiliary equations that predict the SDE parameters at any given irradiance +and temperature. All SDMs use the SDE, but their auxiliary equations differ. +For more background on SDMs, see the `PVPMC website +`_. + +Three SDMs are currently available in pvlib: the CEC SDM, the PVsyst SDM, +and the De Soto SDM. pvlib splits these models into two steps. The first +is to compute the auxiliary equations using one of the following functions: + +* CEC SDM: :py:func:`~pvlib.pvsystem.calcparams_cec` +* PVsyst SDM: :py:func:`~pvlib.pvsystem.calcparams_pvsyst` +* De Soto SDM: :py:func:`~pvlib.pvsystem.calcparams_desoto` + +The second step is to use the output of these functions to compute points on +the SDE's I-V curve. Three points on the SDE I-V curve are typically of special +interest for PV modeling: the maximum power (MP), open circuit (OC), and +short circuit (SC) points. The most convenient function for computing these +points is :py:func:`pvlib.pvsystem.singlediode`. It provides several methods +for solving the SDE: + ++------------------+------------+-----------+-------------------------+ +| Method | Type | Speed | Guaranteed convergence? | ++==================+============+===========+=========================+ +| ``newton`` | iterative | fast | no | ++------------------+------------+-----------+-------------------------+ +| ``brentq`` | iterative | slow | yes | ++------------------+------------+-----------+-------------------------+ +| ``chandrupatla`` | iterative | fast | yes | ++------------------+------------+-----------+-------------------------+ +| ``lambertw`` | explicit | medium | yes | ++------------------+------------+-----------+-------------------------+ + + + +Computing full I-V curves +------------------------- + +Full I-V curves can be computed using +:py:func:`pvlib.pvsystem.i_from_v` and :py:func:`pvlib.pvsystem.v_from_i`, which +calculate either current or voltage from the other, with the methods listed +above. It is often useful to +first compute the open-circuit or short-circuit values using +:py:func:`pvlib.pvsystem.singlediode` and then compute a range +of voltages/currents from zero to those extreme points. This range can then +be used with the above functions to compute the I-V curve. + + +IV curves in reverse bias +------------------------- + +The standard SDE does not account for diode breakdown at reverse bias. The +following functions can optionally include an extra term for modeling it: +:py:func:`pvlib.pvsystem.max_power_point`, +:py:func:`pvlib.singlediode.bishop88_i_from_v`, +and :py:func:`pvlib.singlediode.bishop88_v_from_i`. + + +Recombination current for thin film cells +----------------------------------------- + +The PVsyst SDM optionally modifies the SDE to better represent recombination +current in CdTe and a-Si modules. The modified SDE requires two additional +parameters. pvlib functions can compute the key points or full I-V curves using +the modified SDE: +:py:func:`pvlib.pvsystem.max_power_point`, +:py:func:`pvlib.singlediode.bishop88_i_from_v`, +and :py:func:`pvlib.singlediode.bishop88_v_from_i`. + +Model parameter values +---------------------- + +Despite some models having parameters with similar names, parameter values are +specific to each model and thus must be produced with the intended model in mind. +For some models, sets of parameter values can be read from external sources, +for example: + +* CEC SDM parameter database can be read using :py:func:`~pvlib.pvsystem.retrieve_sam` +* PAN files, which can be read using :py:func:`~pvlib.iotools.read_panond` + +pvlib also provides a set of functions that can estimate SDM parameter values +from various datasources: + ++---------------------------------------------------------------+---------+--------------------+ +| Function | SDM | Inputs | ++===============================================================+=========+====================+ +| :py:func:`~pvlib.ivtools.sdm.fit_cec_sam` | CEC | datasheet | ++---------------------------------------------------------------+---------+--------------------+ +| :py:func:`~pvlib.ivtools.sdm.fit_desoto` | De Soto | datasheet | ++---------------------------------------------------------------+---------+--------------------+ +| :py:func:`~pvlib.ivtools.sdm.fit_desoto_sandia` | De Soto | I-V curves | ++---------------------------------------------------------------+---------+--------------------+ +| :py:func:`~pvlib.ivtools.sdm.fit_pvsyst_sandia` | PVsyst | I-V curves | ++---------------------------------------------------------------+---------+--------------------+ +| :py:func:`~pvlib.ivtools.sdm.fit_pvsyst_iec61853_sandia_2025` | PVsyst | IEC 61853-1 matrix | ++---------------------------------------------------------------+---------+--------------------+ + + +Single-diode equation +--------------------- This section reviews the solutions to the single diode equation used in pvlib-python to generate an IV curve of a PV module. @@ -15,7 +118,7 @@ The :func:`pvlib.pvsystem.singlediode` function allows the user to choose the method using the ``method`` keyword. Lambert W-Function ------------------- +****************** When ``method='lambertw'``, the Lambert W-function is used as previously shown by Jain, Kapoor [1, 2] and Hansen [3]. The following algorithm can be found on `Wikipedia: Theory of Solar Cells @@ -50,7 +153,7 @@ Then the module current can be solved using the Lambert W-function, Bishop's Algorithm ------------------- +****************** The function :func:`pvlib.singlediode.bishop88` uses an explicit solution [4] that finds points on the IV curve by first solving for pairs :math:`(V_d, I)` where :math:`V_d` is the diode voltage :math:`V_d = V + I*Rs`. Then the voltage diff --git a/docs/sphinx/source/whatsnew/v0.13.2.rst b/docs/sphinx/source/whatsnew/v0.13.2.rst index 3177f7a022..e12f7277e5 100644 --- a/docs/sphinx/source/whatsnew/v0.13.2.rst +++ b/docs/sphinx/source/whatsnew/v0.13.2.rst @@ -32,6 +32,7 @@ Enhancements Documentation ~~~~~~~~~~~~~ +* Provide an overview of single-diode modeling functionality in :ref:`singlediode`. (:pull:`2565`) Testing From 2b28ffc7d30a6d332c7d389800fb9986d59dcf86 Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Thu, 9 Oct 2025 17:54:29 +0200 Subject: [PATCH 06/34] Add location class functions to location documentation section (#2560) * Add location class functions to location documentation section * Remove class methods Co-authored-by: Echedey Luis <80125792+echedey-ls@users.noreply.github.com> * Add reviewer suggestion Co-authored-by: Echedey Luis <80125792+echedey-ls@users.noreply.github.com> * Apply suggestions from code review * Add correct spacing * Update docs/sphinx/source/reference/location.rst * Update docs/sphinx/source/reference/location.rst --------- Co-authored-by: Echedey Luis <80125792+echedey-ls@users.noreply.github.com> --- docs/sphinx/source/reference/location.rst | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/sphinx/source/reference/location.rst b/docs/sphinx/source/reference/location.rst index 28ef46812a..7fe859f295 100644 --- a/docs/sphinx/source/reference/location.rst +++ b/docs/sphinx/source/reference/location.rst @@ -9,3 +9,22 @@ Methods for information about locations. :toctree: generated/ location.lookup_altitude + +Classes +------- +.. autosummary:: + :toctree: generated/ + + location.Location +A :py:class:`~pvlib.location.Location` object may be created from the +metadata returned by these file types: + +.. autosummary:: + :toctree: generated/ + + location.Location.from_tmy + location.Location.from_epw + +Methods for calculating time series of certain variables for a given +location are available through this class. + From 28ea577563e0e51f15dc9e82dce5773fb1c251f6 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Fri, 10 Oct 2025 11:10:56 -0400 Subject: [PATCH 07/34] Reorganize irradiance reference section (#2570) * reorganize irradiance reference section * fix title * fix another title --- .../reference/irradiance/angle-of-incidence.rst | 10 ++++++++++ .../reference/irradiance/class-methods.rst | 11 ----------- .../reference/irradiance/clearness-index.rst | 4 ++-- .../source/reference/irradiance/components.rst | 17 ----------------- .../reference/irradiance/decomposition.rst | 4 ++-- .../irradiance/extraterrestrial-radiation.rst | 9 +++++++++ .../source/reference/irradiance/index.rst | 8 ++++---- .../irradiance/{albedo.rst => other.rst} | 7 +++++-- .../reference/irradiance/transposition.rst | 3 +++ 9 files changed, 35 insertions(+), 38 deletions(-) create mode 100644 docs/sphinx/source/reference/irradiance/angle-of-incidence.rst delete mode 100644 docs/sphinx/source/reference/irradiance/class-methods.rst delete mode 100644 docs/sphinx/source/reference/irradiance/components.rst create mode 100644 docs/sphinx/source/reference/irradiance/extraterrestrial-radiation.rst rename docs/sphinx/source/reference/irradiance/{albedo.rst => other.rst} (51%) diff --git a/docs/sphinx/source/reference/irradiance/angle-of-incidence.rst b/docs/sphinx/source/reference/irradiance/angle-of-incidence.rst new file mode 100644 index 0000000000..6645648437 --- /dev/null +++ b/docs/sphinx/source/reference/irradiance/angle-of-incidence.rst @@ -0,0 +1,10 @@ +.. currentmodule:: pvlib + +Angle of incidence +------------------ + +.. autosummary:: + :toctree: ../generated/ + + irradiance.aoi + irradiance.aoi_projection diff --git a/docs/sphinx/source/reference/irradiance/class-methods.rst b/docs/sphinx/source/reference/irradiance/class-methods.rst deleted file mode 100644 index f7fa25663b..0000000000 --- a/docs/sphinx/source/reference/irradiance/class-methods.rst +++ /dev/null @@ -1,11 +0,0 @@ -.. currentmodule:: pvlib - -Methods for irradiance calculations ------------------------------------ - -.. autosummary:: - :toctree: ../generated/ - - pvsystem.PVSystem.get_irradiance - pvsystem.PVSystem.get_aoi - pvsystem.PVSystem.get_iam diff --git a/docs/sphinx/source/reference/irradiance/clearness-index.rst b/docs/sphinx/source/reference/irradiance/clearness-index.rst index bfc6d7bf8e..76825eaf5e 100644 --- a/docs/sphinx/source/reference/irradiance/clearness-index.rst +++ b/docs/sphinx/source/reference/irradiance/clearness-index.rst @@ -1,7 +1,7 @@ .. currentmodule:: pvlib -Clearness index models ----------------------- +Clearness and clearsky index +---------------------------- .. autosummary:: :toctree: ../generated/ diff --git a/docs/sphinx/source/reference/irradiance/components.rst b/docs/sphinx/source/reference/irradiance/components.rst deleted file mode 100644 index ce75d9d083..0000000000 --- a/docs/sphinx/source/reference/irradiance/components.rst +++ /dev/null @@ -1,17 +0,0 @@ -.. currentmodule:: pvlib - -Decomposing and combining irradiance ------------------------------------- - -.. autosummary:: - :toctree: ../generated/ - - irradiance.get_extra_radiation - irradiance.aoi - irradiance.aoi_projection - irradiance.beam_component - irradiance.poa_components - irradiance.get_ground_diffuse - irradiance.dni - irradiance.complete_irradiance - irradiance.diffuse_par_spitters diff --git a/docs/sphinx/source/reference/irradiance/decomposition.rst b/docs/sphinx/source/reference/irradiance/decomposition.rst index b51529829e..cf0d355ab4 100644 --- a/docs/sphinx/source/reference/irradiance/decomposition.rst +++ b/docs/sphinx/source/reference/irradiance/decomposition.rst @@ -2,8 +2,8 @@ .. _dniestmodels: -DNI estimation models ---------------------- +Decomposition models +-------------------- .. autosummary:: :toctree: ../generated/ diff --git a/docs/sphinx/source/reference/irradiance/extraterrestrial-radiation.rst b/docs/sphinx/source/reference/irradiance/extraterrestrial-radiation.rst new file mode 100644 index 0000000000..b08b6334e2 --- /dev/null +++ b/docs/sphinx/source/reference/irradiance/extraterrestrial-radiation.rst @@ -0,0 +1,9 @@ +.. currentmodule:: pvlib + +Extraterrestrial radiation +-------------------------- + +.. autosummary:: + :toctree: ../generated/ + + irradiance.get_extra_radiation diff --git a/docs/sphinx/source/reference/irradiance/index.rst b/docs/sphinx/source/reference/irradiance/index.rst index bf503f3822..b2d5917a0e 100644 --- a/docs/sphinx/source/reference/irradiance/index.rst +++ b/docs/sphinx/source/reference/irradiance/index.rst @@ -6,10 +6,10 @@ Irradiance .. toctree:: :maxdepth: 2 - class-methods - components + decomposition transposition reverse-transposition - decomposition + angle-of-incidence clearness-index - albedo + extraterrestrial-radiation + other diff --git a/docs/sphinx/source/reference/irradiance/albedo.rst b/docs/sphinx/source/reference/irradiance/other.rst similarity index 51% rename from docs/sphinx/source/reference/irradiance/albedo.rst rename to docs/sphinx/source/reference/irradiance/other.rst index 868a065d1a..ebeecbce9f 100644 --- a/docs/sphinx/source/reference/irradiance/albedo.rst +++ b/docs/sphinx/source/reference/irradiance/other.rst @@ -1,9 +1,12 @@ .. currentmodule:: pvlib -Albedo ------- +Other +----- .. autosummary:: :toctree: ../generated/ + irradiance.dni + irradiance.complete_irradiance + irradiance.diffuse_par_spitters albedo.inland_water_dvoracek diff --git a/docs/sphinx/source/reference/irradiance/transposition.rst b/docs/sphinx/source/reference/irradiance/transposition.rst index 7b3624e692..2e15b4cafb 100644 --- a/docs/sphinx/source/reference/irradiance/transposition.rst +++ b/docs/sphinx/source/reference/irradiance/transposition.rst @@ -8,6 +8,9 @@ Transposition models irradiance.get_total_irradiance irradiance.get_sky_diffuse + irradiance.get_ground_diffuse + irradiance.beam_component + irradiance.poa_components irradiance.isotropic irradiance.perez irradiance.perez_driesse From 5b41b3f2e0dcffb46687b3cb6d49d182bc1fffe9 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Fri, 17 Oct 2025 16:00:37 -0400 Subject: [PATCH 08/34] Add ECMWF CDS and EarthData API keys to `pytest-remote-data.yml` (#2574) --- .github/workflows/pytest-remote-data.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/pytest-remote-data.yml b/.github/workflows/pytest-remote-data.yml index 31145bd091..f98219c942 100644 --- a/.github/workflows/pytest-remote-data.yml +++ b/.github/workflows/pytest-remote-data.yml @@ -100,6 +100,9 @@ jobs: SOLARANYWHERE_API_KEY: ${{ secrets.SOLARANYWHERE_API_KEY }} BSRN_FTP_USERNAME: ${{ secrets.BSRN_FTP_USERNAME }} BSRN_FTP_PASSWORD: ${{ secrets.BSRN_FTP_PASSWORD }} + ECMWF_API_KEY: ${{ secrets.ECMWF_API_KEY }} + EARTHDATA_USERNAME: ${{ secrets.EARTHDATA_USERNAME }} + EARTHDATA_PASSWORD: ${{ secrets.EARTHDATA_PASSWORD }} run: pytest tests/iotools --cov=./ --cov-report=xml --remote-data - name: Upload coverage to Codecov From c5301951944aade2155872e1ff22bb16ca4512f2 Mon Sep 17 00:00:00 2001 From: Will Hobbs <45701090+williamhobbs@users.noreply.github.com> Date: Sat, 18 Oct 2025 12:57:09 -0500 Subject: [PATCH 09/34] Fix comment typo in plot_simple_irradiance_adjustment (#2576) --- .../plot_simple_irradiance_adjustment_for_horizon_shading.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/examples/shading/plot_simple_irradiance_adjustment_for_horizon_shading.py b/docs/examples/shading/plot_simple_irradiance_adjustment_for_horizon_shading.py index bdeb414adb..9b608ac734 100644 --- a/docs/examples/shading/plot_simple_irradiance_adjustment_for_horizon_shading.py +++ b/docs/examples/shading/plot_simple_irradiance_adjustment_for_horizon_shading.py @@ -14,7 +14,7 @@ # After location information and a date range is established, solar position # data is calculated using :py:func:`pvlib.solarposition.get_solarposition`. # Horizon data is assigned, and interpolated to the solar azimuth time -# series data. Finally, in times when solar elevation is greater than the +# series data. Finally, in times when solar elevation is less than the # interpolated horizon elevation angle, DNI is set to 0. import numpy as np From bc67019578db0795f8487d7947a8ca3708e9d9e4 Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Wed, 22 Oct 2025 19:37:25 +0200 Subject: [PATCH 10/34] Refactor variable names for clarity in APE example (#2578) --- .../spectrum/average_photon_energy.py | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/examples/spectrum/average_photon_energy.py b/docs/examples/spectrum/average_photon_energy.py index a883f929ea..e4b8ef4cc7 100644 --- a/docs/examples/spectrum/average_photon_energy.py +++ b/docs/examples/spectrum/average_photon_energy.py @@ -11,8 +11,8 @@ # This example demonstrates how to use the # :py:func:`~pvlib.spectrum.average_photon_energy` function to calculate the # Average Photon Energy (APE, :math:`\overline{E_\gamma}`) of spectral -# irradiance distributions. This example uses spectral irradiance simulated -# using :py:func:`~pvlib.spectrum.spectrl2`, but the same method is +# irradiance distributions. This example uses clearsky spectral irradiance +# simulated using :py:func:`~pvlib.spectrum.spectrl2`, but the same method is # applicable to spectral irradiance from any source. # More information on the SPECTRL2 model can be found in [1]_. # The APE parameter is a useful indicator of the overall shape of the solar @@ -35,18 +35,19 @@ from pvlib import spectrum, solarposition, irradiance, atmosphere lat, lon = 39.742, -105.18 # NREL SRRL location -tilt = 25 -azimuth = 180 # south-facing system +surface_tilt = 25 +surface_azimuth = 180 # south-facing system pressure = 81190 # at 1828 metres AMSL, roughly -water_vapor_content = 0.5 # cm -tau500 = 0.1 +precipitable_water = 0.5 # cm +aerosol_turbidity_500nm = 0.1 ozone = 0.31 # atm-cm albedo = 0.2 times = pd.date_range('2023-01-01 08:00', freq='h', periods=9, tz='America/Denver') solpos = solarposition.get_solarposition(times, lat, lon) -aoi = irradiance.aoi(tilt, azimuth, solpos.apparent_zenith, solpos.azimuth) +aoi = irradiance.aoi(surface_tilt, surface_azimuth, + solpos.apparent_zenith, solpos.azimuth) relative_airmass = atmosphere.get_relative_airmass(solpos.apparent_zenith, model='kastenyoung1989') @@ -64,13 +65,13 @@ spectra_components = spectrum.spectrl2( apparent_zenith=solpos.apparent_zenith, aoi=aoi, - surface_tilt=tilt, + surface_tilt=surface_tilt, ground_albedo=albedo, surface_pressure=pressure, relative_airmass=relative_airmass, - precipitable_water=water_vapor_content, + precipitable_water=precipitable_water, ozone=ozone, - aerosol_turbidity_500nm=tau500, + aerosol_turbidity_500nm=aerosol_turbidity_500nm, ) # %% From c052fe89352c4f0f30f7a7e49eb94883055d3ca7 Mon Sep 17 00:00:00 2001 From: Will Hobbs <45701090+williamhobbs@users.noreply.github.com> Date: Fri, 24 Oct 2025 16:23:45 -0500 Subject: [PATCH 11/34] Change DHI to GHI and fix some units in plot_diffuse_fraction.py (#2580) --- .../irradiance-decomposition/plot_diffuse_fraction.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/examples/irradiance-decomposition/plot_diffuse_fraction.py b/docs/examples/irradiance-decomposition/plot_diffuse_fraction.py index 1c33824356..166e0c1f42 100644 --- a/docs/examples/irradiance-decomposition/plot_diffuse_fraction.py +++ b/docs/examples/irradiance-decomposition/plot_diffuse_fraction.py @@ -44,7 +44,7 @@ # %% # pvlib Decomposition Functions # ----------------------------- -# Methods for separating DHI into diffuse and direct components include: +# Methods for separating GHI into diffuse and direct components include: # `DISC`_, `DIRINT`_, `Erbs`_, and `Boland`_. # %% @@ -112,7 +112,7 @@ # ---------------- # In the plots below we compare the four decomposition models to the TMY3 file # for Greensboro, North Carolina. We also compare the clearness index, kt, with -# GHI normalized by a reference irradiance, E0 = 1000 [W/m^2], to highlight +# GHI normalized by a reference irradiance, E0 = 1000 [Wm⁻²], to highlight # spikes caused when cosine of zenith approaches zero, particularly at sunset. # # First we combine the dataframes for the decomposition models and the TMY3 From d5078092d160aa9ceec8280a3599e805dd1b0daf Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Tue, 28 Oct 2025 12:33:14 -0400 Subject: [PATCH 12/34] fix tests (#2583) --- tests/iotools/test_meteonorm.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/iotools/test_meteonorm.py b/tests/iotools/test_meteonorm.py index ca6e77cd7c..3dac14928c 100644 --- a/tests/iotools/test_meteonorm.py +++ b/tests/iotools/test_meteonorm.py @@ -72,9 +72,9 @@ def expected_meteonorm_data(): [0.0, 0.0], [0.0, 0.0], [2.5, 2.68], - [77.5, 77.47], - [165.0, 164.98], - [210.75, 210.74], + [77.5, 77.48], + [165.0, 164.99], + [210.75, 210.75], [221.0, 220.99], ] index = pd.date_range('2023-01-01 00:30', periods=12, freq='1h', tz='UTC') @@ -207,7 +207,7 @@ def test_get_meteonorm_custom_horizon(demo_api_key, demo_url): @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_meteonorm_forecast_HTTPError(demo_api_key, demo_url): with pytest.raises( - HTTPError, match="unknown parameter: not_a_real_parameter"): + HTTPError, match='invalid parameter "not_a_real_parameter"'): _ = pvlib.iotools.get_meteonorm_forecast_basic( latitude=50, longitude=10, start=pd.Timestamp.now(tz='UTC'), @@ -265,9 +265,9 @@ def expected_meteonorm_tmy_data(): [0.], [0.], [0.], - [9.06], - [8.43], - [86.63], + [9.07], + [8.44], + [86.64], [110.44], ] index = pd.date_range( From 6553710e8e87f0931846eba2abc6f21c19ebdadf Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 28 Oct 2025 13:40:55 -0700 Subject: [PATCH 13/34] add modules_per_string to adr_inverter test system (#2577) --- tests/test_modelchain.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_modelchain.py b/tests/test_modelchain.py index ecc2c41447..a3abece9d8 100644 --- a/tests/test_modelchain.py +++ b/tests/test_modelchain.py @@ -139,7 +139,8 @@ def cec_dc_adr_ac_system(sam_data, cec_module_cs5p_220m, module=module_parameters['Name'], module_parameters=module_parameters, temperature_model_parameters=temp_model_params, - inverter_parameters=inverter) + inverter_parameters=inverter, + modules_per_string=14) return system From 258f78f61c3c47dbf2ed8b12d997f0c08d3fedb6 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 3 Nov 2025 10:45:57 -0500 Subject: [PATCH 14/34] Remove `psm3.py` (#2582) * delete psm3.py and test_psm3.py * update references across the docs * whatsnew --------- Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> --- docs/examples/system-models/plot_oedi_9068.py | 10 +- docs/sphinx/source/reference/iotools.rst | 3 - docs/sphinx/source/user_guide/extras/faq.rst | 10 +- .../modeling_topics/weather_data.rst | 5 +- docs/sphinx/source/whatsnew/v0.10.0.rst | 6 +- docs/sphinx/source/whatsnew/v0.10.2.rst | 2 +- docs/sphinx/source/whatsnew/v0.10.3.rst | 2 +- docs/sphinx/source/whatsnew/v0.11.0.rst | 6 +- docs/sphinx/source/whatsnew/v0.13.0.rst | 2 +- docs/sphinx/source/whatsnew/v0.13.2.rst | 3 + docs/sphinx/source/whatsnew/v0.7.0.rst | 2 +- docs/sphinx/source/whatsnew/v0.7.1.rst | 4 +- docs/sphinx/source/whatsnew/v0.8.0.rst | 2 +- docs/sphinx/source/whatsnew/v0.8.1.rst | 2 +- docs/sphinx/source/whatsnew/v0.9.0.rst | 4 +- docs/sphinx/source/whatsnew/v0.9.1.rst | 4 +- docs/sphinx/source/whatsnew/v0.9.2.rst | 2 +- docs/sphinx/source/whatsnew/v0.9.5.rst | 2 +- pvlib/iotools/__init__.py | 3 - pvlib/iotools/psm3.py | 365 ------------------ pvlib/iotools/psm4.py | 1 - tests/iotools/test_psm3.py | 191 --------- 22 files changed, 33 insertions(+), 598 deletions(-) delete mode 100644 pvlib/iotools/psm3.py delete mode 100644 tests/iotools/test_psm3.py diff --git a/docs/examples/system-models/plot_oedi_9068.py b/docs/examples/system-models/plot_oedi_9068.py index 8544f34c9a..9a73bfa9ce 100644 --- a/docs/examples/system-models/plot_oedi_9068.py +++ b/docs/examples/system-models/plot_oedi_9068.py @@ -143,10 +143,12 @@ keys = ['ghi', 'dni', 'dhi', 'temp_air', 'wind_speed', 'albedo', 'precipitable_water'] -psm3, psm3_metadata = pvlib.iotools.get_psm3(latitude, longitude, api_key, - email, interval=5, names=2019, - map_variables=True, leap_day=True, - attributes=keys) +psm3, psm3_metadata = pvlib.iotools.get_nsrdb_psm4_conus(latitude, longitude, + api_key, email, + year=2019, interval=5, + parameters=keys, + map_variables=True, + leap_day=True) # %% # Pre-generate some model inputs diff --git a/docs/sphinx/source/reference/iotools.rst b/docs/sphinx/source/reference/iotools.rst index 12db7d6818..70a727b7bb 100644 --- a/docs/sphinx/source/reference/iotools.rst +++ b/docs/sphinx/source/reference/iotools.rst @@ -81,9 +81,6 @@ Satellite-derived irradiance and weather data for the Americas. iotools.get_nsrdb_psm4_conus iotools.get_nsrdb_psm4_full_disc iotools.read_nsrdb_psm4 - iotools.get_psm3 - iotools.read_psm3 - iotools.parse_psm3 Commercial datasets diff --git a/docs/sphinx/source/user_guide/extras/faq.rst b/docs/sphinx/source/user_guide/extras/faq.rst index f87fa101b5..29eeef7c2b 100644 --- a/docs/sphinx/source/user_guide/extras/faq.rst +++ b/docs/sphinx/source/user_guide/extras/faq.rst @@ -52,15 +52,7 @@ Where can I get irradiance data for my simulation? pvlib has a module called iotools which has several functions for retrieving irradiance data as well as reading standard file formats -such as EPW, TMY2, and TMY3. For free irradiance data, you may -consider NREL's NSRDB which can be accessed using the -:py:func:`pvlib.iotools.get_psm3` function and is available for -North America. For Europe and Africa, you may consider looking into -CAMS (:py:func:`pvlib.iotools.get_cams`). -PVGIS (:py:func:`pvlib.iotools.get_pvgis_hourly`) is another option, which -provides irradiance from several different databases with near global coverage. -pvlib also has functions for accessing a plethora of ground-measured -irradiance datasets, including the BSRN, SURFRAD, SRML, and NREL's MIDC. +such as EPW, TMY2, and TMY3. See :ref:`weatherdata`. Can I use PVsyst (PAN/OND) files with pvlib? diff --git a/docs/sphinx/source/user_guide/modeling_topics/weather_data.rst b/docs/sphinx/source/user_guide/modeling_topics/weather_data.rst index 8199044a5c..8e99895ab5 100644 --- a/docs/sphinx/source/user_guide/modeling_topics/weather_data.rst +++ b/docs/sphinx/source/user_guide/modeling_topics/weather_data.rst @@ -112,7 +112,8 @@ online web APIs. For example, :py:func:`~pvlib.iotools.get_pvgis_hourly` downloads data from PVGIS's webservers and returns it as a python variable. Functions that retrieve data from the internet are named ``get_``, followed by the name of the data source: :py:func:`~pvlib.iotools.get_bsrn`, -:py:func:`~pvlib.iotools.get_psm3`, :py:func:`~pvlib.iotools.get_pvgis_tmy`, +:py:func:`~pvlib.iotools.get_nsrdb_psm4_conus`, +:py:func:`~pvlib.iotools.get_pvgis_tmy`, and so on. For satellite/reanalysis datasets, the location is specified by latitude and @@ -121,7 +122,7 @@ longitude in decimal degrees: .. code-block:: python latitude, longitude = 33.75, -84.39 # Atlanta, Georgia, United States - df, metadata = pvlib.iotools.get_psm3(latitude, longitude, map_variables=True, ...) + df, metadata = pvlib.iotools.get_pvgis_tmy(latitude, longitude, map_variables=True, ...) For ground station networks, the location identifier is the station ID: diff --git a/docs/sphinx/source/whatsnew/v0.10.0.rst b/docs/sphinx/source/whatsnew/v0.10.0.rst index 6b6ae1abe9..eaefe12672 100644 --- a/docs/sphinx/source/whatsnew/v0.10.0.rst +++ b/docs/sphinx/source/whatsnew/v0.10.0.rst @@ -109,9 +109,9 @@ Enhancements :py:func:`pvlib.iotools.get_pvgis_horizon`. (:issue:`1290`, :pull:`1395`) * Update the URL used in the :py:func:`pvlib.iotools.get_cams` function. The new URL supports load-balancing and redirects to the fastest server. (:issue:`1688`, :pull:`1740`) -* :py:func:`pvlib.iotools.get_psm3` now has a ``url`` parameter to give the user +* :py:func:`!pvlib.iotools.get_psm3` now has a ``url`` parameter to give the user the option of controlling what NSRDB endpoint is used. (:pull:`1736`) -* :py:func:`pvlib.iotools.get_psm3` now uses the new NSRDB 3.2.2 endpoint for +* :py:func:`!pvlib.iotools.get_psm3` now uses the new NSRDB 3.2.2 endpoint for hourly and half-hourly single-year datasets. (:issue:`1591`, :pull:`1736`) * The default solar position algorithm (NREL SPA) is now 50-100% faster. (:pull:`1748`) * Added functions to retrieve daily precipitation, temperature, and snowfall data @@ -146,7 +146,7 @@ Testing Documentation ~~~~~~~~~~~~~ * Updated the description of the interval parameter in - :py:func:`pvlib.iotools.get_psm3`. (:issue:`1702`, :pull:`1712`) + :py:func:`!pvlib.iotools.get_psm3`. (:issue:`1702`, :pull:`1712`) * Fixed outdated nbviewer links. (:issue:`1721`, :pull:`1726`) diff --git a/docs/sphinx/source/whatsnew/v0.10.2.rst b/docs/sphinx/source/whatsnew/v0.10.2.rst index 3b82d98613..0bba4c69d1 100644 --- a/docs/sphinx/source/whatsnew/v0.10.2.rst +++ b/docs/sphinx/source/whatsnew/v0.10.2.rst @@ -28,7 +28,7 @@ Enhancements Bug fixes ~~~~~~~~~ -* :py:func:`~pvlib.iotools.get_psm3` no longer incorrectly returns clear-sky +* :py:func:`!pvlib.iotools.get_psm3` no longer incorrectly returns clear-sky DHI instead of clear-sky GHI when requesting ``ghi_clear``. (:pull:`1819`) * :py:func:`pvlib.singlediode.bishop88` with ``method='newton'`` no longer crashes when passed ``pandas.Series`` of length one. diff --git a/docs/sphinx/source/whatsnew/v0.10.3.rst b/docs/sphinx/source/whatsnew/v0.10.3.rst index 4d222fca06..6e4ac8e0ca 100644 --- a/docs/sphinx/source/whatsnew/v0.10.3.rst +++ b/docs/sphinx/source/whatsnew/v0.10.3.rst @@ -29,7 +29,7 @@ Bug fixes * Fixed CAMS error message handler in :py:func:`pvlib.iotools.get_cams`. (:issue:`1799`, :pull:`1905`) * Fix mapping of the dew point column to ``temp_dew`` when ``map_variables`` - is True in :py:func:`pvlib.iotools.get_psm3`. (:pull:`1920`) + is True in :py:func:`!pvlib.iotools.get_psm3`. (:pull:`1920`) * Fix :py:class:`pvlib.modelchain.ModelChain` to use attribute `clearsky_model`. (:pull:`1924`) diff --git a/docs/sphinx/source/whatsnew/v0.11.0.rst b/docs/sphinx/source/whatsnew/v0.11.0.rst index 219b57a059..d71fdcd9d9 100644 --- a/docs/sphinx/source/whatsnew/v0.11.0.rst +++ b/docs/sphinx/source/whatsnew/v0.11.0.rst @@ -14,10 +14,10 @@ Breaking changes * ``pvlib.iotools.read_srml_month_from_solardat`` was deprecated in v0.10.0 and has now been completely removed. The function is replaced by :py:func:`~pvlib.iotools.get_srml()`. (:pull:`1779`, :pull:`1989`) -* The ``leap_day`` parameter in :py:func:`~pvlib.iotools.get_psm3` +* The ``leap_day`` parameter in :py:func:`!pvlib.iotools.get_psm3` now defaults to True instead of False. (:issue:`1481`, :pull:`1991`) -* :py:func:`~pvlib.iotools.get_psm3`, :py:func:`~pvlib.iotools.read_psm3`, and - :py:func:`~pvlib.iotools.parse_psm3` all now have ``map_variables=True`` by +* :py:func:`!pvlib.iotools.get_psm3`, :py:func:`!pvlib.iotools.read_psm3`, and + :py:func:`!pvlib.iotools.parse_psm3` all now have ``map_variables=True`` by default. (:issue:`1425`, :pull:`2094`) * The deprecated ``ivcurve_pnts`` parameter of :py:func:`pvlib.pvsystem.singlediode` is removed. Use :py:func:`pvlib.pvsystem.v_from_i` and diff --git a/docs/sphinx/source/whatsnew/v0.13.0.rst b/docs/sphinx/source/whatsnew/v0.13.0.rst index 754d2a539b..94b7aac356 100644 --- a/docs/sphinx/source/whatsnew/v0.13.0.rst +++ b/docs/sphinx/source/whatsnew/v0.13.0.rst @@ -28,7 +28,7 @@ Deprecations :pull:`2467`, :pull:`2466`) - :py:func:`~pvlib.iotools.parse_epw` - - :py:func:`~pvlib.iotools.parse_psm3` + - :py:func:`!pvlib.iotools.parse_psm3` - :py:func:`~pvlib.iotools.parse_cams` - :py:func:`~pvlib.iotools.parse_bsrn` diff --git a/docs/sphinx/source/whatsnew/v0.13.2.rst b/docs/sphinx/source/whatsnew/v0.13.2.rst index e12f7277e5..0956c74a44 100644 --- a/docs/sphinx/source/whatsnew/v0.13.2.rst +++ b/docs/sphinx/source/whatsnew/v0.13.2.rst @@ -6,6 +6,9 @@ v0.13.2 (Anticipated December, 2025) Breaking Changes ~~~~~~~~~~~~~~~~ +* Following the removal of the NSRDB PSM3 API, the :func:`!pvlib.iotools.get_psm3`, + :func:`!pvlib.iotools.read_psm3`, and :func:`!pvlib.iotools.parse_psm3` + functions are removed. (:issue:`2581`, :pull:`2582`) Deprecations diff --git a/docs/sphinx/source/whatsnew/v0.7.0.rst b/docs/sphinx/source/whatsnew/v0.7.0.rst index 011852e513..41df67ac85 100644 --- a/docs/sphinx/source/whatsnew/v0.7.0.rst +++ b/docs/sphinx/source/whatsnew/v0.7.0.rst @@ -148,7 +148,7 @@ Enhancements diode model fitting function '6parsolve' from NREL's System Advisor Model. * Add :py:func:`~pvlib.ivtools.fit_sdm_desoto`, a method to fit the De Soto single diode model to the typical specifications given in manufacturers datasheets. -* Add `timeout` to :py:func:`pvlib.iotools.get_psm3`. +* Add `timeout` to :py:func:`!pvlib.iotools.get_psm3`. * Add :py:func:`~pvlib.scaling.wvm`, a port of the wavelet variability model for computing reductions in variability due to a spatially distributed plant. * Add :py:meth:`~pvlib.location.Location.from_epw`, a method to create a Location diff --git a/docs/sphinx/source/whatsnew/v0.7.1.rst b/docs/sphinx/source/whatsnew/v0.7.1.rst index af0368821d..630da2cff1 100644 --- a/docs/sphinx/source/whatsnew/v0.7.1.rst +++ b/docs/sphinx/source/whatsnew/v0.7.1.rst @@ -5,8 +5,8 @@ v0.7.1 (January 17, 2020) Enhancements ~~~~~~~~~~~~ -* Added :py:func:`~pvlib.iotools.read_psm3` to read local NSRDB PSM3 files and - :py:func:`~pvlib.iotools.parse_psm3` to parse local NSRDB PSM3 file-like +* Added :py:func:`!pvlib.iotools.read_psm3` to read local NSRDB PSM3 files and + :py:func:`!pvlib.iotools.parse_psm3` to parse local NSRDB PSM3 file-like objects. (:issue:`841`) * Added `leap_day` parameter to `iotools.get_psm3` instead of hardcoding it as False. diff --git a/docs/sphinx/source/whatsnew/v0.8.0.rst b/docs/sphinx/source/whatsnew/v0.8.0.rst index 86fb81574f..e322b9fb0c 100644 --- a/docs/sphinx/source/whatsnew/v0.8.0.rst +++ b/docs/sphinx/source/whatsnew/v0.8.0.rst @@ -19,7 +19,7 @@ Breaking changes * :py:func:`pvlib.iotools.read_tmy3` can now only read local data files because the NREL RREDC server hosting the TMY3 dataset has been retired. For - fetching TMY data from NREL servers, :py:func:`pvlib.iotools.get_psm3` is + fetching TMY data from NREL servers, :py:func:`!pvlib.iotools.get_psm3` is now recommended to retrieve newer PSM3 data over the older TMY3 data. (:issue:`996`) (:pull:`1004`) diff --git a/docs/sphinx/source/whatsnew/v0.8.1.rst b/docs/sphinx/source/whatsnew/v0.8.1.rst index 885973f786..f3ce66c5ff 100644 --- a/docs/sphinx/source/whatsnew/v0.8.1.rst +++ b/docs/sphinx/source/whatsnew/v0.8.1.rst @@ -24,7 +24,7 @@ Enhancements 10 minutes. (:pull:`1074`) * Added :py:func:`pvlib.inverter.sandia_multi` and :py:func:`pvlib.inverter.pvwatts_multi` for modeling inverters with multiple MPPTs (:issue:`457`, :pull:`1085`, :pull:`1106`) -* Added optional ``attributes`` parameter to :py:func:`pvlib.iotools.get_psm3` +* Added optional ``attributes`` parameter to :py:func:`!pvlib.iotools.get_psm3` and added the option of fetching 5- and 15-minute PSM3 data. (:pull:`1086`) * Added :py:func:`pvlib.irradiance.campbell_norman` for estimating DNI, DHI and GHI from extraterrestrial irradiance. This function replaces ``pvlib.irradiance.liujordan``; diff --git a/docs/sphinx/source/whatsnew/v0.9.0.rst b/docs/sphinx/source/whatsnew/v0.9.0.rst index d508f8871a..f574852126 100644 --- a/docs/sphinx/source/whatsnew/v0.9.0.rst +++ b/docs/sphinx/source/whatsnew/v0.9.0.rst @@ -50,7 +50,7 @@ Breaking changes :py:meth:`~pvlib.pvsystem.PVSystem.calcparams_cec` (:issue:`1118`, :pull:`1222`) * Switched the order of the outputs from the PSM3 iotools, notably - :py:func:`~pvlib.iotools.get_psm3` and :py:func:`~pvlib.iotools.read_psm3` + :py:func:`!pvlib.iotools.get_psm3` and :py:func:`!pvlib.iotools.read_psm3` (:issue:`1245`, :pull:`1268`) * Changed the naming of the inputs ``startdate``/``enddate`` to ``start``/``end`` in @@ -223,7 +223,7 @@ Documentation and to make the procedural and OO results match exactly. (:issue:`1116`, :pull:`1144`) * Add a gallery example showing how to appropriately use interval-averaged weather data for modeling. (:pull:`1152`) -* Update documentation links in :py:func:`pvlib.iotools.get_psm3` (:pull:`1169`) +* Update documentation links in :py:func:`!pvlib.iotools.get_psm3` (:pull:`1169`) * Use ``Mount`` classes in ``introtutorial`` and ``pvsystem`` docs pages (:pull:`1267`) * Clarified how statistics are calculated for :py:func:`pvlib.clearsky.detect_clearsky` (:issue:`1070`, :pull:`1243`) diff --git a/docs/sphinx/source/whatsnew/v0.9.1.rst b/docs/sphinx/source/whatsnew/v0.9.1.rst index 0a6a0d70f9..1734398c47 100644 --- a/docs/sphinx/source/whatsnew/v0.9.1.rst +++ b/docs/sphinx/source/whatsnew/v0.9.1.rst @@ -18,8 +18,8 @@ Deprecations Enhancements ~~~~~~~~~~~~ -* Added ``map_variables`` option to :py:func:`pvlib.iotools.get_psm3` and - :py:func:`pvlib.iotools.read_psm3` (:pull:`1374`) +* Added ``map_variables`` option to :py:func:`!pvlib.iotools.get_psm3` and + :py:func:`!pvlib.iotools.read_psm3` (:pull:`1374`) * Added ``pvlib.bifacial.infinite_sheds``, containing a model for irradiance on front and back surfaces of bifacial arrays. (:pull:`717`) * Added ``map_variables`` option to :func:`~pvlib.iotools.read_crn` (:pull:`1368`) diff --git a/docs/sphinx/source/whatsnew/v0.9.2.rst b/docs/sphinx/source/whatsnew/v0.9.2.rst index 2616734036..01612b2fad 100644 --- a/docs/sphinx/source/whatsnew/v0.9.2.rst +++ b/docs/sphinx/source/whatsnew/v0.9.2.rst @@ -35,7 +35,7 @@ Bug fixes timestamps as either 24:00 (which is the standard) as well as 00:00. Previously 00:00 timestamps would incorrectly be moved one day forward. (:pull:`1494`) -* :py:func:`pvlib.iotools.get_psm3` now raises a deprecation warning if +* :py:func:`!pvlib.iotools.get_psm3` now raises a deprecation warning if the ``leap_day`` parameter is not specified in a single-year request. Starting in pvlib 0.11.0 ``leap_day`` will default to True instead of False. (:issue:`1481`, :pull:`1511`) diff --git a/docs/sphinx/source/whatsnew/v0.9.5.rst b/docs/sphinx/source/whatsnew/v0.9.5.rst index 23766d566e..8d9c1b0aec 100644 --- a/docs/sphinx/source/whatsnew/v0.9.5.rst +++ b/docs/sphinx/source/whatsnew/v0.9.5.rst @@ -44,7 +44,7 @@ Bug fixes incorrect loss results for systems that are near the ground. (:issue:`1636`, :pull:`1653`) * Fixed incorrect mapping of requested parameters names when using - :py:func:`pvlib.iotools.get_psm3`. + :py:func:`!pvlib.iotools.get_psm3`. Also fixed the random reordering of the dataframe columns. (:issue:`1629`, :issue:`1647`, :pull:`1648`) * When using ``utc_time_range`` with :py:func:`pvlib.iotools.read_ecmwf_macc`, diff --git a/pvlib/iotools/__init__.py b/pvlib/iotools/__init__.py index 75663507f3..56bbe2cffb 100644 --- a/pvlib/iotools/__init__.py +++ b/pvlib/iotools/__init__.py @@ -8,9 +8,6 @@ from pvlib.iotools.crn import read_crn # noqa: F401 from pvlib.iotools.solrad import read_solrad # noqa: F401 from pvlib.iotools.solrad import get_solrad # noqa: F401 -from pvlib.iotools.psm3 import get_psm3 # noqa: F401 -from pvlib.iotools.psm3 import read_psm3 # noqa: F401 -from pvlib.iotools.psm3 import parse_psm3 # noqa: F401 from pvlib.iotools.psm4 import get_nsrdb_psm4_aggregated # noqa: F401 from pvlib.iotools.psm4 import get_nsrdb_psm4_tmy # noqa: F401 from pvlib.iotools.psm4 import get_nsrdb_psm4_conus # noqa: F401 diff --git a/pvlib/iotools/psm3.py b/pvlib/iotools/psm3.py deleted file mode 100644 index 184a4a7028..0000000000 --- a/pvlib/iotools/psm3.py +++ /dev/null @@ -1,365 +0,0 @@ -""" -Get PSM3 TMY -see https://developer.nrel.gov/docs/solar/nsrdb/psm3_data_download/ -""" - -import io -import requests -import pandas as pd -from json import JSONDecodeError -from pvlib._deprecation import deprecated -from pvlib import tools - -NSRDB_API_BASE = "https://developer.nrel.gov" -PSM_URL = NSRDB_API_BASE + "/api/nsrdb/v2/solar/psm3-2-2-download.csv" -TMY_URL = NSRDB_API_BASE + "/api/nsrdb/v2/solar/psm3-tmy-download.csv" -PSM5MIN_URL = NSRDB_API_BASE + "/api/nsrdb/v2/solar/psm3-5min-download.csv" - -ATTRIBUTES = ( - 'air_temperature', 'dew_point', 'dhi', 'dni', 'ghi', 'surface_albedo', - 'surface_pressure', 'wind_direction', 'wind_speed') -PVLIB_PYTHON = 'pvlib python' - -# Dictionary mapping PSM3 response names to pvlib names -VARIABLE_MAP = { - 'GHI': 'ghi', - 'DHI': 'dhi', - 'DNI': 'dni', - 'Clearsky GHI': 'ghi_clear', - 'Clearsky DHI': 'dhi_clear', - 'Clearsky DNI': 'dni_clear', - 'Solar Zenith Angle': 'solar_zenith', - 'Temperature': 'temp_air', - 'Dew Point': 'temp_dew', - 'Relative Humidity': 'relative_humidity', - 'Pressure': 'pressure', - 'Wind Speed': 'wind_speed', - 'Wind Direction': 'wind_direction', - 'Surface Albedo': 'albedo', - 'Precipitable Water': 'precipitable_water', -} - -# Dictionary mapping pvlib names to PSM3 request names -# Note, PSM3 uses different names for the same variables in the -# response and the request -REQUEST_VARIABLE_MAP = { - 'ghi': 'ghi', - 'dhi': 'dhi', - 'dni': 'dni', - 'ghi_clear': 'clearsky_ghi', - 'dhi_clear': 'clearsky_dhi', - 'dni_clear': 'clearsky_dni', - 'solar_zenith': 'solar_zenith_angle', - 'temp_air': 'air_temperature', - 'temp_dew': 'dew_point', - 'relative_humidity': 'relative_humidity', - 'pressure': 'surface_pressure', - 'wind_speed': 'wind_speed', - 'wind_direction': 'wind_direction', - 'albedo': 'surface_albedo', - 'precipitable_water': 'total_precipitable_water', -} - - -def get_psm3(latitude, longitude, api_key, email, names='tmy', interval=60, - attributes=ATTRIBUTES, leap_day=True, full_name=PVLIB_PYTHON, - affiliation=PVLIB_PYTHON, map_variables=True, url=None, - timeout=30): - """ - Retrieve NSRDB PSM3 timeseries weather data from the PSM3 API. The NSRDB - is described in [1]_ and the PSM3 API is described in [2]_, [3]_, and [4]_. - - .. versionchanged:: 0.9.0 - The function now returns a tuple where the first element is a dataframe - and the second element is a dictionary containing metadata. Previous - versions of this function had the return values switched. - - .. versionchanged:: 0.10.0 - The default endpoint for hourly single-year datasets is now v3.2.2. - The previous datasets can still be accessed (for now) by setting - the ``url`` parameter to the original API endpoint - (``"https://developer.nrel.gov/api/nsrdb/v2/solar/psm3-download.csv"``). - - Parameters - ---------- - latitude : float or int - in decimal degrees, between -90 and 90, north is positive - longitude : float or int - in decimal degrees, between -180 and 180, east is positive - api_key : str - NREL Developer Network API key - email : str - NREL API uses this to automatically communicate messages back - to the user only if necessary - names : str, default 'tmy' - PSM3 API parameter specifing year (e.g. ``2020``) or TMY variant - to download (e.g. ``'tmy'`` or ``'tgy-2019'``). The allowed values - update periodically, so consult the NSRDB references below for the - current set of options. - interval : int, {60, 5, 15, 30} - interval size in minutes, must be 5, 15, 30 or 60. Must be 60 for - typical year requests (i.e., tmy/tgy/tdy). - attributes : list of str, optional - meteorological fields to fetch. If not specified, defaults to - ``pvlib.iotools.psm3.ATTRIBUTES``. See references [2]_, [3]_, and [4]_ - for lists of available fields. Alternatively, pvlib names may also be - used (e.g. 'ghi' rather than 'GHI'); see :const:`REQUEST_VARIABLE_MAP`. - To retrieve all available fields, set ``attributes=[]``. - leap_day : bool, default : True - include leap day in the results. Only used for single-year requests - (i.e., it is ignored for tmy/tgy/tdy requests). - full_name : str, default 'pvlib python' - optional - affiliation : str, default 'pvlib python' - optional - map_variables : bool, default True - When true, renames columns of the Dataframe to pvlib variable names - where applicable. See variable :const:`VARIABLE_MAP`. - url : str, optional - API endpoint URL. If not specified, the endpoint is determined from - the ``names`` and ``interval`` parameters. - timeout : int, default 30 - time in seconds to wait for server response before timeout - - Returns - ------- - data : pandas.DataFrame - timeseries data from NREL PSM3 - metadata : dict - metadata from NREL PSM3 about the record, see - :func:`pvlib.iotools.read_psm3` for fields - - Raises - ------ - requests.HTTPError - if the request response status is not ok, then the ``'errors'`` field - from the JSON response or any error message in the content will be - raised as an exception, for example if the `api_key` was rejected or if - the coordinates were not found in the NSRDB - - Notes - ----- - The required NREL developer key, `api_key`, is available for free by - registering at the `NREL Developer Network `_. - - .. warning:: The "DEMO_KEY" `api_key` is severely rate limited and may - result in rejected requests. - - .. warning:: PSM3 is limited to data found in the NSRDB, please consult the - references below for locations with available data. Additionally, - querying data with < 30-minute resolution uses a different API endpoint - with fewer available fields (see [4]_). - - See Also - -------- - pvlib.iotools.read_psm3 - - References - ---------- - - .. [1] `NREL National Solar Radiation Database (NSRDB) - `_ - .. [2] `Physical Solar Model (PSM) v3.2.2 - `_ - .. [3] `Physical Solar Model (PSM) v3 TMY - `_ - .. [4] `Physical Solar Model (PSM) v3 - Five Minute Temporal Resolution - `_ - """ - # The well know text (WKT) representation of geometry notation is strict. - # A POINT object is a string with longitude first, then the latitude, with - # four decimals each, and exactly one space between them. - longitude = ('%9.4f' % longitude).strip() - latitude = ('%8.4f' % latitude).strip() - # TODO: make format_WKT(object_type, *args) in tools.py - - # convert to string to accomodate integer years being passed in - names = str(names) - - # convert pvlib names in attributes to psm3 convention - attributes = [REQUEST_VARIABLE_MAP.get(a, a) for a in attributes] - - # required query-string parameters for request to PSM3 API - params = { - 'api_key': api_key, - 'full_name': full_name, - 'email': email, - 'affiliation': affiliation, - 'reason': PVLIB_PYTHON, - 'mailing_list': 'false', - 'wkt': 'POINT(%s %s)' % (longitude, latitude), - 'names': names, - 'attributes': ','.join(attributes), - 'leap_day': str(leap_day).lower(), - 'utc': 'false', - 'interval': interval - } - # request CSV download from NREL PSM3 - if url is None: - # determine the endpoint that suits the user inputs - if any(prefix in names for prefix in ('tmy', 'tgy', 'tdy')): - url = TMY_URL - elif interval in (5, 15): - url = PSM5MIN_URL - else: - url = PSM_URL - - response = requests.get(url, params=params, timeout=timeout) - if not response.ok: - # if the API key is rejected, then the response status will be 403 - # Forbidden, and then the error is in the content and there is no JSON - try: - errors = response.json()['errors'] - except JSONDecodeError: - errors = response.content.decode('utf-8') - raise requests.HTTPError(errors, response=response) - # the CSV is in the response content as a UTF-8 bytestring - # to use pandas we need to create a file buffer from the response - fbuf = io.StringIO(response.content.decode('utf-8')) - return read_psm3(fbuf, map_variables) - - -def read_psm3(filename, map_variables=True): - """ - Read an NSRDB PSM3 weather file (formatted as SAM CSV). The NSRDB - is described in [1]_ and the SAM CSV format is described in [2]_. - - .. versionchanged:: 0.9.0 - The function now returns a tuple where the first element is a dataframe - and the second element is a dictionary containing metadata. Previous - versions of this function had the return values switched. - - Parameters - ---------- - filename: str, path-like, or buffer - Filename or in-memory buffer of a file containing data to read. - map_variables: bool, default True - When true, renames columns of the Dataframe to pvlib variable names - where applicable. See variable :const:`VARIABLE_MAP`. - - Returns - ------- - data : pandas.DataFrame - timeseries data from NREL PSM3 - metadata : dict - metadata from NREL PSM3 about the record, see notes for fields - - Notes - ----- - The return is a tuple with two items. The first item is a dataframe with - the PSM3 timeseries data. - - The second item is a dictionary with metadata from NREL PSM3 about the - record containing the following fields: - - * Source - * Location ID - * City - * State - * Country - * Latitude - * Longitude - * Time Zone - * Elevation - * Local Time Zone - * Clearsky DHI Units - * Clearsky DNI Units - * Clearsky GHI Units - * Dew Point Units - * DHI Units - * DNI Units - * GHI Units - * Solar Zenith Angle Units - * Temperature Units - * Pressure Units - * Relative Humidity Units - * Precipitable Water Units - * Wind Direction Units - * Wind Speed Units - * Cloud Type -15 - * Cloud Type 0 - * Cloud Type 1 - * Cloud Type 2 - * Cloud Type 3 - * Cloud Type 4 - * Cloud Type 5 - * Cloud Type 6 - * Cloud Type 7 - * Cloud Type 8 - * Cloud Type 9 - * Cloud Type 10 - * Cloud Type 11 - * Cloud Type 12 - * Fill Flag 0 - * Fill Flag 1 - * Fill Flag 2 - * Fill Flag 3 - * Fill Flag 4 - * Fill Flag 5 - * Surface Albedo Units - * Version - - Examples - -------- - >>> # Read a local PSM3 file: - >>> df, metadata = iotools.read_psm3("data.csv") # doctest: +SKIP - - >>> # Read a file object or an in-memory buffer: - >>> with open(filename, 'r') as f: # doctest: +SKIP - ... df, metadata = iotools.read_psm3(f) # doctest: +SKIP - - See Also - -------- - pvlib.iotools.get_psm3 - - References - ---------- - .. [1] `NREL National Solar Radiation Database (NSRDB) - `_ - .. [2] `Standard Time Series Data File Format - `_ - """ - with tools._file_context_manager(filename) as fbuf: - # The first 2 lines of the response are headers with metadata - metadata_fields = fbuf.readline().split(',') - metadata_values = fbuf.readline().split(',') - # get the column names so we can set the dtypes - columns = fbuf.readline().split(',') - columns[-1] = columns[-1].strip() # strip trailing newline - # Since the header has so many columns, excel saves blank cols in the - # data below the header lines. - columns = [col for col in columns if col != ''] - dtypes = dict.fromkeys(columns, float) # all floats except datevec - dtypes.update({'Year': int, 'Month': int, 'Day': int, 'Hour': int, - 'Minute': int, 'Cloud Type': int, 'Fill Flag': int}) - data = pd.read_csv( - fbuf, header=None, names=columns, usecols=columns, dtype=dtypes, - delimiter=',', lineterminator='\n') # skip carriage returns \r - - metadata_fields[-1] = metadata_fields[-1].strip() # trailing newline - metadata_values[-1] = metadata_values[-1].strip() # trailing newline - metadata = dict(zip(metadata_fields, metadata_values)) - # the response is all strings, so set some metadata types to numbers - metadata['Local Time Zone'] = int(metadata['Local Time Zone']) - metadata['Time Zone'] = int(metadata['Time Zone']) - metadata['Latitude'] = float(metadata['Latitude']) - metadata['Longitude'] = float(metadata['Longitude']) - metadata['Elevation'] = int(metadata['Elevation']) - - # the response 1st 5 columns are a date vector, convert to datetime - dtidx = pd.to_datetime(data[['Year', 'Month', 'Day', 'Hour', 'Minute']]) - # in USA all timezones are integers - tz = 'Etc/GMT%+d' % -metadata['Time Zone'] - data.index = pd.DatetimeIndex(dtidx).tz_localize(tz) - - if map_variables: - data = data.rename(columns=VARIABLE_MAP) - metadata['latitude'] = metadata.pop('Latitude') - metadata['longitude'] = metadata.pop('Longitude') - metadata['altitude'] = metadata.pop('Elevation') - - return data, metadata - - -parse_psm3 = deprecated(since="0.13.0", name="parse_psm3", - alternative="read_psm3")(read_psm3) diff --git a/pvlib/iotools/psm4.py b/pvlib/iotools/psm4.py index 44325364c7..ecab84fd21 100644 --- a/pvlib/iotools/psm4.py +++ b/pvlib/iotools/psm4.py @@ -714,7 +714,6 @@ def read_nsrdb_psm4(filename, map_variables=True): pvlib.iotools.get_nsrdb_psm4_tmy pvlib.iotools.get_nsrdb_psm4_conus pvlib.iotools.get_nsrdb_psm4_full_disc - pvlib.iotools.read_psm3 References ---------- diff --git a/tests/iotools/test_psm3.py b/tests/iotools/test_psm3.py deleted file mode 100644 index 39de06d234..0000000000 --- a/tests/iotools/test_psm3.py +++ /dev/null @@ -1,191 +0,0 @@ -""" -test iotools for PSM3 -""" - -from pvlib.iotools import psm3 -from tests.conftest import ( - TESTS_DATA_DIR, - RERUNS, - RERUNS_DELAY, - assert_index_equal, - nrel_api_key, -) -import numpy as np -import pandas as pd -import pytest -from requests import HTTPError -from io import StringIO - -from pvlib._deprecation import pvlibDeprecationWarning - - -TMY_TEST_DATA = TESTS_DATA_DIR / 'test_psm3_tmy-2017.csv' -YEAR_TEST_DATA = TESTS_DATA_DIR / 'test_psm3_2017.csv' -YEAR_TEST_DATA_5MIN = TESTS_DATA_DIR / 'test_psm3_2019_5min.csv' -MANUAL_TEST_DATA = TESTS_DATA_DIR / 'test_read_psm3.csv' -LATITUDE, LONGITUDE = 40.5137, -108.5449 -METADATA_FIELDS = [ - 'Source', 'Location ID', 'City', 'State', 'Country', 'Latitude', - 'Longitude', 'Time Zone', 'Elevation', 'Local Time Zone', - 'Dew Point Units', 'DHI Units', 'DNI Units', 'GHI Units', - 'Temperature Units', 'Pressure Units', 'Wind Direction Units', - 'Wind Speed Units', 'Surface Albedo Units', 'Version'] -PVLIB_EMAIL = 'pvlib-admin@googlegroups.com' - - -def assert_psm3_equal(data, metadata, expected): - """check consistency of PSM3 data""" - # check datevec columns - assert np.allclose(data.Year, expected.Year) - assert np.allclose(data.Month, expected.Month) - assert np.allclose(data.Day, expected.Day) - assert np.allclose(data.Hour, expected.Hour) - assert np.allclose(data.Minute, expected.Minute) - # check data columns - assert np.allclose(data.GHI, expected.GHI) - assert np.allclose(data.DNI, expected.DNI) - assert np.allclose(data.DHI, expected.DHI) - assert np.allclose(data.Temperature, expected.Temperature) - assert np.allclose(data.Pressure, expected.Pressure) - assert np.allclose(data['Dew Point'], expected['Dew Point']) - assert np.allclose(data['Surface Albedo'], expected['Surface Albedo']) - assert np.allclose(data['Wind Speed'], expected['Wind Speed']) - assert np.allclose(data['Wind Direction'], expected['Wind Direction']) - # check header - for mf in METADATA_FIELDS: - assert mf in metadata - # check timezone - assert (data.index.tzinfo.zone == 'Etc/GMT%+d' % -metadata['Time Zone']) - - -@pytest.mark.remote_data -@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) -def test_get_psm3_tmy(nrel_api_key): - """test get_psm3 with a TMY""" - data, metadata = psm3.get_psm3(LATITUDE, LONGITUDE, nrel_api_key, - PVLIB_EMAIL, names='tmy-2017', - leap_day=False, map_variables=False) - expected = pd.read_csv(TMY_TEST_DATA) - assert_psm3_equal(data, metadata, expected) - - -@pytest.mark.remote_data -@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) -def test_get_psm3_singleyear(nrel_api_key): - """test get_psm3 with a single year""" - data, metadata = psm3.get_psm3(LATITUDE, LONGITUDE, nrel_api_key, - PVLIB_EMAIL, names='2017', - leap_day=False, map_variables=False, - interval=30) - expected = pd.read_csv(YEAR_TEST_DATA) - assert_psm3_equal(data, metadata, expected) - - -@pytest.mark.remote_data -@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) -def test_get_psm3_5min(nrel_api_key): - """test get_psm3 for 5-minute data""" - data, metadata = psm3.get_psm3(LATITUDE, LONGITUDE, nrel_api_key, - PVLIB_EMAIL, names='2019', interval=5, - leap_day=False, map_variables=False) - assert len(data) == 525600/5 - first_day = data.loc['2019-01-01'] - expected = pd.read_csv(YEAR_TEST_DATA_5MIN) - assert_psm3_equal(first_day, metadata, expected) - - -@pytest.mark.remote_data -@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) -def test_get_psm3_check_leap_day(nrel_api_key): - data_2012, _ = psm3.get_psm3(LATITUDE, LONGITUDE, nrel_api_key, - PVLIB_EMAIL, names="2012", interval=60, - leap_day=True, map_variables=False) - assert len(data_2012) == (8760 + 24) - - -@pytest.mark.parametrize('latitude, longitude, api_key, names, interval', - [(LATITUDE, LONGITUDE, 'BAD', 'tmy-2017', 60), - (51, -5, nrel_api_key, 'tmy-2017', 60), - (LATITUDE, LONGITUDE, nrel_api_key, 'bad', 60), - (LATITUDE, LONGITUDE, nrel_api_key, '2017', 15), - ]) -@pytest.mark.remote_data -@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) -def test_get_psm3_tmy_errors( - latitude, longitude, api_key, names, interval -): - """Test get_psm3() for multiple erroneous input scenarios. - - These scenarios include: - * Bad API key -> HTTP 403 forbidden because api_key is rejected - * Bad latitude/longitude -> Coordinates were not found in the NSRDB. - * Bad name -> Name is not one of the available options. - * Bad interval, single year -> Intervals can only be 30 or 60 minutes. - """ - with pytest.raises(HTTPError) as excinfo: - psm3.get_psm3(latitude, longitude, api_key, PVLIB_EMAIL, - names=names, interval=interval, leap_day=False, - map_variables=False) - # ensure the HTTPError caught isn't due to overuse of the API key - assert "OVER_RATE_LIMIT" not in str(excinfo.value) - - -@pytest.fixture -def io_input(request): - """file-like object for read_psm3""" - with MANUAL_TEST_DATA.open() as f: - data = f.read() - obj = StringIO(data) - return obj - - -def test_parse_psm3(io_input): - """test parse_psm3""" - with pytest.warns(pvlibDeprecationWarning, match='Use read_psm3 instead'): - data, metadata = psm3.parse_psm3(io_input, map_variables=False) - expected = pd.read_csv(YEAR_TEST_DATA) - assert_psm3_equal(data, metadata, expected) - - -def test_read_psm3(): - """test read_psm3""" - data, metadata = psm3.read_psm3(MANUAL_TEST_DATA, map_variables=False) - expected = pd.read_csv(YEAR_TEST_DATA) - assert_psm3_equal(data, metadata, expected) - - -def test_read_psm3_buffer(io_input): - data, metadata = psm3.read_psm3(io_input, map_variables=False) - expected = pd.read_csv(YEAR_TEST_DATA) - assert_psm3_equal(data, metadata, expected) - - -def test_read_psm3_map_variables(): - """test read_psm3 map_variables=True""" - data, metadata = psm3.read_psm3(MANUAL_TEST_DATA, map_variables=True) - columns_mapped = ['Year', 'Month', 'Day', 'Hour', 'Minute', 'dhi', 'ghi', - 'dni', 'ghi_clear', 'dhi_clear', 'dni_clear', - 'Cloud Type', 'temp_dew', 'solar_zenith', - 'Fill Flag', 'albedo', 'wind_speed', - 'wind_direction', 'precipitable_water', - 'relative_humidity', 'temp_air', 'pressure'] - data, metadata = psm3.read_psm3(MANUAL_TEST_DATA, map_variables=True) - assert_index_equal(data.columns, pd.Index(columns_mapped)) - - -@pytest.mark.remote_data -@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) -def test_get_psm3_attribute_mapping(nrel_api_key): - """Test that pvlib names can be passed in as attributes and get correctly - reverse mapped to PSM3 names""" - data, meta = psm3.get_psm3(LATITUDE, LONGITUDE, nrel_api_key, PVLIB_EMAIL, - names=2019, interval=60, - attributes=['ghi', 'wind_speed'], - leap_day=False, map_variables=True) - # Check that columns are in the correct order (GH1647) - expected_columns = [ - 'Year', 'Month', 'Day', 'Hour', 'Minute', 'ghi', 'wind_speed'] - pd.testing.assert_index_equal(pd.Index(expected_columns), data.columns) - assert 'latitude' in meta.keys() - assert 'longitude' in meta.keys() - assert 'altitude' in meta.keys() From 5953f82f30df3d33d536fff2b16ca3674fe53a54 Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Mon, 3 Nov 2025 12:19:51 -0700 Subject: [PATCH 15/34] Use chandrupatla to find MPP in lambertw method (#2571) * restart from PR2567 * left interval to neg., calculate i_mp * relax tolerance for i_mp * docstring work * indent * Apply suggestions from code review Co-authored-by: Kevin Anderson * better inspection of values * shorten string in test system * Update docs/sphinx/source/whatsnew/v0.13.2.rst Co-authored-by: Kevin Anderson --------- Co-authored-by: Kevin Anderson --- docs/sphinx/source/whatsnew/v0.13.2.rst | 5 ++- pvlib/pvsystem.py | 34 +++++++++------- pvlib/singlediode.py | 52 ++++++++++++++++++------- tests/test_modelchain.py | 15 ++++++- tests/test_singlediode.py | 2 +- 5 files changed, 77 insertions(+), 31 deletions(-) diff --git a/docs/sphinx/source/whatsnew/v0.13.2.rst b/docs/sphinx/source/whatsnew/v0.13.2.rst index 0956c74a44..c2870cc084 100644 --- a/docs/sphinx/source/whatsnew/v0.13.2.rst +++ b/docs/sphinx/source/whatsnew/v0.13.2.rst @@ -30,7 +30,8 @@ Enhancements :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`) - +* Accelerate :py:func:`~pvlib.pvsystem.singlediode` when scipy>=1.15 is + installed. (:issue:`2497`, :pull:`2571`) Documentation @@ -56,4 +57,4 @@ Maintenance Contributors ~~~~~~~~~~~~ - +* Cliff Hansen (:ghuser:`cwhanse`) diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index 2b703f3a52..edad38b412 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -2534,28 +2534,35 @@ def singlediode(photocurrent, saturation_current, resistance_series, explicit function of :math:`V=f(I)` and :math:`I=f(V)` as shown in [2]_. 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. + is used. It should be safe for well-behaved IV curves, otherwise the + ``'chandrupatla``` or ``'brentq'`` methods are recommended for reliability. If the method is ``'brentq'`` then Brent's bisection search method is used that guarantees convergence by bounding the voltage between zero and - open-circuit. + open-circuit. ``'brentq'`` is generally slower than the other options. + + If the method is ``'chandrupatla'`` then Chandrupatla's method is used + that guarantees convergence. References ---------- - .. [1] S.R. Wenham, M.A. Green, M.E. Watt, "Applied Photovoltaics" ISBN - 0 86758 909 4 + .. [1] S. R. Wenham, M. A. Green, M. E. Watt, "Applied Photovoltaics", + Centre for Photovoltaic Devices and Systems, 1995. ISBN + 0867589094 .. [2] A. Jain, A. Kapoor, "Exact analytical solutions of the parameters of real solar cells using Lambert W-function", Solar - Energy Materials and Solar Cells, 81 (2004) 269-277. + Energy Materials and Solar Cells, vol. 81 no. 2, pp. 269-277, Feb. 2004. + :doi:`10.1016/j.solmat.2003.11.018`. - .. [3] D. King et al, "Sandia Photovoltaic Array Performance Model", - SAND2004-3535, Sandia National Laboratories, Albuquerque, NM + .. [3] D. L. King, E. E. Boyson and J. A. Kratochvil "Photovoltaic Array + Performance Model", Sandia National Laboratories, Albuquerque, NM, USA. + Report SAND2004-3535, 2004. - .. [4] "Computer simulation of the effects of electrical mismatches in - photovoltaic cell interconnection circuits" JW Bishop, Solar Cell (1988) - https://doi.org/10.1016/0379-6787(88)90059-2 + .. [4] J.W. Bishop, "Computer simulation of the effects of electrical + mismatches in photovoltaic cell interconnection circuits" Solar Cells, + vol. 25 no. 1, pp. 73-89, Oct. 1988. + :doi:`doi.org/10.1016/0379-6787(88)90059-2` """ args = (photocurrent, saturation_current, resistance_series, resistance_shunt, nNsVth) # collect args @@ -2565,8 +2572,9 @@ def singlediode(photocurrent, saturation_current, resistance_series, out = _singlediode._lambertw(*args) points = out[:7] else: - # Calculate points on the IV curve using either 'newton' or 'brentq' - # methods. Voltages are determined by first solving the single diode + # Calculate points on the IV curve using Bishop's algorithm and solving + # with 'newton', 'brentq' or 'chandrupatla' method. + # Voltages are determined by first solving the single diode # equation for the diode voltage V_d then backing out voltage v_oc = _singlediode.bishop88_v_from_i( 0.0, *args, method=method.lower() diff --git a/pvlib/singlediode.py b/pvlib/singlediode.py index 43be522437..779467729d 100644 --- a/pvlib/singlediode.py +++ b/pvlib/singlediode.py @@ -141,18 +141,20 @@ def bishop88(diode_voltage, photocurrent, saturation_current, References ---------- - .. [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` - - .. [2] "Improved equivalent circuit and Analytical Model for Amorphous - Silicon Solar Cells and Modules." J. Mertens, et al., IEEE Transactions - on Electron Devices, Vol 45, No 2, Feb 1998. + .. [1] J.W. Bishop, "Computer simulation of the effects of electrical + mismatches in photovoltaic cell interconnection circuits" Solar Cells, + vol. 25 no. 1, pp. 73-89, Oct. 1988. + :doi:`doi.org/10.1016/0379-6787(88)90059-2` + + .. [2] J. Merten, J. M. Asensi, C. Voz, A. V. Shah, R. Platz and J. Andreu, + "Improved equivalent circuit and Analytical Model for Amorphous + Silicon Solar Cells and Modules." , IEEE Transactions + on Electron Devices, vol. 45, no. 2, pp. 423-429, Feb 1998. :doi:`10.1109/16.658676` - .. [3] "Performance assessment of a simulation model for PV modules of any - available technology", André Mermoud and Thibault Lejeune, 25th EUPVSEC, - 2010 + .. [3] A. Mermoud and T. Lejeune, "Performance assessment of a simulation + model for PV modules of any available technology", In Proc. of the 25th + European PVSEC, Valencia, ES, 2010. :doi:`10.4229/25thEUPVSEC2010-4BV.1.114` """ # calculate recombination loss current where d2mutau > 0 @@ -913,10 +915,25 @@ def _lambertw(photocurrent, saturation_current, resistance_series, v_oc = 0. # 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) + # use scipy.elementwise if available + # remove try/except when scipy>=1.15, and golden mean is retired + try: + from scipy.optimize.elementwise import find_minimum + # left negative to insure strict inequality + init = (-1., 0.8*v_oc, v_oc) + res = find_minimum(_vmp_opt, init, + args=(params['photocurrent'], + params['saturation_current'], + params['resistance_series'], + params['resistance_shunt'], + params['nNsVth'],)) + v_mp = res.x + p_mp = -1.*res.f_x + except ModuleNotFoundError: + # switch to old golden section method + p_mp, v_mp = _golden_sect_DataFrame(params, 0., v_oc * 1.14, + _pwr_optfcn) - # Find Imp using Lambert W i_mp = _lambertw_i_from_v(v_mp, **params) # Find Ix and Ixx using Lambert W @@ -938,6 +955,15 @@ def _lambertw(photocurrent, saturation_current, resistance_series, return out +def _vmp_opt(v, iph, io, rs, rsh, nNsVth): + ''' + Function to find negative of power from ``i_from_v``. + ''' + current = _lambertw_i_from_v(v, iph, io, rs, rsh, nNsVth) + + return -v * current + + def _pwr_optfcn(df, loc): ''' Function to find power from ``i_from_v``. diff --git a/tests/test_modelchain.py b/tests/test_modelchain.py index a3abece9d8..9ea804f014 100644 --- a/tests/test_modelchain.py +++ b/tests/test_modelchain.py @@ -140,7 +140,7 @@ def cec_dc_adr_ac_system(sam_data, cec_module_cs5p_220m, module_parameters=module_parameters, temperature_model_parameters=temp_model_params, inverter_parameters=inverter, - modules_per_string=14) + modules_per_string=13) return system @@ -1383,6 +1383,12 @@ def test_ac_models(sapm_dc_snl_ac_system, cec_dc_adr_ac_system, 'adr': 'adr', 'pvwatts': 'pvwatts', 'pvwatts_multi': 'pvwatts'} + inverter_to_ac_model_param = { + 'sandia': 'Paco', + 'sandia_multi': 'Paco', + 'adr': 'Pnom', + 'pvwatts': 'pdc0', + 'pvwatts_multi': 'pdc0'} ac_model = inverter_to_ac_model[inverter_model] system = ac_systems[inverter_model] @@ -1399,7 +1405,12 @@ def test_ac_models(sapm_dc_snl_ac_system, cec_dc_adr_ac_system, assert m.call_count == 1 assert isinstance(mc.results.ac, pd.Series) assert not mc.results.ac.empty - assert mc.results.ac.iloc[1] < 1 + # irradiance 800 W/m2 at 1st timestamp + inv_param = mc.system.inverter_parameters[ + inverter_to_ac_model_param[inverter_model]] + assert (mc.results.ac.iloc[0] > inv_param / 2.) + # irradiance 0 W/m2 at the 2nd timestamp + assert (np.isnan(mc.results.ac.iloc[1]) or (mc.results.ac.iloc[1] < 1)) def test_ac_model_user_func(pvwatts_dc_pvwatts_ac_system, location, weather, diff --git a/tests/test_singlediode.py b/tests/test_singlediode.py index 8f3b4012c3..ca30bc0e2b 100644 --- a/tests/test_singlediode.py +++ b/tests/test_singlediode.py @@ -159,7 +159,7 @@ def test_singlediode_precision(method, precise_iv_curves): assert np.allclose(pc['i_sc'], outs['i_sc'], atol=1e-10, rtol=0) assert np.allclose(pc['v_oc'], outs['v_oc'], atol=1e-10, rtol=0) - assert np.allclose(pc['i_mp'], outs['i_mp'], atol=7e-8, rtol=0) + assert np.allclose(pc['i_mp'], outs['i_mp'], atol=1e-7, rtol=0) assert np.allclose(pc['v_mp'], outs['v_mp'], atol=1e-6, rtol=0) assert np.allclose(pc['p_mp'], outs['p_mp'], atol=1e-10, rtol=0) assert np.allclose(pc['i_x'], outs['i_x'], atol=1e-10, rtol=0) From 90af1f286f6ece75e391e01a1673923c39cb6d54 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 3 Nov 2025 14:22:14 -0500 Subject: [PATCH 16/34] Add Batzelis 2017 simple nonlinear PV model (#2563) * add function * docs * tests * add SDM parameter estimation and key point functions * lint * tests for the two additional functions * whatsnew * lint * Apply suggestions from code review Co-authored-by: Cliff Hansen * lint * better handling of Rsh=np.inf * rename parameters * switch to non-normalized temp coeffs * fix whatsnew * changes from review --------- Co-authored-by: Cliff Hansen --- .../source/reference/pv_modeling/sdm.rst | 2 + .../reference/pv_modeling/system_models.rst | 8 ++ docs/sphinx/source/whatsnew/v0.13.2.rst | 7 + pvlib/ivtools/sdm/__init__.py | 3 +- pvlib/ivtools/sdm/desoto.py | 72 ++++++++++ pvlib/pvarray.py | 131 +++++++++++++++++- pvlib/singlediode.py | 83 +++++++++++ tests/ivtools/sdm/test_desoto.py | 24 ++++ tests/test_pvarray.py | 39 ++++++ tests/test_singlediode.py | 56 +++++++- 10 files changed, 421 insertions(+), 4 deletions(-) diff --git a/docs/sphinx/source/reference/pv_modeling/sdm.rst b/docs/sphinx/source/reference/pv_modeling/sdm.rst index bfd5103ebe..077cdb16ee 100644 --- a/docs/sphinx/source/reference/pv_modeling/sdm.rst +++ b/docs/sphinx/source/reference/pv_modeling/sdm.rst @@ -17,6 +17,7 @@ Functions relevant for single diode models. pvsystem.v_from_i pvsystem.max_power_point ivtools.sdm.pvsyst_temperature_coeff + singlediode.batzelis Low-level functions for solving the single diode equation. @@ -37,3 +38,4 @@ Functions for fitting diode models ivtools.sde.fit_sandia_simple ivtools.sdm.fit_cec_sam ivtools.sdm.fit_desoto + ivtools.sdm.fit_desoto_batzelis diff --git a/docs/sphinx/source/reference/pv_modeling/system_models.rst b/docs/sphinx/source/reference/pv_modeling/system_models.rst index fb637ee8ed..3b8f29d0b2 100644 --- a/docs/sphinx/source/reference/pv_modeling/system_models.rst +++ b/docs/sphinx/source/reference/pv_modeling/system_models.rst @@ -55,3 +55,11 @@ PVGIS model :toctree: ../generated/ pvarray.huld + +Other +^^^^^ + +.. autosummary:: + :toctree: ../generated/ + + pvarray.batzelis diff --git a/docs/sphinx/source/whatsnew/v0.13.2.rst b/docs/sphinx/source/whatsnew/v0.13.2.rst index c2870cc084..30460158f3 100644 --- a/docs/sphinx/source/whatsnew/v0.13.2.rst +++ b/docs/sphinx/source/whatsnew/v0.13.2.rst @@ -21,6 +21,13 @@ Bug fixes Enhancements ~~~~~~~~~~~~ +* Add :py:func:`~pvlib.ivtools.sdm.fit_desoto_batzelis`, a function to estimate + parameters for the De Soto single-diode model from datasheet values. (:pull:`2563`) +* Add :py:func:`~pvlib.singlediode.batzelis`, a function to estimate + maximum power, open circuit, and short circuit points using parameters for + the single-diode equation. (:pull:`2563`) +* Add :py:func:`~pvlib.pvarray.batzelis`, a function to estimate maximum power + open circuit, and short circuit points from datasheet values. (:pull:`2563`) * Add ``method='chandrupatla'`` (faster than ``brentq`` and slower than ``newton``, but convergence is guaranteed) as an option for :py:func:`pvlib.pvsystem.singlediode`, diff --git a/pvlib/ivtools/sdm/__init__.py b/pvlib/ivtools/sdm/__init__.py index 8535f1f5e6..2bb8b9876b 100644 --- a/pvlib/ivtools/sdm/__init__.py +++ b/pvlib/ivtools/sdm/__init__.py @@ -10,7 +10,8 @@ from pvlib.ivtools.sdm.desoto import ( # noqa: F401 fit_desoto, - fit_desoto_sandia + fit_desoto_batzelis, + fit_desoto_sandia, ) from pvlib.ivtools.sdm.pvsyst import ( # noqa: F401 diff --git a/pvlib/ivtools/sdm/desoto.py b/pvlib/ivtools/sdm/desoto.py index 6b7fa619ca..389e0f0ef0 100644 --- a/pvlib/ivtools/sdm/desoto.py +++ b/pvlib/ivtools/sdm/desoto.py @@ -2,6 +2,7 @@ from scipy import constants from scipy import optimize +from scipy.special import lambertw from pvlib.ivtools.utils import rectify_iv_curve from pvlib.ivtools.sde import _fit_sandia_cocontent @@ -399,3 +400,74 @@ def _fit_desoto_sandia_diode(ee, voc, vth, tc, specs, const): new_x = sm.add_constant(x) res = sm.RLM(y, new_x).fit() return np.array(res.params)[1] + + +def fit_desoto_batzelis(v_mp, i_mp, v_oc, i_sc, alpha_sc, beta_voc): + """ + Determine De Soto single-diode model parameters from datasheet values + using Batzelis's method. + + This method is described in Section II.C of [1]_ and fully documented + in [2]_. + + Parameters + ---------- + v_mp : float + Maximum power point voltage at STC. [V] + i_mp : float + Maximum power point current at STC. [A] + v_oc : float + Open-circuit voltage at STC. [V] + i_sc : float + Short-circuit current at STC. [A] + alpha_sc : float + Short-circuit current temperature coefficient at STC. [A/K] + beta_voc : float + Open-circuit voltage temperature coefficient at STC. [V/K] + + Returns + ------- + dict + The returned dict contains the keys: + + * ``alpha_sc`` [A/K] + * ``a_ref`` [V] + * ``I_L_ref`` [A] + * ``I_o_ref`` [A] + * ``R_sh_ref`` [Ohm] + * ``R_s`` [Ohm] + + References + ---------- + .. [1] E. I. Batzelis, "Simple PV Performance Equations Theoretically Well + Founded on the Single-Diode Model," Journal of Photovoltaics vol. 7, + no. 5, pp. 1400-1409, Sep 2017, :doi:`10.1109/JPHOTOV.2017.2711431` + .. [2] E. I. Batzelis and S. A. Papathanassiou, "A method for the + analytical extraction of the single-diode PV model parameters," + IEEE Trans. Sustain. Energy, vol. 7, no. 2, pp. 504-512, Apr 2016. + :doi:`10.1109/TSTE.2015.2503435` + """ + # convert temp coeffs from A/K and V/K to 1/K + alpha_sc = alpha_sc / i_sc + beta_voc = beta_voc / v_oc + + # Equation numbers refer to [1] + t0 = 298.15 # K + del0 = (1 - beta_voc * t0) / (50.1 - alpha_sc * t0) # Eq 9 + w0 = np.real(lambertw(np.exp(1/del0 + 1))) + + # Eqs 11-15 + a0 = del0 * v_oc + Rs0 = (a0 * (w0 - 1) - v_mp) / i_mp + Rsh0 = a0 * (w0 - 1) / (i_sc * (1 - 1/w0) - i_mp) + Iph0 = (1 + Rs0 / Rsh0) * i_sc + Isat0 = Iph0 * np.exp(-1/del0) + + return { + 'alpha_sc': alpha_sc * i_sc, # convert 1/K to A/K + 'a_ref': a0, + 'I_L_ref': Iph0, + 'I_o_ref': Isat0, + 'R_sh_ref': Rsh0, + 'R_s': Rs0, + } diff --git a/pvlib/pvarray.py b/pvlib/pvarray.py index b156baa757..b43d8d349a 100644 --- a/pvlib/pvarray.py +++ b/pvlib/pvarray.py @@ -9,8 +9,9 @@ """ import numpy as np +import pandas as pd from scipy.optimize import curve_fit -from scipy.special import exp10 +from scipy.special import exp10, lambertw def pvefficiency_adr(effective_irradiance, temp_cell, @@ -394,3 +395,131 @@ def huld(effective_irradiance, temp_mod, pdc0, k=None, cell_type=None, k[5] * tprime**2 ) return pdc + + +def batzelis(effective_irradiance, temp_cell, + v_mp, i_mp, v_oc, i_sc, alpha_sc, beta_voc): + """ + Compute maximum power point, open circuit, and short circuit + values using Batzelis's method. + + Batzelis's method (described in Section III of [1]_) is a fast method + of computing the maximum power current and voltage. The calculations + are rooted in the De Soto single-diode model, but require only typical + datasheet information. + + Parameters + ---------- + effective_irradiance : numeric, non-negative + Effective irradiance incident on the PV module. [Wm⁻²] + temp_cell : numeric + PV module operating temperature. [°C] + v_mp : float + Maximum power point voltage at STC. [V] + i_mp : float + Maximum power point current at STC. [A] + v_oc : float + Open-circuit voltage at STC. [V] + i_sc : float + Short-circuit current at STC. [A] + alpha_sc : float + Short-circuit current temperature coefficient at STC. [A/K] + beta_voc : float + Open-circuit voltage temperature coefficient at STC. [V/K] + + Returns + ------- + dict + The returned dict-like object contains the keys/columns: + + * ``p_mp`` - power at maximum power point. [W] + * ``i_mp`` - current at maximum power point. [A] + * ``v_mp`` - voltage at maximum power point. [V] + * ``i_sc`` - short circuit current. [A] + * ``v_oc`` - open circuit voltage. [V] + + Notes + ----- + This method is the combination of three sub-methods for: + + 1. estimating single-diode model parameters from datasheet information + 2. translating SDM parameters from STC to operating conditions + (taken from the De Soto model) + 3. estimating the MPP, OC, and SC points on the resulting I-V curve. + + At extremely low irradiance (e.g. 1e-10 Wm⁻²), this model can produce + negative voltages. This function clips any negative voltages to zero. + + References + ---------- + .. [1] E. I. Batzelis, "Simple PV Performance Equations Theoretically Well + Founded on the Single-Diode Model," Journal of Photovoltaics vol. 7, + no. 5, pp. 1400-1409, Sep 2017, :doi:`10.1109/JPHOTOV.2017.2711431` + + Examples + -------- + >>> params = {'i_sc': 15.98, 'v_oc': 50.26, 'i_mp': 15.27, 'v_mp': 42.57, + ... 'alpha_sc': 0.007351, 'beta_voc': -0.120624} + >>> batzelis(np.array([1000, 800]), np.array([25, 30]), **params) + {'p_mp': array([650.0439 , 512.99199048]), + 'i_mp': array([15.27 , 12.23049303]), + 'v_mp': array([42.57 , 41.94368856]), + 'i_sc': array([15.98 , 12.813404]), + 'v_oc': array([50.26 , 49.26532902])} + """ + # convert temp coeffs from A/K and V/K to 1/K + alpha_sc = alpha_sc / i_sc + beta_voc = beta_voc / v_oc + + t0 = 298.15 + delT = temp_cell - (t0 - 273.15) + lamT = (temp_cell + 273.15) / t0 + g = effective_irradiance / 1000 + # for zero/negative irradiance, use lnG=large negative number so that + # computed voltages are negative and then clipped to zero + with np.errstate(divide='ignore'): # needed for pandas for some reason + lnG = np.log(g, out=np.full_like(g, -9e9), where=(g > 0)) + lnG = np.where(np.isfinite(g), lnG, np.nan) # also preserve nans + + # Eq 9-10 + del0 = (1 - beta_voc * t0) / (50.1 - alpha_sc * t0) + w0 = np.real(lambertw(np.exp(1/del0 + 1))) + + # Eqs 27-28 + alpha_imp = alpha_sc + (beta_voc - 1/t0) / (w0 - 1) + beta_vmp = (v_oc / v_mp) * ( + beta_voc / (1 + del0) + + (del0 * (w0 - 1) - 1/(1 + del0)) / t0 + ) + + # Eq 26 + eps0 = (del0 / (1 + del0)) * (v_oc / v_mp) + eps1 = del0 * (w0 - 1) * (v_oc / v_mp) - 1 + + # Eqs 22-25 + isc = g * i_sc * (1 + alpha_sc * delT) + voc = v_oc * (1 + del0 * lamT * lnG + beta_voc * delT) + imp = g * i_mp * (1 + alpha_imp * delT) + vmp = v_mp * (1 + eps0 * lamT * lnG + eps1 * (1 - g) + beta_vmp * delT) + + # handle negative voltages from zero and extremely small irradiance + vmp = np.clip(vmp, a_min=0, a_max=None) + voc = np.clip(voc, a_min=0, a_max=None) + + out = { + 'p_mp': vmp * imp, + 'i_mp': imp, + 'v_mp': vmp, + 'i_sc': isc, + 'v_oc': voc, + } + + # if pandas in, ensure pandas out + pandas_inputs = [ + x for x in [effective_irradiance, temp_cell] + if isinstance(x, pd.Series) + ] + if pandas_inputs: + out = pd.DataFrame(out, index=pandas_inputs[0].index) + + return out diff --git a/pvlib/singlediode.py b/pvlib/singlediode.py index 779467729d..f4fb98d956 100644 --- a/pvlib/singlediode.py +++ b/pvlib/singlediode.py @@ -3,6 +3,7 @@ """ import numpy as np +import pandas as pd from pvlib.tools import _golden_sect_DataFrame from scipy.optimize import brentq, newton @@ -975,3 +976,85 @@ def _pwr_optfcn(df, loc): df['resistance_shunt'], df['nNsVth']) return current * df[loc] + + +def batzelis(photocurrent, saturation_current, resistance_series, + resistance_shunt, nNsVth): + """ + Estimate maximum power, open-circuit, and short-circuit points from + single-diode equation parameters using Batzelis's method. + + This method is described in Section II.B of [1]_. + + Parameters + ---------- + photocurrent : numeric + Light-generated current. [A] + saturation_current : numeric + Diode saturation current. [A] + resistance_series : numeric + Series resistance. [Ohm] + resistance_shunt : numeric + Shunt resistance. [Ohm] + nNsVth : numeric + The product of the usual diode ideality factor (n, unitless), + number of cells in series (Ns), and cell thermal voltage at + specified effective irradiance and cell temperature. [V] + + Returns + ------- + dict + The returned dict-like object contains the keys/columns: + + * ``p_mp`` - power at maximum power point. [W] + * ``i_mp`` - current at maximum power point. [A] + * ``v_mp`` - voltage at maximum power point. [V] + * ``i_sc`` - short circuit current. [A] + * ``v_oc`` - open circuit voltage. [V] + + References + ---------- + .. [1] E. I. Batzelis, "Simple PV Performance Equations Theoretically Well + Founded on the Single-Diode Model," Journal of Photovoltaics vol. 7, + no. 5, pp. 1400-1409, Sep 2017, :doi:`10.1109/JPHOTOV.2017.2711431` + """ + # convenience variables + Iph = photocurrent + Is = saturation_current + Rsh = resistance_shunt + Rs = resistance_series + a = nNsVth + + # Eqs 3-4 + isc = Iph / (Rs / Rsh + 1) # manipulated to handle Rsh=np.inf correctly + with np.errstate(divide='ignore'): # zero Iph + voc = a * np.log(Iph / Is) + + # Eqs 5-8 + w = np.real(lambertw(np.e * Iph / Is)) + # vmp = (1 + Rs/Rsh) * a * (w - 1) - Rs * Iph * (1 - 1/w) # not needed + with np.errstate(divide='ignore', invalid='ignore'): # zero Iph -> zero w + imp = Iph * (1 - 1/w) - a * (w - 1) / Rsh + + vmp = a * (w - 1) - Rs * imp + + vmp = np.where(Iph > 0, vmp, 0) + voc = np.where(Iph > 0, voc, 0) + imp = np.where(Iph > 0, imp, 0) + isc = np.where(Iph > 0, isc, 0) + + out = { + 'p_mp': imp * vmp, + 'i_mp': imp, + 'v_mp': vmp, + 'i_sc': isc, + 'v_oc': voc, + } + + # if pandas in, ensure pandas out + pandas_inputs = [ + x for x in [Iph, Is, Rsh, Rs, a] if isinstance(x, pd.Series)] + if pandas_inputs: + out = pd.DataFrame(out, index=pandas_inputs[0].index) + + return out diff --git a/tests/ivtools/sdm/test_desoto.py b/tests/ivtools/sdm/test_desoto.py index b861b26819..710bf15d51 100644 --- a/tests/ivtools/sdm/test_desoto.py +++ b/tests/ivtools/sdm/test_desoto.py @@ -92,3 +92,27 @@ def test_fit_desoto_sandia(cec_params_cansol_cs5p_220p): assert_allclose(result['dEgdT'], -0.0002677) assert_allclose(result['EgRef'], 1.3112547292120638) assert_allclose(result['cells_in_series'], specs['cells_in_series']) + + +def test_fit_desoto_batzelis(): + params = {'i_sc': 15.98, 'v_oc': 50.26, 'i_mp': 15.27, 'v_mp': 42.57, + 'alpha_sc': 0.007351, 'beta_voc': -0.120624} + expected = { # calculated with the function itself + 'alpha_sc': 0.007351, + 'a_ref': 1.7257632483733483, + 'I_L_ref': 15.985408866796396, + 'I_o_ref': 3.594308384705643e-12, + 'R_sh_ref': 389.4379947026243, + 'R_s': 0.13181590981241956, + } + out = sdm.fit_desoto_batzelis(**params) + for k in expected: + assert out[k] == pytest.approx(expected[k]) + + # ensure the STC values are reproduced + iv = pvsystem.singlediode(out['I_L_ref'], out['I_o_ref'], out['R_s'], + out['R_sh_ref'], out['a_ref']) + assert iv['i_sc'] == pytest.approx(params['i_sc']) + assert iv['i_mp'] == pytest.approx(params['i_mp'], rel=3e-3) + assert iv['v_oc'] == pytest.approx(params['v_oc'], rel=3e-4) + assert iv['v_mp'] == pytest.approx(params['v_mp'], rel=4e-3) diff --git a/tests/test_pvarray.py b/tests/test_pvarray.py index a1fc1c4ac3..e614f885db 100644 --- a/tests/test_pvarray.py +++ b/tests/test_pvarray.py @@ -1,4 +1,5 @@ import numpy as np +from numpy import nan import pandas as pd from numpy.testing import assert_allclose from .conftest import assert_series_equal @@ -114,3 +115,41 @@ def test_huld_errors(): pvarray.huld( eff_irr, temp_mod, pdc0, cell_type='csi', k_version='2021' ) + + +def test_batzelis(): + params = {'i_sc': 15.98, 'v_oc': 50.26, 'i_mp': 15.27, 'v_mp': 42.57, + 'alpha_sc': 0.007351, 'beta_voc': -0.120624} + g = np.array([1000, 500, 1200, 500, 1200, 0, nan, 1000]) + t = np.array([25, 20, 20, 50, 50, 25, 0, nan]) + expected = { # these values were computed using pvarray.batzelis itself + 'p_mp': [650.044, 328.599, 789.136, 300.079, 723.401, 0, nan, nan], + 'i_mp': [ 15.270, 7.626, 18.302, 7.680, 18.433, 0, nan, nan], + 'v_mp': [ 42.570, 43.090, 43.117, 39.071, 39.246, 0, nan, nan], + 'i_sc': [ 15.980, 7.972, 19.132, 8.082, 19.397, 0, nan, nan], + 'v_oc': [ 50.260, 49.687, 51.172, 45.948, 47.585, 0, nan, nan], + } + + # numpy array + actual = pvarray.batzelis(g, t, **params) + for key, exp in expected.items(): + np.testing.assert_allclose(actual[key], exp, atol=1e-3) + + # pandas series + actual = pvarray.batzelis(pd.Series(g), pd.Series(t), **params) + assert isinstance(actual, pd.DataFrame) + for key, exp in expected.items(): + np.testing.assert_allclose(actual[key], pd.Series(exp), atol=1e-3) + + # scalar + actual = pvarray.batzelis(g[1], t[1], **params) + for key, exp in expected.items(): + assert pytest.approx(exp[1], abs=1e-3) == actual[key] + + +def test_batzelis_negative_voltage(): + params = {'i_sc': 15.98, 'v_oc': 50.26, 'i_mp': 15.27, 'v_mp': 42.57, + 'alpha_sc': 0.007351, 'beta_voc': -0.120624} + actual = pvarray.batzelis(1e-10, 25, **params) + assert actual['v_mp'] == 0 + assert actual['v_oc'] == 0 diff --git a/tests/test_singlediode.py b/tests/test_singlediode.py index ca30bc0e2b..10b589b1b1 100644 --- a/tests/test_singlediode.py +++ b/tests/test_singlediode.py @@ -7,7 +7,8 @@ import scipy from pvlib import pvsystem from pvlib.singlediode import (bishop88_mpp, estimate_voc, VOLTAGE_BUILTIN, - bishop88, bishop88_i_from_v, bishop88_v_from_i) + bishop88, bishop88_i_from_v, bishop88_v_from_i, + batzelis) import pytest from numpy.testing import assert_array_equal from .conftest import TESTS_DATA_DIR @@ -617,9 +618,60 @@ def test_bishop88_init_cond(method): NsVbi=NsVbi)) bad_results = np.isnan(vmp2) | (vmp2 < 0) | (err > 0.00001) assert not bad_results.any() - # test v_from_i + # test i_from_v 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() + + +def test_batzelis(): + params = {'photocurrent': 10, 'saturation_current': 1e-10, + 'resistance_series': 0.2, 'resistance_shunt': 3000, + 'nNsVth': 1.7} + + exact_values = { # calculated using pvlib.pvsystem.singlediode + 'i_sc': 9.999333377550565, + 'v_oc': 43.05589965219406, + 'i_mp': 9.513255314772051, + 'v_mp': 35.97259289596944, + 'p_mp': 342.21646055371264, + } + rtol = 5e-3 # accurate to within half a percent in this case + + output = batzelis(**params) + for key in exact_values: + assert output[key] == pytest.approx(exact_values[key], rel=rtol) + + # numpy arrays + params2 = {k: np.array([v] * 2) for k, v in params.items()} + output2 = batzelis(**params2) + for key in exact_values: + exp = np.array([exact_values[key]] * 2) + np.testing.assert_allclose(output2[key], exp, rtol=rtol) + + # pandas + params3 = {k: pd.Series(v) for k, v in params2.items()} + output3 = batzelis(**params3) + assert isinstance(output3, pd.DataFrame) + for key in exact_values: + exp = pd.Series([exact_values[key]] * 2) + np.testing.assert_allclose(output3[key], exp, rtol=rtol) + + +def test_batzelis_night(): + # The De Soto SDM produces photocurrent=0 and resistance_shunt=inf + # at 0 W/m2 irradiance + out = batzelis(photocurrent=0, saturation_current=1e-10, + resistance_series=0.2, resistance_shunt=np.inf, + nNsVth=1.7) + for k, v in out.items(): + assert v == 0, k # ensure all outputs are zero (not nan, etc) + + # test also when Rsh=inf but Iph > 0 + out = batzelis(photocurrent=0.1, saturation_current=1e-10, + resistance_series=0.2, resistance_shunt=np.inf, + nNsVth=1.7) + for k, v in out.items(): + assert v > 0, k # ensure all outputs >0 (not nan, etc) From b70fb0f70d0a1a7cb03c38dc32c97b8cb05823ab Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 3 Nov 2025 15:17:04 -0500 Subject: [PATCH 17/34] Add function for accessing ERA5 (#2573) * function, docs, tests * make api key secret accessible to tests * bit more docs * handle another API error * lint * fix test * fix tests, again * one more * use Timeout instead of Exception * Apply suggestions from code review Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> * rename from ECMWF to ERA5 * and fix tests * make unit conversion funcs private * Apply suggestions from code review Co-authored-by: Anton Driesse * convert input times to UTC if not localized * lint * fix test bug --------- Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> Co-authored-by: Anton Driesse --- docs/sphinx/source/reference/iotools.rst | 11 ++ docs/sphinx/source/whatsnew/v0.13.2.rst | 2 + pvlib/iotools/__init__.py | 1 + pvlib/iotools/era5.py | 205 +++++++++++++++++++++++ tests/conftest.py | 13 ++ tests/iotools/test_era5.py | 94 +++++++++++ 6 files changed, 326 insertions(+) create mode 100644 pvlib/iotools/era5.py create mode 100644 tests/iotools/test_era5.py diff --git a/docs/sphinx/source/reference/iotools.rst b/docs/sphinx/source/reference/iotools.rst index 70a727b7bb..15bf4103dc 100644 --- a/docs/sphinx/source/reference/iotools.rst +++ b/docs/sphinx/source/reference/iotools.rst @@ -234,6 +234,17 @@ lower quality. iotools.read_crn +ECMWF ERA5 +^^^^^^^^^^ + +A global reanalysis dataset providing weather and solar resource data. + +.. autosummary:: + :toctree: generated/ + + iotools.get_era5 + + Generic data file readers ------------------------- diff --git a/docs/sphinx/source/whatsnew/v0.13.2.rst b/docs/sphinx/source/whatsnew/v0.13.2.rst index 30460158f3..d4cd10b5e7 100644 --- a/docs/sphinx/source/whatsnew/v0.13.2.rst +++ b/docs/sphinx/source/whatsnew/v0.13.2.rst @@ -39,6 +39,8 @@ Enhancements :py:func:`~pvlib.singlediode.bishop88_i_from_v`. (:issue:`2497`, :pull:`2498`) * Accelerate :py:func:`~pvlib.pvsystem.singlediode` when scipy>=1.15 is installed. (:issue:`2497`, :pull:`2571`) +* Add :py:func:`~pvlib.iotools.get_era5`, a function for accessing + ERA5 reanalysis data. (:pull:`2573`) Documentation diff --git a/pvlib/iotools/__init__.py b/pvlib/iotools/__init__.py index 56bbe2cffb..6a57daad8d 100644 --- a/pvlib/iotools/__init__.py +++ b/pvlib/iotools/__init__.py @@ -42,3 +42,4 @@ from pvlib.iotools.meteonorm import get_meteonorm_observation_training # noqa: F401, E501 from pvlib.iotools.meteonorm import get_meteonorm_tmy # noqa: F401 from pvlib.iotools.nasa_power import get_nasa_power # noqa: F401 +from pvlib.iotools.era5 import get_era5 # noqa: F401 diff --git a/pvlib/iotools/era5.py b/pvlib/iotools/era5.py new file mode 100644 index 0000000000..601800f904 --- /dev/null +++ b/pvlib/iotools/era5.py @@ -0,0 +1,205 @@ +import requests +import pandas as pd +from io import BytesIO, StringIO +import zipfile +import time + + +VARIABLE_MAP = { + # short names + 'd2m': 'temp_dew', + 't2m': 'temp_air', + 'sp': 'pressure', + 'ssrd': 'ghi', + 'tp': 'precipitation', + + # long names + '2m_dewpoint_temperature': 'temp_dew', + '2m_temperature': 'temp_air', + 'surface_pressure': 'pressure', + 'surface_solar_radiation_downwards': 'ghi', + 'total_precipitation': 'precipitation', +} + + +def _same(x): + return x + + +def _k_to_c(temp_k): + return temp_k - 273.15 + + +def _j_to_w(j): + return j / 3600 + + +def _m_to_cm(m): + return m / 100 + + +UNITS = { + 'u100': _same, + 'v100': _same, + 'u10': _same, + 'v10': _same, + 'd2m': _k_to_c, + 't2m': _k_to_c, + 'msl': _same, + 'sst': _k_to_c, + 'skt': _k_to_c, + 'sp': _same, + 'ssrd': _j_to_w, + 'strd': _j_to_w, + 'tp': _m_to_cm, +} + + +def get_era5(latitude, longitude, start, end, variables, api_key, + map_variables=True, timeout=60, + url='https://cds.climate.copernicus.eu/api/retrieve/v1/'): + """ + Retrieve ERA5 reanalysis data from the ECMWF's Copernicus Data Store. + + A CDS API key is needed to access this API. Register for one at [1]_. + + This API [2]_ provides a subset of the full ERA5 dataset. See [3]_ for + the available variables. Data are available on a 0.25° x 0.25° grid. + + Parameters + ---------- + latitude : float + In decimal degrees, north is positive (ISO 19115). + longitude: float + In decimal degrees, east is positive (ISO 19115). + start : datetime like or str + First day of the requested period. Assumed to be UTC if not localized. + end : datetime like or str + Last day of the requested period. Assumed to be UTC if not localized. + variables : list of str + List of variable names to retrieve, for example + ``['ghi', 'temp_air']``. Both pvlib and ERA5 names can be used. + See [1]_ for additional options. + api_key : str + ECMWF CDS API key. + map_variables : bool, default True + When true, renames columns of the DataFrame to pvlib variable names + where applicable. Also converts units of some variables. See variable + :const:`VARIABLE_MAP` and :const:`UNITS`. + timeout : int, default 60 + Number of seconds to wait for the requested data to become available + before timeout. + url : str, optional + API endpoint URL. + + Raises + ------ + Exception + If ``timeout`` is reached without the job finishing. + + Returns + ------- + data : pd.DataFrame + Time series data. The index corresponds to the start of the interval. + meta : dict + Metadata. + + References + ---------- + .. [1] https://cds.climate.copernicus.eu/ + .. [2] https://cds.climate.copernicus.eu/datasets/reanalysis-era5-single-levels-timeseries?tab=overview + .. [3] https://confluence.ecmwf.int/pages/viewpage.action?pageId=505390919 + """ # noqa: E501 + + def _to_utc_dt_notz(dt): + dt = pd.to_datetime(dt) + if dt.tzinfo is not None: + dt = dt.tz_convert("UTC") + return dt + + start = _to_utc_dt_notz(start).strftime("%Y-%m-%d") + end = _to_utc_dt_notz(end).strftime("%Y-%m-%d") + + headers = {'PRIVATE-TOKEN': api_key} + + # allow variables to be specified with pvlib names + reverse_map = {v: k for k, v in VARIABLE_MAP.items()} + variables = [reverse_map.get(k, k) for k in variables] + + # Step 1: submit data request (add it to the queue) + params = { + "inputs": { + "variable": variables, + "location": {"longitude": longitude, "latitude": latitude}, + "date": [f"{start}/{end}"], + "data_format": "csv" + } + } + slug = "processes/reanalysis-era5-single-levels-timeseries/execution" + response = requests.post(url + slug, json=params, headers=headers, + timeout=timeout) + submission_response = response.json() + if not response.ok: + raise Exception(submission_response) # likely need to accept license + + job_id = submission_response['jobID'] + + # Step 2: poll until the data request is ready + slug = "jobs/" + job_id + poll_interval = 1 + num_polls = 0 + while True: + response = requests.get(url + slug, headers=headers, timeout=timeout) + poll_response = response.json() + job_status = poll_response['status'] + + if job_status == 'successful': + break # ready to proceed to next step + elif job_status == 'failed': + msg = ( + 'Request failed. Please check the ECMWF website for details: ' + 'https://cds.climate.copernicus.eu/requests?tab=all' + ) + raise Exception(msg) + + num_polls += 1 + if num_polls * poll_interval > timeout: + raise requests.exceptions.Timeout( + 'Request timed out. Try increasing the timeout parameter or ' + 'reducing the request size.' + ) + + time.sleep(1) + + # Step 3: get the download link for our requested dataset + slug = "jobs/" + job_id + "/results" + response = requests.get(url + slug, headers=headers, timeout=timeout) + results_response = response.json() + download_url = results_response['asset']['value']['href'] + + # Step 4: finally, download our dataset. it's a zipfile of one CSV + response = requests.get(download_url, timeout=timeout) + zipbuffer = BytesIO(response.content) + archive = zipfile.ZipFile(zipbuffer) + filename = archive.filelist[0].filename + csvbuffer = StringIO(archive.read(filename).decode('utf-8')) + df = pd.read_csv(csvbuffer) + + # and parse into the usual formats + metadata = submission_response['metadata'] # include messages from ECMWF + metadata['jobID'] = job_id + if not df.empty: + metadata['latitude'] = df['latitude'].values[0] + metadata['longitude'] = df['longitude'].values[0] + + df.index = pd.to_datetime(df['valid_time']).dt.tz_localize('UTC') + df = df.drop(columns=['valid_time', 'latitude', 'longitude']) + + if map_variables: + # convert units and rename + for shortname in df.columns: + converter = UNITS.get(shortname, _same) + df[shortname] = converter(df[shortname]) + df = df.rename(columns=VARIABLE_MAP) + + return df, metadata diff --git a/tests/conftest.py b/tests/conftest.py index 0dc957751b..6207001c0a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -130,6 +130,19 @@ def nrel_api_key(): reason='requires solaranywhere credentials') +try: + # Attempt to load ECMWF API key used for testing + # pvlib.iotools.get_era5 + ecwmf_api_key = os.environ["ECMWF_API_KEY"] + has_ecmwf_credentials = True +except KeyError: + has_ecmwf_credentials = False + +requires_ecmwf_credentials = pytest.mark.skipif( + not has_ecmwf_credentials, + reason='requires ECMWF credentials') + + try: import statsmodels # noqa: F401 has_statsmodels = True diff --git a/tests/iotools/test_era5.py b/tests/iotools/test_era5.py new file mode 100644 index 0000000000..bea2fe2ee1 --- /dev/null +++ b/tests/iotools/test_era5.py @@ -0,0 +1,94 @@ +""" +tests for pvlib/iotools/era5.py +""" + +import pandas as pd +import pytest +import pvlib +import requests +import os +from tests.conftest import RERUNS, RERUNS_DELAY, requires_ecmwf_credentials + + +@pytest.fixture +def params(): + api_key = os.environ["ECMWF_API_KEY"] + + return { + 'latitude': 40.01, 'longitude': -80.01, + 'start': '2020-06-01', 'end': '2020-06-01', + 'variables': ['ghi', 'temp_air'], + 'api_key': api_key, + } + + +@pytest.fixture +def expected(): + index = pd.date_range("2020-06-01 00:00", "2020-06-01 23:59", freq="h", + tz="UTC") + index.name = 'valid_time' + temp_air = [16.6, 15.2, 13.5, 11.2, 10.8, 9.1, 7.3, 6.8, 7.6, 7.4, 8.5, + 8.1, 9.8, 11.5, 14.1, 17.4, 18.3, 20., 20.7, 20.9, 21.5, + 21.6, 21., 20.7] + ghi = [153., 18.4, 0., 0., 0., 0., 0., 0., 0., 0., 0., 60., 229.5, + 427.8, 620.1, 785.5, 910.1, 984.2, 1005.9, 962.4, 844.1, 685.2, + 526.9, 331.4] + df = pd.DataFrame({'temp_air': temp_air, 'ghi': ghi}, index=index) + return df + + +@requires_ecmwf_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_era5(params, expected): + df, meta = pvlib.iotools.get_era5(**params) + pd.testing.assert_frame_equal(df, expected, check_freq=False, atol=0.1) + assert meta['longitude'] == -80.0 + assert meta['latitude'] == 40.0 + assert isinstance(meta['jobID'], str) + + +@requires_ecmwf_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_era5_timezone(params, expected): + params['start'] = pd.to_datetime(params['start']).tz_localize('Etc/GMT+8') + params['end'] = pd.to_datetime(params['end']).tz_localize('Etc/GMT+8') + df, meta = pvlib.iotools.get_era5(**params) + pd.testing.assert_frame_equal(df, expected, check_freq=False, atol=0.1) + assert meta['longitude'] == -80.0 + assert meta['latitude'] == 40.0 + assert isinstance(meta['jobID'], str) + + +@requires_ecmwf_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_era5_map_variables(params, expected): + df, meta = pvlib.iotools.get_era5(**params, map_variables=False) + expected = expected.rename(columns={'temp_air': 't2m', 'ghi': 'ssrd'}) + df['t2m'] -= 273.15 # apply unit conversions manually + df['ssrd'] /= 3600 + pd.testing.assert_frame_equal(df, expected, check_freq=False, atol=0.1) + assert meta['longitude'] == -80.0 + assert meta['latitude'] == 40.0 + assert isinstance(meta['jobID'], str) + + +@requires_ecmwf_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_era5_error(params): + params['variables'] = ['nonexistent'] + match = 'Request failed. Please check the ECMWF website' + with pytest.raises(Exception, match=match): + df, meta = pvlib.iotools.get_era5(**params) + + +@requires_ecmwf_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_era5_timeout(params): + match = 'Request timed out. Try increasing' + with pytest.raises(requests.exceptions.Timeout, match=match): + df, meta = pvlib.iotools.get_era5(**params, timeout=1) From 1e3dc5f89ccc385dfcdb4f0442412e24efd15cb8 Mon Sep 17 00:00:00 2001 From: Will Hobbs <45701090+williamhobbs@users.noreply.github.com> Date: Fri, 7 Nov 2025 16:09:56 -0600 Subject: [PATCH 18/34] add marion's adjustment to pvwatts_dc (#2569) * add marion's adjustment * linting * Update pvlib/pvsystem.py Co-authored-by: Cliff Hansen * Update pvlib/pvsystem.py Co-authored-by: Cliff Hansen * Update pvlib/pvsystem.py Co-authored-by: Cliff Hansen * Update pvlib/pvsystem.py Co-authored-by: Cliff Hansen * Update pvlib/pvsystem.py Co-authored-by: Cliff Hansen * more suggested changes * Update pvlib/pvsystem.py doi sphinx Co-authored-by: RDaxini <143435106+RDaxini@users.noreply.github.com> * Update pvlib/pvsystem.py Co-authored-by: RDaxini <143435106+RDaxini@users.noreply.github.com> * Update pvlib/pvsystem.py Co-authored-by: RDaxini <143435106+RDaxini@users.noreply.github.com> * cleaning up docs * Update pvlib/pvsystem.py Co-authored-by: Will Holmgren * prevent negative power * fix typo in error eqn * fix the right typo this time... * reorder err eqns * add more detail on `cap_adjustment` * return same object type that was input * fix issue with scalars * another fix * add tests * increase test coverage * more test coverage * fix shallow copy issue * Update pvlib/pvsystem.py Co-authored-by: RDaxini <143435106+RDaxini@users.noreply.github.com> * Update pvlib/pvsystem.py Co-authored-by: RDaxini <143435106+RDaxini@users.noreply.github.com> * Update pvlib/pvsystem.py Co-authored-by: RDaxini <143435106+RDaxini@users.noreply.github.com> * update whatsnew * reorganize to clean it up * single comp path on numpy array * added some comments * unit formatting * call keyword arguments as keyword arguments * quick fix * a few more missed keyword args * simplify with_k_and_cap_adjustmen * Update pvlib/pvsystem.py Co-authored-by: Cliff Hansen * Update pvlib/pvsystem.py Co-authored-by: Cliff Hansen * Update pvlib/pvsystem.py Co-authored-by: Cliff Hansen * doc formatting/linting --------- Co-authored-by: Cliff Hansen Co-authored-by: RDaxini <143435106+RDaxini@users.noreply.github.com> Co-authored-by: Will Holmgren --- docs/sphinx/source/whatsnew/v0.13.2.rst | 4 +- pvlib/pvsystem.py | 107 +++++++++++++++++++----- tests/test_pvsystem.py | 48 +++++++++++ 3 files changed, 137 insertions(+), 22 deletions(-) diff --git a/docs/sphinx/source/whatsnew/v0.13.2.rst b/docs/sphinx/source/whatsnew/v0.13.2.rst index d4cd10b5e7..baac834500 100644 --- a/docs/sphinx/source/whatsnew/v0.13.2.rst +++ b/docs/sphinx/source/whatsnew/v0.13.2.rst @@ -37,12 +37,13 @@ Enhancements :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`) +* Add Marion 2008 non-linear irradiance adjustment factor to + :py:func:`pvlib.pvsystem.pvwatts_dc`. (:issue:`2566`, :pull:`2569`) * Accelerate :py:func:`~pvlib.pvsystem.singlediode` when scipy>=1.15 is installed. (:issue:`2497`, :pull:`2571`) * Add :py:func:`~pvlib.iotools.get_era5`, a function for accessing ERA5 reanalysis data. (:pull:`2573`) - Documentation ~~~~~~~~~~~~~ * Provide an overview of single-diode modeling functionality in :ref:`singlediode`. (:pull:`2565`) @@ -66,4 +67,5 @@ Maintenance Contributors ~~~~~~~~~~~~ +* Will Hobbs (:ghuser:`williamhobbs`) * Cliff Hansen (:ghuser:`cwhanse`) diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index edad38b412..4c8eab0b11 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -2886,53 +2886,118 @@ def scale_voltage_current_power(data, voltage=1, current=1): @renamed_kwarg_warning( "0.13.0", "g_poa_effective", "effective_irradiance") -def pvwatts_dc(effective_irradiance, temp_cell, pdc0, gamma_pdc, temp_ref=25.): +def pvwatts_dc(effective_irradiance, temp_cell, pdc0, gamma_pdc, temp_ref=25., + k=None, cap_adjustment=False): r""" - Implements NREL's PVWatts DC power model. The PVWatts DC model [1]_ is: - - .. math:: - - P_{dc} = \frac{G_{poa eff}}{1000} P_{dc0} ( 1 + \gamma_{pdc} (T_{cell} - T_{ref})) - - Note that ``pdc0`` is also used as a symbol in - :py:func:`pvlib.inverter.pvwatts`. ``pdc0`` in this function refers to the DC - power of the modules at reference conditions. ``pdc0`` in - :py:func:`pvlib.inverter.pvwatts` refers to the DC power input limit of - the inverter. + Implement NREL's PVWatts (Version 5) DC power model. Parameters ---------- effective_irradiance: numeric - Irradiance transmitted to the PV cells. To be - fully consistent with PVWatts, the user must have already - applied angle of incidence losses, but not soiling, spectral, - etc. [W/m^2] + Irradiance transmitted to the PV cells. To be fully consistent with + PVWatts, the user must have already applied angle of incidence losses, + but not soiling, spectral, etc. [Wm⁻²] temp_cell: numeric Cell temperature [C]. pdc0: numeric - Power of the modules at 1000 W/m^2 and cell reference temperature. [W] + Power of the modules at 1000 Wm⁻² and cell reference temperature. [W] gamma_pdc: numeric - The temperature coefficient of power. Typically -0.002 to - -0.005 per degree C. [1/C] + The temperature coefficient of power. Typically -0.002 to -0.005 per + degree C. [1/°C] temp_ref: numeric, default 25.0 - Cell reference temperature. PVWatts defines it to be 25 C and - is included here for flexibility. [C] + Cell reference temperature. PVWatts defines it to be 25 °C and is + included here for flexibility. [°C] + k: numeric, optional + Irradiance correction factor, defined in [2]_. Typically positive. + [unitless] + cap_adjustment: Boolean, default False + If True, only apply the optional adjustment at and below 1000 Wm⁻² Returns ------- pdc: numeric DC power. [W] + Notes + ----- + The PVWatts Version 5 DC model [1]_ is: + + .. math:: + + P_{dc} = \frac{G_{poa eff}}{1000} P_{dc0} ( 1 + \gamma_{pdc} (T_{cell} - T_{ref})) + + This model has also been referred to as the power temperature coefficient + model. + + An optional adjustment can be applied to :math:`P_{dc}` as described in + [2]_. The adjustment accounts for the variation in module efficiency with + irradiance. The piece-wise adjustment to power is parameterized by `k`, + where `k` is the reduction in actual power at 200 Wm⁻² relative to power + calculated at 200 Wm⁻² as 0.2*`pdc0`. For example, a module that is rated + at 500 W at STC but produces 95 W at 200 Wm⁻² (a 5% relative reduction in + efficiency) would have a value of `k` = 0.01. + + .. math:: + + k=\frac{0.2P_{dc0}-P_{200}}{P_{dc0}} + + For positive `k` values, and `k` is typically positive, this adjustment + would also increase relative efficiency when irradiance is above 1000 Wm⁻². + This may not be desired, as modules with nonlinear irradiance response + often have peak efficiency near 1000 Wm⁻², and it is either flat or + declining at higher irradiance. An optional parameter, `cap_adjustment`, + can address this by modifying the adjustment from [2]_ to only apply below + 1000 Wm⁻². + + Note that ``pdc0`` is also used as a symbol in + :py:func:`pvlib.inverter.pvwatts`. ``pdc0`` in this function refers to the + DC power of the modules at reference conditions. ``pdc0`` in + :py:func:`pvlib.inverter.pvwatts` refers to the DC power input limit of + the inverter. + References ---------- .. [1] A. P. Dobos, "PVWatts Version 5 Manual" http://pvwatts.nrel.gov/downloads/pvwattsv5.pdf (2014). + .. [2] B. Marion, "Comparison of Predictive Models for + Photovoltaic Module Performance," + :doi:`10.1109/PVSC.2008.4922586`, + https://docs.nrel.gov/docs/fy08osti/42511.pdf + (2008). """ # noqa: E501 pdc = (effective_irradiance * 0.001 * pdc0 * (1 + gamma_pdc * (temp_cell - temp_ref))) + # apply Marion's correction if k is provided + if k is not None: + + # preserve input types + index = pdc.index if isinstance(pdc, pd.Series) else None + is_scalar = np.isscalar(pdc) + + # calculate error adjustments + err_1 = k * (1 - (1 - effective_irradiance / 200)**4) + err_2 = k * (1000 - effective_irradiance) / (1000 - 200) + err = np.where(effective_irradiance <= 200, err_1, err_2) + + # cap adjustment, if needed + if cap_adjustment: + err = np.where(effective_irradiance >= 1000, 0, err) + + # make error adjustment + pdc = pdc - pdc0 * err + + # set negative power to zero + pdc = np.where(pdc < 0, 0, pdc) + + # preserve input types + if index is not None: + pdc = pd.Series(pdc, index=index) + elif is_scalar: + pdc = float(pdc) + return pdc diff --git a/tests/test_pvsystem.py b/tests/test_pvsystem.py index b7d8ba6173..4fbd782e65 100644 --- a/tests/test_pvsystem.py +++ b/tests/test_pvsystem.py @@ -2181,6 +2181,54 @@ def test_pvwatts_dc_series(): assert_series_equal(expected, out) +def test_pvwatts_dc_scalars_with_k(): + expected = 8.9125 + out = pvsystem.pvwatts_dc(100, 30, 100, -0.003, k=0.01) + assert_allclose(out, expected) + + +def test_pvwatts_dc_arrays_with_k(): + irrad_trans = np.array([np.nan, 100, 1200]) + temp_cell = np.array([30, np.nan, 30]) + irrad_trans, temp_cell = np.meshgrid(irrad_trans, temp_cell) + expected = np.array([[nan, 8.9125, 118.45], + [nan, nan, nan], + [nan, 8.9125, 118.45]]) + out = pvsystem.pvwatts_dc(irrad_trans, temp_cell, 100, -0.003, k=0.01) + assert_allclose(out, expected, equal_nan=True) + + +def test_pvwatts_dc_series_with_k(): + irrad_trans = pd.Series([np.nan, 100, 100, 1200]) + temp_cell = pd.Series([30, np.nan, 30, 30]) + expected = pd.Series(np.array([ nan, nan, 8.9125, 118.45])) + out = pvsystem.pvwatts_dc(irrad_trans, temp_cell, 100, -0.003, k=0.01) + assert_series_equal(expected, out) + + +def test_pvwatts_dc_with_k_and_cap_adjustment(): + irrad_trans = [100, 1200] + temp_cell = 25 + out = [] + expected = [0, 120.0] + for irrad in irrad_trans: + out.append(pvsystem.pvwatts_dc(irrad, temp_cell, 100, -0.003, k=0.15, + cap_adjustment=True)) + assert_allclose(out, expected) + + +def test_pvwatts_dc_arrays_with_k_and_cap_adjustment(): + irrad_trans = np.array([np.nan, 100, 1200]) + temp_cell = np.array([30, np.nan, 30]) + irrad_trans, temp_cell = np.meshgrid(irrad_trans, temp_cell) + expected = np.array([[nan, 8.9125, 118.2], + [nan, nan, nan], + [nan, 8.9125, 118.2]]) + out = pvsystem.pvwatts_dc(irrad_trans, temp_cell, 100, -0.003, k=0.01, + cap_adjustment=True) + assert_allclose(out, expected, equal_nan=True) + + def test_pvwatts_losses_default(): expected = 14.075660688264469 out = pvsystem.pvwatts_losses() From eaac4f8eaaf7f90ce5f9ec16c3bc087ecc588f09 Mon Sep 17 00:00:00 2001 From: Joseph Radford Date: Thu, 13 Nov 2025 01:35:37 +1100 Subject: [PATCH 19/34] Remove `scripts` from source code distribution (#2593) Move top ranking script to .github/workflows Update path to script in corresponding action Fix #2520 --- .github/workflows/top-ranked-issues.yml | 2 +- {scripts => .github/workflows}/update_top_ranking_issues.py | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename {scripts => .github/workflows}/update_top_ranking_issues.py (100%) diff --git a/.github/workflows/top-ranked-issues.yml b/.github/workflows/top-ranked-issues.yml index 8b4ff30881..5845691d34 100644 --- a/.github/workflows/top-ranked-issues.yml +++ b/.github/workflows/top-ranked-issues.yml @@ -31,4 +31,4 @@ jobs: - name: Run update_top_ranking_issues.py run: | - python ./scripts/update_top_ranking_issues.py + python .github/workflows/update_top_ranking_issues.py diff --git a/scripts/update_top_ranking_issues.py b/.github/workflows/update_top_ranking_issues.py similarity index 100% rename from scripts/update_top_ranking_issues.py rename to .github/workflows/update_top_ranking_issues.py From 65b68100fad21a8eb7a12c040c7dfc35e822e10a Mon Sep 17 00:00:00 2001 From: Joseph Radford Date: Thu, 13 Nov 2025 02:10:22 +1100 Subject: [PATCH 20/34] Bugfix/atmos refract typo (#2592) * Prevent atmos_refract docstring typo regression Add tests to ensure parameter name is documented as 'atmos_refract' (not 'atmos_refrac') in spa_python and solar_position docstrings. Related to #2532 * Fix atmos_refract parameter name typo in docstrings Correct parameter name from 'atmos_refrac' to 'atmos_refract' in docstrings for solarposition.spa_python and spa.solar_position to match the actual function signatures. Update whatsnew to describe this change. Closes #2532 * Break long lines up in test files * Revert "Break long lines up in test files" This reverts commit 452876d1bb8090279b64aed630e6a680a072dc26. * Revert "Prevent atmos_refract docstring typo regression" This reverts commit 216db9e888eada077a806c49ec11a9353a975d2e. * Add PR number and contributer name to whatsnew --- docs/sphinx/source/whatsnew/v0.13.2.rst | 3 +++ pvlib/solarposition.py | 2 +- pvlib/spa.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/sphinx/source/whatsnew/v0.13.2.rst b/docs/sphinx/source/whatsnew/v0.13.2.rst index baac834500..3f9827ad2d 100644 --- a/docs/sphinx/source/whatsnew/v0.13.2.rst +++ b/docs/sphinx/source/whatsnew/v0.13.2.rst @@ -47,6 +47,8 @@ Enhancements Documentation ~~~~~~~~~~~~~ * Provide an overview of single-diode modeling functionality in :ref:`singlediode`. (:pull:`2565`) +* Fix typo in parameter name ``atmos_refract`` in :py:func:`pvlib.solarposition.spa_python` + and :py:func:`pvlib.spa.solar_position`. (:issue:`2532`, :pull:`2592`) Testing @@ -69,3 +71,4 @@ Contributors ~~~~~~~~~~~~ * Will Hobbs (:ghuser:`williamhobbs`) * Cliff Hansen (:ghuser:`cwhanse`) +* Joseph Radford (:ghuser:`josephradford`) diff --git a/pvlib/solarposition.py b/pvlib/solarposition.py index ba861c9d82..2b8a43e9d0 100644 --- a/pvlib/solarposition.py +++ b/pvlib/solarposition.py @@ -314,7 +314,7 @@ def spa_python(time, latitude, longitude, using time.year and time.month from pandas.DatetimeIndex. For most simulations the default delta_t is sufficient. The USNO has historical and forecasted delta_t [3]_. - atmos_refrac : float, optional + atmos_refract : float, optional The approximate atmospheric refraction (in degrees) at sunrise and sunset. how : str, optional, default 'numpy' diff --git a/pvlib/spa.py b/pvlib/spa.py index d4181aaa49..6297e88c35 100644 --- a/pvlib/spa.py +++ b/pvlib/spa.py @@ -1057,7 +1057,7 @@ def solar_position(unixtime, lat, lon, elev, pressure, temp, delta_t, degrees C; used for atmospheric correction delta_t : float or array Difference between terrestrial time and UT1. - atmos_refrac : float + atmos_refract : float The approximate atmospheric refraction (in degrees) at sunrise and sunset. numthreads: int, optional, default 8 From b16cecf86026234c80217e8becc5ecdb443b0b67 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 12 Nov 2025 17:05:28 -0500 Subject: [PATCH 21/34] use IEEE style for pvwatts v5 citation (#2595) --- pvlib/inverter.py | 4 ++-- pvlib/pvsystem.py | 20 +++++++++----------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/pvlib/inverter.py b/pvlib/inverter.py index 1dda69336c..a6a80863eb 100644 --- a/pvlib/inverter.py +++ b/pvlib/inverter.py @@ -388,8 +388,8 @@ def pvwatts(pdc, pdc0, eta_inv_nom=0.96, eta_inv_ref=0.9637): References ---------- - .. [1] A. P. Dobos, "PVWatts Version 5 Manual," - http://pvwatts.nrel.gov/downloads/pvwattsv5.pdf (2014). + .. [1] A. P. Dobos, "PVWatts Version 5 Manual", NREL, Golden, CO, USA, + Technical Report NREL/TP-6A20-62641, 2014, :doi:`10.2172/1158421`. """ pac0 = eta_inv_nom * pdc0 diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index 4c8eab0b11..fe85359b99 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -2957,14 +2957,13 @@ def pvwatts_dc(effective_irradiance, temp_cell, pdc0, gamma_pdc, temp_ref=25., References ---------- - .. [1] A. P. Dobos, "PVWatts Version 5 Manual" - http://pvwatts.nrel.gov/downloads/pvwattsv5.pdf - (2014). - .. [2] B. Marion, "Comparison of Predictive Models for - Photovoltaic Module Performance," - :doi:`10.1109/PVSC.2008.4922586`, - https://docs.nrel.gov/docs/fy08osti/42511.pdf - (2008). + .. [1] A. P. Dobos, "PVWatts Version 5 Manual", NREL, Golden, CO, USA, + Technical Report NREL/TP-6A20-62641, 2014, :doi:`10.2172/1158421`. + .. [2] B. Marion, "Comparison of Predictive Models for Photovoltaic + Module Performance," In Proc. 33rd IEEE Photovoltaic Specialists + Conference (PVSC), San Diego, CA, USA, 2008, pp. 1-6, + :doi:`10.1109/PVSC.2008.4922586`. + Pre-print: https://docs.nrel.gov/docs/fy08osti/42511.pdf """ # noqa: E501 pdc = (effective_irradiance * 0.001 * pdc0 * @@ -3036,9 +3035,8 @@ def pvwatts_losses(soiling=2, shading=3, snow=0, mismatch=2, wiring=2, References ---------- - .. [1] A. P. Dobos, "PVWatts Version 5 Manual" - http://pvwatts.nrel.gov/downloads/pvwattsv5.pdf - (2014). + .. [1] A. P. Dobos, "PVWatts Version 5 Manual", NREL, Golden, CO, USA, + Technical Report NREL/TP-6A20-62641, 2014, :doi:`10.2172/1158421`. """ params = [soiling, shading, snow, mismatch, wiring, connections, lid, From b24411c988d278c6bfdcd456613d7389c5f6cfc3 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 12 Nov 2025 17:09:47 -0500 Subject: [PATCH 22/34] Fix pytest config for new pytest 9.0 release (#2596) * adapt pytest config to work with pytest 9.0 * whatsnew --- docs/sphinx/source/whatsnew/v0.13.2.rst | 2 ++ pyproject.toml | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/sphinx/source/whatsnew/v0.13.2.rst b/docs/sphinx/source/whatsnew/v0.13.2.rst index 3f9827ad2d..754350d741 100644 --- a/docs/sphinx/source/whatsnew/v0.13.2.rst +++ b/docs/sphinx/source/whatsnew/v0.13.2.rst @@ -53,6 +53,8 @@ Documentation Testing ~~~~~~~ +* Update pytest configuration in ``pyproject.toml`` to work with pytest 9.0. + (:pull:`2596`) Benchmarking diff --git a/pyproject.toml b/pyproject.toml index 00ff4460aa..406551680d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,7 +99,9 @@ pvlib = ["data/*"] [tool.pytest] junit_family = "xunit2" -testpaths = "tests" +testpaths = [ + "tests" +] # warning messages to suppress from pytest output. useful in cases # where a dependency hasn't addressed a deprecation yet, and there's # nothing we can do to fix it ourselves. From 03fb34029fb48ff4e4b690998e672fa017c102c4 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Thu, 13 Nov 2025 13:00:59 -0500 Subject: [PATCH 23/34] Add python 3.14 to test matrix (#2590) * add python 3.14 to test matrix * nix numba * nix pysam --- .github/workflows/pytest-remote-data.yml | 2 +- .github/workflows/pytest.yml | 2 +- ci/requirements-py3.14.yml | 28 ++++++++++++++++++++++++ docs/sphinx/source/whatsnew/v0.13.2.rst | 1 + 4 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 ci/requirements-py3.14.yml diff --git a/.github/workflows/pytest-remote-data.yml b/.github/workflows/pytest-remote-data.yml index f98219c942..66fda9cdea 100644 --- a/.github/workflows/pytest-remote-data.yml +++ b/.github/workflows/pytest-remote-data.yml @@ -56,7 +56,7 @@ jobs: strategy: fail-fast: false # don't cancel other matrix jobs when one fails matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] suffix: [''] # the alternative to "-min" include: - python-version: "3.10" diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index c71cf7ed89..0008571d2b 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false # don't cancel other matrix jobs when one fails matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] environment-type: [conda, bare] suffix: [''] # placeholder as an alternative to "-min" include: diff --git a/ci/requirements-py3.14.yml b/ci/requirements-py3.14.yml new file mode 100644 index 0000000000..1b1501c66f --- /dev/null +++ b/ci/requirements-py3.14.yml @@ -0,0 +1,28 @@ +name: test_env +channels: + - defaults + - conda-forge +dependencies: + - coveralls + - cython + - ephem + - h5py + # - numba # not available for py 3.14 as of 2025-11-03 + - numpy >= 1.21.2 + - pandas >= 1.3.3 + - pip + - pytest + - pytest-cov + - pytest-mock + - requests-mock + - pytest-timeout + - pytest-rerunfailures + - conda-forge::pytest-remotedata # version in default channel is old + - python=3.14 + - pytz + - requests + - scipy >= 1.7.2 + - statsmodels + - pip: + # - nrel-pysam>=2.0 # not available for py 3.14 as of 2025-11-03 + - solarfactors diff --git a/docs/sphinx/source/whatsnew/v0.13.2.rst b/docs/sphinx/source/whatsnew/v0.13.2.rst index 754350d741..7d0f0e0747 100644 --- a/docs/sphinx/source/whatsnew/v0.13.2.rst +++ b/docs/sphinx/source/whatsnew/v0.13.2.rst @@ -53,6 +53,7 @@ Documentation Testing ~~~~~~~ +* Add Python 3.14 to test suite. (:pull:`2590`) * Update pytest configuration in ``pyproject.toml`` to work with pytest 9.0. (:pull:`2596`) From 4ef18d002aa18f5df7102871dedf747ad4a8bc00 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Mon, 1 Dec 2025 15:08:07 -0500 Subject: [PATCH 24/34] Pandas deprecations (#2600) * test_sodapro.py: use ME instead of M * acis.py: use mask instead of replace --- pvlib/iotools/acis.py | 4 ++-- tests/iotools/test_sodapro.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pvlib/iotools/acis.py b/pvlib/iotools/acis.py index 3be16cfa4c..634af07933 100644 --- a/pvlib/iotools/acis.py +++ b/pvlib/iotools/acis.py @@ -413,8 +413,8 @@ def get_acis_station_data(station, start, end, trace_val=0.001, 'climdiv,valid_daterange,tzo,network') } df, metadata = _get_acis(start, end, params, map_variables, url, **kwargs) - df = df.replace("M", np.nan) - df = df.replace("T", trace_val) + df = df.mask(df == 'M', np.nan) + df = df.mask(df == 'T', trace_val) df = df.astype(float) return df, metadata diff --git a/tests/iotools/test_sodapro.py b/tests/iotools/test_sodapro.py index 7105e9ac98..09f5ee38ea 100644 --- a/tests/iotools/test_sodapro.py +++ b/tests/iotools/test_sodapro.py @@ -21,7 +21,7 @@ index_verbose = pd.date_range('2020-06-01 12', periods=4, freq='1min', tz='UTC') -index_monthly = pd.date_range('2020-01-01', periods=4, freq='1M') +index_monthly = pd.date_range('2020-01-01', periods=4, freq='1ME') dtypes_mcclear_verbose = [ From 6c93b716c5762c2e2c2334926e3e3b832b324350 Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Mon, 1 Dec 2025 21:09:36 +0100 Subject: [PATCH 25/34] Remove unused parameter in _fuentes_hconv (#2605) --- pvlib/temperature.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/pvlib/temperature.py b/pvlib/temperature.py index 53920ee8ab..14d67a168e 100644 --- a/pvlib/temperature.py +++ b/pvlib/temperature.py @@ -713,8 +713,7 @@ def ross(poa_global, temp_air, noct=None, k=None): return temp_air + k * poa_global -def _fuentes_hconv(tave, windmod, tinoct, temp_delta, xlen, tilt, - check_reynold): +def _fuentes_hconv(tave, windmod, temp_delta, xlen, tilt, check_reynold): # Calculate the convective coefficient as in Fuentes 1987 -- a mixture of # free, laminar, and turbulent convection. densair = 0.003484 * 101325.0 / tave # density @@ -836,7 +835,7 @@ def fuentes(poa_global, temp_air, wind_speed, noct_installed, module_height=5, # convective coefficient of top surface of module at NOCT windmod = 1.0 tave = (tinoct + 293.15) / 2 - hconv = _fuentes_hconv(tave, windmod, tinoct, tinoct - 293.15, xlen, + hconv = _fuentes_hconv(tave, windmod, tinoct - 293.15, xlen, surface_tilt, False) # determine the ground temperature ratio and the ratio of the total @@ -896,7 +895,7 @@ def fuentes(poa_global, temp_air, wind_speed, noct_installed, module_height=5, for j in range(10): # overall convective coefficient tave = (tmod + tamb) / 2 - hconv = convrat * _fuentes_hconv(tave, windmod, tinoct, + hconv = convrat * _fuentes_hconv(tave, windmod, abs(tmod-tamb), xlen, surface_tilt, True) # sky radiation coefficient (Equation 3) From 31ccbb5e1429511672a65b12bdf9b313956a3a72 Mon Sep 17 00:00:00 2001 From: Ioannis Sifnaios <88548539+IoannisSifnaios@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:18:26 +0100 Subject: [PATCH 26/34] Denote Faiman default coefficients are for module temperature (#2607) * Denote Faiman default coefficients are for module temperature * Same update for faiman_rad --- pvlib/temperature.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pvlib/temperature.py b/pvlib/temperature.py index 14d67a168e..cb487ee77b 100644 --- a/pvlib/temperature.py +++ b/pvlib/temperature.py @@ -456,14 +456,14 @@ def faiman(poa_global, temp_air, wind_speed=1.0, u0=25.0, u1=6.84): speed at module height used to determine NOCT. [m/s] u0 : numeric, default 25.0 - Combined heat loss factor coefficient. The default value is one - determined by Faiman for 7 silicon modules + Combined heat loss factor coefficient. The default value is for module + temperature determined by Faiman for 7 silicon modules in the Negev desert on an open rack at 30.9° tilt. :math:`\left[\frac{\text{W}/{\text{m}^2}}{\text{C}}\right]` u1 : numeric, default 6.84 - Combined heat loss factor influenced by wind. The default value is one - determined by Faiman for 7 silicon modules + Combined heat loss factor influenced by wind. The default value is + for module temperature determined by Faiman for 7 silicon modules in the Negev desert on an open rack at 30.9° tilt. :math:`\left[ \frac{\text{W}/\text{m}^2}{\text{C}\ \left( \text{m/s} \right)} \right]` @@ -539,14 +539,14 @@ def faiman_rad(poa_global, temp_air, wind_speed=1.0, ir_down=None, surface. [W/m^2] u0 : numeric, default 25.0 - Combined heat loss factor coefficient. The default value is one - determined by Faiman for 7 silicon modules + Combined heat loss factor coefficient. The default value is for module + temperature determined by Faiman for 7 silicon modules in the Negev desert on an open rack at 30.9° tilt. :math:`\left[\frac{\text{W}/{\text{m}^2}}{\text{C}}\right]` u1 : numeric, default 6.84 - Combined heat loss factor influenced by wind. The default value is one - determined by Faiman for 7 silicon modules + Combined heat loss factor influenced by wind. The default value is for + module temperature determined by Faiman for 7 silicon modules in the Negev desert on an open rack at 30.9° tilt. :math:`\left[ \frac{\text{W}/\text{m}^2}{\text{C}\ \left( \text{m/s} \right)} \right]` From cc75255d7df6ce939db7ee6ebaf81e79741c2e88 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Wed, 3 Dec 2025 14:46:33 -0500 Subject: [PATCH 27/34] Fix failing iotools tests (#2609) * fix solargis test; expect new error message * sodapro: fix ME issue on old pandas introduced in #2600 * use atol in meteonorm tests --- tests/iotools/test_meteonorm.py | 10 ++++++++-- tests/iotools/test_sodapro.py | 3 ++- tests/iotools/test_solargis.py | 3 ++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/iotools/test_meteonorm.py b/tests/iotools/test_meteonorm.py index 3dac14928c..2eeb93431f 100644 --- a/tests/iotools/test_meteonorm.py +++ b/tests/iotools/test_meteonorm.py @@ -124,7 +124,10 @@ def test_get_meteonorm_training( assert meta == expected_meta pd.testing.assert_index_equal(data.index, expected_meteonorm_index) - pd.testing.assert_frame_equal(data.iloc[:12], expected_meteonorm_data) + # meteonorm API only guarantees similar, not identical, results between + # calls. so we allow a small amount of variation with atol. + pd.testing.assert_frame_equal(data.iloc[:12], expected_meteonorm_data, + check_exact=False, atol=1) @pytest.mark.remote_data @@ -304,4 +307,7 @@ def test_get_meteonorm_tmy( map_variables=False, url=demo_url) assert meta == expected_meteonorm_tmy_meta - pd.testing.assert_frame_equal(data.iloc[:12], expected_meteonorm_tmy_data) + # meteonorm API only guarantees similar, not identical, results between + # calls. so we allow a small amount of variation with atol. + pd.testing.assert_frame_equal(data.iloc[:12], expected_meteonorm_tmy_data, + check_exact=False, atol=1) diff --git a/tests/iotools/test_sodapro.py b/tests/iotools/test_sodapro.py index 09f5ee38ea..044ca97aa6 100644 --- a/tests/iotools/test_sodapro.py +++ b/tests/iotools/test_sodapro.py @@ -21,7 +21,8 @@ index_verbose = pd.date_range('2020-06-01 12', periods=4, freq='1min', tz='UTC') -index_monthly = pd.date_range('2020-01-01', periods=4, freq='1ME') +index_monthly = pd.to_datetime(['2020-01-31', '2020-02-29', '2020-03-31', + '2020-04-30']) dtypes_mcclear_verbose = [ diff --git a/tests/iotools/test_solargis.py b/tests/iotools/test_solargis.py index f7aedd58f0..eced226595 100644 --- a/tests/iotools/test_solargis.py +++ b/tests/iotools/test_solargis.py @@ -65,7 +65,8 @@ def test_get_solargis_utc_start_timestamp(hourly_index_start_utc): @pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) def test_get_solargis_http_error(): # Test if HTTPError is raised if date outside range is specified - with pytest.raises(requests.HTTPError, match="data coverage"): + match = r"request fromDate .* is before the available start date" + with pytest.raises(requests.HTTPError, match=match): _, _ = pvlib.iotools.get_solargis( latitude=48.61259, longitude=20.827079, start='1920-01-01', end='1920-01-01', # date outside range From 6e07565a666f898c481f888310f749f5f2a18ede Mon Sep 17 00:00:00 2001 From: Cliff Hansen Date: Tue, 9 Dec 2025 07:38:43 -0700 Subject: [PATCH 28/34] Fix test for _fit_sandia_cocontent (#2615) * correct argument and value order * whatsnew * move note to Testing section * Update docs/sphinx/source/whatsnew/v0.13.2.rst Co-authored-by: Echedey Luis <80125792+echedey-ls@users.noreply.github.com> --------- Co-authored-by: Echedey Luis <80125792+echedey-ls@users.noreply.github.com> --- docs/sphinx/source/whatsnew/v0.13.2.rst | 3 ++- tests/ivtools/test_sde.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/sphinx/source/whatsnew/v0.13.2.rst b/docs/sphinx/source/whatsnew/v0.13.2.rst index 7d0f0e0747..6bc36560f8 100644 --- a/docs/sphinx/source/whatsnew/v0.13.2.rst +++ b/docs/sphinx/source/whatsnew/v0.13.2.rst @@ -18,7 +18,6 @@ Deprecations Bug fixes ~~~~~~~~~ - Enhancements ~~~~~~~~~~~~ * Add :py:func:`~pvlib.ivtools.sdm.fit_desoto_batzelis`, a function to estimate @@ -56,6 +55,8 @@ Testing * Add Python 3.14 to test suite. (:pull:`2590`) * Update pytest configuration in ``pyproject.toml`` to work with pytest 9.0. (:pull:`2596`) +* Correct argument and value order in :py:func:`~pvlib.tests.ivtools.test_sde`, + in tests of :py:func:`~pvlib.ivtools.sde._fit_sandia_cocontent`. (:issue:`2613`, :pull:`2615`) Benchmarking diff --git a/tests/ivtools/test_sde.py b/tests/ivtools/test_sde.py index a465d448c4..079dad2091 100644 --- a/tests/ivtools/test_sde.py +++ b/tests/ivtools/test_sde.py @@ -57,18 +57,18 @@ def test_fit_sandia_simple_bad_iv(get_bad_iv_curves): (np.array([3., 2.9, 2.8, 2.7, 2.6, 2.5, 2.4, 1.7, 0.8, 0.]), np.array([0., 0.2, 0.4, 0.6, 0.8, 1., 1.2, 1.4, 1.45, 1.5]), 10., - (2.3392, 11.6865, -.232, -.2596, -.7119)), + (2.3392, 11.6865, -.2596, -.232, -.7119)), (np.array( [5., 4.9, 4.8, 4.7, 4.6, 4.5, 4.4, 4.3, 4.2, 4.1, 4., 3.8, 3.5, 1.7, 0.]), np.array( [0., .1, .2, .3, .4, .5, .6, .7, .8, .9, 1., 1.1, 1.18, 1.2, 1.22]), 15., - (-22.0795, 27.1196, -4.2076, -.0056, -.0498))]) + (-22.0795, 27.1196, -.0056, -4.2076, -.0498))]) def test__fit_sandia_cocontent(i, v, nsvth, expected): # test confirms agreement with Matlab code. The returned parameters # are nonsense - iph, io, rsh, rs, n = sde._fit_sandia_cocontent(v, i, nsvth) + iph, io, rs, rsh, n = sde._fit_sandia_cocontent(v, i, nsvth) np.testing.assert_allclose(iph, np.array(expected[0]), atol=.0001) np.testing.assert_allclose(io, np.array([expected[1]]), atol=.0001) np.testing.assert_allclose(rs, np.array([expected[2]]), atol=.0001) From 47fde77fc319419e7c5a7d1f9567098fc288112c Mon Sep 17 00:00:00 2001 From: "Adam R. Jensen" <39184289+AdamRJensen@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:42:06 +0100 Subject: [PATCH 29/34] Add longwave_down to ERA5 variable map (#2611) --- pvlib/iotools/era5.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pvlib/iotools/era5.py b/pvlib/iotools/era5.py index 601800f904..b19096b5f6 100644 --- a/pvlib/iotools/era5.py +++ b/pvlib/iotools/era5.py @@ -12,6 +12,7 @@ 'sp': 'pressure', 'ssrd': 'ghi', 'tp': 'precipitation', + 'strd': 'longwave_down', # long names '2m_dewpoint_temperature': 'temp_dew', @@ -19,6 +20,7 @@ 'surface_pressure': 'pressure', 'surface_solar_radiation_downwards': 'ghi', 'total_precipitation': 'precipitation', + 'surface_thermal_radiation_downwards': 'longwave_down', } From bbf301efe59f3e3bc0c026d2a7a338ad915d0b2d Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Tue, 9 Dec 2025 10:39:09 -0500 Subject: [PATCH 30/34] Add guidance about temperature modeling to User Guide (#2591) * create temperature user guide page * Apply suggestions from code review Co-authored-by: Cliff Hansen * Apply suggestions from code review Co-authored-by: RDaxini <143435106+RDaxini@users.noreply.github.com> * add mention of parameter conversion with GenericLinearModel * add note about IR * move "other functions" up to below the table * fix spelling * Apply suggestions from code review Co-authored-by: Anton Driesse * feedback from review Co-Authored-By: Ioannis Sifnaios <88548539+IoannisSifnaios@users.noreply.github.com> * more changes from review * Update docs/sphinx/source/user_guide/modeling_topics/temperature.rst * Update docs/sphinx/source/user_guide/modeling_topics/temperature.rst Co-authored-by: Echedey Luis <80125792+echedey-ls@users.noreply.github.com> * split paragraph --------- Co-authored-by: Cliff Hansen Co-authored-by: RDaxini <143435106+RDaxini@users.noreply.github.com> Co-authored-by: Anton Driesse Co-authored-by: Ioannis Sifnaios <88548539+IoannisSifnaios@users.noreply.github.com> Co-authored-by: Echedey Luis <80125792+echedey-ls@users.noreply.github.com> --- docs/sphinx/source/user_guide/index.rst | 1 + .../modeling_topics/temperature.rst | 107 ++++++++++++++++++ docs/sphinx/source/whatsnew/v0.13.2.rst | 1 + 3 files changed, 109 insertions(+) create mode 100644 docs/sphinx/source/user_guide/modeling_topics/temperature.rst diff --git a/docs/sphinx/source/user_guide/index.rst b/docs/sphinx/source/user_guide/index.rst index 07102a5630..f8da3b6b44 100644 --- a/docs/sphinx/source/user_guide/index.rst +++ b/docs/sphinx/source/user_guide/index.rst @@ -26,6 +26,7 @@ This user guide is an overview and explains some of the key features of pvlib. modeling_topics/clearsky modeling_topics/weather_data modeling_topics/singlediode + modeling_topics/temperature .. toctree:: :maxdepth: 2 diff --git a/docs/sphinx/source/user_guide/modeling_topics/temperature.rst b/docs/sphinx/source/user_guide/modeling_topics/temperature.rst new file mode 100644 index 0000000000..5b292b9f2c --- /dev/null +++ b/docs/sphinx/source/user_guide/modeling_topics/temperature.rst @@ -0,0 +1,107 @@ +.. _temperature: + +Temperature models +================== + +pvlib provides a variety of models for predicting the operating temperature +of a PV module from irradiance and weather inputs. These models range from +simple empirical equations requiring just a few multiplications to more complex +thermal balance models with numerical integration. + +Types of models +--------------- + +Temperature models predict one of two quantities: + +- *module temperature*: the temperature as measured at the back surface + of a PV module. Easy to measure, but usually marginally less + than the cell temperature which determines efficiency. +- *cell temperature*: the temperature of the PV cell itself. The relevant + temperature for PV modeling, but almost never measured directly. + +Temperature models estimate these quantities using inputs like incident +irradiance, ambient temperature, and wind speed. Each model also takes +a set of parameter values that represent how a PV module responds to +those inputs. + +Parameter values generally depend on both the PV +module technologies, the mounting configuration of the module, +and on any weather parameters that are not included in the model. +Note that, despite models conventionally being associated with either +cell or module temperature, it is actually the parameter values that determine +which of the two temperatures are predicted, as they will produce the same +type of temperature from which they were originally derived. + +Another aspect of temperature models is whether they account for +the thermal inertia of a PV module. Temperature models are either: + +- *steady-state*: the module is assumed to have been at the specified operating + conditions for a sufficiently long time for its temperature to reach + equilibrium. +- *transient*: the module's thermal inertia is included in the model, + causing a lag in modeled temperature change following changes in the inputs. + +Other effects that temperature models may consider include the +photoconversion efficiency and radiative cooling. + +The temperature models currently available in pvlib are summarized in the +following table: + ++----------------------------------------------+--------+------------+---------------------------------------------------------------------------+ +| Model | Type | Transient? | Weather inputs | +| | | +----------------+---------------------+------------+-----------------------+ +| | | | POA irradiance | Ambient temperature | Wind speed | Downwelling IR [#f1]_ | ++==============================================+========+============+================+=====================+============+=======================+ +| :py:func:`~pvlib.temperature.faiman` | either | | ✓ | ✓ | ✓ | | ++----------------------------------------------+--------+------------+----------------+---------------------+------------+-----------------------+ +| :py:func:`~pvlib.temperature.faiman_rad` | either | | ✓ | ✓ | ✓ | ✓ | ++----------------------------------------------+--------+------------+----------------+---------------------+------------+-----------------------+ +| :py:func:`~pvlib.temperature.fuentes` | either | ✓ | ✓ | ✓ | ✓ | | ++----------------------------------------------+--------+------------+----------------+---------------------+------------+-----------------------+ +| :py:func:`~pvlib.temperature.generic_linear` | either | | ✓ | ✓ | ✓ | | ++----------------------------------------------+--------+------------+----------------+---------------------+------------+-----------------------+ +| :py:func:`~pvlib.temperature.noct_sam` | cell | | ✓ | ✓ | ✓ | | ++----------------------------------------------+--------+------------+----------------+---------------------+------------+-----------------------+ +| :py:func:`~pvlib.temperature.pvsyst_cell` | cell | | ✓ | ✓ | ✓ | | ++----------------------------------------------+--------+------------+----------------+---------------------+------------+-----------------------+ +| :py:func:`~pvlib.temperature.ross` | cell | | ✓ | ✓ | | | ++----------------------------------------------+--------+------------+----------------+---------------------+------------+-----------------------+ +| :py:func:`~pvlib.temperature.sapm_cell` | cell | | ✓ | ✓ | ✓ | | ++----------------------------------------------+--------+------------+----------------+---------------------+------------+-----------------------+ +| :py:func:`~pvlib.temperature.sapm_module` | module | | ✓ | ✓ | ✓ | | ++----------------------------------------------+--------+------------+----------------+---------------------+------------+-----------------------+ + +.. [#f1] Downwelling infrared radiation. + +In addition to the core models above, pvlib provides several other functions +for temperature modeling: + +- :py:func:`~pvlib.temperature.prilliman`: an "add-on" model that reprocesses + the output of a steady-state model to apply transient effects. +- :py:func:`~pvlib.temperature.sapm_cell_from_module`: a model for + estimating cell temperature from module temperature. + + +Model parameters +---------------- + +Some temperature model functions provide default values for their parameters, +and several additional sets of temperature model parameter values are +available in :py:data:`pvlib.temperature.TEMPERATURE_MODEL_PARAMETERS`. +However, these generic values may not be suitable for all modules and mounting +configurations. It should be noted that using the default parameter values for each +model generally leads to different modules temperature predictions. This alone +does not mean one model is better than another; it's just evidence that the measurements +used to derive the default parameter values were taken on different PV systems in different +locations under different conditions. + +Parameter values for one model (e.g. ``u0``, ``u1`` for :py:func:`~pvlib.temperature.faiman`) +can be converted to another model (e.g. ``u_c``, ``u_v`` for :py:func:`~pvlib.temperature.pvsyst_cell`) +using :py:class:`~pvlib.temperature.GenericLinearModel`. + +Module-specific values can be obtained via testing, for example following +the IEC 61853-2 standard for the Faiman model; however, such values still do not capture +the dependency of temperature on system design and other variables. + +Currently, pvlib provides no functionality for fitting parameter values +using measured temperature. diff --git a/docs/sphinx/source/whatsnew/v0.13.2.rst b/docs/sphinx/source/whatsnew/v0.13.2.rst index 6bc36560f8..12c5d3ca49 100644 --- a/docs/sphinx/source/whatsnew/v0.13.2.rst +++ b/docs/sphinx/source/whatsnew/v0.13.2.rst @@ -46,6 +46,7 @@ Enhancements Documentation ~~~~~~~~~~~~~ * Provide an overview of single-diode modeling functionality in :ref:`singlediode`. (:pull:`2565`) +* Provide an overview of temperature modeling functionality in :ref:`temperature`. (:pull:`2591`) * Fix typo in parameter name ``atmos_refract`` in :py:func:`pvlib.solarposition.spa_python` and :py:func:`pvlib.spa.solar_position`. (:issue:`2532`, :pull:`2592`) From be59498432546101fb5f5bda58684dad0a175433 Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Tue, 9 Dec 2025 12:20:51 -0500 Subject: [PATCH 31/34] Add function for accessing MERRA-2 (#2572) * function, tests, whatsnew, docs * lint * simplify tz handling * make new earthdata secrets accessible to tests * add test for tz-aware inputs * add tests for HTTPError on bad inputs * add a few more variables to docstring * Apply suggestions from code review Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> * add link to datasets; add table with variable description * tweak tests * add LWGNT, LWGEM to variable map and docstring * change pvlib names to "longwave_net", "longwave_up" * add LWGAB --------- Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> --- docs/sphinx/source/reference/iotools.rst | 12 +- docs/sphinx/source/whatsnew/v0.13.2.rst | 3 + pvlib/iotools/__init__.py | 1 + pvlib/iotools/merra2.py | 196 +++++++++++++++++++++++ tests/conftest.py | 14 ++ tests/iotools/test_merra2.py | 111 +++++++++++++ 6 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 pvlib/iotools/merra2.py create mode 100644 tests/iotools/test_merra2.py diff --git a/docs/sphinx/source/reference/iotools.rst b/docs/sphinx/source/reference/iotools.rst index 15bf4103dc..1461534a4c 100644 --- a/docs/sphinx/source/reference/iotools.rst +++ b/docs/sphinx/source/reference/iotools.rst @@ -233,7 +233,6 @@ lower quality. iotools.read_crn - ECMWF ERA5 ^^^^^^^^^^ @@ -244,6 +243,17 @@ A global reanalysis dataset providing weather and solar resource data. iotools.get_era5 +MERRA-2 +^^^^^^^ + +A global reanalysis dataset providing weather, aerosol, and solar irradiance +data. + +.. autosummary:: + :toctree: generated/ + + iotools.get_merra2 + Generic data file readers ------------------------- diff --git a/docs/sphinx/source/whatsnew/v0.13.2.rst b/docs/sphinx/source/whatsnew/v0.13.2.rst index 12c5d3ca49..e4b7dc3758 100644 --- a/docs/sphinx/source/whatsnew/v0.13.2.rst +++ b/docs/sphinx/source/whatsnew/v0.13.2.rst @@ -42,6 +42,8 @@ Enhancements installed. (:issue:`2497`, :pull:`2571`) * Add :py:func:`~pvlib.iotools.get_era5`, a function for accessing ERA5 reanalysis data. (:pull:`2573`) +* Add :py:func:`~pvlib.iotools.get_merra2`, a function for accessing + MERRA-2 reanalysis data. (:pull:`2572`) Documentation ~~~~~~~~~~~~~ @@ -77,3 +79,4 @@ Contributors * Will Hobbs (:ghuser:`williamhobbs`) * Cliff Hansen (:ghuser:`cwhanse`) * Joseph Radford (:ghuser:`josephradford`) +* Kevin Anderson (:ghuser:`kandersolar`) diff --git a/pvlib/iotools/__init__.py b/pvlib/iotools/__init__.py index 6a57daad8d..e680e38c2d 100644 --- a/pvlib/iotools/__init__.py +++ b/pvlib/iotools/__init__.py @@ -43,3 +43,4 @@ from pvlib.iotools.meteonorm import get_meteonorm_tmy # noqa: F401 from pvlib.iotools.nasa_power import get_nasa_power # noqa: F401 from pvlib.iotools.era5 import get_era5 # noqa: F401 +from pvlib.iotools.merra2 import get_merra2 # noqa: F401 diff --git a/pvlib/iotools/merra2.py b/pvlib/iotools/merra2.py new file mode 100644 index 0000000000..8a7770b9f4 --- /dev/null +++ b/pvlib/iotools/merra2.py @@ -0,0 +1,196 @@ +import pandas as pd +import requests +from io import StringIO + + +VARIABLE_MAP = { + 'SWGDN': 'ghi', + 'SWGDNCLR': 'ghi_clear', + 'ALBEDO': 'albedo', + 'LWGNT': 'longwave_net', + 'LWGEM': 'longwave_up', + 'LWGAB': 'longwave_down', + 'T2M': 'temp_air', + 'T2MDEW': 'temp_dew', + 'PS': 'pressure', + 'TOTEXTTAU': 'aod550', +} + + +def get_merra2(latitude, longitude, start, end, username, password, dataset, + variables, map_variables=True): + """ + Retrieve MERRA-2 time-series irradiance and meteorological reanalysis data + from NASA's GESDISC data archive. + + MERRA-2 [1]_ offers modeled data for many atmospheric quantities at hourly + resolution on a 0.5° x 0.625° global grid. + + Access must be granted to the GESDISC data archive before EarthData + credentials will work. See [2]_ for instructions. + + Parameters + ---------- + latitude : float + In decimal degrees, north is positive (ISO 19115). + longitude: float + In decimal degrees, east is positive (ISO 19115). + start : datetime like or str + First timestamp of the requested period. If a timezone is not + specified, UTC is assumed. + end : datetime like or str + Last timestamp of the requested period. If a timezone is not + specified, UTC is assumed. Must be in the same year as ``start``. + username : str + NASA EarthData username. + password : str + NASA EarthData password. + dataset : str + Dataset name (with version), e.g. "M2T1NXRAD.5.12.4". + variables : list of str + List of variable names to retrieve. See the documentation of the + specific dataset you are accessing for options. + map_variables : bool, default True + When true, renames columns of the DataFrame to pvlib variable names + where applicable. See variable :const:`VARIABLE_MAP`. + + Raises + ------ + ValueError + If ``start`` and ``end`` are in different years, when converted to UTC. + + Returns + ------- + data : pd.DataFrame + Time series data. The index corresponds to the middle of the interval. + meta : dict + Metadata. + + Notes + ----- + The following datasets provide quantities useful for PV modeling: + + +------------------------------------+-----------+---------------+ + | Dataset | Variable | pvlib name | + +====================================+===========+===============+ + | `M2T1NXRAD.5.12.4 `_ | SWGDN | ghi | + | +-----------+---------------+ + | | SWGDNCLR | ghi_clear | + | +-----------+---------------+ + | | ALBEDO | albedo | + | +-----------+---------------+ + | | LWGAB | longwave_down | + | +-----------+---------------+ + | | LWGNT | longwave_net | + | +-----------+---------------+ + | | LWGEM | longwave_up | + +------------------------------------+-----------+---------------+ + | `M2T1NXSLV.5.12.4 `_ | T2M | temp_air | + | +-----------+---------------+ + | | U10 | n/a | + | +-----------+---------------+ + | | V10 | n/a | + | +-----------+---------------+ + | | T2MDEW | temp_dew | + | +-----------+---------------+ + | | PS | pressure | + | +-----------+---------------+ + | | TO3 | n/a | + | +-----------+---------------+ + | | TQV | n/a | + +------------------------------------+-----------+---------------+ + | `M2T1NXAER.5.12.4 `_ | TOTEXTTAU | aod550 | + | +-----------+---------------+ + | | TOTSCATAU | n/a | + | +-----------+---------------+ + | | TOTANGSTR | n/a | + +------------------------------------+-----------+---------------+ + + .. _M2T1NXRAD: https://disc.gsfc.nasa.gov/datasets/M2T1NXRAD_5.12.4/summary + .. _M2T1NXSLV: https://disc.gsfc.nasa.gov/datasets/M2T1NXSLV_5.12.4/summary + .. _M2T1NXAER: https://disc.gsfc.nasa.gov/datasets/M2T1NXAER_5.12.4/summary + + A complete list of datasets and their documentation is available at [3]_. + + Note that MERRA2 does not currently provide DNI or DHI. + + References + ---------- + .. [1] https://gmao.gsfc.nasa.gov/gmao-products/merra-2/ + .. [2] https://disc.gsfc.nasa.gov/earthdata-login + .. [3] https://disc.gsfc.nasa.gov/datasets?project=MERRA-2 + """ + + # general API info here: + # https://docs.unidata.ucar.edu/tds/5.0/userguide/netcdf_subset_service_ref.html # noqa: E501 + + def _to_utc_dt_notz(dt): + dt = pd.to_datetime(dt) + if dt.tzinfo is not None: + # convert to utc, then drop tz so that isoformat() is clean + dt = dt.tz_convert("UTC").tz_localize(None) + return dt + + start = _to_utc_dt_notz(start) + end = _to_utc_dt_notz(end) + + if (year := start.year) != end.year: + raise ValueError("start and end must be in the same year (in UTC)") + + url = ( + "https://goldsmr4.gesdisc.eosdis.nasa.gov/thredds/ncss/grid/" + f"MERRA2_aggregation/{dataset}/{dataset}_Aggregation_{year}.ncml" + ) + + parameters = { + 'var': ",".join(variables), + 'latitude': latitude, + 'longitude': longitude, + 'time_start': start.isoformat() + "Z", + 'time_end': end.isoformat() + "Z", + 'accept': 'csv', + } + + auth = (username, password) + + with requests.Session() as session: + session.auth = auth + login = session.request('get', url, params=parameters) + response = session.get(login.url, auth=auth, params=parameters) + + response.raise_for_status() + + content = response.content.decode('utf-8') + buffer = StringIO(content) + df = pd.read_csv(buffer) + + df.index = pd.to_datetime(df['time']) + + meta = {} + meta['dataset'] = dataset + meta['station'] = df['station'].values[0] + meta['latitude'] = df['latitude[unit="degrees_north"]'].values[0] + meta['longitude'] = df['longitude[unit="degrees_east"]'].values[0] + + # drop the non-data columns + dropcols = ['time', 'station', 'latitude[unit="degrees_north"]', + 'longitude[unit="degrees_east"]'] + df = df.drop(columns=dropcols) + + # column names are like T2M[unit="K"] by default. extract the unit + # for the metadata, then rename col to just T2M + units = {} + rename = {} + for col in df.columns: + name, _ = col.split("[", maxsplit=1) + unit = col.split('"')[1] + units[name] = unit + rename[col] = name + + meta['units'] = units + df = df.rename(columns=rename) + + if map_variables: + df = df.rename(columns=VARIABLE_MAP) + + return df, meta diff --git a/tests/conftest.py b/tests/conftest.py index 6207001c0a..dc14a34254 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -143,6 +143,20 @@ def nrel_api_key(): reason='requires ECMWF credentials') +try: + # Attempt to load NASA EarthData credentials used for testing + # pvlib.iotools.get_merra2 + earthdata_username = os.environ["EARTHDATA_USERNAME"] + earthdata_password = os.environ["EARTHDATA_PASSWORD"] + has_earthdata_credentials = True +except KeyError: + has_earthdata_credentials = False + +requires_earthdata_credentials = pytest.mark.skipif( + not has_earthdata_credentials, + reason='requires EarthData credentials') + + try: import statsmodels # noqa: F401 has_statsmodels = True diff --git a/tests/iotools/test_merra2.py b/tests/iotools/test_merra2.py new file mode 100644 index 0000000000..55f28b04e2 --- /dev/null +++ b/tests/iotools/test_merra2.py @@ -0,0 +1,111 @@ +""" +tests for pvlib/iotools/merra2.py +""" + +import pandas as pd +import pytest +import pvlib +import os +import requests +from tests.conftest import RERUNS, RERUNS_DELAY, requires_earthdata_credentials + + +@pytest.fixture +def params(): + earthdata_username = os.environ["EARTHDATA_USERNAME"] + earthdata_password = os.environ["EARTHDATA_PASSWORD"] + + return { + 'latitude': 40.01, 'longitude': -80.01, + 'start': '2020-06-01 15:00', 'end': '2020-06-01 20:00', + 'dataset': 'M2T1NXRAD.5.12.4', 'variables': ['ALBEDO', 'SWGDN'], + 'username': earthdata_username, 'password': earthdata_password, + } + + +@pytest.fixture +def expected(): + index = pd.date_range("2020-06-01 15:30", "2020-06-01 20:30", freq="h", + tz="UTC") + index.name = 'time' + albedo = [0.163931, 0.1609407, 0.1601474, 0.1612476, 0.164664, 0.1711341] + ghi = [ 930., 1002.75, 1020.25, 981.25, 886.5, 743.5] + df = pd.DataFrame({'albedo': albedo, 'ghi': ghi}, index=index) + return df + + +@pytest.fixture +def expected_meta(): + return { + 'dataset': 'M2T1NXRAD.5.12.4', + 'station': 'GridPointRequestedAt[40.010N_80.010W]', + 'latitude': 40.0, + 'longitude': -80.0, + 'units': {'ALBEDO': '1', 'SWGDN': 'W m-2'} + } + + +@requires_earthdata_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_merra2(params, expected, expected_meta): + df, meta = pvlib.iotools.get_merra2(**params) + pd.testing.assert_frame_equal(df, expected, check_freq=False) + assert meta == expected_meta + + +@requires_earthdata_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_merra2_map_variables(params, expected, expected_meta): + df, meta = pvlib.iotools.get_merra2(**params, map_variables=False) + expected = expected.rename(columns={'albedo': 'ALBEDO', 'ghi': 'SWGDN'}) + pd.testing.assert_frame_equal(df, expected, check_freq=False) + assert meta == expected_meta + + +def test_get_merra2_error(): + with pytest.raises(ValueError, match='must be in the same year'): + pvlib.iotools.get_merra2(40, -80, '2019-12-31', '2020-01-02', + username='anything', password='anything', + dataset='anything', variables=[]) + + +@requires_earthdata_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_merra2_timezones(params, expected, expected_meta): + # check with tz-aware start/end inputs + for key in ['start', 'end']: + dt = pd.to_datetime(params[key]) + params[key] = dt.tz_localize('UTC').tz_convert('Etc/GMT+5') + df, meta = pvlib.iotools.get_merra2(**params) + pd.testing.assert_frame_equal(df, expected, check_freq=False) + assert meta == expected_meta + + +@requires_earthdata_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_merra2_bad_credentials(params, expected, expected_meta): + params['username'] = 'nonexistent' + with pytest.raises(requests.exceptions.HTTPError, match='Unauthorized'): + pvlib.iotools.get_merra2(**params) + + +@requires_earthdata_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_merra2_bad_dataset(params, expected, expected_meta): + params['dataset'] = 'nonexistent' + with pytest.raises(requests.exceptions.HTTPError, match='404'): + pvlib.iotools.get_merra2(**params) + + +@requires_earthdata_credentials +@pytest.mark.remote_data +@pytest.mark.flaky(reruns=RERUNS, reruns_delay=RERUNS_DELAY) +def test_get_merra2_bad_variables(params, expected, expected_meta): + params['variables'] = ['nonexistent'] + with pytest.raises(requests.exceptions.HTTPError, match='400'): + pvlib.iotools.get_merra2(**params) From 9e267ca1b7996ca083c0f96efefbb6bf957cc954 Mon Sep 17 00:00:00 2001 From: Aman Srivastava <160766756+aman-coder03@users.noreply.github.com> Date: Thu, 11 Dec 2025 22:51:42 +0530 Subject: [PATCH 32/34] =?UTF-8?q?Fix=20typo=20in=20GitHub=20username:=20so?= =?UTF-8?q?lphie=20=E2=86=92=20sophie=20(#2622)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/sphinx/source/whatsnew/v0.12.0.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sphinx/source/whatsnew/v0.12.0.rst b/docs/sphinx/source/whatsnew/v0.12.0.rst index 98abb20d14..34aa38ad0c 100644 --- a/docs/sphinx/source/whatsnew/v0.12.0.rst +++ b/docs/sphinx/source/whatsnew/v0.12.0.rst @@ -94,7 +94,7 @@ Contributors * Adam R. Jensen (:ghuser:`AdamRJensen`) * Ioannis Sifnaios (:ghuser:`IoannisSifnaios`) * Will Holmgren (:ghuser:`wholmgren`) -* Sophie Pelland (:ghuser:`solphie-pelland`) +* Sophie Pelland (:ghuser:`sophie-pelland`) * Will Hobbs (:ghuser:`williamhobbs`) * Karel De Brabandere (:ghuser:`kdebrab`) * Kenneth J. Sauer (:ghuser:`kjsauer`) From b1f48bb267c8c014722d9aebff7e3c979a0d8172 Mon Sep 17 00:00:00 2001 From: Jesus Polo Date: Tue, 16 Dec 2025 15:54:08 +0100 Subject: [PATCH 33/34] Add polo spectral factor model (#2491) * Add polo-smm Add the polo spectral model for BIPV facades * Apply suggestions from code review Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> * Update mismatch.py * Update mismatch.py * Update mismatch.py * Update mismatch.py * Update mismatch.py * Update mismatch.py * Update pvlib/spectrum/mismatch.py Co-authored-by: Echedey Luis <80125792+echedey-ls@users.noreply.github.com> * Update pvlib/spectrum/mismatch.py Co-authored-by: Echedey Luis <80125792+echedey-ls@users.noreply.github.com> * Changes from code review * Add function to spectrum/__init__.py * Update test_mismatch.py * Update test_mismatch.py * Fix module import in tests * Update test_mismatch.py * Fix linter * Remove tab character * Update test_mismatch.py * Update test_mismatch.py * fix line legth linter error * Update test_mismatch.py * Update mismatch.py * Update mismatch.py * Apply suggestions from code review * replace `altitude` parameter with `pressure` * `albedo : numeric` * make albedo coefficients user-specified as well * complete tests * docstring cleanup * whatsnew note and contributors * add to user guide SMM model comparison table * lint * minor doc fixes * Update mismatch.py * Update mismatch.py * clip AOI to max of 90 degrees as discussed in review --------- Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> Co-authored-by: Echedey Luis <80125792+echedey-ls@users.noreply.github.com> Co-authored-by: Kevin Anderson --- .../effects_on_pv_system_output/spectrum.rst | 5 +- .../user_guide/modeling_topics/spectrum.rst | 27 +++-- docs/sphinx/source/whatsnew/v0.13.2.rst | 7 ++ pvlib/spectrum/__init__.py | 5 +- pvlib/spectrum/mismatch.py | 101 ++++++++++++++++++ tests/spectrum/test_mismatch.py | 87 +++++++++++++++ 6 files changed, 221 insertions(+), 11 deletions(-) diff --git a/docs/sphinx/source/reference/effects_on_pv_system_output/spectrum.rst b/docs/sphinx/source/reference/effects_on_pv_system_output/spectrum.rst index 982bc91742..b453a300ee 100644 --- a/docs/sphinx/source/reference/effects_on_pv_system_output/spectrum.rst +++ b/docs/sphinx/source/reference/effects_on_pv_system_output/spectrum.rst @@ -12,9 +12,10 @@ Spectrum spectrum.calc_spectral_mismatch_field spectrum.spectral_factor_caballero spectrum.spectral_factor_firstsolar - spectrum.spectral_factor_sapm - spectrum.spectral_factor_pvspec spectrum.spectral_factor_jrc + spectrum.spectral_factor_polo + spectrum.spectral_factor_pvspec + spectrum.spectral_factor_sapm spectrum.sr_to_qe spectrum.qe_to_sr spectrum.average_photon_energy diff --git a/docs/sphinx/source/user_guide/modeling_topics/spectrum.rst b/docs/sphinx/source/user_guide/modeling_topics/spectrum.rst index bd6440a47c..0747c0b295 100644 --- a/docs/sphinx/source/user_guide/modeling_topics/spectrum.rst +++ b/docs/sphinx/source/user_guide/modeling_topics/spectrum.rst @@ -61,11 +61,21 @@ Reference [2]_. | +-----------------------------+ | ✓ | ✓ | | | + [4]_ | | | clearsky_index | | | | | | | | +-----------------------------------------------------+-----------------------------+---------+---------+------+------+------+------------+-----------+ +| :py:func:`Polo ` | :term:`precipitable_water`, | | | | | | | | +| +-----------------------------+ ✓ | | ✓ | ✓ | ✓ | + [5]_ | +| | :term:`airmass_absolute`, | | | | | | | | +| +-----------------------------+ | | | | | | | +| | aod500, | | | | | | | | +| +-----------------------------+ | | | | | | | +| | :term:`aoi`, | | | | | | | | +| +-----------------------------+ | | | | | | | +| | :term:`pressure` | | | | | | | | ++-----------------------------------------------------+-----------------------------+---------+---------+------+------+------+------------+-----------+ | :py:func:`PVSPEC ` | :term:`airmass_absolute`, | | | | | | | | -| +-----------------------------+ ✓ | ✓ | ✓ | ✓ | ✓ | | [5]_ | +| +-----------------------------+ ✓ | ✓ | ✓ | ✓ | ✓ | | [6]_ | | | clearsky_index | | | | | | | | +-----------------------------------------------------+-----------------------------+---------+---------+------+------+------+------------+-----------+ -| :py:func:`SAPM ` | :term:`airmass_absolute` | | | | | | | [6]_ | +| :py:func:`SAPM ` | :term:`airmass_absolute` | | | | | | | [7]_ | +-----------------------------------------------------+-----------------------------+---------+---------+------+------+------+------------+-----------+ @@ -88,16 +98,19 @@ References PVSPEC Model of Photovoltaic Spectral Mismatch Factor," in Proc. 2020 IEEE 47th Photovoltaic Specialists Conference (PVSC), Calgary, AB, Canada, 2020, pp. 1–6. :doi:`10.1109/PVSC45281.2020.9300932` -.. [5] D. L. King, W. E. Boyson, and J. A. Kratochvil, Photovoltaic Array +.. [5] J. Polo and C. Sanz-Saiz, 'Development of spectral mismatch models + for BIPV applications in building façades', Renewable Energy, vol. 245, + p. 122820, Jun. 2025, :doi:`10.1016/j.renene.2025.122820` +.. [6] D. L. King, W. E. Boyson, and J. A. Kratochvil, Photovoltaic Array Performance Model, Sandia National Laboratories, Albuquerque, NM, USA, Tech. Rep. SAND2004-3535, Aug. 2004. :doi:`10.2172/919131` -.. [6] M. Lee and A. Panchula, "Spectral Correction for Photovoltaic Module +.. [7] M. Lee and A. Panchula, "Spectral Correction for Photovoltaic Module Performance Based on Air Mass and Precipitable Water," 2016 IEEE 43rd Photovoltaic Specialists Conference (PVSC), Portland, OR, USA, 2016, pp. 3696-3699. :doi:`10.1109/PVSC.2016.7749836` -.. [7] H. Thomas, S. Tony, and D. Ewan, “A Simple Model for Estimating the - Influence of Spectrum Variations on PV Performance,” pp. 3385–3389, Nov. +.. [8] T. Huld, T. Sample, and E. Dunlop, "A Simple Model for Estimating the + Influence of Spectrum Variations on PV Performance," pp. 3385–3389, Nov. 2009, :doi:`10.4229/24THEUPVSEC2009-4AV.3.27` -.. [8] IEC 60904-7:2019, Photovoltaic devices — Part 7: Computation of the +.. [9] IEC 60904-7:2019, Photovoltaic devices — Part 7: Computation of the spectral mismatch correction for measurements of photovoltaic devices, International Electrotechnical Commission, Geneva, Switzerland, 2019. \ No newline at end of file diff --git a/docs/sphinx/source/whatsnew/v0.13.2.rst b/docs/sphinx/source/whatsnew/v0.13.2.rst index e4b7dc3758..b6170a6053 100644 --- a/docs/sphinx/source/whatsnew/v0.13.2.rst +++ b/docs/sphinx/source/whatsnew/v0.13.2.rst @@ -44,6 +44,8 @@ Enhancements ERA5 reanalysis data. (:pull:`2573`) * Add :py:func:`~pvlib.iotools.get_merra2`, a function for accessing MERRA-2 reanalysis data. (:pull:`2572`) +* Add :py:func:`~pvlib.spectrum.spectral_factor_polo`, a function for estimating + spectral mismatch factors for vertical PV façades. (:issue:`2406`, :pull:`2491`) Documentation ~~~~~~~~~~~~~ @@ -79,4 +81,9 @@ Contributors * Will Hobbs (:ghuser:`williamhobbs`) * Cliff Hansen (:ghuser:`cwhanse`) * Joseph Radford (:ghuser:`josephradford`) +* Jesús Polo (:ghuser:`jesuspolo`) +* Adam R. Jensen (:ghuser:`adamrjensen`) +* Echedey Luis (:ghuser:`echedey-ls`) +* Anton Driesse (:ghuser:`adriesse`) +* Rajiv Daxini (:ghuser:`RDaxini`) * Kevin Anderson (:ghuser:`kandersolar`) diff --git a/pvlib/spectrum/__init__.py b/pvlib/spectrum/__init__.py index 59f9db9582..1e551c83fa 100644 --- a/pvlib/spectrum/__init__.py +++ b/pvlib/spectrum/__init__.py @@ -3,9 +3,10 @@ calc_spectral_mismatch_field, spectral_factor_caballero, spectral_factor_firstsolar, - spectral_factor_sapm, - spectral_factor_pvspec, spectral_factor_jrc, + spectral_factor_polo, + spectral_factor_pvspec, + spectral_factor_sapm, ) from pvlib.spectrum.irradiance import ( # noqa: F401 get_reference_spectra, diff --git a/pvlib/spectrum/mismatch.py b/pvlib/spectrum/mismatch.py index 9f68d77c83..b4385e255a 100644 --- a/pvlib/spectrum/mismatch.py +++ b/pvlib/spectrum/mismatch.py @@ -698,3 +698,104 @@ def spectral_factor_jrc(airmass, clearsky_index, module_type=None, + coeff[2] * (airmass - 1.5) ) return mismatch + + +def spectral_factor_polo(precipitable_water, airmass_absolute, aod500, aoi, + pressure, module_type=None, coefficients=None, + albedo=0.2): + """ + Estimate the spectral mismatch for BIPV application in vertical facades. + + The model's authors note that this model could also be applied to + vertical bifacial ground-mount systems [1]_, although it has not been + validated in that context. + + Parameters + ---------- + precipitable_water : numeric + Atmospheric precipitable water. [cm] + airmass_absolute : numeric + Absolute (pressure-adjusted) airmass. See :term:`airmass_absolute`. + [unitless] + aod500 : numeric + Atmospheric aerosol optical depth at 500 nm. [unitless] + aoi : numeric + Angle of incidence on the vertical surface. See :term:`aoi`. + [degrees] + pressure : numeric + Atmospheric pressure. See :term:`pressure`. [Pa] + module_type : str, optional + One of the following PV technology strings from [1]_: + + * ``'cdte'`` - anonymous CdTe module. + * ``'monosi'`` - anonymous monocrystalline silicon module. + * ``'cigs'`` - anonymous copper indium gallium selenide module. + * ``'asi'`` - anonymous amorphous silicon module. + coefficients : array-like, optional + User-defined coefficients, if not using one of the coefficient + sets via the ``module_type`` parameter. Must have nine elements. + The first six elements correspond to the [p1, p2, p3, p4, b, c] + parameters of the SMM model. The last three elements corresponds + to the [c1, c2, c3] parameters of the albedo correction factor. + albedo : numeric, default 0.2 + Ground albedo. See :term:`albedo`. [unitless] + + Returns + ------- + modifier: numeric + spectral mismatch factor (unitless) which is multiplied + with broadband irradiance reaching a module's cells to estimate + effective irradiance, i.e., the irradiance that is converted to + electrical current. + + Notes + ----- + The Polo model was developed using only SMM values computed for scenarios + when the sun is visible from the module's surface (i.e., for ``aoi<90``), + and no provision was made in [1]_ for the case of ``aoi>90``. This would + create issues in the air mass calculation internal to the model. + Following discussion with the model's author, the pvlib implementation + handles ``aoi>90`` by truncating the input ``aoi`` to a maximum of + 90 degrees. + + References + ---------- + .. [1] J. Polo and C. Sanz-Saiz, 'Development of spectral mismatch models + for BIPV applications in building façades', Renewable Energy, vol. 245, + p. 122820, Jun. 2025, :doi:`10.1016/j.renene.2025.122820` + """ + if module_type is None and coefficients is None: + raise ValueError('Must provide either `module_type` or `coefficients`') + if module_type is not None and coefficients is not None: + raise ValueError('Only one of `module_type` and `coefficients` should ' + 'be provided') + # prevent nan for aoi greater than 90; see docstring Notes + aoi = np.clip(aoi, a_min=None, a_max=90) + f_aoi_rel = pvlib.atmosphere.get_relative_airmass(aoi, + model='kastenyoung1989') + f_aoi = pvlib.atmosphere.get_absolute_airmass(f_aoi_rel, pressure) + Ram = f_aoi / airmass_absolute + _coefficients = { + 'cdte': (-0.0009, 46.80, 49.20, -0.87, 0.00041, 0.053), + 'monosi': (0.0027, 10.34, 9.48, 0.31, 0.00077, 0.006), + 'cigs': (0.0017, 2.33, 1.30, 0.11, 0.00098, -0.018), + 'asi': (0.0024, 7.32, 7.09, -0.72, -0.0013, 0.089), + } + c = { + 'asi': (0.0056, -0.020, 1.014), + 'cigs': (-0.0009, -0.0003, 1), + 'cdte': (0.0021, -0.01, 1.01), + 'monosi': (0, -0.003, 1.0), + } + if module_type is not None: + coeff = _coefficients[module_type] + c_albedo = c[module_type] + else: + coeff = coefficients[:6] + c_albedo = coefficients[6:] + smm = coeff[0] * Ram + coeff[1] / (coeff[2] + Ram**coeff[3]) \ + + coeff[4] / aod500 + coeff[5]*np.sqrt(precipitable_water) + # Ground albedo correction + g = c_albedo[0] * (albedo/0.2)**2 \ + + c_albedo[1] * (albedo/0.2) + c_albedo[2] + return g*smm diff --git a/tests/spectrum/test_mismatch.py b/tests/spectrum/test_mismatch.py index edd780b2b1..f0443d4ce7 100644 --- a/tests/spectrum/test_mismatch.py +++ b/tests/spectrum/test_mismatch.py @@ -288,3 +288,90 @@ def test_spectral_factor_jrc_supplied_ambiguous(): with pytest.raises(ValueError, match='No valid input provided'): spectrum.spectral_factor_jrc(1.0, 0.8, module_type=None, coefficients=None) + + +@pytest.mark.parametrize("module_type,expected", [ + ('cdte', np.array( + [0.992801, 1.00004, 1.011576, 0.995003, 0.950156, 0.975665])), + ('monosi', np.array( + [1.000152, 0.969588, 0.984636, 1.015405, 1.024238, 1.005061])), + ('cigs', np.array( + [1.004621, 0.956719, 0.971668, 1.0254, 1.060066, 1.020196])), + ('asi', np.array( + [0.986968, 1.049725, 1.051978, 0.957968, 0.842258, 0.941927])), +]) +def test_spectral_factor_polo(module_type, expected): + pws = np.array([0.96, 0.96, 1.85, 1.88, 0.66, 0.66]) + aods = np.array([0.085, 0.085, 0.16, 0.19, 0.088, 0.088]) + ams = np.array([1.34, 1.34, 2.2, 2.2, 2.6, 2.6]) + aois = np.array([46.0, 76.0, 74.0, 28.0, 24.0, 55.0]) + pressure = np.array([101300, 101400, 100500, 101325, 80000, 120000]) + alb = np.array([0.15, 0.2, 0.3, 0.18, 0.32, 0.26]) + out = spectrum.spectral_factor_polo( + pws, ams, aods, aois, pressure, module_type=module_type, albedo=alb) + np.testing.assert_allclose(out, expected, atol=1e-6) + + +@pytest.fixture +def polo_inputs(): + return {'precipitable_water': 0.96, + 'airmass_absolute': 1.34, + 'aod500': 0.085, + 'aoi': 76, + 'pressure': 101400, + 'albedo': 0.2} + + +def test_spectral_factor_polo_coefficients(polo_inputs): + # test that supplying custom coefficients works as expected + coefficients = ( + (0.0027, 10.34, 9.48, 0.31, 0.00077, 0.006) # base Si coeffs + + (0, -0.003, 1.0) # Si albedo correction coeffs + ) + out = spectrum.spectral_factor_polo(**polo_inputs, + coefficients=coefficients) + np.testing.assert_allclose(out, 0.969588, atol=1e-6) + + +def test_spectral_factor_polo_errors(polo_inputs): + with pytest.raises(ValueError, match='Must provide either'): + spectrum.spectral_factor_polo(**polo_inputs) + with pytest.raises(ValueError, match='Only one of'): + spectrum.spectral_factor_polo(**polo_inputs, module_type='CdTe', + coefficients=(1, 1, 1, 1, 1, 1)) + + +def test_spectral_factor_polo_types(polo_inputs): + # float: + out = spectrum.spectral_factor_polo(**polo_inputs, module_type='monosi') + assert isinstance(out, float) + np.testing.assert_allclose(out, 0.969588, atol=1e-6) + + # array: + arrays = {k: np.array([v, v]) for k, v in polo_inputs.items()} + out = spectrum.spectral_factor_polo(**arrays, module_type='monosi') + assert isinstance(out, np.ndarray) + np.testing.assert_allclose(out, [0.969588]*2, atol=1e-6) + + # series: + series = {k: pd.Series(v) for k, v in arrays.items()} + out = spectrum.spectral_factor_polo(**series, module_type='monosi') + assert isinstance(out, pd.Series) + pd.testing.assert_series_equal(out, pd.Series([0.969588]*2), atol=1e-6) + + +def test_spectral_factor_polo_NaN(polo_inputs): + # nan in -> nan out + for key in polo_inputs: + inputs = polo_inputs.copy() + inputs[key] = np.nan + out = spectrum.spectral_factor_polo(**inputs, module_type='monosi') + assert np.isnan(out) + + +def test_spectral_factor_polo_aoi_gt_90(polo_inputs): + polo_inputs['aoi'] = 95 + out95 = spectrum.spectral_factor_polo(**polo_inputs, module_type='monosi') + polo_inputs['aoi'] = 90 + out90 = spectrum.spectral_factor_polo(**polo_inputs, module_type='monosi') + assert out95 == out90 From 770bcd1200ca16f330cb268242812343b673e28b Mon Sep 17 00:00:00 2001 From: Kevin Anderson Date: Tue, 16 Dec 2025 10:10:37 -0500 Subject: [PATCH 34/34] Fix meteonorm tests, again (#2623) * update metadata checks * apply same fix to TMY test --- tests/iotools/test_meteonorm.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/iotools/test_meteonorm.py b/tests/iotools/test_meteonorm.py index 2eeb93431f..0bcaa10b00 100644 --- a/tests/iotools/test_meteonorm.py +++ b/tests/iotools/test_meteonorm.py @@ -122,7 +122,9 @@ def test_get_meteonorm_training( time_step='1h', url=demo_url) - assert meta == expected_meta + assert meta.items() >= expected_meta.items() # check stable subset + for key in ['version', 'commit']: + assert key in meta # value changes, so only check presence pd.testing.assert_index_equal(data.index, expected_meteonorm_index) # meteonorm API only guarantees similar, not identical, results between # calls. so we allow a small amount of variation with atol. @@ -306,7 +308,9 @@ def test_get_meteonorm_tmy( interval_index=True, map_variables=False, url=demo_url) - assert meta == expected_meteonorm_tmy_meta + assert meta.items() >= expected_meteonorm_tmy_meta.items() + for key in ['version', 'commit']: + assert key in meta # value changes, so only check presence # meteonorm API only guarantees similar, not identical, results between # calls. so we allow a small amount of variation with atol. pd.testing.assert_frame_equal(data.iloc[:12], expected_meteonorm_tmy_data,