diff --git a/docs/sphinx/source/whatsnew/v0.15.3.rst b/docs/sphinx/source/whatsnew/v0.15.3.rst index adfeff1dfe..257b6c2637 100644 --- a/docs/sphinx/source/whatsnew/v0.15.3.rst +++ b/docs/sphinx/source/whatsnew/v0.15.3.rst @@ -28,6 +28,10 @@ Enhancements * Add ``return_components`` kwarg to :py:func:`pvlib.irradiance.reindl` to support returning the components of sky diffuse irradiance. (:issue:`2750`, :pull:`2775`) +* Add ``return_components`` kwarg to :py:func:`pvlib.irradiance.get_sky_diffuse` + and ``diffuse_components`` kwarg to :py:func:`pvlib.irradiance.get_total_irradiance` + to support returning the components of sky diffuse irradiance. + (:issue:`2750`, :pull:`2800`) * Add iotools functions to retrieve irradiance and weather data from NSRDB PSM4 Polar, which provides satellite-derived irradiance data above 60 degree latitude. :py:func:`~pvlib.iotools.get_nsrdb_psm4_polar` and diff --git a/pvlib/irradiance.py b/pvlib/irradiance.py index f875afeb7b..6620b739df 100644 --- a/pvlib/irradiance.py +++ b/pvlib/irradiance.py @@ -281,7 +281,8 @@ def get_total_irradiance(surface_tilt, surface_azimuth, dni, ghi, dhi, dni_extra=None, airmass=None, albedo=0.25, surface_type=None, model='isotropic', - model_perez='allsitescomposite1990'): + model_perez='allsitescomposite1990', + diffuse_components=False): r""" Determine total in-plane irradiance and its beam, sky diffuse and ground reflected components, using the specified sky diffuse irradiance model. @@ -332,12 +333,22 @@ def get_total_irradiance(surface_tilt, surface_azimuth, ``'perez-driesse'``. model_perez : str, default 'allsitescomposite1990' Used only if ``model='perez'``. See :py:func:`~pvlib.irradiance.perez`. + diffuse_components : bool, default False + If `True`, returns values for the different diffuse irradiance + components available from the selected model + (e.g., isotropic, circumsolar, horizon brightening). + If `False`, only the total diffuse irradiance is returned. + This option is not available for the ``'klucher'`` and + ``'king'`` models. Returns ------- - total_irrad : OrderedDict or DataFrame + total_irrad : dict or DataFrame Contains keys/columns ``'poa_global', 'poa_direct', 'poa_diffuse', 'poa_sky_diffuse', 'poa_ground_diffuse'``. [Wm⁻²] + If ``diffuse_components`` is `True`, additional keys/columns are + returned for each of the sky diffuse components returned by the + selected model. Notes ----- @@ -353,7 +364,7 @@ def get_total_irradiance(surface_tilt, surface_azimuth, poa_sky_diffuse = get_sky_diffuse( surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, dni, ghi, dhi, dni_extra=dni_extra, airmass=airmass, model=model, - model_perez=model_perez) + model_perez=model_perez, return_components=diffuse_components) poa_ground_diffuse = get_ground_diffuse(surface_tilt, ghi, albedo, surface_type) @@ -366,7 +377,8 @@ def get_sky_diffuse(surface_tilt, surface_azimuth, solar_zenith, solar_azimuth, dni, ghi, dhi, dni_extra=None, airmass=None, model='isotropic', - model_perez='allsitescomposite1990'): + model_perez='allsitescomposite1990', + return_components=False): r""" Determine in-plane sky diffuse irradiance component using the specified sky diffuse irradiance model. @@ -408,11 +420,22 @@ def get_sky_diffuse(surface_tilt, surface_azimuth, ``'perez-driesse'``. model_perez : str, default 'allsitescomposite1990' Used only if ``model='perez'``. See :py:func:`~pvlib.irradiance.perez`. + return_components : bool, default False + If `True`, returns values for the different diffuse irradiance + components available from the selected model + (e.g., isotropic, circumsolar, horizon brightening). + If `False`, only the total diffuse irradiance is returned. + This option is not available for the ``'klucher'`` and + ``'king'`` models. Returns ------- - poa_sky_diffuse : numeric - Sky diffuse irradiance in the plane of array. [Wm⁻²] + numeric, Dict, or DataFrame + Return type controlled by ``return_components`` argument. + If `False`, total sky diffuse irradiance in the plane of array + is returned (numeric). [Wm⁻²] + If `True`, the different diffuse components are returned + (dict or DataFrame). [Wm⁻²] Raises ------ @@ -438,21 +461,27 @@ def get_sky_diffuse(surface_tilt, surface_azimuth, model = model.lower() + if return_components and model in {'klucher', 'king'}: + raise ValueError('return_components is not supported for' + f' model {model}') + if dni_extra is None and model in {'haydavies', 'reindl', 'perez', 'perez-driesse'}: raise ValueError(f'dni_extra is required for model {model}') if model == 'isotropic': - sky = isotropic(surface_tilt, dhi) + sky = isotropic(surface_tilt, dhi, return_components=return_components) elif model == 'klucher': sky = klucher(surface_tilt, surface_azimuth, dhi, ghi, solar_zenith, solar_azimuth) elif model == 'haydavies': sky = haydavies(surface_tilt, surface_azimuth, dhi, dni, dni_extra, - solar_zenith, solar_azimuth) + solar_zenith, solar_azimuth, + return_components=return_components) elif model == 'reindl': sky = reindl(surface_tilt, surface_azimuth, dhi, dni, ghi, dni_extra, - solar_zenith, solar_azimuth) + solar_zenith, solar_azimuth, + return_components=return_components) elif model == 'king': sky = king(surface_tilt, dhi, ghi, solar_zenith) elif model == 'perez': @@ -460,11 +489,12 @@ def get_sky_diffuse(surface_tilt, surface_azimuth, airmass = atmosphere.get_relative_airmass(solar_zenith) sky = perez(surface_tilt, surface_azimuth, dhi, dni, dni_extra, solar_zenith, solar_azimuth, airmass, - model=model_perez) + model=model_perez, return_components=return_components) elif model == 'perez-driesse': # perez_driesse will calculate its own airmass if needed sky = perez_driesse(surface_tilt, surface_azimuth, dhi, dni, dni_extra, - solar_zenith, solar_azimuth, airmass) + solar_zenith, solar_azimuth, airmass, + return_components=return_components) else: raise ValueError(f'invalid model selection {model}') @@ -488,7 +518,7 @@ def poa_components(aoi, dni, poa_sky_diffuse, poa_ground_diffuse): Direct normal irradiance, as measured from a TMY file or calculated with a clearsky model. See :term:`dni`. [Wm⁻²] - poa_sky_diffuse : numeric + poa_sky_diffuse : numeric, Dict or DataFrame Diffuse irradiance in the plane of the modules, as calculated by a diffuse irradiance translation function. [Wm⁻²] @@ -499,31 +529,48 @@ def poa_components(aoi, dni, poa_sky_diffuse, poa_ground_diffuse): Returns ------- - irrads : OrderedDict or DataFrame + irrads : Dict or DataFrame Contains the following keys: - * ``poa_global`` : Total in-plane irradiance. [Wm⁻²] - * ``poa_direct`` : Total in-plane beam irradiance. [Wm⁻²] - * ``poa_diffuse`` : Total in-plane diffuse irradiance. [Wm⁻²] - * ``poa_sky_diffuse`` : In-plane diffuse irradiance from sky. [Wm⁻²] - * ``poa_ground_diffuse`` : In-plane diffuse irradiance from ground. - [Wm⁻²] + * ``poa_global`` : Total irradiance on a tilted plane. [Wm⁻²] + * ``poa_direct`` : Direct irradiance on a tilted plane. [Wm⁻²] + * ``poa_diffuse`` : Diffuse irradiance on a tilted plane. [Wm⁻²] + * ``poa_sky_diffuse`` : The sky diffuse component of irradiance on a + tilted plane. [Wm⁻²] + * ``poa_ground_diffuse`` : The ground diffuse component of irradiance + on a tilted plane. [Wm⁻²] + + If ``poa_sky_diffuse`` is a Dict or DataFrame, ``irrads`` will + contain additional keys for each of the diffuse components returned by + the selected diffuse irradiance model. Notes ------ Negative beam irradiation due to AOI > 90° or AOI < 0° is set to zero. ''' + if isinstance(poa_sky_diffuse, dict): + sky_components = poa_sky_diffuse.copy() + total_poa_sky_diffuse = sky_components.pop('poa_sky_diffuse') + elif isinstance(poa_sky_diffuse, pd.DataFrame): + sky_components = poa_sky_diffuse.to_dict(orient='series') + total_poa_sky_diffuse = sky_components.pop('poa_sky_diffuse') + else: + sky_components = {} + total_poa_sky_diffuse = poa_sky_diffuse + poa_direct = np.maximum(dni * np.cos(np.radians(aoi)), 0) - poa_diffuse = poa_sky_diffuse + poa_ground_diffuse + poa_diffuse = total_poa_sky_diffuse + poa_ground_diffuse poa_global = poa_direct + poa_diffuse - irrads = OrderedDict() - irrads['poa_global'] = poa_global - irrads['poa_direct'] = poa_direct - irrads['poa_diffuse'] = poa_diffuse - irrads['poa_sky_diffuse'] = poa_sky_diffuse - irrads['poa_ground_diffuse'] = poa_ground_diffuse + irrads = { + 'poa_global': poa_global, + 'poa_direct': poa_direct, + 'poa_diffuse': poa_diffuse, + 'poa_sky_diffuse': total_poa_sky_diffuse, + 'poa_ground_diffuse': poa_ground_diffuse, + **sky_components + } if isinstance(poa_direct, pd.Series): irrads = pd.DataFrame(irrads) @@ -650,6 +697,11 @@ def isotropic(surface_tilt, dhi, return_components=False): * poa_isotropic: The portion of sky diffuse irradiance on a tilted plane from the isotropic sky dome. [Wm⁻²] + diffuse_components : Dict (array input) or DataFrame (Series input) + Keys/columns are: + * poa_sky_diffuse: Total sky diffuse + * poa_isotropic + References ---------- .. [1] Loutzenhiser P.G. et al. "Empirical validation of models to diff --git a/tests/test_irradiance.py b/tests/test_irradiance.py index 454409aefa..b4efcdd50c 100644 --- a/tests/test_irradiance.py +++ b/tests/test_irradiance.py @@ -520,6 +520,13 @@ def test_get_sky_diffuse_model_invalid(): model='invalid') +def test_get_sky_diffuse_components_model_not_supported(): + with pytest.raises(ValueError): + irradiance.get_sky_diffuse( + 30, 180, 0, 180, 1000, 1100, 100, dni_extra=1360, airmass=1, + model='klucher', return_components=True) + + def test_get_sky_diffuse_missing_dni_extra(): msg = 'dni_extra is required' with pytest.raises(ValueError, match=msg): @@ -573,6 +580,46 @@ def test_get_total_irradiance(irrad_data, ephem_data, dni_et, 'poa_ground_diffuse'] +def test_get_total_irradiance_diffuse_components(irrad_data, ephem_data, + dni_et, relative_airmass): + models = ['reindl', 'perez', 'perez-driesse'] + + for model in models: + total = irradiance.get_total_irradiance( + 32, 180, + ephem_data['apparent_zenith'], ephem_data['azimuth'], + dni=irrad_data['dni'], ghi=irrad_data['ghi'], + dhi=irrad_data['dhi'], + dni_extra=dni_et, airmass=relative_airmass, + model=model, + surface_type='urban', + diffuse_components=True) + + assert total.columns.tolist() == ['poa_global', 'poa_direct', + 'poa_diffuse', 'poa_sky_diffuse', + 'poa_ground_diffuse', + 'poa_isotropic', 'poa_circumsolar', + 'poa_horizon'] + + for model in models: + total = irradiance.get_total_irradiance( + 32, 180, + ephem_data['apparent_zenith'].to_numpy(), + ephem_data['azimuth'].to_numpy(), + dni=irrad_data['dni'].to_numpy(), ghi=irrad_data['ghi'].to_numpy(), + dhi=irrad_data['dhi'].to_numpy(), + dni_extra=dni_et, airmass=relative_airmass, + model=model, + surface_type='urban', + diffuse_components=True) + + assert list(total.keys()) == ['poa_global', 'poa_direct', + 'poa_diffuse', 'poa_sky_diffuse', + 'poa_ground_diffuse', + 'poa_isotropic', 'poa_circumsolar', + 'poa_horizon'] + + @pytest.mark.parametrize('model', ['isotropic', 'klucher', 'haydavies', 'reindl', 'king', 'perez', 'perez-driesse']) @@ -662,6 +709,59 @@ def test_poa_components(irrad_data, ephem_data, dni_et, relative_airmass): assert_frame_equal(out, expected) +def test_poa_components_diffuse_components_perez(irrad_data, ephem_data, + dni_et, relative_airmass): + aoi = irradiance.aoi(40, 180, ephem_data['apparent_zenith'], + ephem_data['azimuth']) + gr_sand = irradiance.get_ground_diffuse(40, irrad_data['ghi'], + surface_type='sand') + diff_perez = irradiance.perez( + 40, 180, irrad_data['dhi'], irrad_data['dni'], dni_et, + ephem_data['apparent_zenith'], ephem_data['azimuth'], relative_airmass, + return_components=True) + out = irradiance.poa_components( + aoi, irrad_data['dni'], diff_perez, gr_sand) + expected = pd.DataFrame(np.array( + [[0., -0., 0., 0., + 0., 0., 0., 0.], + [35.19456561, 0., 35.19456561, 31.4635077, + 3.73105791, 26.841386, 0.000000, 4.622122], + [956.18253696, 798.31939281, 157.86314414, 109.08433162, + 48.77881252, 41.621826, 61.619987, 5.842518], + [90.99624896, 33.50143401, 57.49481495, 45.45978964, + 12.03502531, 31.726961, 4.479664, 9.253165]]), + columns=['poa_global', 'poa_direct', 'poa_diffuse', 'poa_sky_diffuse', + 'poa_ground_diffuse', 'poa_isotropic', 'poa_circumsolar', + 'poa_horizon'], + index=irrad_data.index) + assert_frame_equal(out, expected) + + +def test_poa_components_diffuse_components_isotropic(irrad_data, ephem_data, + dni_et, relative_airmass): + aoi = irradiance.aoi(40, 180, ephem_data['apparent_zenith'], + ephem_data['azimuth']) + gr_sand = irradiance.get_ground_diffuse(40, irrad_data['ghi'], + surface_type='sand') + diff_isotropic = irradiance.isotropic( + 40, irrad_data['dhi'], return_components=True) + out = irradiance.poa_components( + aoi, irrad_data['dni'], diff_isotropic, gr_sand) + expected = pd.DataFrame(np.array( + [[0., -0., 0., 0., + 0., 0.], + [39.459460, 0.000000, 39.459460, 35.728402, + 3.731058, 35.728402], + [951.699533, 798.319393, 153.380140, 104.601328, + 48.778813, 104.601328], + [100.313650, 33.501434, 66.812216, 54.777191, + 12.035025, 54.777191]]), + columns=['poa_global', 'poa_direct', 'poa_diffuse', 'poa_sky_diffuse', + 'poa_ground_diffuse', 'poa_isotropic'], + index=irrad_data.index) + assert_frame_equal(out, expected) + + @pytest.mark.parametrize('pressure,expected', [ (93193, [[830.46567, 0.79742, 0.93505], [676.18340, 0.63782, 3.02102]]),