Repository: pvlib/pvlib-python

Issue:
Create calculate_avg_photon_energy function
I think a function to calculate the average photon energy (APE) using spectral irradiance data as an input would be a useful feature in pvlib.

**Additional context**
This would support PR #2126. While there are some discussions ongoing regarding that PR in Issue #2065, I think this may still be a useful feature in pvlib anyway regardless of the outcome of that PR.

The function would be in `pvlib/spectrum/spectral_irradiance.py` following the resolution of #2125 (I will create a PR for that soon issue soon)

I am wondering what people think about naming this function. Possibilities:

`calculate_avg_photon_energy`
`calculate_average_photon_energy`
`calculate_ape`
Switch "calculate" for "get" or "calc", or just omit this first word entirely?
Alternatives? 

Any other thoughts on this?


Discussion:
> or just omit this first word entirely?

+1 for `average_photon_energy`
> > or just omit this first word entirely?
> 
> +1 for `average_photon_energy`

+2



Patch:
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 15fd1d09b8..23b5f5bb6d 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
@@ -18,3 +18,4 @@ Spectrum
    spectrum.spectral_factor_jrc
    spectrum.sr_to_qe
    spectrum.qe_to_sr
+   spectrum.average_photon_energy
diff --git a/docs/sphinx/source/whatsnew/v0.11.1.rst b/docs/sphinx/source/whatsnew/v0.11.1.rst
index 2839a0ff25..beaca6c466 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 function to calculate the average photon energy,
+  :py:func:`pvlib.spectrum.average_photon_energy`.
+  (:issue:`2135`, :pull:`2140`)
 * Add new losses function that accounts for non-uniform irradiance on bifacial
   modules, :py:func:`pvlib.bifacial.power_mismatch_deline`.
   (:issue:`2045`, :pull:`2046`)
