diff --git a/docs/examples/irradiance-transposition/use_perez_modelchain.py b/docs/examples/irradiance-transposition/use_perez_modelchain.py new file mode 100644 index 0000000000..dd0d12f2a5 --- /dev/null +++ b/docs/examples/irradiance-transposition/use_perez_modelchain.py @@ -0,0 +1,126 @@ +""" +Use different Perez coefficients with the ModelChain +==================================================== + +This example demonstrates how to customize the ModelChain +to use site-specific Perez transposition coefficients. +""" + +# %% +# The :py:class:`pvlib.modelchain.ModelChain` object provides a useful method +# for easily constructing a PV system model with a simple, unified interface. +# However, a user may want to customize the steps +# in the system model in various ways. +# One such example is during the irradiance transposition step. +# The Perez model perform very well on field data, but +# it requires a set of fitted coefficients from various sites. +# It has been noted that these coefficients can be specific to +# various climates, so users may see improved model accuracy +# when using a site-specific set of coefficients. +# However, the base :py:class:`~pvlib.modelchain.ModelChain` +# only supports the default coefficients. +# This example shows how the :py:class:`~pvlib.modelchain.ModelChain` can +# be adjusted to use a different set of Perez coefficients. + +import pandas as pd +from pvlib.pvsystem import PVSystem +from pvlib.modelchain import ModelChain +from pvlib.temperature import TEMPERATURE_MODEL_PARAMETERS +from pvlib import iotools, location, irradiance +import pvlib +import os +import matplotlib.pyplot as plt + +# load in TMY weather data from North Carolina included with pvlib +PVLIB_DIR = pvlib.__path__[0] +DATA_FILE = os.path.join(PVLIB_DIR, 'data', '723170TYA.CSV') + +tmy, metadata = iotools.read_tmy3(DATA_FILE, coerce_year=1990, + map_variables=True) + +weather_data = tmy[['ghi', 'dhi', 'dni', 'temp_air', 'wind_speed']] + +loc = location.Location.from_tmy(metadata) + +#%% +# Now, let's set up a standard PV model using the ``ModelChain`` + +surface_tilt = metadata['latitude'] +surface_azimuth = 180 + +# define an example module and inverter +sandia_modules = pvlib.pvsystem.retrieve_sam('SandiaMod') +cec_inverters = pvlib.pvsystem.retrieve_sam('cecinverter') +sandia_module = sandia_modules['Canadian_Solar_CS5P_220M___2009_'] +cec_inverter = cec_inverters['ABB__MICRO_0_25_I_OUTD_US_208__208V_'] + +temp_params = TEMPERATURE_MODEL_PARAMETERS['sapm']['open_rack_glass_glass'] + +# define the system and ModelChain +system = PVSystem(arrays=None, + surface_tilt=surface_tilt, + surface_azimuth=surface_azimuth, + module_parameters=sandia_module, + inverter_parameters=cec_inverter, + temperature_model_parameters=temp_params) + +mc = ModelChain(system, location=loc) + +# %% +# Now, let's calculate POA irradiance values outside of the ``ModelChain``. +# We do this for both the default Perez coefficients and the desired +# alternative Perez coefficients. This enables comparison at the end. + +# Cape Canaveral seems like the most likely match for climate +model_perez = 'capecanaveral1988' + +solar_position = loc.get_solarposition(times=weather_data.index) +dni_extra = irradiance.get_extra_radiation(weather_data.index) + +POA_irradiance = irradiance.get_total_irradiance( + surface_tilt=surface_tilt, + surface_azimuth=surface_azimuth, + dni=weather_data['dni'], + ghi=weather_data['ghi'], + dhi=weather_data['dhi'], + solar_zenith=solar_position['apparent_zenith'], + solar_azimuth=solar_position['azimuth'], + model='perez', + dni_extra=dni_extra) + +POA_irradiance_new_perez = irradiance.get_total_irradiance( + surface_tilt=surface_tilt, + surface_azimuth=surface_azimuth, + dni=weather_data['dni'], + ghi=weather_data['ghi'], + dhi=weather_data['dhi'], + solar_zenith=solar_position['apparent_zenith'], + solar_azimuth=solar_position['azimuth'], + model='perez', + model_perez=model_perez, + dni_extra=dni_extra) + +# %% +# Now, run the ``ModelChain`` with both sets of irradiance data and compare +# (note that to use POA irradiance as input to the ModelChain the method +# `.run_model_from_poa` is used): + +mc.run_model_from_poa(POA_irradiance) +ac_power_default = mc.results.ac + +mc.run_model_from_poa(POA_irradiance_new_perez) +ac_power_new_perez = mc.results.ac + +start, stop = '1990-05-05 06:00:00', '1990-05-05 19:00:00' +plt.plot(ac_power_default.loc[start:stop], + label="Default Composite Perez Model") +plt.plot(ac_power_new_perez.loc[start:stop], + label="Cape Canaveral Perez Model") +plt.xticks(rotation=90) +plt.ylabel("AC Power ($W$)") +plt.legend() +plt.tight_layout() +plt.show() +# %% +# Note that there is a small, but noticeable difference from the default +# coefficients that may add up over longer periods of time. 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 85d6395ca3..72d480e32d 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`) @@ -45,6 +48,10 @@ Documentation * Added gallery example on calculating cell temperature for floating PV. (:pull:`2110`) +* Added gallery example demonstrating how to use + different Perez coefficients in a ModelChain. + (:issue:`2127`, :pull:`2148`) + * Removed unused "times" input from dni_et() function (:issue:`2105`) Requirements @@ -60,4 +67,5 @@ Contributors * Echedey Luis (:ghuser:`echedey-ls`) * Rajiv Daxini (:ghuser:`RDaxini`) * Mark A. Mikofski (:ghuser:`mikofski`) +* Ben Pierce (:ghuser:`bgpierc`) * Jose Meza (:ghuser:`JoseMezaMendieta`) 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 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)