Repository: pvlib/pvlib-python

Issue:
Spectral responsivity and quantum efficiency conversion functions
**Is your feature request related to a problem? Please describe.**
Add functions to convert from SR to EQE and vice versa. 

**Describe the solution you'd like**
Two distinct functions for each one of the purposes, that work both with `pandas.Series` and vector inputs.

**Describe alternatives you've considered**
N/A.

**Additional context**
Initially proposed by @markcampanelli in #1963.


Discussion:

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 8041d8f49b..fde3b170a9 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
@@ -13,3 +13,5 @@ Spectrum
    spectrum.spectral_factor_caballero
    spectrum.spectral_factor_firstsolar
    spectrum.spectral_factor_sapm
+   spectrum.sr_to_qe
+   spectrum.qe_to_sr
diff --git a/docs/sphinx/source/whatsnew/v0.11.0.rst b/docs/sphinx/source/whatsnew/v0.11.0.rst
index b5d543bff1..2f056cd3e4 100644
--- a/docs/sphinx/source/whatsnew/v0.11.0.rst
+++ b/docs/sphinx/source/whatsnew/v0.11.0.rst
@@ -19,6 +19,10 @@ Enhancements
   shade perpendicular to ``axis_azimuth``. The function is applicable to both
   fixed-tilt and one-axis tracking systems.
   (:issue:`1689`, :pull:`1725`, :pull:`1962`)
+* Added conversion functions from spectral response ([A/W]) to quantum
+  efficiency ([unitless]) and vice versa. The conversion functions are
+  :py:func:`pvlib.spectrum.sr_to_qe` and :py:func:`pvlib.spectrum.qe_to_sr`
+  respectively. (:issue:`2040`, :pull:`2041`)
 
 
 Bug fixes
@@ -42,3 +46,4 @@ Contributors
 * Cliff Hansen (:ghuser:`cwhanse`)
 * Mark Mikofski (:ghuser:`mikofski`)
 * Siddharth Kaul (:ghuser:`k10blogger`)
+* Mark Campanelli (:ghuser:`markcampanelli`)
diff --git a/pvlib/spectrum/__init__.py b/pvlib/spectrum/__init__.py
index 6c97df978e..0b9f7b03e9 100644
--- a/pvlib/spectrum/__init__.py
+++ b/pvlib/spectrum/__init__.py
@@ -6,4 +6,6 @@
     spectral_factor_caballero,
     spectral_factor_firstsolar,
     spectral_factor_sapm,
+    sr_to_qe,
+    qe_to_sr,
 )
