Repository: pvlib/pvlib-python

Issue:
Add a model for spectral corrections
**Additional context**
First of all, I introduce myself, my name is Jose Antonio Caballero, and I have recently finished my PhD in photovoltaic engineering at the University of Jaén, Spain.

I have developed a python script to apply spectral corrections as a function of AM, AOD, PW based on this work (https://doi.org/10.1109/jphotov.2017.2787019).

We have found that in pvlib there is already a similar methodology developed by First solar, in which the spectral corrections are based only on the AM and PW parameters, so we intend to include our proposed method in pvlib in a similar way.

As an example, I attach the code developed in python (.zip file) to estimate the spectral effects related to different flat photovoltaic technologies from the AM, AOD and PW parameters included in a .csv file.
[PV-MM-AM_AOD_PW_data.csv](https://github.com/pvlib/pvlib-python/files/6970716/PV-MM-AM_AOD_PW_data.csv)
[PV_Spectral_Corrections.zip](https://github.com/pvlib/pvlib-python/files/6970727/PV_Spectral_Corrections.zip)

Kind regards


Discussion:
Hello @Jacc0027 and welcome to this community.

I would welcome submission of a function implementing that model. It differs from the existing functions by including parameters for perovskite cells.

It would need to be coded as a function similar to [first_solar_spectral_correction](https://github.com/pvlib/pvlib-python/blob/f318c1c1527f69d9bf9aed6167ca1f6ce9e9d764/pvlib/atmosphere.py#L324) using the same parameter names, where possible.

The script isn't in a function format. The time series in the .csv file appear to be related to the SMARTS simulations used to set up this model, is that correct? If so, then these data don't need to come into pvlib.
My pleasure @cwhanse , thank you very much for the welcome.

No problem, I can adapt the code in a similar way to the one you attached, and assuming the same variables.

Yes, you are right, the script I attached is not made as a function, it was just a sample to verify the results. And again you are right, the time series in the .csv file are obtained through SMARTS simulations

Therefore, I will attach in this thread the modified code as we have just agreed.


Hi @cwhanse , please find attached the updated script, in which we have tried to meet the requested requirements.

I look forward to any comments you may have.

[AM_AOD_PW_spectral_correction.zip](https://github.com/pvlib/pvlib-python/files/6984688/AM_AOD_PW_spectral_correction.zip)

Hi Jacc0027, I have looked at the code in the attachment. Can you make a pull request? When there are some tests, I think we can start the review process.

Patch:
diff --git a/pvlib/spectrum/__init__.py b/pvlib/spectrum/__init__.py
--- a/pvlib/spectrum/__init__.py
+++ b/pvlib/spectrum/__init__.py
@@ -3,6 +3,7 @@
     calc_spectral_mismatch_field,
     get_am15g,
     get_example_spectral_response,
+    spectral_factor_caballero,
     spectral_factor_firstsolar,
     spectral_factor_sapm,
 )
diff --git a/pvlib/spectrum/mismatch.py b/pvlib/spectrum/mismatch.py
--- a/pvlib/spectrum/mismatch.py
+++ b/pvlib/spectrum/mismatch.py
@@ -450,3 +450,123 @@ def spectral_factor_sapm(airmass_absolute, module):
         spectral_loss = pd.Series(spectral_loss, airmass_absolute.index)
 
     return spectral_loss
+
+
+def spectral_factor_caballero(precipitable_water, airmass_absolute, aod500,
+                              module_type=None, coefficients=None):
+    r"""
+    Estimate a technology-specific spectral mismatch modifier from
+    airmass, aerosol optical depth, and atmospheric precipitable water,
+    using the Caballero model.
+
+    The model structure was motivated by examining the effect of these three
+    atmospheric parameters on simulated irradiance spectra and spectral
+    modifiers.  However, the coefficient values reported in [1]_ and
+    available here via the ``module_type`` parameter were determined
+    by fitting the model equations to spectral factors calculated from
+    global tilted spectral irradiance measurements taken in the city of
+    Jaén, Spain.  See [1]_ for details.
+
+    Parameters
+    ----------
+    precipitable_water : numeric
+        atmospheric precipitable water. [cm]
+
+    airmass_absolute : numeric
+        absolute (pressure-adjusted) airmass. [unitless]
+
+    aod500 : numeric
+        atmospheric aerosol optical depth at 500 nm. [unitless]
+
+    module_type : str, optional
+        One of the following PV technology strings from [1]_:
+
+        * ``'cdte'`` - anonymous CdTe module.
+        * ``'monosi'``, - anonymous sc-si module.
+        * ``'multisi'``, - anonymous mc-si- module.
+        * ``'cigs'`` - anonymous copper indium gallium selenide module.
+        * ``'asi'`` - anonymous amorphous silicon module.
+        * ``'perovskite'`` - anonymous pervoskite module.
+
+    coefficients : array-like, optional
+        user-defined coefficients, if not using one of the default coefficient
+        sets via the ``module_type`` parameter.
+
+    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.
+
+    References
+    ----------
+    .. [1] Caballero, J.A., Fernández, E., Theristis, M.,
+        Almonacid, F., and Nofuentes, G. "Spectral Corrections Based on
+        Air Mass, Aerosol Optical Depth and Precipitable Water
+        for PV Performance Modeling."
+        IEEE Journal of Photovoltaics 2018, 8(2), 552-558.
+        :doi:`10.1109/jphotov.2017.2787019`
+    """
+
+    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')
+
+    # Experimental coefficients from [1]_.
+    # The extra 0/1 coefficients at the end are used to enable/disable
+    # terms to match the different equation forms in Table 1.
+    _coefficients = {}
+    _coefficients['cdte'] = (
+        1.0044, 0.0095, -0.0037, 0.0002, 0.0000, -0.0046,
+        -0.0182, 0, 0.0095, 0.0068, 0, 1)
+    _coefficients['monosi'] = (
+        0.9706, 0.0377, -0.0123, 0.0025, -0.0002, 0.0159,
+        -0.0165, 0, -0.0016, -0.0027, 1, 0)
+    _coefficients['multisi'] = (
+        0.9836, 0.0254, -0.0085, 0.0016, -0.0001, 0.0094,
+        -0.0132, 0, -0.0002, -0.0011, 1, 0)
+    _coefficients['cigs'] = (
+        0.9801, 0.0283, -0.0092, 0.0019, -0.0001, 0.0117,
+        -0.0126, 0, -0.0011, -0.0019, 1, 0)
+    _coefficients['asi'] = (
+        1.1060, -0.0848, 0.0302, -0.0076, 0.0006, -0.1283,
+        0.0986, -0.0254, 0.0156, 0.0146, 1, 0)
+    _coefficients['perovskite'] = (
+        1.0637, -0.0491, 0.0180, -0.0047, 0.0004, -0.0773,
+        0.0583, -0.0159, 0.01251, 0.0109, 1, 0)
+
+    if module_type is not None:
+        coeff = _coefficients[module_type]
+    else:
+        coeff = coefficients
+
+    # Evaluate spectral correction factor
+    ama = airmass_absolute
+    aod500_ref = 0.084
+    pw_ref = 1.4164
+
+    f_AM = (
+        coeff[0]
+        + coeff[1] * ama
+        + coeff[2] * ama**2
+        + coeff[3] * ama**3
+        + coeff[4] * ama**4
+    )
+    # Eq 6, with Table 1
+    f_AOD = (aod500 - aod500_ref) * (
+        coeff[5]
+        + coeff[10] * coeff[6] * ama
+        + coeff[11] * coeff[6] * np.log(ama)
+        + coeff[7] * ama**2
+    )
+    # Eq 7, with Table 1
+    f_PW = (precipitable_water - pw_ref) * (
+        coeff[8]
+        + coeff[9] * np.log(ama)
+    )
+    modifier = f_AM + f_AOD + f_PW  # Eq 5
+    return modifier


Test patch:
diff --git a/pvlib/tests/test_spectrum.py b/pvlib/tests/test_spectrum.py
--- a/pvlib/tests/test_spectrum.py
+++ b/pvlib/tests/test_spectrum.py
@@ -271,3 +271,47 @@ def test_spectral_factor_sapm(sapm_module_params, airmass, expected):
         assert_series_equal(out, expected, check_less_precise=4)
     else:
         assert_allclose(out, expected, atol=1e-4)
+
+
+@pytest.mark.parametrize("module_type,expected", [
+    ('asi', np.array([0.9108, 0.9897, 0.9707, 1.0265, 1.0798, 0.9537])),
+    ('perovskite', np.array([0.9422, 0.9932, 0.9868, 1.0183, 1.0604, 0.9737])),
+    ('cdte', np.array([0.9824, 1.0000, 1.0065, 1.0117, 1.042, 0.9979])),
+    ('multisi', np.array([0.9907, 0.9979, 1.0203, 1.0081, 1.0058, 1.019])),
+    ('monosi', np.array([0.9935, 0.9987, 1.0264, 1.0074, 0.9999, 1.0263])),
+    ('cigs', np.array([1.0014, 1.0011, 1.0270, 1.0082, 1.0029, 1.026])),
+])
+def test_spectral_factor_caballero(module_type, expected):
+    ams = np.array([3.0, 1.5, 3.0, 1.5, 1.5, 3.0])
+    aods = np.array([1.0, 1.0, 0.02, 0.02, 0.08, 0.08])
+    pws = np.array([1.42, 1.42, 1.42, 1.42, 4.0, 1.0])
+    out = spectrum.spectral_factor_caballero(pws, ams, aods,
+                                             module_type=module_type)
+    assert np.allclose(expected, out, atol=1e-3)
+
+
+def test_spectral_factor_caballero_supplied():
+    # use the cdte coeffs
+    coeffs = (
+        1.0044, 0.0095, -0.0037, 0.0002, 0.0000, -0.0046,
+        -0.0182, 0, 0.0095, 0.0068, 0, 1)
+    out = spectrum.spectral_factor_caballero(1, 1, 1, coefficients=coeffs)
+    expected = 1.0021964
+    assert_allclose(out, expected, atol=1e-3)
+
+
+def test_spectral_factor_caballero_supplied_redundant():
+    # Error when specifying both module_type and coefficients
+    coeffs = (
+        1.0044, 0.0095, -0.0037, 0.0002, 0.0000, -0.0046,
+        -0.0182, 0, 0.0095, 0.0068, 0, 1)
+    with pytest.raises(ValueError):
+        spectrum.spectral_factor_caballero(1, 1, 1, module_type='cdte',
+                                           coefficients=coeffs)
+
+
+def test_spectral_factor_caballero_supplied_ambiguous():
+    # Error when specifying neither module_type nor coefficients
+    with pytest.raises(ValueError):
+        spectrum.spectral_factor_caballero(1, 1, 1, module_type=None,
+                                           coefficients=None)