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/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) 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