Repository: pvlib/pvlib-python

Issue:
Reduced order electrical mismatch model
**Is your feature request related to a problem? Please describe.**
Electrical mismatch loss due to cell-to-cell irradiance nonuniformity is relevant for bifacial system performance, but pvlib does not offer any tools to model this.  PVMismatch can, but maybe I don't want to learn a new tool, maybe I don't have 2-diode model parameters, maybe I don't want another dependency, maybe I just want a quick mismatch estimate without the computational burden of a full I-V circuit solver...

**Describe the solution you'd like**
One of the many feature requests suggested at PVPMC (thanks @shirubana!) is an implementation of the reduced order electrical mismatch model described in [Deline, Pelaez et al 2020, "Estimating and parameterizing mismatch power loss in bifacial photovoltaic systems"](https://doi.org/10.1002/pip.3259).  

The model itself is straightforward: given irradiance at several locations across a module, summarize the irradiance variation using mean absolute difference and feed that into a polynomial with known coefficients.  What might be more difficult is deciding what the structure of the inputs should be, as we don't currently have a data structure convention for representing multiple irradiance values for a single module.  It must be at least 2D (time, location in module), but maybe 3D would be better to allow vectorization across modules.

**Describe alternatives you've considered**
Of course a full I-V curve circuit simulator is still desirable in the long run, but this model is a lot simpler both to implement and to use.  No reason not to have both IMHO.

**Additional context**

- `pvfactors` will give you localized/partitioned irradiance going up the row height if you ask it nicely; perhaps we should add a gallery example demonstrating how to do that.  I don't think the infinite sheds code can return irradiance for small parts of the module, can it?
- `bifacial_radiance` already has a BSD-3 implementation we could use as a starting point:
https://github.com/NREL/bifacial_radiance/blob/5f9de36861ec9bcffe169c9a4371806860c0e224/bifacial_radiance/mismatch.py#L166-L220



Discussion:
There's also a pytest for half of it.  

[https://github.com/NREL/bifacial_radiance/blob/main/tests/test_mismatch.py#L51-L63
](https://github.com/NREL/bifacial_radiance/blob/main/tests/test_mismatch.py#L51-L63
)


Which module do you envision this function to live? 
pvsystem.py for this simple model, I think. Code for detailed mismatch calculations would go into singlediode or ivtools. 



Patch:
diff --git a/docs/examples/bifacial/plot_irradiance_nonuniformity_loss.py b/docs/examples/bifacial/plot_irradiance_nonuniformity_loss.py
new file mode 100644
index 0000000000..8b19ebeb79
--- /dev/null
+++ b/docs/examples/bifacial/plot_irradiance_nonuniformity_loss.py
@@ -0,0 +1,129 @@
+"""
+Plot Irradiance Non-uniformity Loss
+===================================
+
+Calculate the DC power lost to irradiance non-uniformity in a bifacial PV
+array.
+"""
+
+# %%
+# The incident irradiance on the backside of a bifacial PV module is
+# not uniform due to neighboring rows, the ground albedo, and site conditions.
+# When each cell works at different irradiance levels, the power produced by
+# the module is less than the sum of the power produced by each cell since the
+# maximum power point (MPP) of each cell is different, but cells connected in
+# series will operate at the same current.
+# This is known as irradiance non-uniformity loss.
+#
+# Calculating the IV curve of each cell and then matching the working point of
+# the whole module is computationally expensive, so a simple model to account
+# for this loss is of interest. Deline et al. [1]_ proposed a model based on
+# the Relative Mean Absolute Difference (RMAD) of the irradiance of each cell.
+# They considered the standard deviation of the cells' irradiances, but they
+# found that the RMAD was a better predictor of the mismatch loss.
+#
+# This example demonstrates how to model the irradiance non-uniformity loss
+# from the irradiance levels of each cell in a PV module.
+#
+# The function
+# :py:func:`pvlib.bifacial.power_mismatch_deline` is
+# used to transform the Relative Mean Absolute Difference (RMAD) of the
+# irradiance into a power loss mismatch. Down below you will find a
+# numpy-based implementation of the RMAD function.
+#
+# References
+# ----------
+# .. [1] C. Deline, S. Ayala Pelaez, S. MacAlpine, and C. Olalla, 'Estimating
+#    and parameterizing mismatch power loss in bifacial photovoltaic
+#    systems', Progress in Photovoltaics: Research and Applications, vol. 28,
+#    no. 7, pp. 691-703, 2020, :doi:`10.1002/pip.3259`.
+#
+# .. sectionauthor:: Echedey Luis <echelual (at) gmail.com>
+
+import numpy as np
+import matplotlib.pyplot as plt
+from matplotlib.cm import ScalarMappable
+from matplotlib.colors import Normalize
+
+from pvlib.bifacial import power_mismatch_deline
+
+# %%
+# Problem description
+# -------------------
+# Let's set a fixed irradiance to each cell row of the PV array with the values
+# described in Figure 1 (A), [1]_. We will cover this case for educational
+# purposes, although it can be achieved with the packages
+# :ref:`solarfactors <https://github.com/pvlib/solarfactors/>` and
+# :ref:`bifacial_radiance <https://github.com/NREL/bifacial_radiance>`.
+#
+# Here we set and plot the global irradiance level of each cell.
+
+x = np.arange(12, 0, -1)
+y = np.arange(6, 0, -1)
+cells_irrad = np.repeat([1059, 976, 967, 986, 1034, 1128], len(x)).reshape(
+    len(y), len(x)
+)
+
+color_map = "gray"
+color_norm = Normalize(930, 1150)
+
+fig, ax = plt.subplots()
+fig.suptitle("Global Irradiance Levels of Each Cell")
+fig.colorbar(
+    ScalarMappable(cmap=color_map, norm=color_norm),
+    ax=ax,
+    orientation="vertical",
+    label="$[W/m^2]$",
+)
+ax.set_aspect("equal")
+ax.pcolormesh(
+    x,
+    y,
+    cells_irrad,
+    shading="nearest",
+    edgecolors="black",
+    cmap=color_map,
+    norm=color_norm,
+)
+
+# %%
+# Relative Mean Absolute Difference
+# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+# Calculate the Relative Mean Absolute Difference (RMAD) of the cells'
+# irradiances with the following function, Eq. (4) of [1]_:
+#
+# .. math::
+#
+#    \Delta \left[ unitless \right] = \frac{1}{n^2 \bar{G}_{total}}
+#    \sum_{i=1}^{n} \sum_{j=1}^{n} \lvert G_{total,i} - G_{total,j} \rvert
+#
+
+
+def rmad(data, axis=None):
+    """
+    Relative Mean Absolute Difference. Output is [Unitless].
+    https://stackoverflow.com/a/19472336/19371110
+    """
+    mean = np.mean(data, axis)
+    mad = np.mean(np.absolute(data - mean), axis)
+    return mad / mean
+
+
+rmad_cells = rmad(cells_irrad)
+
+# this is the same as a column's RMAD!
+print(rmad_cells == rmad(cells_irrad[:, 0]))
+
+# %%
+# Mismatch Loss
+# ^^^^^^^^^^^^^
+# Calculate the power loss ratio due to the irradiance non-uniformity
+# with :py:func:`pvlib.bifacial.power_mismatch_deline`.
+
+mismatch_loss = power_mismatch_deline(rmad_cells)
+
+print(f"RMAD of the cells' irradiance: {rmad_cells:.3} [unitless]")
+print(
+    "Power loss due to the irradiance non-uniformity: "
+    + f"{mismatch_loss:.3} [unitless]"
+)
diff --git a/docs/sphinx/source/reference/bifacial.rst b/docs/sphinx/source/reference/bifacial.rst
index 6405fe4afc..a107062fd2 100644
--- a/docs/sphinx/source/reference/bifacial.rst
+++ b/docs/sphinx/source/reference/bifacial.rst
@@ -12,6 +12,13 @@ Functions for calculating front and back surface irradiance
    bifacial.infinite_sheds.get_irradiance
    bifacial.infinite_sheds.get_irradiance_poa
 
+Loss models that are specific to bifacial PV systems
+
+.. autosummary::
+   :toctree: generated/
+
+   bifacial.power_mismatch_deline
+
 Utility functions for bifacial modeling
 
 .. autosummary::
diff --git a/docs/sphinx/source/whatsnew/v0.11.1.rst b/docs/sphinx/source/whatsnew/v0.11.1.rst
index aa2205bb43..1e839c596c 100644
--- a/docs/sphinx/source/whatsnew/v0.11.1.rst
+++ b/docs/sphinx/source/whatsnew/v0.11.1.rst
@@ -10,6 +10,9 @@ Deprecations
 
 Enhancements
 ~~~~~~~~~~~~
+* Add new losses function that accounts for non-uniform irradiance on bifacial
+  modules, :py:func:`pvlib.bifacial.power_mismatch_deline`.
+  (:issue:`2045`, :pull:`2046`)
 
 
 Bug fixes
@@ -30,4 +33,4 @@ Requirements
 
 Contributors
 ~~~~~~~~~~~~
-
+* Echedey Luis (:ghuser:`echedey-ls`)
diff --git a/pvlib/bifacial/__init__.py b/pvlib/bifacial/__init__.py
index 0a6b98d4f5..e166c55108 100644
--- a/pvlib/bifacial/__init__.py
+++ b/pvlib/bifacial/__init__.py
@@ -1,10 +1,10 @@
 """
-The ``bifacial`` module contains functions to model irradiance for bifacial
-modules.
-
+The ``bifacial`` submodule contains functions to model bifacial modules.
 """
+
 from pvlib._deprecation import deprecated
-from pvlib.bifacial import pvfactors, infinite_sheds, utils   # noqa: F401
+from pvlib.bifacial import pvfactors, infinite_sheds, utils  # noqa: F401
+from .loss_models import power_mismatch_deline  # noqa: F401
 
 pvfactors_timeseries = deprecated(
     since='0.9.1',
diff --git a/pvlib/bifacial/loss_models.py b/pvlib/bifacial/loss_models.py
new file mode 100644
index 0000000000..cbcb2ba4d1
--- /dev/null
+++ b/pvlib/bifacial/loss_models.py
@@ -0,0 +1,155 @@
+import numpy as np
+import pandas as pd
+
+
+def power_mismatch_deline(
+    rmad,
+    coefficients=(0, 0.142, 0.032 * 100),
+    fill_factor: float = None,
+    fill_factor_reference: float = 0.79,
+):
+    r"""
+    Estimate DC power loss due to irradiance non-uniformity.
+
+    This model is described for bifacial modules in [1]_, where the backside
+    irradiance is less uniform due to mounting and site conditions.
+
+    The power loss is estimated by a polynomial model of the Relative Mean
+    Absolute Difference (RMAD) of the cell-by-cell total irradiance.
+
+    Use ``fill_factor`` to account for different fill factors between the
+    data used to fit the model and the module of interest. Specify the model's fill factor with
+    ``fill_factor_reference``.
+
+    .. versionadded:: 0.11.1
+
+    Parameters
+    ----------
+    rmad : numeric
+        The Relative Mean Absolute Difference of the cell-by-cell total
+        irradiance. [Unitless]
+
+        See the *Notes* section for the equation to calculate ``rmad`` from the
+        bifaciality and the front and back irradiances.
+
+    coefficients : float collection or numpy.polynomial.polynomial.Polynomial, default ``(0, 0.142, 0.032 * 100)``
+        The polynomial coefficients to use.
+
+        If a :external:class:`numpy.polynomial.polynomial.Polynomial`,
+        it is evaluated as is. If not a ``Polynomial``, it must be the
+        coefficients of a polynomial in ``rmad``, where the first element is
+        the constant term and the last element is the highest order term. A
+        :external:class:`~numpy.polynomial.polynomial.Polynomial`
+        will be created internally.
+
+    fill_factor : float, optional
+        Fill factor at standard test condition (STC) of the module.
+        Accounts for different fill factors between the trained model and the
+        module under non-uniform irradiance.
+        If not provided, the default ``fill_factor_reference`` of 0.79 is used.
+
+    fill_factor_reference : float, default 0.79
+        Fill factor at STC of the module used to train the model.
+
+    Returns
+    -------
+    loss : numeric
+        The fractional power loss. [Unitless]
+
+        Output will be a ``pandas.Series`` if ``rmad`` is a ``pandas.Series``.
+
+    Notes
+    -----
+    The default model implemented is equation (11) [1]_:
+
+    .. math::
+
+       M[\%] &= 0.142 \Delta[\%] + 0.032 \Delta^2[\%] \qquad \text{(11)}
+
+       M[-] &= 0.142 \Delta[-] + 0.032 \times 100 \Delta^2[-]
+
+    where the upper equation is in percentage (same as paper) and the lower
+    one is unitless. The implementation uses the unitless version, where
+    :math:`M[-]` is the mismatch power loss [unitless] and
+    :math:`\Delta[-]` is the Relative Mean Absolute Difference [unitless]
+    of the global irradiance, Eq. (4) of [1]_ and [2]_.
+    Note that the n-th power coefficient is multiplied by :math:`100^{n-1}`
+    to convert the percentage to unitless.
+
+    The losses definition is Eq. (1) of [1]_, and it's defined as a loss of the
+    output power:
+
+    .. math::
+
+       M = 1 - \frac{P_{Array}}{\sum P_{Cells}} \qquad \text{(1)}
+
+    To account for a module with a fill factor distinct from the one used to
+    train the model (``0.79`` by default), the output of the model can be
+    modified with Eq. (7):
+
+    .. math::
+
+       M_{FF_1} = M_{FF_0} \frac{FF_1}{FF_0} \qquad \text{(7)}
+
+    where parameter ``fill_factor`` is :math:`FF_1` and
+    ``fill_factor_reference`` is :math:`FF_0`.
+
+    In the section *See Also*, you will find two packages that can be used to
+    calculate the irradiance at different points of the module.
+
+    .. note::
+       The global irradiance RMAD is different from the backside irradiance
+       RMAD.
+
+    In case the RMAD of the backside irradiance is known, the global RMAD can
+    be calculated as follows, assuming the front irradiance RMAD is
+    negligible [2]_:
+
+    .. math::
+
+       RMAD(k \cdot X + c) = RMAD(X) \cdot k \frac{k \bar{X}}{k \bar{X} + c}
+       = RMAD(X) \cdot \frac{k}{1 + \frac{c}{k \bar{X}}}
+
+    by similarity with equation (2) of [1]_:
+
+    .. math::
+
+       G_{total\,i} = G_{front\,i} + \phi_{Bifi} G_{rear\,i} \qquad \text{(2)}
+
+    which yields:
+
+    .. math::
+
+       RMAD_{total} = RMAD_{rear} \frac{\phi_{Bifi}}
+       {1 + \frac{G_{front}}{\phi_{Bifi} \bar{G}_{rear}}}
+
+    See Also
+    --------
+    `solarfactors <https://github.com/pvlib/solarfactors/>`_
+        Calculate the irradiance at different points of the module.
+    `bifacial_radiance <https://github.com/NREL/bifacial_radiance>`_
+        Calculate the irradiance at different points of the module.
+
+    References
+    ----------
+    .. [1] C. Deline, S. Ayala Pelaez, S. MacAlpine, and C. Olalla, 'Estimating
+       and parameterizing mismatch power loss in bifacial photovoltaic
+       systems', Progress in Photovoltaics: Research and Applications, vol. 28,
+       no. 7, pp. 691-703, 2020, :doi:`10.1002/pip.3259`.
+    .. [2] “Mean absolute difference,” Wikipedia, Sep. 05, 2023.
+       https://en.wikipedia.org/wiki/Mean_absolute_difference#Relative_mean_absolute_difference
+       (accessed 2024-04-14).
+    """  # noqa: E501
+    if isinstance(coefficients, np.polynomial.Polynomial):
+        model_polynom = coefficients
+    else:  # expect an iterable
+        model_polynom = np.polynomial.Polynomial(coef=coefficients)
+
+    if fill_factor:  # Eq. (7), [1]
+        # Scale output of trained model to account for different fill factors
+        model_polynom = model_polynom * fill_factor / fill_factor_reference
+
+    mismatch = model_polynom(rmad)
+    if isinstance(rmad, pd.Series):
+        mismatch = pd.Series(mismatch, index=rmad.index)
+    return mismatch


Test patch:
diff --git a/pvlib/tests/bifacial/test_losses_models.py b/pvlib/tests/bifacial/test_losses_models.py
new file mode 100644
index 0000000000..72dd050928
--- /dev/null
+++ b/pvlib/tests/bifacial/test_losses_models.py
@@ -0,0 +1,54 @@
+from pvlib import bifacial
+
+import pandas as pd
+import numpy as np
+from numpy.testing import assert_allclose
+
+
+def test_power_mismatch_deline():
+    """tests bifacial.power_mismatch_deline"""
+    premise_rmads = np.array([0.0, 0.05, 0.1, 0.15, 0.2, 0.25])
+    # test default model is for fixed tilt
+    expected_ft_mms = np.array([0.0, 0.0151, 0.0462, 0.0933, 0.1564, 0.2355])
+    result_def_mms = bifacial.power_mismatch_deline(premise_rmads)
+    assert_allclose(result_def_mms, expected_ft_mms, atol=1e-5)
+    assert np.all(np.diff(result_def_mms) > 0)  # higher RMADs => higher losses
+
+    # test custom coefficients, set model to 1+1*RMAD
+    # as Polynomial class
+    polynomial = np.polynomial.Polynomial([1, 1, 0])
+    result_custom_mms = bifacial.power_mismatch_deline(
+        premise_rmads, coefficients=polynomial
+    )
+    assert_allclose(result_custom_mms, 1 + premise_rmads)
+    # as list
+    result_custom_mms = bifacial.power_mismatch_deline(
+        premise_rmads, coefficients=[1, 1, 0]
+    )
+    assert_allclose(result_custom_mms, 1 + premise_rmads)
+
+    # test datatypes IO with Series
+    result_mms = bifacial.power_mismatch_deline(pd.Series(premise_rmads))
+    assert isinstance(result_mms, pd.Series)
+
+    # test fill_factor, fill_factor_reference
+    # default model + default fill_factor_reference
+    ff_ref_default = 0.79
+    ff_of_interest = 0.65
+    result_mms = bifacial.power_mismatch_deline(
+        premise_rmads, fill_factor=ff_of_interest
+    )
+    assert_allclose(
+        result_mms,
+        expected_ft_mms * ff_of_interest / ff_ref_default,
+        atol=1e-5,
+    )
+    # default model + custom fill_factor_reference
+    ff_of_interest = 0.65
+    ff_ref = 0.75
+    result_mms = bifacial.power_mismatch_deline(
+        premise_rmads, fill_factor=ff_of_interest, fill_factor_reference=ff_ref
+    )
+    assert_allclose(
+        result_mms, expected_ft_mms * ff_of_interest / ff_ref, atol=1e-5
+    )