diff --git a/docs/examples/iv-modeling/plot_mismatch.py b/docs/examples/iv-modeling/plot_mismatch.py new file mode 100644 index 0000000000..f6f38ef5f5 --- /dev/null +++ b/docs/examples/iv-modeling/plot_mismatch.py @@ -0,0 +1,72 @@ +""" +Calculating a combined IV curve +=============================== + +Here we show how to use pvlib to combine IV curves in series. + +Differences in weather (irradiance or temperature) or module condition can +cause two modules (or cells) to produce different current-voltage (IV) +characteristics or curves. Series-connected modules produce a string-level IV +curve, which can be obtained by combining the curves for the individual +modules. Combining the curves involves modeling IV curves at negative voltage, +because some modules (cells) in the series circuit will produce more +photocurrent than others but the combined current cannot exceed that of the +lowest-current module (cell). +""" + +# %% +# +# pvlib provides two functions to combine series-connected curves: +# +#* :py:func:`pvlib.ivtools.mismatch.prepare_curves` uses parameters for the +# single diode equation and a simple model for negative voltage behavior to +# compute IV curves at a common set of currents +# +#* :py:func:`pvlib.ivtools.mismatch.combine_curves` produces the combined IV +# curve from a set of IV curves with common current values. + + +import numpy as np +import matplotlib.pyplot as plt +from scipy.constants import Boltzmann, elementary_charge + +from pvlib.ivtools.mismatch import prepare_curves, combine_curves + + +# set up parameter array + +# the parameters should be in the order: photocurrent, saturation +# current, series resistance, shunt resistance, and n*Vth*Ns +# these are the parameters for the single diode function + +# example array of parameters +vth = 298.15 * Boltzmann / elementary_charge +params = np.array([[1.0, 3e-08, 1.0, 300, 1.3*vth*72], + [3, 3e-08, 0.1, 300, 1.01*vth*72], + [2, 5e-10, 0.1, 300, 1.1*vth*72]]) + +# prepare inputs for combine_curves +brk_voltage = -1.5 +currents, voltages_array = prepare_curves(params, num_pts=100, + breakdown_voltage=brk_voltage) + +# compute combined curve +combined_curve_dict = combine_curves(currents, voltages_array) + +# plot all curves and combined curve +for idx in range(len(voltages_array)): + v = voltages_array[idx] + plt.plot(v, currents, label=f"Panel {idx+1}") + +plt.plot(combined_curve_dict['v'], combined_curve_dict['i'], + label="Combined curve") + +# plot vertical line at breakdown voltage (used in simplified +# reverse bias model) +plt.vlines(brk_voltage, ymin=0.0, ymax=combined_curve_dict['i_sc'], ls='--', + color='k', linewidth=1, label="Breakdown voltage") + +plt.xlabel("Voltage [V]") +plt.ylabel("Current [A]") +plt.legend() +plt.show() diff --git a/docs/sphinx/source/reference/pv_modeling/sdm.rst b/docs/sphinx/source/reference/pv_modeling/sdm.rst index bfd5103ebe..9561ce3f1a 100644 --- a/docs/sphinx/source/reference/pv_modeling/sdm.rst +++ b/docs/sphinx/source/reference/pv_modeling/sdm.rst @@ -18,6 +18,14 @@ Functions relevant for single diode models. pvsystem.max_power_point ivtools.sdm.pvsyst_temperature_coeff +Functions for combining IV curves in series. + +.. autosummary:: + :toctree: ../generated/ + + ivtools.mismatch.prepare_curves + ivtools.mismatch.combine_curves + Low-level functions for solving the single diode equation. .. autosummary:: diff --git a/docs/sphinx/source/whatsnew/v0.10.0.rst b/docs/sphinx/source/whatsnew/v0.10.0.rst index e7816020bd..8e26c96fc8 100644 --- a/docs/sphinx/source/whatsnew/v0.10.0.rst +++ b/docs/sphinx/source/whatsnew/v0.10.0.rst @@ -91,6 +91,7 @@ Enhancements :py:func:`~pvlib.singlediode.bishop88_v_from_i`. Among others, tolerance and number of iterations can be set. (:issue:`1249`, :pull:`1764`) +* Improved `ModelChainResult.__repr__` (:pull:`1236`) * Improved ``ModelChainResult.__repr__`` (:pull:`1236`) * Exposes several functions useful for bifacial and shading calculations (:pull:`1666`): diff --git a/docs/sphinx/source/whatsnew/v0.10.2.rst b/docs/sphinx/source/whatsnew/v0.10.2.rst index ee25c29103..c6b5ddf1d4 100644 --- a/docs/sphinx/source/whatsnew/v0.10.2.rst +++ b/docs/sphinx/source/whatsnew/v0.10.2.rst @@ -15,6 +15,10 @@ Enhancements :py:func:`pvlib.iotools.get_pvgis_hourly`, :py:func:`pvlib.iotools.get_cams`, :py:func:`pvlib.iotools.get_bsrn`, and :py:func:`pvlib.iotools.read_midc_raw_data_from_nrel`. (:pull:`1800`) +* Added a new module :py:mod:`pvlib.ivtools.mismatch` to contain functions for + combining IV curves. Added functions + :py:func:`pvlib.ivtools.mismatch.prepare_curves` and + :py:func:`pvlib.ivtools.mismatch.combine_curves`. (:pull:`1781`) * Added option to infer threshold values for :py:func:`pvlib.clearsky.detect_clearsky` (:issue:`1808`, :pull:`1784`) diff --git a/pvlib/ivtools/__init__.py b/pvlib/ivtools/__init__.py index de897e2989..c6edaf6eff 100644 --- a/pvlib/ivtools/__init__.py +++ b/pvlib/ivtools/__init__.py @@ -4,4 +4,4 @@ """ -from pvlib.ivtools import sde, sdm, utils # noqa: F401 +from pvlib.ivtools import mismatch, sde, sdm, utils # noqa: F401 diff --git a/pvlib/ivtools/mismatch.py b/pvlib/ivtools/mismatch.py new file mode 100644 index 0000000000..fc00198b76 --- /dev/null +++ b/pvlib/ivtools/mismatch.py @@ -0,0 +1,194 @@ +""" +The `mismatch` module contains functions for combining curves in +series using the single diode model. +""" + +import numpy as np +from pvlib.singlediode import bishop88_i_from_v, bishop88_v_from_i + + +def prepare_curves(params, num_pts, breakdown_voltage=-0.5): + """ + Calculates currents and voltages on IV curves with the given + parameters, using the single diode equation, and a simple + model for reverse bias behavior. + + The current values are linearly spaced from the maximum Isc for all + curves to 0. All curves have the same current values. + + Returns currents and voltages in the format needed for input to + :func:`combine_curves`. + + Parameters + ---------- + params : array-like + An array of parameters representing a set of :math:`n` IV + curves. The array should contain :math:`n` rows and five + columns. Each row contains the five parameters needed for a + single curve in the following order: + + photocurrent : numeric + photo-generated current :math:`I_{L}` [A] + + saturation_current : numeric + diode reverse saturation current :math:`I_{0}` [A] + + resistance_series : numeric + series resistance :math:`R_{s}` [ohms] + + resistance_shunt : numeric + shunt resistance :math:`R_{sh}` [ohms] + + nNsVth : numeric + product of thermal voltage :math:`V_{th}` [V], diode + ideality factor :math:`n`, and number of series cells + :math:`N_{s}` [V] + + num_pts : int + Number of points to compute for each IV curve. + + breakdown_voltage : float + Vertical asymptote to use left of the y-axis. Any voltages that + are smaller than ``breakdown_voltage`` will be replaced by it. + + Returns + ------- + tuple + currents : np.ndarray + A 1D array of current values. Has shape (``num_pts``,). + + voltages : np.ndarray + A 2D array of voltage values, where each row corresponds to + a single IV curve. Has shape (:math:`n`, ``num_pts``), where + :math:`n` is the number of IV curves passed in. + + Notes + ----- + This function assumes a simplified reverse bias model. IV curves are + solved using :func:`pvlib.singlediode.bishop88_v_from_i` with + ``breakdown_factor`` left at the default value of 0., which excludes + the reverse bias term from the calculation. Here, voltages that are + less than ``breakdown_voltage`` are set equal to ``breakdown_voltage`` + effectively modeling the reverse bias curve by a vertical line + at ``breakdown_voltage``. + + """ + + params = np.asarray(params) + # in case params is a list containing scalars, add a dimension + if params.ndim == 1: + params = params[np.newaxis,:] + + # get range of currents from 0 to max_isc + max_isc = np.max(bishop88_i_from_v(0.0, *params.T, method='newton')) + currents = np.linspace(max_isc, 0, num=num_pts, endpoint=True) + + # prepare inputs for bishop88 + bishop_inputs = np.array([[currents[idx]]*len(params) for idx in + range(num_pts)]) + # each row of bishop_inputs contains n copies of a single current + # value, where n is the number of curves being added together + # there is a row for each current value + + # get voltages for each curve + # (note: expecting to vectorize for both the inputted currents and + # the inputted curve parameters) + # transpose result so each row contains voltages for a single curve + voltages = bishop88_v_from_i(bishop_inputs, *params.T, method='newton').T + + # any voltages in array that are smaller than breakdown_voltage are + # clipped to be breakdown_voltage + voltages = np.clip(voltages, a_min=breakdown_voltage, a_max=None) + + return currents, voltages + + +def combine_curves(currents, voltages): + """ + Combines IV curves in series. + + Parameters + ---------- + currents : array-like + A 1D array-like object. Its last element must be zero, and it + should be decreasing. + + voltages : array-like + A 2D array-like object. Each row corresponds to a single IV + curve and contains the voltages for that curve that are + associated to elements of ``currents``. Each row must be + increasing. + + Returns + ------- + dict + Contains the following keys: + i_sc : scalar + short circuit current of combined curve [A] + + v_oc : scalar + open circuit voltage of combined curve [V] + + i_mp : scalar + current at maximum power point of combined curve [A] + + v_mp : scalar + voltage at maximum power point of combined curve [V] + + p_mp : scalar + power at maximum power point of combined curve [W] + + i : np.ndarray + currents of combined curve [A] + + v : np.ndarray + voltages of combined curve [V] + + Notes + ----- + If the combined curve does not cross the y-axis, then the first (and + hence largest) current is returned for short circuit current. + + The maximum power point that is returned is the maximum power point + of the dataset. Its accuracy will improve as more points are passed + in. + + """ + + currents = np.asarray(currents) + voltages = np.asarray(voltages) + assert currents.ndim == 1 + assert voltages.ndim == 2 + + # for each current, add the associated voltages of all the curves + # in our setup, this means summing each column of the voltage array + combined_voltages = np.sum(voltages, axis=0) + + # combined_voltages should now have same shape as currents + assert np.shape(combined_voltages) == np.shape(currents) + + # find max power point (in the dataset) + powers = currents*combined_voltages + mpp_idx = np.argmax(powers) + vmp = combined_voltages[mpp_idx] + imp = currents[mpp_idx] + pmp = powers[mpp_idx] + + # we're assuming voltages are increasing, so combined_voltages + # should also be increasing + if not np.all(np.diff(combined_voltages) > 0): + raise ValueError("Each row of voltages array must be increasing.") + # get isc + # note that np.interp requires second argument is increasing (which + # we just checked) + isc = np.interp(0., combined_voltages, currents) + + # the last element of currents must be zero + if currents[-1] != 0: + raise ValueError("Last element of currents array must be zero.") + # get voc + voc = combined_voltages[-1] + + return {'i_sc': isc, 'v_oc': voc, 'i_mp': imp, 'v_mp': vmp, 'p_mp': pmp, + 'i': currents, 'v': combined_voltages} + diff --git a/pvlib/tests/ivtools/test_mismatch.py b/pvlib/tests/ivtools/test_mismatch.py new file mode 100644 index 0000000000..91049dd1ab --- /dev/null +++ b/pvlib/tests/ivtools/test_mismatch.py @@ -0,0 +1,131 @@ + +import pytest +import numpy as np +import pandas as pd +from pvlib.ivtools.mismatch import prepare_curves, combine_curves + + +# inputs to test combine_curves +@pytest.mark.parametrize('currents, voltages, expected', [ + # different iscs and vocs + ( + np.array([2, 1, 0.]), + np.array([[0., 7.5, 8], [-1, 9.5, 10]]), + {'i_sc': 1.944444444444, 'v_oc': 18, 'i_mp': 1, 'v_mp': 17, + 'p_mp': 17, + 'i': np.array([2, 1, 0.]), + 'v': np.array([-1, 17, 18]) + } + ), + + # pandas inputs + ( + pd.Series([2, 1, 0.]), + pd.DataFrame([[0., 7.5, 8], [-1, 9.5, 10]]), + {'i_sc': 1.944444444444, 'v_oc': 18, 'i_mp': 1, 'v_mp': 17, + 'p_mp': 17, + 'i': np.array([2, 1, 0.]), + 'v': np.array([-1, 17, 18]) + } + ), + + # check that isc is actually largest current if curve doesn't + # cross y-axis + ( + np.array([1, 0.5, 0]), + np.array([[0., 1.75, 2], [0.5, 1, 1.5], [0.25, 0.75, 1]]), + {'i_sc': 1, 'v_oc': 4.5, 'i_mp': 0.5, 'v_mp': 3.5, 'p_mp': 1.75, + 'i': np.array([1, 0.5, 0.]), + 'v': np.array([0.75, 3.5, 4.5]) + } + ), + + # same curve twice + ( + np.array([1, 0.9, 0.]), + np.array([[0., 0.5, 1], [0, 0.5, 1]]), + {'i_sc': 1, 'v_oc': 2, 'i_mp': 0.9, 'v_mp': 1, 'p_mp': 0.9, + 'i': np.array([1, 0.9, 0.]), + 'v': np.array([0, 1, 2]) + } + ) + ]) +def test_combine(currents, voltages, expected): + out = combine_curves(currents, voltages) + + # check that outputted dictionary is close to expected + for k, v in expected.items(): + assert np.all(np.isclose(out[k], v)) + + +def test_combine_curves_args_fail(): + # voltages are not increasing + with pytest.raises(ValueError): + combine_curves([1,0], [[2,1],[0.1, 1]]) + + # currents don't end at zero + with pytest.raises(ValueError): + combine_curves([1.1,0.1], [[1,2],[3,4]]) + + +# inputs to test prepare_curves +@pytest.mark.parametrize('num_pts, breakdown_voltage, params, expected', [ + # standard use + # also tests vectorization of both the inputted currents and the + # curve parameters (when calling bishop88_v_from_i) + ( + 3, -0.5, + np.array([[1, 3e-08, 1., 300, 1.868364353685363], + [5, 1e-09, 0.1, 3000, 2.404825405733636]]), + ( + np.array([4.99983334, 2.49991667, 0.]), + np.array([[-0.5, -0.5, 3.21521313e+01], + [-2.52464716e-13, 5.17727056e+01, 5.36976290e+01]]) + ) + ), + + # different breakdown_voltage from default + ( + 3, -2, + np.array([[1, 3e-08, 1., 300, 1.868364353685363], + [5, 1e-09, 0.1, 3000, 2.404825405733636]]), + ( + np.array([4.99983334, 2.49991667, 0.]), + np.array([[-2, -2, 3.21521313e+01], + [-2.52464716e-13, 5.17727056e+01, 5.36976290e+01]]) + ) + ), + + # pandas input + ( + 3, -0.5, + pd.DataFrame([[1, 3e-08, 1., 300, 1.868364353685363], + [5, 1e-09, 0.1, 3000, 2.404825405733636]]), + ( + np.array([4.99983334, 2.49991667, 0.]), + np.array([[-0.5, -0.5, 3.21521313e+01], + [-2.52464716e-13, 5.17727056e+01, 5.36976290e+01]]) + ) + ), + + # params is just a list (no dimension) + ( + 5, -0.5, + [1, 3e-08, 1., 300, 1.868364353685363], + ( + np.array([0.99667772, 0.74750829, 0.49833886, 0.24916943, 0.]), + np.array([2.42028619e-14, 2.81472975e+01, 3.01512748e+01, + 3.12974337e+01, 3.21521313e+01]) + ) + ) + ]) +def test_prepare_curves(params, num_pts, breakdown_voltage, expected): + out = prepare_curves(params, num_pts, breakdown_voltage) + + # check that outputted currents array is close to expected + assert np.all(np.isclose(out[0], expected[0])) + + # check that outputted voltages array is close to expected + assert np.all(np.isclose(out[1], expected[1])) + +