diff --git a/pvlib/spectrum/mismatch.py b/pvlib/spectrum/mismatch.py
index 5e00b2472d..e3f434f99c 100644
--- a/pvlib/spectrum/mismatch.py
+++ b/pvlib/spectrum/mismatch.py
@@ -3,15 +3,25 @@
 """
 
 import pvlib
+from pvlib.tools import normalize_max2one
 import numpy as np
 import pandas as pd
-from scipy.interpolate import interp1d
+import scipy.constants
 from scipy.integrate import trapezoid
+from scipy.interpolate import interp1d
 import os
 
 from warnings import warn
 
 
+_PLANCK_BY_LIGHT_SPEED_OVER_ELEMENTAL_CHARGE_BY_BILLION = (
+    scipy.constants.speed_of_light
+    * scipy.constants.Planck
+    / scipy.constants.elementary_charge
+    * 1e9
+)
+
+
 def get_example_spectral_response(wavelength=None):
     '''
     Generate a generic smooth spectral response (SR) for tests and experiments.
@@ -154,7 +164,7 @@ def calc_spectral_mismatch_field(sr, e_sun, e_ref=None):
 
     e_sun: pandas.DataFrame or pandas.Series
         One or more measured solar irradiance spectra in a pandas.DataFrame
-        having wavelength in nm as column index.  A single spectrum may be
+        having wavelength in nm as column index. A single spectrum may be
         be given as a pandas.Series having wavelength in nm as index.
         [(W/m^2)/nm]
 
@@ -571,3 +581,201 @@ def spectral_factor_caballero(precipitable_water, airmass_absolute, aod500,
     )
     modifier = f_AM + f_AOD + f_PW  # Eq 5
     return modifier
+
+
+def sr_to_qe(sr, wavelength=None, normalize=False):
+    """
+    Convert spectral responsivities to quantum efficiencies.
+    If ``wavelength`` is not provided, the spectral responsivity ``sr`` must be
+    a :py:class:`pandas.Series` or :py:class:`pandas.DataFrame`, with the
+    wavelengths in the index.
+
+    Provide wavelengths in nanometers, [nm].
+
+    Conversion is described in [1]_.
+
+    .. versionadded:: 0.11.0
+
+    Parameters
+    ----------
+    sr : numeric, pandas.Series or pandas.DataFrame
+        Spectral response, [A/W].
+        Index must be the wavelength in nanometers, [nm].
+
+    wavelength : numeric, optional
+        Points where spectral response is measured, in nanometers, [nm].
+
+    normalize : bool, default False
+        If True, the quantum efficiency is normalized so that the maximum value
+        is 1.
+        For ``pandas.DataFrame``, normalization is done for each column.
+        For 2D arrays, normalization is done for each sub-array.
+
+    Returns
+    -------
+    quantum_efficiency : numeric, same type as ``sr``
+        Quantum efficiency, in the interval [0, 1].
+
+    Notes
+    -----
+    - If ``sr`` is of type ``pandas.Series`` or ``pandas.DataFrame``,
+      column names will remain unchanged in the returned object.
+    - If ``wavelength`` is provided it will be used independently of the
+      datatype of ``sr``.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> import pandas as pd
+    >>> from pvlib import spectrum
+    >>> wavelengths = np.array([350, 550, 750])
+    >>> spectral_response = np.array([0.25, 0.40, 0.57])
+    >>> quantum_efficiency = spectrum.sr_to_qe(spectral_response, wavelengths)
+    >>> print(quantum_efficiency)
+    array([0.88560142, 0.90170326, 0.94227991])
+
+    >>> spectral_response_series = pd.Series(spectral_response, index=wavelengths, name="dataset")
+    >>> qe = spectrum.sr_to_qe(spectral_response_series)
+    >>> print(qe)
+    350    0.885601
+    550    0.901703
+    750    0.942280
+    Name: dataset, dtype: float64
+
+    >>> qe = spectrum.sr_to_qe(spectral_response_series, normalize=True)
+    >>> print(qe)
+    350    0.939850
+    550    0.956938
+    750    1.000000
+    Name: dataset, dtype: float64
+
+    References
+    ----------
+    .. [1] “Spectral Response,” PV Performance Modeling Collaborative (PVPMC).
+        https://pvpmc.sandia.gov/modeling-guide/2-dc-module-iv/effective-irradiance/spectral-response/
+    .. [2] “Spectral Response | PVEducation,” www.pveducation.org.
+        https://www.pveducation.org/pvcdrom/solar-cell-operation/spectral-response
+
+    See Also
+    --------
+    pvlib.spectrum.qe_to_sr
+    """  # noqa: E501
+    if wavelength is None:
+        if hasattr(sr, "index"):  # true for pandas objects
+            # use reference to index values instead of index alone so
+            # sr / wavelength returns a series with the same name
+            wavelength = sr.index.array
+        else:
+            raise TypeError(
+                "'sr' must have an '.index' attribute"
+                + " or 'wavelength' must be provided"
+            )
+    quantum_efficiency = (
+        sr
+        / wavelength
+        * _PLANCK_BY_LIGHT_SPEED_OVER_ELEMENTAL_CHARGE_BY_BILLION
+    )
+
+    if normalize:
+        quantum_efficiency = normalize_max2one(quantum_efficiency)
+
+    return quantum_efficiency
+
+
+def qe_to_sr(qe, wavelength=None, normalize=False):
+    """
+    Convert quantum efficiencies to spectral responsivities.
+    If ``wavelength`` is not provided, the quantum efficiency ``qe`` must be
+    a :py:class:`pandas.Series` or :py:class:`pandas.DataFrame`, with the
+    wavelengths in the index.
+
+    Provide wavelengths in nanometers, [nm].
+
+    Conversion is described in [1]_.
+
+    .. versionadded:: 0.11.0
+
+    Parameters
+    ----------
+    qe : numeric, pandas.Series or pandas.DataFrame
+        Quantum efficiency.
+        If pandas subtype, index must be the wavelength in nanometers, [nm].
+
+    wavelength : numeric, optional
+        Points where quantum efficiency is measured, in nanometers, [nm].
+
+    normalize : bool, default False
+        If True, the spectral response is normalized so that the maximum value
+        is 1.
+        For ``pandas.DataFrame``, normalization is done for each column.
+        For 2D arrays, normalization is done for each sub-array.
+
+    Returns
+    -------
+    spectral_response : numeric, same type as ``qe``
+        Spectral response, [A/W].
+
+    Notes
+    -----
+    - If ``qe`` is of type ``pandas.Series`` or ``pandas.DataFrame``,
+      column names will remain unchanged in the returned object.
+    - If ``wavelength`` is provided it will be used independently of the
+      datatype of ``qe``.
+
+    Examples
+    --------
+    >>> import numpy as np
+    >>> import pandas as pd
+    >>> from pvlib import spectrum
+    >>> wavelengths = np.array([350, 550, 750])
+    >>> quantum_efficiency = np.array([0.86, 0.90, 0.94])
+    >>> spectral_response = spectrum.qe_to_sr(quantum_efficiency, wavelengths)
+    >>> print(spectral_response)
+    array([0.24277287, 0.39924442, 0.56862085])
+
+    >>> quantum_efficiency_series = pd.Series(quantum_efficiency, index=wavelengths, name="dataset")
+    >>> sr = spectrum.qe_to_sr(quantum_efficiency_series)
+    >>> print(sr)
+    350    0.242773
+    550    0.399244
+    750    0.568621
+    Name: dataset, dtype: float64
+
+    >>> sr = spectrum.qe_to_sr(quantum_efficiency_series, normalize=True)
+    >>> print(sr)
+    350    0.426950
+    550    0.702128
+    750    1.000000
+    Name: dataset, dtype: float64
+
+    References
+    ----------
+    .. [1] “Spectral Response,” PV Performance Modeling Collaborative (PVPMC).
+        https://pvpmc.sandia.gov/modeling-guide/2-dc-module-iv/effective-irradiance/spectral-response/
+    .. [2] “Spectral Response | PVEducation,” www.pveducation.org.
+        https://www.pveducation.org/pvcdrom/solar-cell-operation/spectral-response
+
+    See Also
+    --------
+    pvlib.spectrum.sr_to_qe
+    """  # noqa: E501
+    if wavelength is None:
+        if hasattr(qe, "index"):  # true for pandas objects
+            # use reference to index values instead of index alone so
+            # sr / wavelength returns a series with the same name
+            wavelength = qe.index.array
+        else:
+            raise TypeError(
+                "'qe' must have an '.index' attribute"
+                + " or 'wavelength' must be provided"
+            )
+    spectral_responsivity = (
+        qe
+        * wavelength
+        / _PLANCK_BY_LIGHT_SPEED_OVER_ELEMENTAL_CHARGE_BY_BILLION
+    )
+
+    if normalize:
+        spectral_responsivity = normalize_max2one(spectral_responsivity)
+
+    return spectral_responsivity
diff --git a/pvlib/tools.py b/pvlib/tools.py
index adf502a79d..3d766b6f72 100644
--- a/pvlib/tools.py
+++ b/pvlib/tools.py
@@ -507,3 +507,30 @@ def get_pandas_index(*args):
         (a.index for a in args if isinstance(a, (pd.DataFrame, pd.Series))),
         None
     )
+
+
+def normalize_max2one(a):
+    r"""
+    Normalize an array so that the largest absolute value is ±1.
+
+    Handles both numpy arrays and pandas objects.
+    On 2D arrays, normalization is row-wise.
+    On pandas DataFrame, normalization is column-wise.
+
+    If all values of row are 0, the array is set to NaNs.
+
+    Parameters
+    ----------
+    a : array-like
+        The array to normalize.
+
+    Returns
+    -------
+    array-like
+        The normalized array.
+    """
+    try:  # expect numpy array
+        res = a / np.max(np.absolute(a), axis=-1, keepdims=True)
+    except ValueError:  # fails for pandas objects
+        res = a.div(a.abs().max(axis=0, skipna=True))
+    return res


Test patch:
diff --git a/pvlib/tests/test_spectrum.py b/pvlib/tests/test_spectrum.py
index 793eaacfdf..7b86cb713e 100644
--- a/pvlib/tests/test_spectrum.py
+++ b/pvlib/tests/test_spectrum.py
@@ -140,7 +140,7 @@ def test_get_am15g():
 
 def test_calc_spectral_mismatch_field(spectrl2_data):
     # test that the mismatch is calculated correctly with
-    # - default and custom reference sepctrum
+    # - default and custom reference spectrum
     # - single or multiple sun spectra
 
     # sample data
@@ -315,3 +315,107 @@ def test_spectral_factor_caballero_supplied_ambiguous():
     with pytest.raises(ValueError):
         spectrum.spectral_factor_caballero(1, 1, 1, module_type=None,
                                            coefficients=None)
+
+
+@pytest.fixture
+def sr_and_eqe_fixture():
+    # Just some arbitrary data for testing the conversion functions
+    df = pd.DataFrame(
+        columns=("wavelength", "quantum_efficiency", "spectral_response"),
+        data=[
+            # nm, [0,1], A/W
+            [300, 0.85, 0.205671370402405],
+            [350, 0.86, 0.242772872514211],
+            [400, 0.87, 0.280680929019753],
+            [450, 0.88, 0.319395539919029],
+            [500, 0.89, 0.358916705212040],
+            [550, 0.90, 0.399244424898786],
+            [600, 0.91, 0.440378698979267],
+            [650, 0.92, 0.482319527453483],
+            [700, 0.93, 0.525066910321434],
+            [750, 0.94, 0.568620847583119],
+            [800, 0.95, 0.612981339238540],
+            [850, 0.90, 0.617014111207215],
+            [900, 0.80, 0.580719163489143],
+            [950, 0.70, 0.536358671833723],
+            [1000, 0.6, 0.483932636240953],
+            [1050, 0.4, 0.338752845368667],
+        ],
+    )
+    df.set_index("wavelength", inplace=True)
+    return df
+
+
+def test_sr_to_qe(sr_and_eqe_fixture):
+    # vector type
+    qe = spectrum.sr_to_qe(
+        sr_and_eqe_fixture["spectral_response"].values,
+        sr_and_eqe_fixture.index.values,  # wavelength, nm
+    )
+    assert_allclose(qe, sr_and_eqe_fixture["quantum_efficiency"])
+    # pandas series type
+    # note: output Series' name should match the input
+    qe = spectrum.sr_to_qe(
+        sr_and_eqe_fixture["spectral_response"]
+    )
+    pd.testing.assert_series_equal(
+        qe, sr_and_eqe_fixture["quantum_efficiency"],
+        check_names=False
+    )
+    assert qe.name == "spectral_response"
+    # series normalization
+    qe = spectrum.sr_to_qe(
+        sr_and_eqe_fixture["spectral_response"] * 10, normalize=True
+    )
+    pd.testing.assert_series_equal(
+        qe,
+        sr_and_eqe_fixture["quantum_efficiency"]
+        / max(sr_and_eqe_fixture["quantum_efficiency"]),
+        check_names=False,
+    )
+    # error on lack of wavelength parameter if no pandas object is provided
+    with pytest.raises(TypeError, match="must have an '.index' attribute"):
+        _ = spectrum.sr_to_qe(sr_and_eqe_fixture["spectral_response"].values)
+
+
+def test_qe_to_sr(sr_and_eqe_fixture):
+    # vector type
+    sr = spectrum.qe_to_sr(
+        sr_and_eqe_fixture["quantum_efficiency"].values,
+        sr_and_eqe_fixture.index.values,  # wavelength, nm
+    )
+    assert_allclose(sr, sr_and_eqe_fixture["spectral_response"])
+    # pandas series type
+    # note: output Series' name should match the input
+    sr = spectrum.qe_to_sr(
+        sr_and_eqe_fixture["quantum_efficiency"]
+    )
+    pd.testing.assert_series_equal(
+        sr, sr_and_eqe_fixture["spectral_response"],
+        check_names=False
+    )
+    assert sr.name == "quantum_efficiency"
+    # series normalization
+    sr = spectrum.qe_to_sr(
+        sr_and_eqe_fixture["quantum_efficiency"] * 10, normalize=True
+    )
+    pd.testing.assert_series_equal(
+        sr,
+        sr_and_eqe_fixture["spectral_response"]
+        / max(sr_and_eqe_fixture["spectral_response"]),
+        check_names=False,
+    )
+    # error on lack of wavelength parameter if no pandas object is provided
+    with pytest.raises(TypeError, match="must have an '.index' attribute"):
+        _ = spectrum.qe_to_sr(
+            sr_and_eqe_fixture["quantum_efficiency"].values
+        )
+
+
+def test_qe_and_sr_reciprocal_conversion(sr_and_eqe_fixture):
+    # test that the conversion functions are reciprocal
+    qe = spectrum.sr_to_qe(sr_and_eqe_fixture["spectral_response"])
+    sr = spectrum.qe_to_sr(qe)
+    assert_allclose(sr, sr_and_eqe_fixture["spectral_response"])
+    qe = spectrum.sr_to_qe(sr)
+    assert_allclose(qe, sr_and_eqe_fixture["quantum_efficiency"])
diff --git a/pvlib/tests/test_tools.py b/pvlib/tests/test_tools.py
index 583141a726..eb9e65c895 100644
--- a/pvlib/tests/test_tools.py
+++ b/pvlib/tests/test_tools.py
@@ -3,6 +3,7 @@
 from pvlib import tools
 import numpy as np
 import pandas as pd
+from numpy.testing import assert_allclose
 
 
 @pytest.mark.parametrize('keys, input_dict, expected', [
@@ -120,3 +121,26 @@ def test_get_pandas_index(args, args_idx):
         assert index is None
     else:
         pd.testing.assert_index_equal(args[args_idx].index, index)
+
+
+@pytest.mark.parametrize('data_in,expected', [
+    (np.array([1, 2, 3, 4, 5]),
+     np.array([0.2, 0.4, 0.6, 0.8, 1])),
+    (np.array([[0, 1, 2], [0, 3, 6]]),
+     np.array([[0, 0.5, 1], [0, 0.5, 1]])),
+    (pd.Series([1, 2, 3, 4, 5]),
+     pd.Series([0.2, 0.4, 0.6, 0.8, 1])),
+    (pd.DataFrame({"a": [0, 1, 2], "b": [0, 2, 8]}),
+     pd.DataFrame({"a": [0, 0.5, 1], "b": [0, 0.25, 1]})),
+    # test with NaN and all zeroes
+    (pd.DataFrame({"a": [0, np.nan, 1], "b": [0, 0, 0]}),
+     pd.DataFrame({"a": [0, np.nan, 1], "b": [np.nan]*3})),
+    # test with negative values
+    (np.array([1, 2, -3, 4, -5]),
+     np.array([0.2, 0.4, -0.6, 0.8, -1])),
+    (pd.Series([-2, np.nan, 1]),
+     pd.Series([-1, np.nan, 0.5])),
+])
+def test_normalize_max2one(data_in, expected):
+    result = tools.normalize_max2one(data_in)
+    assert_allclose(result, expected)