diff --git a/pvlib/spectrum/__init__.py b/pvlib/spectrum/__init__.py
index 87deb86018..e282afc01f 100644
--- a/pvlib/spectrum/__init__.py
+++ b/pvlib/spectrum/__init__.py
@@ -10,6 +10,7 @@
 from pvlib.spectrum.irradiance import (  # noqa: F401
     get_am15g,
     get_reference_spectra,
+    average_photon_energy,
 )
 from pvlib.spectrum.response import (  # noqa: F401
     get_example_spectral_response,
diff --git a/pvlib/spectrum/irradiance.py b/pvlib/spectrum/irradiance.py
index 45846a0046..cb3e5e1ddb 100644
--- a/pvlib/spectrum/irradiance.py
+++ b/pvlib/spectrum/irradiance.py
@@ -9,6 +9,8 @@
 import pandas as pd
 from pathlib import Path
 from functools import partial
+from scipy import constants
+from scipy.integrate import trapezoid
 
 
 @deprecated(
@@ -176,3 +178,95 @@ def get_reference_spectra(wavelengths=None, standard="ASTM G173-03"):
         )
 
     return standard
+
+
+def average_photon_energy(spectra):
+    r"""
+    Calculate the average photon energy of one or more spectral irradiance
+    distributions.
+
+    Parameters
+    ----------
+    spectra : pandas.Series or pandas.DataFrame
+
+        Spectral irradiance, must be positive. [Wm⁻²nm⁻¹]
+
+        A single spectrum must be a :py:class:`pandas.Series` with wavelength
+        [nm] as the index, while multiple spectra must be rows in a
+        :py:class:`pandas.DataFrame` with column headers as wavelength [nm].
+
+    Returns
+    -------
+    ape : numeric or pandas.Series
+        Average Photon Energy [eV].
+        Note: returns ``np.nan`` in the case of all-zero spectral irradiance
+        input.
+
+    Notes
+    -----
+    The average photon energy (APE) is an index used to characterise the solar
+    spectrum. It has been used widely in the physics literature since the
+    1900s, but its application for solar spectral irradiance characterisation
+    in the context of PV performance modelling was proposed in 2002 [1]_. The
+    APE is calculated based on the principle that a photon's energy is
+    inversely proportional to its wavelength:
+
+    .. math::
+
+        E_\gamma = \frac{hc}{\lambda},
+
+    where :math:`E_\gamma` is the energy of a photon with wavelength
+    :math:`\lambda`, :math:`h` is the Planck constant, and :math:`c` is the
+    speed of light. Therefore, the average energy of all photons within a
+    single spectral irradiance distribution provides an indication of the
+    general shape of the spectrum. A higher average photon energy
+    (shorter wavelength) indicates a blue-shifted spectrum, while a lower
+    average photon energy (longer wavelength) would indicate a red-shifted
+    spectrum. This value of the average photon energy can be calculated by
+    dividing the total energy in the spectrum by the total number of photons
+    in the spectrum as follows [1]_:
+
+    .. math::
+
+        \overline{E_\gamma} = \frac{1}{q} \cdot \frac{\int G(\lambda) \,
+                                                       d\lambda}
+        {\int \Phi(\lambda) \, d\lambda}.
+
+    :math:`\Phi(\lambda)` is the photon flux density as a function of
+    wavelength, :math:`G(\lambda)` is the spectral irradiance, :math:`q` is the
+    elementary charge used here so that the average photon energy,
+    :math:`\overline{E_\gamma}`, is expressed in electronvolts (eV). The
+    integrals are computed over the full wavelength range of the ``spectra``
+    parameter.
+
+    References
+    ----------
+    .. [1] Jardine, C., et al., 2002, January. Influence of spectral effects on
+       the performance of multijunction amorphous silicon cells. In Proc.
+       Photovoltaic in Europe Conference (pp. 1756-1759).
+    """
+
+    if not isinstance(spectra, (pd.Series, pd.DataFrame)):
+        raise TypeError('`spectra` must be either a'
+                        ' pandas Series or DataFrame')
+
+    if (spectra < 0).any().any():
+        raise ValueError('Spectral irradiance data must be positive')
+
+    hclambda = pd.Series((constants.h*constants.c)/(spectra.T.index*1e-9))
+    hclambda.index = spectra.T.index
+    pfd = spectra.div(hclambda)
+
+    def integrate(e):
+        return trapezoid(e, x=e.T.index, axis=-1)
+
+    int_spectra = integrate(spectra)
+    int_pfd = integrate(pfd)
+
+    with np.errstate(invalid='ignore'):
+        ape = (1/constants.elementary_charge)*int_spectra/int_pfd
+
+    if isinstance(spectra, pd.DataFrame):
+        ape = pd.Series(ape, index=spectra.index)
+
+    return ape
diff --git a/pvlib/spectrum/mismatch.py b/pvlib/spectrum/mismatch.py
index ab805130d0..3afc210e73 100644
--- a/pvlib/spectrum/mismatch.py
+++ b/pvlib/spectrum/mismatch.py
@@ -8,6 +8,7 @@
 import numpy as np
 import pandas as pd
 from scipy.integrate import trapezoid
+
 from warnings import warn
 
 


Test patch:
diff --git a/pvlib/tests/spectrum/test_irradiance.py b/pvlib/tests/spectrum/test_irradiance.py
index dd6740a02f..63b0bc95d2 100644
--- a/pvlib/tests/spectrum/test_irradiance.py
+++ b/pvlib/tests/spectrum/test_irradiance.py
@@ -72,3 +72,67 @@ def test_get_reference_spectra_invalid_reference():
     # test that an invalid reference identifier raises a ValueError
     with pytest.raises(ValueError, match="Invalid standard identifier"):
         spectrum.get_reference_spectra(standard="invalid")
+
+
+def test_average_photon_energy_series():
+    # test that the APE is calculated correctly with single spectrum
+    # series input
+
+    spectra = spectrum.get_reference_spectra()
+    spectra = spectra['global']
+    ape = spectrum.average_photon_energy(spectra)
+    expected = 1.45017
+    assert_allclose(ape, expected, rtol=1e-4)
+
+
+def test_average_photon_energy_dataframe():
+    # test that the APE is calculated correctly with multiple spectra
+    # dataframe input and that the output is a series
+
+    spectra = spectrum.get_reference_spectra().T
+    ape = spectrum.average_photon_energy(spectra)
+    expected = pd.Series([1.36848, 1.45017, 1.40885])
+    expected.index = spectra.index
+    assert_series_equal(ape, expected, rtol=1e-4)
+
+
+def test_average_photon_energy_invalid_type():
+    # test that spectrum argument is either a pandas Series or dataframe
+    spectra = 5
+    with pytest.raises(TypeError, match='must be either a pandas Series or'
+                       ' DataFrame'):
+        spectrum.average_photon_energy(spectra)
+
+
+def test_average_photon_energy_neg_irr_series():
+    # test for handling of negative spectral irradiance values with a
+    # pandas Series input
+
+    spectra = spectrum.get_reference_spectra()['global']*-1
+    with pytest.raises(ValueError, match='must be positive'):
+        spectrum.average_photon_energy(spectra)
+
+
+def test_average_photon_energy_neg_irr_dataframe():
+    # test for handling of negative spectral irradiance values with a
+    # pandas DataFrame input
+
+    spectra = spectrum.get_reference_spectra().T*-1
+
+    with pytest.raises(ValueError, match='must be positive'):
+        spectrum.average_photon_energy(spectra)
+
+
+def test_average_photon_energy_zero_irr():
+    # test for handling of zero spectral irradiance values with
+    # pandas DataFrame and pandas Series input
+
+    spectra_df_zero = spectrum.get_reference_spectra().T
+    spectra_df_zero.iloc[1] = 0
+    spectra_series_zero = spectrum.get_reference_spectra()['global']*0
+    out_1 = spectrum.average_photon_energy(spectra_df_zero)
+    out_2 = spectrum.average_photon_energy(spectra_series_zero)
+    expected_1 = np.array([1.36848, np.nan, 1.40885])
+    expected_2 = np.nan
+    assert_allclose(out_1, expected_1, atol=1e-3)
+    assert_allclose(out_2, expected_2, atol=1e-3)