From 1c459d3e4e39cc2bf36327d410224ed46970a1b1 Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Tue, 9 Jun 2026 10:50:12 +0100 Subject: [PATCH 01/28] Add return_components to isotropic --- pvlib/irradiance.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/pvlib/irradiance.py b/pvlib/irradiance.py index 50f02426de..b2bd3c4076 100644 --- a/pvlib/irradiance.py +++ b/pvlib/irradiance.py @@ -595,7 +595,7 @@ def get_ground_diffuse(surface_tilt, ghi, albedo=.25, surface_type=None): return diffuse_irrad -def isotropic(surface_tilt, dhi): +def isotropic(surface_tilt, dhi, return_components=False): r''' Determine diffuse irradiance from the sky on a tilted surface using the isotropic sky model. @@ -619,11 +619,27 @@ def isotropic(surface_tilt, dhi): dhi : numeric Diffuse horizontal irradiance, must be >=0. See :term:`dhi`. + return_components : bool, default `False` + If `False`, ``sky_diffuse`` is returned. + If `True`, ``diffuse_components`` is returned. + For this model, return_components does not add more information, + but it is included for consistency with the other sky diffuse models. + Returns ------- - diffuse : numeric + numeric, OrderedDict, or DataFrame + Return type controlled by ``return_components`` argument. + If `False`, ``sky_diffuse`` is returned. + If `True`, ``diffuse_components`` is returned. + + sky_diffuse : numeric The sky diffuse component of the solar radiation. [Wm⁻²] + diffuse_components : OrderedDict (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 @@ -638,7 +654,17 @@ def isotropic(surface_tilt, dhi): ''' sky_diffuse = dhi * (1 + tools.cosd(surface_tilt)) * 0.5 - return sky_diffuse + if return_components: + diffuse_components = OrderedDict() + diffuse_components['poa_sky_diffuse'] = sky_diffuse + diffuse_components['poa_isotropic'] = sky_diffuse + + if isinstance(sky_diffuse, pd.Series): + diffuse_components = pd.DataFrame(diffuse_components) + + return diffuse_components + else: + return sky_diffuse def klucher(surface_tilt, surface_azimuth, dhi, ghi, solar_zenith, From 4148c5327371fda1653a3599e29bf9f96199341e Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Wed, 17 Jun 2026 11:36:52 +0100 Subject: [PATCH 02/28] Change OrderedDict to dict --- pvlib/irradiance.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pvlib/irradiance.py b/pvlib/irradiance.py index b2bd3c4076..6a4238ddf0 100644 --- a/pvlib/irradiance.py +++ b/pvlib/irradiance.py @@ -627,7 +627,7 @@ def isotropic(surface_tilt, dhi, return_components=False): Returns ------- - numeric, OrderedDict, or DataFrame + numeric, Dict, or DataFrame Return type controlled by ``return_components`` argument. If `False`, ``sky_diffuse`` is returned. If `True`, ``diffuse_components`` is returned. @@ -635,7 +635,7 @@ def isotropic(surface_tilt, dhi, return_components=False): sky_diffuse : numeric The sky diffuse component of the solar radiation. [Wm⁻²] - diffuse_components : OrderedDict (array input) or DataFrame (Series input) + diffuse_components : Dict (array input) or DataFrame (Series input) Keys/columns are: * poa_sky_diffuse: Total sky diffuse * poa_isotropic @@ -655,9 +655,10 @@ def isotropic(surface_tilt, dhi, return_components=False): sky_diffuse = dhi * (1 + tools.cosd(surface_tilt)) * 0.5 if return_components: - diffuse_components = OrderedDict() - diffuse_components['poa_sky_diffuse'] = sky_diffuse - diffuse_components['poa_isotropic'] = sky_diffuse + diffuse_components = { + 'poa_sky_diffuse': sky_diffuse, + 'poa_isotropic': sky_diffuse + } if isinstance(sky_diffuse, pd.Series): diffuse_components = pd.DataFrame(diffuse_components) From f3e69b3eb4e23199e75d6cf7af7e2413d05ee2a9 Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Wed, 17 Jun 2026 12:06:11 +0100 Subject: [PATCH 03/28] Add tests for isotropic with return_components=True --- pvlib/irradiance.py | 2 +- tests/test_irradiance.py | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/pvlib/irradiance.py b/pvlib/irradiance.py index 6a4238ddf0..82b144cbe9 100644 --- a/pvlib/irradiance.py +++ b/pvlib/irradiance.py @@ -631,7 +631,7 @@ def isotropic(surface_tilt, dhi, return_components=False): Return type controlled by ``return_components`` argument. If `False`, ``sky_diffuse`` is returned. If `True`, ``diffuse_components`` is returned. - + sky_diffuse : numeric The sky diffuse component of the solar radiation. [Wm⁻²] diff --git a/tests/test_irradiance.py b/tests/test_irradiance.py index a416636ae9..ce7878fdbc 100644 --- a/tests/test_irradiance.py +++ b/tests/test_irradiance.py @@ -168,6 +168,32 @@ def test_isotropic_series(irrad_data): assert_allclose(result, [0, 35.728402, 104.601328, 54.777191], atol=1e-4) +def test_isotropic_components(irrad_data): + keys = ['poa_sky_diffuse', 'poa_isotropic'] + expected = pd.DataFrame(np.array( + [[0, 35.728402, 104.601328, 54.777191], + [0, 35.728402, 104.601328, 54.777191]]).T, + columns=keys, + index=irrad_data.index + ) + # pandas + result = irradiance.isotropic( + 40, irrad_data['dhi'], return_components=True) + assert_frame_equal(result, expected, check_less_precise=4) + # numpy + result = irradiance.isotropic( + 40, irrad_data['dhi'].values, return_components=True) + for key in keys: + assert_allclose(result[key], expected[key], atol=1e-4) + assert isinstance(result, dict) + # scalar + result = irradiance.isotropic( + 40, irrad_data['dhi'].values[-1], return_components=True) + for key in keys: + assert_allclose(result[key], expected[key].iloc[-1], atol=1e-4) + assert isinstance(result, dict) + + def test_klucher_series_float(): # klucher inputs surface_tilt, surface_azimuth = 40.0, 180.0 From 97a34f61bee1f70811933302a587d286588ba56b Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Mon, 22 Jun 2026 15:43:59 +0100 Subject: [PATCH 04/28] Add what's new entry --- docs/sphinx/source/whatsnew/v0.15.3.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/sphinx/source/whatsnew/v0.15.3.rst b/docs/sphinx/source/whatsnew/v0.15.3.rst index 87ded069ee..7f0c087fa0 100644 --- a/docs/sphinx/source/whatsnew/v0.15.3.rst +++ b/docs/sphinx/source/whatsnew/v0.15.3.rst @@ -18,6 +18,8 @@ Bug fixes Enhancements ~~~~~~~~~~~~ +* Add support for diffuse irradiance components to :py:func:`pvlib.irradiance.isotropic` + when ``return_components=True``. (:issue:`2750`, :pull:`2787`) Documentation From dcd202c0706e4ecb9deed59186b1e79e87eac5af Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Wed, 24 Jun 2026 12:20:36 +0100 Subject: [PATCH 05/28] Add support for 'return_components' --- pvlib/irradiance.py | 78 +++++++++++++++++++++++++++++++++------------ 1 file changed, 57 insertions(+), 21 deletions(-) diff --git a/pvlib/irradiance.py b/pvlib/irradiance.py index cb411b061b..bf01ace615 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,10 +333,15 @@ 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. 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⁻²] @@ -353,7 +359,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 +372,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 +415,20 @@ 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. 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 ------ @@ -443,16 +459,19 @@ def get_sky_diffuse(surface_tilt, surface_azimuth, 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) + solar_zenith, solar_azimuth, + return_components=return_components) 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 +479,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 +508,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,7 +519,7 @@ 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⁻²] @@ -508,22 +528,38 @@ def poa_components(aoi, dni, poa_sky_diffuse, poa_ground_diffuse): * ``poa_sky_diffuse`` : In-plane diffuse irradiance from sky. [Wm⁻²] * ``poa_ground_diffuse`` : In-plane diffuse irradiance from ground. [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) From 722f6fd27651cae943291b23f46cd6f1f1af374a Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Mon, 29 Jun 2026 12:03:02 +0100 Subject: [PATCH 06/28] Add tests --- pvlib/irradiance.py | 2 +- tests/test_irradiance.py | 75 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/pvlib/irradiance.py b/pvlib/irradiance.py index bf01ace615..cec3267ff7 100644 --- a/pvlib/irradiance.py +++ b/pvlib/irradiance.py @@ -528,7 +528,7 @@ def poa_components(aoi, dni, poa_sky_diffuse, poa_ground_diffuse): * ``poa_sky_diffuse`` : In-plane diffuse irradiance from sky. [Wm⁻²] * ``poa_ground_diffuse`` : In-plane diffuse irradiance from ground. [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. diff --git a/tests/test_irradiance.py b/tests/test_irradiance.py index f4fe6ea547..2c3c080d6c 100644 --- a/tests/test_irradiance.py +++ b/tests/test_irradiance.py @@ -536,6 +536,28 @@ 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 = ['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'] + + @pytest.mark.parametrize('model', ['isotropic', 'klucher', 'haydavies', 'reindl', 'king', 'perez', 'perez-driesse']) @@ -625,6 +647,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]]), From 2eb64ca73508e806bcf4da1a61ef6b6b0196d2cc Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Wed, 1 Jul 2026 13:16:31 +0100 Subject: [PATCH 07/28] Raise error if return_components=True used with king or klucher --- pvlib/irradiance.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pvlib/irradiance.py b/pvlib/irradiance.py index cec3267ff7..3ea5b55832 100644 --- a/pvlib/irradiance.py +++ b/pvlib/irradiance.py @@ -338,6 +338,8 @@ def get_total_irradiance(surface_tilt, surface_azimuth, 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 ------- @@ -420,6 +422,8 @@ def get_sky_diffuse(surface_tilt, surface_azimuth, 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 ------- @@ -454,6 +458,10 @@ 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}') @@ -462,8 +470,7 @@ def get_sky_diffuse(surface_tilt, surface_azimuth, sky = isotropic(surface_tilt, dhi, return_components=return_components) elif model == 'klucher': sky = klucher(surface_tilt, surface_azimuth, dhi, ghi, - solar_zenith, solar_azimuth, - return_components=return_components) + solar_zenith, solar_azimuth) elif model == 'haydavies': sky = haydavies(surface_tilt, surface_azimuth, dhi, dni, dni_extra, solar_zenith, solar_azimuth, From 8d698e32f809e3e8a01c5753e3cb09a68f1036a7 Mon Sep 17 00:00:00 2001 From: cbcrespo <97249533+cbcrespo@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:25:04 +0100 Subject: [PATCH 08/28] Update pvlib/irradiance.py Co-authored-by: Adam R. Jensen <39184289+AdamRJensen@users.noreply.github.com> --- pvlib/irradiance.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pvlib/irradiance.py b/pvlib/irradiance.py index 3ea5b55832..b344c82075 100644 --- a/pvlib/irradiance.py +++ b/pvlib/irradiance.py @@ -343,7 +343,7 @@ def get_total_irradiance(surface_tilt, surface_azimuth, Returns ------- - total_irrad : Dict or DataFrame + total_irrad : dict or DataFrame Contains keys/columns ``'poa_global', 'poa_direct', 'poa_diffuse', 'poa_sky_diffuse', 'poa_ground_diffuse'``. [Wm⁻²] From 4be64309e5f827aec3753db63a2c6bdbe941d516 Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Fri, 17 Jul 2026 15:01:52 +0100 Subject: [PATCH 09/28] Adjust docstrings --- pvlib/irradiance.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/pvlib/irradiance.py b/pvlib/irradiance.py index 30d718340b..23f7676649 100644 --- a/pvlib/irradiance.py +++ b/pvlib/irradiance.py @@ -346,6 +346,9 @@ def get_total_irradiance(surface_tilt, surface_azimuth, 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 ----- @@ -432,7 +435,7 @@ def get_sky_diffuse(surface_tilt, surface_azimuth, 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⁻²] + (dict or DataFrame). [Wm⁻²] Raises ------ @@ -529,12 +532,13 @@ def poa_components(aoi, dni, poa_sky_diffuse, poa_ground_diffuse): 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 diffuse 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 From 41c3ff0604666783d4e2e26c4e1a8c7100f96e80 Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Thu, 23 Jul 2026 14:16:24 +0100 Subject: [PATCH 10/28] Fix list indentation --- pvlib/irradiance.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pvlib/irradiance.py b/pvlib/irradiance.py index 23f7676649..0fdc3d3592 100644 --- a/pvlib/irradiance.py +++ b/pvlib/irradiance.py @@ -532,13 +532,13 @@ def poa_components(aoi, dni, poa_sky_diffuse, poa_ground_diffuse): irrads : Dict or DataFrame Contains the following keys: - * ``poa_global`` : Total diffuse irradiance on a tilted plane. [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⁻²] + tilted plane. [Wm⁻²] * ``poa_ground_diffuse`` : The ground diffuse component of irradiance - on a tilted plane. [Wm⁻²] + 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 From d360853685dbbc48d0a0bcc5dd3768b774c643bf Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Thu, 30 Jul 2026 11:54:24 +0100 Subject: [PATCH 11/28] Add more tests --- tests/test_irradiance.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/test_irradiance.py b/tests/test_irradiance.py index 3d14e67482..ae375f23ff 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): @@ -575,7 +582,7 @@ def test_get_total_irradiance(irrad_data, ephem_data, dni_et, def test_get_total_irradiance_diffuse_components(irrad_data, ephem_data, dni_et, relative_airmass): - models = ['perez', 'perez-driesse'] + models = ['reindl', 'perez', 'perez-driesse'] for model in models: total = irradiance.get_total_irradiance( @@ -594,6 +601,23 @@ def test_get_total_irradiance_diffuse_components(irrad_data, ephem_data, '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', From eddb1c33c01a4e0490db06d758578b96db5c4c1b Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Thu, 30 Jul 2026 12:02:43 +0100 Subject: [PATCH 12/28] Minor fix --- tests/test_irradiance.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/test_irradiance.py b/tests/test_irradiance.py index ae375f23ff..1f96391e43 100644 --- a/tests/test_irradiance.py +++ b/tests/test_irradiance.py @@ -603,15 +603,16 @@ def test_get_total_irradiance_diffuse_components(irrad_data, ephem_data, 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) - + 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', From 1795658aa4897a092b6a3878763f54d100e1a605 Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Thu, 30 Jul 2026 12:14:36 +0100 Subject: [PATCH 13/28] Add whatsnew entry --- docs/sphinx/source/whatsnew/v0.15.3.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/sphinx/source/whatsnew/v0.15.3.rst b/docs/sphinx/source/whatsnew/v0.15.3.rst index 4ed36937a0..357381840b 100644 --- a/docs/sphinx/source/whatsnew/v0.15.3.rst +++ b/docs/sphinx/source/whatsnew/v0.15.3.rst @@ -26,6 +26,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 From 4871948f343d864fcfaa8e17d4f590508e3c948f Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Wed, 5 Aug 2026 15:16:28 +0100 Subject: [PATCH 14/28] Add get_iam_diffuse to the Array and PVSystem classes --- pvlib/pvsystem.py | 145 ++++++++++++++++++++++++++++++++++++----- tests/test_pvsystem.py | 47 +++++++++++++ 2 files changed, 176 insertions(+), 16 deletions(-) diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index 3e39012a8f..5238db47b5 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -308,7 +308,7 @@ def get_aoi(self, solar_zenith, solar_azimuth): @_unwrap_single_value def get_irradiance(self, solar_zenith, solar_azimuth, dni, ghi, dhi, dni_extra=None, airmass=None, albedo=None, - model='haydavies', **kwargs): + model='haydavies', diffuse_components=False, **kwargs): """ Uses :py:func:`pvlib.irradiance.get_total_irradiance` to calculate the plane of array irradiance components on the tilted @@ -335,6 +335,11 @@ def get_irradiance(self, solar_zenith, solar_azimuth, dni, ghi, dhi, Ground surface albedo. [unitless] model : String, default 'haydavies' Irradiance model. + 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. kwargs Extra parameters passed to @@ -374,7 +379,9 @@ def get_irradiance(self, solar_zenith, solar_azimuth, dni, ghi, dhi, array.get_irradiance(solar_zenith, solar_azimuth, dni, ghi, dhi, dni_extra=dni_extra, airmass=airmass, - albedo=albedo, model=model, **kwargs) + albedo=albedo, model=model, + diffuse_components=diffuse_components, + **kwargs) for array, dni, ghi, dhi, albedo in zip( self.arrays, dni, ghi, dhi, albedo ) @@ -383,8 +390,8 @@ def get_irradiance(self, solar_zenith, solar_azimuth, dni, ghi, dhi, @_unwrap_single_value def get_iam(self, aoi, iam_model='physical'): """ - Determine the incidence angle modifier using the method specified by - ``iam_model``. + Determine the incidence angle modifier for direct irradiance + using the method specified by ``iam_model``. Parameters for the selected IAM model are expected to be in ``PVSystem.module_parameters``. Default parameters are available for @@ -412,6 +419,48 @@ def get_iam(self, aoi, iam_model='physical'): return tuple(array.get_iam(aoi, iam_model) for array, aoi in zip(self.arrays, aoi)) + @_unwrap_single_value + def get_iam_diffuse(self, surface_tilt, iam_model='marion_diffuse', + marion_model=None, **kwargs): + """ + Determine the incidence angle modifier for diffuse irradiance using the + method specified by ``iam_model``. + + Parameters for the selected IAM model are expected to be in + ``Array.module_parameters``. Default parameters are available for + the 'marion_diffuse' and 'martin_ruiz_diffuse' models. + + Parameters + ---------- + surface_tilt : float or Series + The tilt angle of the surface in degrees. + iam_model : string, default 'marion_diffuse' + The IAM model to be used. Valid strings are 'marion_diffuse' + and 'martin_ruiz_diffuse'. + marion_model : string, default None + The IAM function to evaluate across a solid angle. Only used when + ``iam_model='marion_diffuse'``. Must be one of `'ashrae', + 'physical', 'martin_ruiz', 'sapm', and 'schlick'`. + + kwargs : dict, optional + Additional keyword arguments passed to the IAM model function. + + Returns + ------- + iam_diffuse : dict + The AOI modifiers for different diffuse irradiance components. + Included components depend on the selected ``iam_model``. + + Raises + ------ + ValueError + if `iam_model` is not a valid model name. + """ + surface_tilt = self._validate_per_array(surface_tilt) + return tuple(array.get_iam_diffuse(tilt, iam_model=iam_model, + marion_model=marion_model, **kwargs) + for array, tilt in zip(self.arrays, surface_tilt)) + @_unwrap_single_value def get_cell_temperature(self, poa_global, temp_air, wind_speed, model, effective_irradiance=None, longwave_down=None): @@ -1096,7 +1145,7 @@ def get_aoi(self, solar_zenith, solar_azimuth): def get_irradiance(self, solar_zenith, solar_azimuth, dni, ghi, dhi, dni_extra=None, airmass=None, albedo=None, - model='haydavies', **kwargs): + model='haydavies', diffuse_components=False, **kwargs): """ Get plane of array irradiance components. @@ -1124,6 +1173,11 @@ def get_irradiance(self, solar_zenith, solar_azimuth, dni, ghi, dhi, Ground surface albedo. [unitless] model : String, default 'haydavies' Irradiance model. + 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. kwargs Extra parameters passed to @@ -1164,20 +1218,23 @@ def get_irradiance(self, solar_zenith, solar_azimuth, dni, ghi, dhi, airmass = atmosphere.get_relative_airmass(solar_zenith) orientation = self.mount.get_orientation(solar_zenith, solar_azimuth) - return irradiance.get_total_irradiance(orientation['surface_tilt'], - orientation['surface_azimuth'], - solar_zenith, solar_azimuth, - dni, ghi, dhi, - dni_extra=dni_extra, - airmass=airmass, - albedo=albedo, - model=model, - **kwargs) + return irradiance.get_total_irradiance( + orientation['surface_tilt'], + orientation['surface_azimuth'], + solar_zenith, solar_azimuth, + dni, ghi, dhi, + dni_extra=dni_extra, + airmass=airmass, + albedo=albedo, + model=model, + diffuse_components=diffuse_components, + **kwargs + ) def get_iam(self, aoi, iam_model='physical'): """ - Determine the incidence angle modifier using the method specified by - ``iam_model``. + Determine the incidence angle modifier for direct irradiance + using the method specified by ``iam_model``. Parameters for the selected IAM model are expected to be in ``Array.module_parameters``. Default parameters are available for @@ -1216,6 +1273,62 @@ def get_iam(self, aoi, iam_model='physical'): else: raise ValueError(model + ' is not a valid IAM model') + def get_iam_diffuse(self, surface_tilt, iam_model='marion_diffuse', + marion_model=None, **kwargs): + """ + Determine the incidence angle modifier for various diffuse irradiance + components using the method specified by ``iam_model``. + + Parameters for the selected IAM model are expected to be in + ``Array.module_parameters``. Default parameters are available for + the 'marion_diffuse' and 'martin_ruiz_diffuse' models. + + Parameters + ---------- + surface_tilt : float or Series + The tilt angle of the surface in degrees. + iam_model : string, default 'marion_diffuse' + The IAM model to be used. Valid strings are 'marion_diffuse' + and 'martin_ruiz_diffuse'. + marion_model : string, default None + The IAM function to evaluate across a solid angle. Only used when + ``iam_model='marion_diffuse'``. Must be one of `'ashrae', + 'physical', 'martin_ruiz' and 'sapm'. + + kwargs : dict, optional + Additional keyword arguments passed to the IAM model function. + + Returns + ------- + iam_diffuse : dict + The AOI modifiers for different diffuse irradiance components. + Included components depend on the selected ``iam_model``. + + Raises + ------ + ValueError + if `iam_model` is not a valid model name. + ValueError + if `iam_model` is 'marion_diffuse' and `marion_model` is None. + """ + model = iam_model.lower() + if model == 'marion_diffuse' and marion_model is None: + raise ValueError('marion_model must be specified when ' + 'iam_model="marion_diffuse"') + if model in ['marion_diffuse', 'martin_ruiz_diffuse']: + func = getattr(iam, model) # get function at pvlib.iam + # get all parameters from function signature to retrieve them from + # module_parameters if present + params = set(inspect.signature(func).parameters.keys()) + kwargs.update(_build_kwargs(params, self.module_parameters)) + if iam_model == 'marion_diffuse': + return func(model=marion_model, surface_tilt=surface_tilt, + **kwargs) + else: + return func(surface_tilt=surface_tilt, **kwargs) + else: + raise ValueError(model + ' is not a valid diffuse IAM model') + def get_cell_temperature(self, poa_global, temp_air, wind_speed, model, effective_irradiance=None, longwave_down=None): """ diff --git a/tests/test_pvsystem.py b/tests/test_pvsystem.py index 22a7789537..cba54b909e 100644 --- a/tests/test_pvsystem.py +++ b/tests/test_pvsystem.py @@ -104,6 +104,53 @@ def test_PVSystem_get_iam_invalid(sapm_module_params, mocker): system.get_iam(45, iam_model='not_a_model') +def test_PVSystem_get_iam_diffuse_marion(mocker): + model_params = {'b': 0.05} + m = mocker.spy(_iam, 'marion_diffuse') + system = pvsystem.PVSystem(module_parameters=model_params) + tilt = 30 + iam = system.get_iam_diffuse(tilt, iam_model='marion_diffuse', + marion_model='ashrae', **model_params) + print(m.call_args) + m.assert_called_with(model='ashrae', surface_tilt=tilt, **model_params) + assert isinstance(iam, dict) + assert set(iam.keys()) == {'sky', 'ground', 'horizon'} + + +def test_PVSystem_get_iam_diffuse_martin_ruiz(mocker): + model_params = {'a_r': 0.16} + m = mocker.spy(_iam, 'martin_ruiz_diffuse') + system = pvsystem.PVSystem(module_parameters=model_params) + tilt = 30 + iam = system.get_iam_diffuse(tilt, iam_model='martin_ruiz_diffuse') + m.assert_called_with(surface_tilt=tilt, **model_params) + assert isinstance(iam, dict) + + +def test_PVSystem_multi_array_get_iam_diffuse(): + model_params = {'b': 0.05} + system = pvsystem.PVSystem( + arrays=[pvsystem.Array(mount=pvsystem.FixedMount(0, 180), + module_parameters=model_params), + pvsystem.Array(mount=pvsystem.FixedMount(0, 180), + module_parameters=model_params)] + ) + iam = system.get_iam_diffuse((30, 60), iam_model='marion_diffuse', + marion_model='ashrae', **model_params) + assert len(iam) == 2 + assert iam[0] != iam[1] + with pytest.raises(ValueError, + match="Length mismatch for per-array parameter"): + system.get_iam_diffuse((30,), iam_model='marion_diffuse', + marion_model='ashrae', **model_params) + + +def test_PVSystem_get_iam_diffuse_invalid(sapm_module_params, mocker): + system = pvsystem.PVSystem(module_parameters=sapm_module_params) + with pytest.raises(ValueError): + system.get_iam_diffuse(45, iam_model='not_a_model') + + def test_retrieve_sam_raises_exceptions(): """ Raise an exception if an invalid parameter is provided to `retrieve_sam()`. From 6cbb1aa3ba09db774a60fd03fb34d24f5d1b03ca Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Tue, 11 Aug 2026 10:12:36 +0100 Subject: [PATCH 15/28] Set all IAM outputs to dict --- pvlib/iam.py | 24 ++++++++++++++---------- tests/test_iam.py | 42 ++++++++++++++++++++++-------------------- 2 files changed, 36 insertions(+), 30 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 9ba981c5ea..32989d04d1 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -346,11 +346,11 @@ def martin_ruiz_diffuse(surface_tilt, a_r=0.16, c1=0.4244, c2=None): Returns ------- - iam_sky : numeric - The incident angle modifier for sky diffuse + iam : dict + IAM values for each type of diffuse irradiance: - iam_ground : numeric - The incident angle modifier for ground-reflected diffuse + * 'sky': radiation from the sky dome + * 'ground': radiation reflected from the ground Notes ----- @@ -419,7 +419,9 @@ def martin_ruiz_diffuse(surface_tilt, a_r=0.16, c1=0.4244, c2=None): iam_sky = pd.Series(iam_sky, index=out_index, name='iam_sky') iam_gnd = pd.Series(iam_gnd, index=out_index, name='iam_ground') - return iam_sky, iam_gnd + iam = {'sky': iam_sky, 'ground': iam_gnd} + + return iam def interp(aoi, theta_ref, iam_ref, method='linear', normalize=True): @@ -890,11 +892,11 @@ def schlick_diffuse(surface_tilt): Returns ------- - iam_sky : numeric - The incident angle modifier for sky diffuse. + iam : dict + IAM values for each type of diffuse irradiance: - iam_ground : numeric - The incident angle modifier for ground-reflected diffuse. + * 'sky': radiation from the sky dome + * 'ground': radiation reflected from the ground See Also -------- @@ -961,7 +963,9 @@ def schlick_diffuse(surface_tilt): cuk = pd.Series(cuk, surface_tilt.index) cug = pd.Series(cug, surface_tilt.index) - return cuk, cug + iam = {'sky': cuk, 'ground': cug} + + return iam def _get_model(model_name): diff --git a/tests/test_iam.py b/tests/test_iam.py index 123548cd6e..0287fd8fd2 100644 --- a/tests/test_iam.py +++ b/tests/test_iam.py @@ -134,15 +134,18 @@ def test_martin_ruiz_diffuse(): surface_tilt = 30. a_r = 0.16 - expected = (0.9549735, 0.7944426) + expected_sky = 0.9549735 + expected_ground = 0.7944426 # will fail if default values change - iam = _iam.martin_ruiz_diffuse(surface_tilt) - assert_allclose(iam, expected) + actual_iam = _iam.martin_ruiz_diffuse(surface_tilt) + assert_allclose(actual_iam['sky'], expected_sky) + assert_allclose(actual_iam['ground'], expected_ground) # will fail if parameter names change iam = _iam.martin_ruiz_diffuse(surface_tilt=surface_tilt, a_r=a_r) - assert_allclose(iam, expected) + assert_allclose(iam['sky'], expected_sky) + assert_allclose(iam['ground'], expected_ground) a_r = 0.18 surface_tilt = [0, 30, 90, 120, 180, np.nan, np.inf] @@ -153,21 +156,21 @@ def test_martin_ruiz_diffuse(): # check various inputs as list iam = _iam.martin_ruiz_diffuse(surface_tilt, a_r) - assert_allclose(iam[0], expected_sky, atol=1e-7, equal_nan=True) - assert_allclose(iam[1], expected_gnd, atol=1e-7, equal_nan=True) + assert_allclose(iam['sky'], expected_sky, atol=1e-7, equal_nan=True) + assert_allclose(iam['ground'], expected_gnd, atol=1e-7, equal_nan=True) # check various inputs as array iam = _iam.martin_ruiz_diffuse(np.array(surface_tilt), a_r) - assert_allclose(iam[0], expected_sky, atol=1e-7, equal_nan=True) - assert_allclose(iam[1], expected_gnd, atol=1e-7, equal_nan=True) + assert_allclose(iam['sky'], expected_sky, atol=1e-7, equal_nan=True) + assert_allclose(iam['ground'], expected_gnd, atol=1e-7, equal_nan=True) # check various inputs as Series surface_tilt = pd.Series(surface_tilt) expected_sky = pd.Series(expected_sky, name='iam_sky') expected_gnd = pd.Series(expected_gnd, name='iam_ground') iam = _iam.martin_ruiz_diffuse(surface_tilt, a_r) - assert_series_equal(iam[0], expected_sky) - assert_series_equal(iam[1], expected_gnd) + assert_series_equal(iam['sky'], expected_sky) + assert_series_equal(iam['ground'], expected_gnd) def test_iam_interp(): @@ -441,22 +444,21 @@ def test_schlick_diffuse(): expected_ground = np.array([0, 0.62693858, 0.93218737, 0.95238094]) # numpy arrays - actual_sky, actual_ground = _iam.schlick_diffuse(surface_tilt) - assert_allclose(expected_sky, actual_sky) - assert_allclose(expected_ground, actual_ground, rtol=1e-6) + actual_iam = _iam.schlick_diffuse(surface_tilt) + assert_allclose(expected_sky, actual_iam['sky']) + assert_allclose(expected_ground, actual_iam['ground'], rtol=1e-6) # scalars for i in range(len(surface_tilt)): - actual_sky, actual_ground = _iam.schlick_diffuse(surface_tilt[i]) - assert_allclose(expected_sky[i], actual_sky) - assert_allclose(expected_ground[i], actual_ground, rtol=1e-6) + actual_iam = _iam.schlick_diffuse(surface_tilt[i]) + assert_allclose(expected_sky[i], actual_iam['sky'], rtol=1e-6) + assert_allclose(expected_ground[i], actual_iam['ground'], rtol=1e-6) # pandas Series idx = pd.date_range('2019-01-01', freq='h', periods=len(surface_tilt)) - actual_sky, actual_ground = _iam.schlick_diffuse(pd.Series(surface_tilt, - idx)) - assert_series_equal(pd.Series(expected_sky, idx), actual_sky) - assert_series_equal(pd.Series(expected_ground, idx), actual_ground, + actual_iam = _iam.schlick_diffuse(pd.Series(surface_tilt, idx)) + assert_series_equal(pd.Series(expected_sky, idx), actual_iam['sky']) + assert_series_equal(pd.Series(expected_ground, idx), actual_iam['ground'], rtol=1e-6) From 8772a7c1c89ab6454c7da30041c3f0379d464a3c Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Tue, 11 Aug 2026 10:34:58 +0100 Subject: [PATCH 16/28] Add whatsnew entry --- docs/sphinx/source/whatsnew/v0.16.0.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/sphinx/source/whatsnew/v0.16.0.rst b/docs/sphinx/source/whatsnew/v0.16.0.rst index 3ea266e6cb..78fe64be82 100644 --- a/docs/sphinx/source/whatsnew/v0.16.0.rst +++ b/docs/sphinx/source/whatsnew/v0.16.0.rst @@ -30,6 +30,10 @@ Breaking Changes * Removed the deprecated ``server`` keyword argument from :py:func:`pvlib.iotools.sodapro.get_cams`. Use ``url`` instead. (:issue:`2767`, :pull:`2766`) +* Changed the output type of :py:func:`pvlib.iam.marion_ruiz_diffuse` + and :py:func:`pvlib.iam.schlick_diffuse` from tuple to ``dict``, to be + consistent with :py:func:`pvlib.iam.marion_diffuse`. (:issue:`2837`, + :pull:`2842`) Deprecations ~~~~~~~~~~~~ From 720c8eade658826e35a4e96548660580be479ada Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Tue, 11 Aug 2026 12:09:10 +0100 Subject: [PATCH 17/28] Minor change to docstrings --- pvlib/iam.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index 32989d04d1..ef9812e153 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -347,7 +347,7 @@ def martin_ruiz_diffuse(surface_tilt, a_r=0.16, c1=0.4244, c2=None): Returns ------- iam : dict - IAM values for each type of diffuse irradiance: + IAM values for each type of diffuse irradiance (assuming isotropy): * 'sky': radiation from the sky dome * 'ground': radiation reflected from the ground @@ -605,7 +605,7 @@ def marion_diffuse(model, surface_tilt, **kwargs): Returns ------- iam : dict - IAM values for each type of diffuse irradiance: + IAM values for each type of diffuse irradiance (assuming isotropy): * 'sky': radiation from the sky dome (zenith <= 90) * 'horizon': radiation from the region of the sky near the horizon @@ -893,7 +893,7 @@ def schlick_diffuse(surface_tilt): Returns ------- iam : dict - IAM values for each type of diffuse irradiance: + IAM values for each type of diffuse irradiance (assuming isotropy): * 'sky': radiation from the sky dome * 'ground': radiation reflected from the ground From b4270a21db0783fecf1804cfc317a768795358a7 Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Wed, 12 Aug 2026 10:29:34 +0100 Subject: [PATCH 18/28] Docstring changes --- pvlib/iam.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/pvlib/iam.py b/pvlib/iam.py index ef9812e153..4c7c4adc91 100644 --- a/pvlib/iam.py +++ b/pvlib/iam.py @@ -316,9 +316,12 @@ def martin_ruiz(aoi, a_r=0.16): def martin_ruiz_diffuse(surface_tilt, a_r=0.16, c1=0.4244, c2=None): ''' - Determine the incidence angle modifiers (iam) for diffuse sky and + Determine the incidence angle modifiers (IAM) for sky diffuse and ground-reflected irradiance using the Martin and Ruiz incident angle model. + As described in [1]_, the IAMs result from integrals that assume the + incoming sky diffuse and ground-reflected irradiance are isotropic. + Parameters ---------- surface_tilt: float or array-like, default 0 @@ -347,7 +350,8 @@ def martin_ruiz_diffuse(surface_tilt, a_r=0.16, c1=0.4244, c2=None): Returns ------- iam : dict - IAM values for each type of diffuse irradiance (assuming isotropy): + Incident Angle Modifier (see :term:`iam`) values for each type of + diffuse irradiance: * 'sky': radiation from the sky dome * 'ground': radiation reflected from the ground @@ -585,8 +589,8 @@ def sapm(aoi, module, upper=None): def marion_diffuse(model, surface_tilt, **kwargs): """ - Determine diffuse irradiance incidence angle modifiers using Marion's - method of integrating over solid angle. + Determine diffuse irradiance incidence angle modifiers (IAM) using + Marion's method of integrating over solid angle. Parameters ---------- @@ -605,7 +609,8 @@ def marion_diffuse(model, surface_tilt, **kwargs): Returns ------- iam : dict - IAM values for each type of diffuse irradiance (assuming isotropy): + Incident Angle Modifier (see :term:`iam`) values for each type of + diffuse irradiance: * 'sky': radiation from the sky dome (zenith <= 90) * 'horizon': radiation from the region of the sky near the horizon @@ -861,7 +866,7 @@ def schlick(aoi): def schlick_diffuse(surface_tilt): r""" - Determine the incidence angle modifiers (IAM) for diffuse sky and + Determine the incidence angle modifiers (IAM) for sky diffuse and ground-reflected irradiance on a tilted surface using the Schlick incident angle model. @@ -893,7 +898,8 @@ def schlick_diffuse(surface_tilt): Returns ------- iam : dict - IAM values for each type of diffuse irradiance (assuming isotropy): + Incident Angle Modifier (see :term:`iam`) values for each type of + diffuse irradiance: * 'sky': radiation from the sky dome * 'ground': radiation reflected from the ground From f09a971a54c9007af9a42bb159cbaba2a96d749b Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Wed, 12 Aug 2026 10:44:46 +0100 Subject: [PATCH 19/28] Add schlick_diffuse --- pvlib/pvsystem.py | 11 ++++++----- tests/test_pvsystem.py | 10 ++++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index 5238db47b5..cce22bf92c 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -435,8 +435,8 @@ def get_iam_diffuse(self, surface_tilt, iam_model='marion_diffuse', surface_tilt : float or Series The tilt angle of the surface in degrees. iam_model : string, default 'marion_diffuse' - The IAM model to be used. Valid strings are 'marion_diffuse' - and 'martin_ruiz_diffuse'. + The IAM model to be used. Valid strings are 'marion_diffuse', + 'martin_ruiz_diffuse', and 'schlick_diffuse'. marion_model : string, default None The IAM function to evaluate across a solid angle. Only used when ``iam_model='marion_diffuse'``. Must be one of `'ashrae', @@ -1288,8 +1288,8 @@ def get_iam_diffuse(self, surface_tilt, iam_model='marion_diffuse', surface_tilt : float or Series The tilt angle of the surface in degrees. iam_model : string, default 'marion_diffuse' - The IAM model to be used. Valid strings are 'marion_diffuse' - and 'martin_ruiz_diffuse'. + The IAM model to be used. Valid strings are 'marion_diffuse', + 'martin_ruiz_diffuse' and 'schlick_diffuse'. marion_model : string, default None The IAM function to evaluate across a solid angle. Only used when ``iam_model='marion_diffuse'``. Must be one of `'ashrae', @@ -1315,7 +1315,8 @@ def get_iam_diffuse(self, surface_tilt, iam_model='marion_diffuse', if model == 'marion_diffuse' and marion_model is None: raise ValueError('marion_model must be specified when ' 'iam_model="marion_diffuse"') - if model in ['marion_diffuse', 'martin_ruiz_diffuse']: + if model in ['marion_diffuse', 'martin_ruiz_diffuse', + 'schlick_diffuse']: func = getattr(iam, model) # get function at pvlib.iam # get all parameters from function signature to retrieve them from # module_parameters if present diff --git a/tests/test_pvsystem.py b/tests/test_pvsystem.py index cba54b909e..1c6cc9c02c 100644 --- a/tests/test_pvsystem.py +++ b/tests/test_pvsystem.py @@ -117,12 +117,14 @@ def test_PVSystem_get_iam_diffuse_marion(mocker): assert set(iam.keys()) == {'sky', 'ground', 'horizon'} -def test_PVSystem_get_iam_diffuse_martin_ruiz(mocker): - model_params = {'a_r': 0.16} - m = mocker.spy(_iam, 'martin_ruiz_diffuse') +@pytest.mark.parametrize('iam_model', ['martin_ruiz_diffuse', + 'schlick_diffuse']) +def test_PVSystem_get_iam_diffuse_martin_ruiz(iam_model, mocker): + model_params = {'a_r': 0.16} if iam_model == 'martin_ruiz_diffuse' else {} + m = mocker.spy(_iam, iam_model) system = pvsystem.PVSystem(module_parameters=model_params) tilt = 30 - iam = system.get_iam_diffuse(tilt, iam_model='martin_ruiz_diffuse') + iam = system.get_iam_diffuse(tilt, iam_model=iam_model) m.assert_called_with(surface_tilt=tilt, **model_params) assert isinstance(iam, dict) From 7911c179f208386c72f12662097022e63ffe259c Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Fri, 14 Aug 2026 12:26:02 +0100 Subject: [PATCH 20/28] Add error test --- pvlib/pvsystem.py | 6 +++--- tests/test_pvsystem.py | 8 +++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index 39a2b4f59b..efaac14cab 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -438,8 +438,8 @@ def get_iam_diffuse(self, surface_tilt, iam_model='marion_diffuse', 'martin_ruiz_diffuse', and 'schlick_diffuse'. marion_model : string, default None The IAM function to evaluate across a solid angle. Only used when - ``iam_model='marion_diffuse'``. Must be one of `'ashrae', - 'physical', 'martin_ruiz', 'sapm', and 'schlick'`. + ``iam_model='marion_diffuse'``. Must be one of 'ashrae', + 'physical', 'martin_ruiz', 'sapm', and 'schlick'. kwargs : dict, optional Additional keyword arguments passed to the IAM model function. @@ -1289,7 +1289,7 @@ def get_iam_diffuse(self, surface_tilt, iam_model='marion_diffuse', 'martin_ruiz_diffuse' and 'schlick_diffuse'. marion_model : string, default None The IAM function to evaluate across a solid angle. Only used when - ``iam_model='marion_diffuse'``. Must be one of `'ashrae', + ``iam_model='marion_diffuse'``. Must be one of 'ashrae', 'physical', 'martin_ruiz' and 'sapm'. kwargs : dict, optional diff --git a/tests/test_pvsystem.py b/tests/test_pvsystem.py index 27caa7079b..28542f7088 100644 --- a/tests/test_pvsystem.py +++ b/tests/test_pvsystem.py @@ -156,12 +156,18 @@ def test_PVSystem_multi_array_get_iam_diffuse(): marion_model='ashrae', **model_params) -def test_PVSystem_get_iam_diffuse_invalid(sapm_module_params, mocker): +def test_PVSystem_get_iam_diffuse_invalid(sapm_module_params): system = pvsystem.PVSystem(module_parameters=sapm_module_params) with pytest.raises(ValueError): system.get_iam_diffuse(45, iam_model='not_a_model') +def test_PVSystem_get_iam_diffuse_marion_missing_model(sapm_module_params): + system = pvsystem.PVSystem(module_parameters=sapm_module_params) + with pytest.raises(ValueError, match="marion_model must be specified"): + system.get_iam_diffuse(45, iam_model='marion_diffuse') + + def test_retrieve_sam_raises_exceptions(): """ Raise an exception if an invalid parameter is provided to `retrieve_sam()`. From d9bf1000b57a12945296314c2baa16f6fc769cea Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Fri, 14 Aug 2026 14:36:39 +0100 Subject: [PATCH 21/28] Add Dataframe support --- pvlib/pvsystem.py | 13 +++++++++---- tests/test_pvsystem.py | 8 ++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index efaac14cab..5078f11f15 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -446,7 +446,7 @@ def get_iam_diffuse(self, surface_tilt, iam_model='marion_diffuse', Returns ------- - iam_diffuse : dict + iam_diffuse : dict or DataFrame The AOI modifiers for different diffuse irradiance components. Included components depend on the selected ``iam_model``. @@ -1297,7 +1297,7 @@ def get_iam_diffuse(self, surface_tilt, iam_model='marion_diffuse', Returns ------- - iam_diffuse : dict + iam_diffuse : dict or DataFrame The AOI modifiers for different diffuse irradiance components. Included components depend on the selected ``iam_model``. @@ -1320,13 +1320,18 @@ def get_iam_diffuse(self, surface_tilt, iam_model='marion_diffuse', params = set(inspect.signature(func).parameters.keys()) kwargs.update(_build_kwargs(params, self.module_parameters)) if iam_model == 'marion_diffuse': - return func(model=marion_model, surface_tilt=surface_tilt, + iams = func(model=marion_model, surface_tilt=surface_tilt, **kwargs) else: - return func(surface_tilt=surface_tilt, **kwargs) + iams = func(surface_tilt=surface_tilt, **kwargs) else: raise ValueError(model + ' is not a valid diffuse IAM model') + if isinstance(surface_tilt, pd.Series): + iams = pd.DataFrame(iams, index=surface_tilt.index) + + return iams + def get_cell_temperature(self, poa_global, temp_air, wind_speed, model, effective_irradiance=None, longwave_down=None): """ diff --git a/tests/test_pvsystem.py b/tests/test_pvsystem.py index 28542f7088..90f640dfd3 100644 --- a/tests/test_pvsystem.py +++ b/tests/test_pvsystem.py @@ -120,15 +120,19 @@ def test_PVSystem_get_iam_diffuse_marion(mocker): tilt = 30 iam = system.get_iam_diffuse(tilt, iam_model='marion_diffuse', marion_model='ashrae', **model_params) - print(m.call_args) m.assert_called_with(model='ashrae', surface_tilt=tilt, **model_params) assert isinstance(iam, dict) assert set(iam.keys()) == {'sky', 'ground', 'horizon'} + tilt = pd.Series([30, 60]) + iam = system.get_iam_diffuse(tilt, iam_model='marion_diffuse', + marion_model='ashrae', **model_params) + assert isinstance(iam, pd.DataFrame) + @pytest.mark.parametrize('iam_model', ['martin_ruiz_diffuse', 'schlick_diffuse']) -def test_PVSystem_get_iam_diffuse_martin_ruiz(iam_model, mocker): +def test_PVSystem_get_iam_diffuse(iam_model, mocker): model_params = {'a_r': 0.16} if iam_model == 'martin_ruiz_diffuse' else {} m = mocker.spy(_iam, iam_model) system = pvsystem.PVSystem(module_parameters=model_params) From ae7d01770e679a334326b61d44264e15b2038fa2 Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Fri, 14 Aug 2026 14:46:57 +0100 Subject: [PATCH 22/28] Add whatsnew entry --- docs/sphinx/source/whatsnew/v0.16.0.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/sphinx/source/whatsnew/v0.16.0.rst b/docs/sphinx/source/whatsnew/v0.16.0.rst index 78fe64be82..d7ea94ae8d 100644 --- a/docs/sphinx/source/whatsnew/v0.16.0.rst +++ b/docs/sphinx/source/whatsnew/v0.16.0.rst @@ -70,6 +70,9 @@ Enhancements (:issue:`2828`, :pull:`2832`) * Allow variables from multiple datasets to be requested at once in :py:func:`~pvlib.iotools.get_merra2`. (:pull:`2839`) +* Add support for diffuse IAM in the :py:class:`pvlib.pvsystem.Array` and + :py:class:`pvlib.pvsystem.PVSystem` classes (see `pvlib.pvsystem.Array.get_iam_diffuse` + and `pvlib.pvsystem.PVSystem.get_iam_diffuse`). (:issue:`2812`, :pull:`2845`) Documentation From 4103f29349f5ce5416a5e9ddf2f05a88790df91e Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Thu, 20 Aug 2026 14:50:10 +0100 Subject: [PATCH 23/28] Add diffuse IAM support to ModelChain --- pvlib/modelchain.py | 205 +++++++++++++++++++++++++++++++++++++-- tests/test_modelchain.py | 19 ++++ 2 files changed, 215 insertions(+), 9 deletions(-) diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 32af129fd7..097b366b7c 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -151,6 +151,14 @@ class ModelChainResult: see :py:meth:`~pvlib.pvsystem.PVSystem.get_iam` for details. """ + iam_diffuse_modifier: Optional[PerArray[dict]] = field(default=None) + """Dictionary (or tuple of dictionaries, one for each array) containing + incidence angle modifiers (unitless) calculated by + ``ModelChain.iam_diffuse_model``, which reduces diffuse irradiance for + reflections; see :py:meth:`~pvlib.pvsystem.PVSystem.get_iam_diffuse` + for details. + """ + spectral_modifier: Optional[PerArray[Union[pd.Series, float]]] = \ field(default=None) """Series (or tuple of Series, one for each array) containing spectral @@ -300,6 +308,18 @@ class ModelChain: 'schlick', 'interp' and 'no_loss'. The ModelChain instance will be passed as the first argument to a user-defined function. + iam_diffuse_model : str, or function, optional + Valid strings are 'martin_ruiz_diffuse', 'schlick_diffuse', + 'marion_diffuse', and 'no_loss'. If not specified, default + behavior is to apply the 'FD' parameter from the module parameters + if available, otherwise it defaults to 1 (no loss). The ModelChain + instance will be passed as the first argument to a user-defined + function. + + marion_diffuse_model : str, optional + Valid strings are 'ashrae', 'physical', 'martin_ruiz', 'sapm', and + 'schlick'. + spectral_model : str or function, optional Valid strings are: @@ -334,6 +354,8 @@ def __init__(self, system, location, solar_position_method='nrel_numpy', airmass_model='kastenyoung1989', dc_model=None, ac_model=None, aoi_model=None, + iam_diffuse_model=None, + marion_diffuse_model=None, spectral_model=None, temperature_model=None, dc_ohmic_model='no_loss', losses_model='no_loss', name=None): @@ -351,6 +373,8 @@ def __init__(self, system, location, self.dc_model = dc_model self.ac_model = ac_model self.aoi_model = aoi_model + self.iam_diffuse_model = iam_diffuse_model + self.marion_diffuse_model = marion_diffuse_model self.spectral_model = spectral_model self.temperature_model = temperature_model @@ -533,6 +557,7 @@ def __repr__(self): 'name', 'clearsky_model', 'transposition_model', 'solar_position_method', 'airmass_model', 'dc_model', 'ac_model', 'aoi_model', + 'iam_diffuse_model', 'marion_diffuse_model', 'spectral_model', 'temperature_model', 'losses_model' ] return ('ModelChain: \n ' + '\n '.join( @@ -833,6 +858,116 @@ def no_aoi_loss(self): self.results.aoi_modifier = (1.0,) * self.system.num_arrays return self + @property + def iam_diffuse_model(self): + return self._iam_diffuse_model + + @iam_diffuse_model.setter + def iam_diffuse_model(self, model): + if isinstance(model, str): + model = model.lower() + if model == 'martin_ruiz_diffuse': + self._iam_diffuse_model = self.martin_ruiz_diffuse_loss + elif model == 'schlick_diffuse': + self._iam_diffuse_model = self.schlick_diffuse_loss + elif model == 'marion_diffuse': + self._iam_diffuse_model = self.marion_diffuse_loss + elif model == 'no_loss': + self._iam_diffuse_model = self.no_diffuse_loss + else: + raise ValueError(model + ' is not a valid diffuse IAM loss ' + 'model') + elif model is None: + self._iam_diffuse_model = self.fd_diffuse_loss + else: + self._iam_diffuse_model = partial(model, self) + + def martin_ruiz_diffuse_loss(self): + self.results.iam_diffuse_modifier = self.system.get_iam_diffuse( + tuple(array.mount.surface_tilt + for array in self.system.arrays), + iam_model='martin_ruiz_diffuse' + ) + return self + + def schlick_diffuse_loss(self): + self.results.iam_diffuse_modifier = self.system.get_iam_diffuse( + tuple(array.mount.surface_tilt + for array in self.system.arrays), + iam_model='schlick_diffuse' + ) + return self + + def marion_diffuse_loss(self): + if self.marion_diffuse_model is None: + self.marion_diffuse_model = self.infer_marion_diffuse_model() + self.results.iam_diffuse_modifier = self.system.get_iam_diffuse( + tuple(array.mount.surface_tilt + for array in self.system.arrays), + iam_model='marion_diffuse', + marion_model=self.marion_diffuse_model, + ) + return self + + def no_diffuse_loss(self): + self.results.iam_diffuse_modifier = tuple( + 1.0 for _ in self.system.arrays + ) + return self + + def fd_diffuse_loss(self): + self.results.iam_diffuse_modifier = tuple( + array.module_parameters.get('FD', 1.0) + for array in self.system.arrays + ) + + @property + def marion_diffuse_model(self): + return self._marion_diffuse_model + + @marion_diffuse_model.setter + def marion_diffuse_model(self, model): + if model is None: + self._marion_diffuse_model = None + elif isinstance(model, str): + model = model.lower() + if model not in ['physical', 'sapm', 'ashrae', 'martin_ruiz', + 'schlick']: + raise ValueError(f'{model} is not a valid ' + 'marion_diffuse_model.') + self._marion_diffuse_model = model + else: + raise TypeError( + 'marion_diffuse_model must be a string or None.' + ) + + def infer_marion_diffuse_model(self): + module_parameters = tuple( + array.module_parameters for array in self.system.arrays) + params = _common_keys(module_parameters) + if iam._IAM_MODEL_PARAMS['physical'] <= params: + return 'physical' + elif iam._IAM_MODEL_PARAMS['sapm'] <= params: + return 'sapm' + elif iam._IAM_MODEL_PARAMS['ashrae'] <= params: + return 'ashrae' + elif iam._IAM_MODEL_PARAMS['martin_ruiz'] <= params: + return 'martin_ruiz' + # 'schlick' is intentionally excluded from inference. Since it + # requires no parameters, it would always match and effectively + # become the default, which is undesirable because it is not + # commonly used for PV applications. + else: + raise ValueError('could not infer the IAM model to be used with ' + 'marion_diffuse from at least one Array\'s ' + 'module_parameters. Check that the' + 'module_parameters for all Arrays in ' + 'system.arrays contain parameters for the ' + 'physical, sapm, ashrae, or martin_ruiz ' + 'model; explicitly set the model with the ' + 'marion_diffuse_model kwarg; or use a different ' + 'iam_diffuse_model.') + @property def spectral_model(self): return self._spectral_model @@ -1061,22 +1196,70 @@ def no_extra_losses(self): return self def effective_irradiance_model(self): - def _eff_irrad(module_parameters, total_irrad, spect_mod, aoi_mod): - fd = module_parameters.get('FD', 1.) - return spect_mod * (total_irrad['poa_direct'] * aoi_mod + - fd * total_irrad['poa_diffuse']) + def _eff_irrad(module_parameters, total_irrad, spect_mod, aoi_mod, + iam_diffuse_mod): + if isinstance(iam_diffuse_mod, dict): + direct = total_irrad['poa_direct'] + if 'poa_circumsolar' in total_irrad: + direct += total_irrad['poa_circumsolar'] + direct *= aoi_mod + + diffuse_components = { + 'poa_isotropic': 'sky', + 'poa_horizon': 'horizon', + 'poa_ground_diffuse': 'ground', + } + available_components = { + irradiance_key: iam_key + for irradiance_key, iam_key in diffuse_components.items() + if irradiance_key in total_irrad + } + + if not available_components: + raise ValueError( + 'Using a diffuse IAM model requires at least one of ' + '"poa_isotropic", "poa_horizon", or ' + '"poa_ground_diffuse" irradiance components, ' + 'none of which are provided by the selected ' + 'transposition_model ' + self.transposition_model + + '. Please select a different transposition_model, ' + 'or set iam_diffuse_model to "no_loss" or None.' + ) + diffuse = 0.0 + for irrad_key, iam_key in available_components.items(): + iam = iam_diffuse_mod.get(iam_key) + if iam is None: + warnings.warn( + 'The selected iam_diffuse_model ' + 'does not provide diffuse IAM for the ' + f'"{iam_key}" component, provided by the ' + 'selected transposition_model ' + f'"{self.transposition_model}". ' + 'Using an IAM of 1.0.', + UserWarning, + ) + iam = 1.0 + diffuse += total_irrad[irrad_key] * iam + else: + direct = total_irrad['poa_direct'] * aoi_mod + diffuse = total_irrad['poa_diffuse'] * iam_diffuse_mod + return spect_mod * (direct + diffuse) if isinstance(self.results.total_irrad, tuple): self.results.effective_irradiance = tuple( - _eff_irrad(array.module_parameters, ti, sm, am) for - array, ti, sm, am in zip( + _eff_irrad(array.module_parameters, ti, sm, am, di) for + array, ti, sm, am, di in zip( self.system.arrays, self.results.total_irrad, - self.results.spectral_modifier, self.results.aoi_modifier)) + self.results.spectral_modifier, self.results.aoi_modifier, + self.results.iam_diffuse_modifier + ) + ) else: self.results.effective_irradiance = _eff_irrad( self.system.arrays[0].module_parameters, self.results.total_irrad, self.results.spectral_modifier, - self.results.aoi_modifier + self.results.aoi_modifier, + self.results.iam_diffuse_modifier ) return self @@ -1398,7 +1581,8 @@ def prepare_inputs(self, weather): _tuple_from_dfs(self.results.weather, 'dhi'), albedo=self.results.albedo, airmass=self.results.airmass['airmass_relative'], - model=self.transposition_model + model=self.transposition_model, + diffuse_components=True ) return self @@ -1643,6 +1827,8 @@ def run_model(self, weather): weather = _to_tuple(weather) self.prepare_inputs(weather) self.aoi_model() + if callable(self.iam_diffuse_model): + self.iam_diffuse_model() self.spectral_model() self.effective_irradiance_model() @@ -1775,6 +1961,7 @@ def run_model_from_poa(self, data): self.prepare_inputs_from_poa(data) self.aoi_model() + self.iam_diffuse_model() self.spectral_model() self.effective_irradiance_model() diff --git a/tests/test_modelchain.py b/tests/test_modelchain.py index e15e155dbb..2b3e63dc14 100644 --- a/tests/test_modelchain.py +++ b/tests/test_modelchain.py @@ -1560,6 +1560,23 @@ def test_infer_aoi_model_invalid(location, system_no_aoi): ModelChain(system_no_aoi, location, spectral_model='no_loss') +@pytest.mark.parametrize('iam_diffuse_model', [ + 'marion_diffuse', 'martin_ruiz_diffuse', 'schlick_diffuse', + 'no_loss', None +]) +def test_iam_diffuse_models(sapm_dc_snl_ac_system, location, + iam_diffuse_model, weather, mocker): + mc = ModelChain(sapm_dc_snl_ac_system, location, dc_model='sapm', + iam_diffuse_model=iam_diffuse_model, + spectral_model='no_loss') + m = mocker.spy(sapm_dc_snl_ac_system, 'get_iam_diffuse') + mc.run_model(weather=weather) + assert m.call_count == 1 + assert isinstance(mc.results.ac, pd.Series) + assert mc.results.ac.iloc[0] > 150 and mc.results.ac.iloc[0] < 200 + assert mc.results.ac.iloc[1] < 1 + + def constant_spectral_loss(mc): mc.results.spectral_modifier = 0.9 @@ -2052,6 +2069,8 @@ def test_ModelChain___repr__(sapm_dc_snl_ac_system, location): ' dc_model: sapm', ' ac_model: sandia_inverter', ' aoi_model: sapm_aoi_loss', + ' iam_diffuse_model: fd_diffuse_loss', + ' marion_diffuse_model: None', ' spectral_model: sapm_spectral_loss', ' temperature_model: sapm_temp', ' losses_model: no_extra_losses' From a52ff3f5b2bb018a5f96ee3afb08ee26a0cfd6fa Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Thu, 20 Aug 2026 15:10:29 +0100 Subject: [PATCH 24/28] Fix diffuse IAM kwargs --- pvlib/pvsystem.py | 37 ++++++++++++++++++++++++------------- tests/test_pvsystem.py | 11 ++++++----- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index 5078f11f15..f69a7fb578 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -1271,7 +1271,7 @@ def get_iam(self, aoi, iam_model='physical'): raise ValueError(model + ' is not a valid IAM model') def get_iam_diffuse(self, surface_tilt, iam_model='marion_diffuse', - marion_model=None, **kwargs): + marion_model=None): """ Determine the incidence angle modifier for various diffuse irradiance components using the method specified by ``iam_model``. @@ -1290,10 +1290,7 @@ def get_iam_diffuse(self, surface_tilt, iam_model='marion_diffuse', marion_model : string, default None The IAM function to evaluate across a solid angle. Only used when ``iam_model='marion_diffuse'``. Must be one of 'ashrae', - 'physical', 'martin_ruiz' and 'sapm'. - - kwargs : dict, optional - Additional keyword arguments passed to the IAM model function. + 'physical', 'martin_ruiz', 'sapm', and 'schlick'. Returns ------- @@ -1312,18 +1309,32 @@ def get_iam_diffuse(self, surface_tilt, iam_model='marion_diffuse', if model == 'marion_diffuse' and marion_model is None: raise ValueError('marion_model must be specified when ' 'iam_model="marion_diffuse"') - if model in ['marion_diffuse', 'martin_ruiz_diffuse', - 'schlick_diffuse']: + if model == 'marion_diffuse': + if marion_model in ['ashrae', 'physical', 'martin_ruiz', + 'schlick']: + func = getattr(iam, marion_model) + params = set(inspect.signature(func).parameters.keys()) + params.discard('aoi') + kwargs = _build_kwargs(params, self.module_parameters) + iams = iam.marion_diffuse(model=marion_model, + surface_tilt=surface_tilt, + **kwargs) + elif marion_model == 'sapm': + iams = iam.marion_diffuse(model='sapm', + surface_tilt=surface_tilt, + module=self.module_parameters) + else: + raise ValueError(marion_model + ' is not a valid IAM model') + elif model == 'martin_ruiz_diffuse': func = getattr(iam, model) # get function at pvlib.iam # get all parameters from function signature to retrieve them from # module_parameters if present params = set(inspect.signature(func).parameters.keys()) - kwargs.update(_build_kwargs(params, self.module_parameters)) - if iam_model == 'marion_diffuse': - iams = func(model=marion_model, surface_tilt=surface_tilt, - **kwargs) - else: - iams = func(surface_tilt=surface_tilt, **kwargs) + params.discard('aoi') + kwargs = _build_kwargs(params, self.module_parameters) + iams = iam.martin_ruiz_diffuse(surface_tilt=surface_tilt, **kwargs) + elif model == 'schlick_diffuse': + iams = iam.schlick_diffuse(surface_tilt=surface_tilt) else: raise ValueError(model + ' is not a valid diffuse IAM model') diff --git a/tests/test_pvsystem.py b/tests/test_pvsystem.py index 90f640dfd3..1186fd9e3a 100644 --- a/tests/test_pvsystem.py +++ b/tests/test_pvsystem.py @@ -119,14 +119,15 @@ def test_PVSystem_get_iam_diffuse_marion(mocker): system = pvsystem.PVSystem(module_parameters=model_params) tilt = 30 iam = system.get_iam_diffuse(tilt, iam_model='marion_diffuse', - marion_model='ashrae', **model_params) - m.assert_called_with(model='ashrae', surface_tilt=tilt, **model_params) + marion_model='ashrae') + m.assert_called_with(model='ashrae', surface_tilt=tilt, + **model_params) assert isinstance(iam, dict) assert set(iam.keys()) == {'sky', 'ground', 'horizon'} tilt = pd.Series([30, 60]) iam = system.get_iam_diffuse(tilt, iam_model='marion_diffuse', - marion_model='ashrae', **model_params) + marion_model='ashrae') assert isinstance(iam, pd.DataFrame) @@ -151,13 +152,13 @@ def test_PVSystem_multi_array_get_iam_diffuse(): module_parameters=model_params)] ) iam = system.get_iam_diffuse((30, 60), iam_model='marion_diffuse', - marion_model='ashrae', **model_params) + marion_model='ashrae') assert len(iam) == 2 assert iam[0] != iam[1] with pytest.raises(ValueError, match="Length mismatch for per-array parameter"): system.get_iam_diffuse((30,), iam_model='marion_diffuse', - marion_model='ashrae', **model_params) + marion_model='ashrae') def test_PVSystem_get_iam_diffuse_invalid(sapm_module_params): From 3f76a9054eca66fcfb94f109409f31c1d4604f57 Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Thu, 20 Aug 2026 15:26:57 +0100 Subject: [PATCH 25/28] Add tests to fix codecov --- tests/test_pvsystem.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/test_pvsystem.py b/tests/test_pvsystem.py index 1186fd9e3a..1ba409600d 100644 --- a/tests/test_pvsystem.py +++ b/tests/test_pvsystem.py @@ -113,7 +113,7 @@ def test_PVSystem_get_iam_invalid(sapm_module_params, mocker): system.get_iam(45, iam_model='not_a_model') -def test_PVSystem_get_iam_diffuse_marion(mocker): +def test_PVSystem_get_iam_diffuse_marion(sapm_module_params, mocker): model_params = {'b': 0.05} m = mocker.spy(_iam, 'marion_diffuse') system = pvsystem.PVSystem(module_parameters=model_params) @@ -125,9 +125,10 @@ def test_PVSystem_get_iam_diffuse_marion(mocker): assert isinstance(iam, dict) assert set(iam.keys()) == {'sky', 'ground', 'horizon'} + system = pvsystem.PVSystem(module_parameters=sapm_module_params) tilt = pd.Series([30, 60]) iam = system.get_iam_diffuse(tilt, iam_model='marion_diffuse', - marion_model='ashrae') + marion_model='sapm') assert isinstance(iam, pd.DataFrame) @@ -167,6 +168,13 @@ def test_PVSystem_get_iam_diffuse_invalid(sapm_module_params): system.get_iam_diffuse(45, iam_model='not_a_model') +def test_PVSystem_get_iam_diffuse_marion_invalid(sapm_module_params): + system = pvsystem.PVSystem(module_parameters=sapm_module_params) + with pytest.raises(ValueError): + system.get_iam_diffuse(45, iam_model='marion_diffuse', + marion_model='not_a_model') + + def test_PVSystem_get_iam_diffuse_marion_missing_model(sapm_module_params): system = pvsystem.PVSystem(module_parameters=sapm_module_params) with pytest.raises(ValueError, match="marion_model must be specified"): From 858e27dfb7f9a911b58fc527e2faa9772a097a21 Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Fri, 21 Aug 2026 12:12:50 +0100 Subject: [PATCH 26/28] Add tests --- pvlib/modelchain.py | 88 +++++++++++++-------- tests/test_modelchain.py | 166 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 214 insertions(+), 40 deletions(-) diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 097b366b7c..eb8e3838d0 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -910,9 +910,10 @@ def marion_diffuse_loss(self): return self def no_diffuse_loss(self): - self.results.iam_diffuse_modifier = tuple( - 1.0 for _ in self.system.arrays - ) + if self.system.num_arrays == 1: + self.results.iam_diffuse_modifier = 1.0 + else: + self.results.iam_diffuse_modifier = (1.0,) * self.system.num_arrays return self def fd_diffuse_loss(self): @@ -1200,6 +1201,7 @@ def _eff_irrad(module_parameters, total_irrad, spect_mod, aoi_mod, iam_diffuse_mod): if isinstance(iam_diffuse_mod, dict): direct = total_irrad['poa_direct'] + # circumsolar is treated as direct if 'poa_circumsolar' in total_irrad: direct += total_irrad['poa_circumsolar'] direct *= aoi_mod @@ -1209,42 +1211,59 @@ def _eff_irrad(module_parameters, total_irrad, spect_mod, aoi_mod, 'poa_horizon': 'horizon', 'poa_ground_diffuse': 'ground', } - available_components = { - irradiance_key: iam_key - for irradiance_key, iam_key in diffuse_components.items() - if irradiance_key in total_irrad + sky_components = { + 'poa_isotropic', + 'poa_circumsolar', + 'poa_horizon', } + has_sky_components = any( + component in total_irrad for component in sky_components + ) - if not available_components: - raise ValueError( - 'Using a diffuse IAM model requires at least one of ' - '"poa_isotropic", "poa_horizon", or ' - '"poa_ground_diffuse" irradiance components, ' - 'none of which are provided by the selected ' - 'transposition_model ' + self.transposition_model + - '. Please select a different transposition_model, ' - 'or set iam_diffuse_model to "no_loss" or None.' + if not has_sky_components: + # The transposition model does not provide component-level + # sky diffuse irradiance, so the sky diffuse component cannot + # be corrected with the selected diffuse IAM model. + warnings.warn( + 'The selected transposition_model does not provide ' + 'component-level sky diffuse irradiance required by the ' + 'selected iam_diffuse_model. Using an IAM of 1.0 for ' + '"poa_sky_diffuse".', + UserWarning, ) - diffuse = 0.0 - for irrad_key, iam_key in available_components.items(): - iam = iam_diffuse_mod.get(iam_key) - if iam is None: - warnings.warn( - 'The selected iam_diffuse_model ' - 'does not provide diffuse IAM for the ' - f'"{iam_key}" component, provided by the ' - 'selected transposition_model ' - f'"{self.transposition_model}". ' - 'Using an IAM of 1.0.', - UserWarning, - ) - iam = 1.0 - diffuse += total_irrad[irrad_key] * iam + iam_ground = iam_diffuse_mod['ground'] + diffuse = (total_irrad['poa_sky_diffuse'] + + total_irrad['poa_ground_diffuse'] * iam_ground) + else: + available_components = { + irradiance_key: iam_key + for irradiance_key, iam_key in diffuse_components.items() + if irradiance_key in total_irrad + } + + diffuse = 0.0 + for irrad_key, iam_key in available_components.items(): + iam = iam_diffuse_mod.get(iam_key) + if iam is None: + warnings.warn( + 'The selected iam_diffuse_model ' + 'does not provide diffuse IAM for the ' + f'"{iam_key}" component, provided by the ' + 'selected transposition_model ' + f'"{self.transposition_model}". ' + 'Using an IAM of 1.0.', + UserWarning, + ) + iam = 1.0 + diffuse += total_irrad[irrad_key] * iam else: direct = total_irrad['poa_direct'] * aoi_mod diffuse = total_irrad['poa_diffuse'] * iam_diffuse_mod return spect_mod * (direct + diffuse) if isinstance(self.results.total_irrad, tuple): + if not isinstance(self.results.iam_diffuse_modifier, tuple): + self.results.iam_diffuse_modifier = ( + self.results.iam_diffuse_modifier,) self.results.effective_irradiance = tuple( _eff_irrad(array.module_parameters, ti, sm, am, di) for array, ti, sm, am, di in zip( @@ -1573,6 +1592,8 @@ def prepare_inputs(self, weather): self._prep_inputs_albedo(weather) self._prep_inputs_fixed() + diffuse_components = self.transposition_model != 'klucher' + self.results.total_irrad = self.system.get_irradiance( self.results.solar_position['apparent_zenith'], self.results.solar_position['azimuth'], @@ -1582,7 +1603,7 @@ def prepare_inputs(self, weather): albedo=self.results.albedo, airmass=self.results.airmass['airmass_relative'], model=self.transposition_model, - diffuse_components=True + diffuse_components=diffuse_components, ) return self @@ -1827,8 +1848,7 @@ def run_model(self, weather): weather = _to_tuple(weather) self.prepare_inputs(weather) self.aoi_model() - if callable(self.iam_diffuse_model): - self.iam_diffuse_model() + self.iam_diffuse_model() self.spectral_model() self.effective_irradiance_model() diff --git a/tests/test_modelchain.py b/tests/test_modelchain.py index 2b3e63dc14..4a13af0ed2 100644 --- a/tests/test_modelchain.py +++ b/tests/test_modelchain.py @@ -1560,23 +1560,177 @@ def test_infer_aoi_model_invalid(location, system_no_aoi): ModelChain(system_no_aoi, location, spectral_model='no_loss') -@pytest.mark.parametrize('iam_diffuse_model', [ - 'marion_diffuse', 'martin_ruiz_diffuse', 'schlick_diffuse', - 'no_loss', None -]) +@pytest.mark.parametrize( + 'iam_diffuse_model, expected_call_count',[ + ('marion_diffuse', 1), + ('martin_ruiz_diffuse', 1), + ('schlick_diffuse', 1), + ('no_loss', 0), + (None, 0)]) def test_iam_diffuse_models(sapm_dc_snl_ac_system, location, - iam_diffuse_model, weather, mocker): + iam_diffuse_model, expected_call_count, + weather, mocker): mc = ModelChain(sapm_dc_snl_ac_system, location, dc_model='sapm', iam_diffuse_model=iam_diffuse_model, spectral_model='no_loss') m = mocker.spy(sapm_dc_snl_ac_system, 'get_iam_diffuse') mc.run_model(weather=weather) - assert m.call_count == 1 + assert m.call_count == expected_call_count assert isinstance(mc.results.ac, pd.Series) assert mc.results.ac.iloc[0] > 150 and mc.results.ac.iloc[0] < 200 assert mc.results.ac.iloc[1] < 1 +@pytest.mark.parametrize('iam_diffuse_model', [ + 'marion_diffuse', + 'martin_ruiz_diffuse', + 'schlick_diffuse', + 'no_loss', + None +]) +def test_iam_diffuse_models_singleton_weather_single_array( + sapm_dc_snl_ac_system, location, iam_diffuse_model, weather): + mc = ModelChain(sapm_dc_snl_ac_system, location, dc_model='sapm', + iam_diffuse_model=iam_diffuse_model, + spectral_model='no_loss') + mc.run_model(weather=[weather]) + assert isinstance(mc.results.iam_diffuse_modifier, tuple) + assert len(mc.results.iam_diffuse_modifier) == 1 + assert isinstance(mc.results.ac, pd.Series) + assert not mc.results.ac.empty + assert mc.results.ac.iloc[0] > 150 and mc.results.ac.iloc[0] < 200 + assert mc.results.ac.iloc[1] < 1 + + +def test_iam_diffuse_model_no_loss(sapm_dc_snl_ac_system, cec_dc_snl_ac_arrays, + location, weather): + mc = ModelChain(sapm_dc_snl_ac_system, location, dc_model='sapm', + iam_diffuse_model='no_loss', spectral_model='no_loss') + mc.run_model(weather) + assert mc.results.iam_diffuse_modifier == 1.0 + assert not mc.results.ac.empty + assert mc.results.ac.iloc[0] > 150 and mc.results.ac.iloc[0] < 200 + assert mc.results.ac.iloc[1] < 1 + + # multi-array + mc = ModelChain(cec_dc_snl_ac_arrays, location, + dc_model='cec', iam_diffuse_model='no_loss', + spectral_model='no_loss') + mc.run_model(weather) + assert mc.results.iam_diffuse_modifier == (1.0, 1.0) + assert not mc.results.ac.empty + + +def constant_iam_diffuse_loss(mc): + mc.results.iam_diffuse_modifier = 0.9 + + +def test_iam_diffuse_model_user_func(sapm_dc_snl_ac_system, + location, weather, mocker): + m = mocker.spy(sys.modules[__name__], 'constant_iam_diffuse_loss') + mc = ModelChain(sapm_dc_snl_ac_system, location, dc_model='sapm', + iam_diffuse_model=constant_iam_diffuse_loss, + spectral_model='no_loss') + mc.run_model(weather) + assert m.call_count == 1 + assert mc.results.iam_diffuse_modifier == 0.9 + assert not mc.results.ac.empty + assert mc.results.ac.iloc[0] > 140 and mc.results.ac.iloc[0] < 200 + assert mc.results.ac.iloc[1] < 1 + + +def test_iam_diffuse_model_invalid(location, system_no_aoi): + text = 'not a valid diffuse IAM loss model' + with pytest.raises(ValueError, match=text): + ModelChain(system_no_aoi, location, aoi_model='no_loss', + iam_diffuse_model='not_a_model') + + +def test_marion_diffuse_model_invalid(location, system_no_aoi): + text = "not a valid marion_diffuse_model" + with pytest.raises(ValueError, match=text): + ModelChain(system_no_aoi, location, aoi_model='no_loss', + iam_diffuse_model='marion_diffuse', + marion_diffuse_model='not_a_model') + + text = "marion_diffuse_model must be a string or None" + with pytest.raises(TypeError, match=text): + ModelChain(system_no_aoi, location, aoi_model='no_loss', + iam_diffuse_model='marion_diffuse', + marion_diffuse_model=1) + + +@pytest.mark.parametrize('marion_diffuse_model', [ + 'sapm', 'ashrae', 'physical', 'martin_ruiz', +]) +def test_infer_marion_diffuse_model( + location, system_no_aoi, marion_diffuse_model): + for k in iam._IAM_MODEL_PARAMS[marion_diffuse_model]: + system_no_aoi.arrays[0].module_parameters.update({k: 1.0}) + mc = ModelChain( + system_no_aoi, + location, + iam_diffuse_model='marion_diffuse', + spectral_model='no_loss', + ) + mc.iam_diffuse_model() + assert mc.marion_diffuse_model == marion_diffuse_model + + +def test_infer_iam_marion_diffuse_model_with_extra_params( + location, system_no_aoi, weather, mocker): + model_kwargs = {'n': 1.526, 'K': 4.0, 'L': 0.002, # required + 'n_ar': 1.8} # extra + # test extra parameters not defined at iam._IAM_MODEL_PARAMS are passed + m = mocker.spy(iam, 'physical') + system_no_aoi.arrays[0].module_parameters.update(**model_kwargs) + mc = ModelChain(system_no_aoi, location, spectral_model='no_loss') + assert isinstance(mc, ModelChain) + mc.run_model(weather=weather) + _, call_kwargs = m.call_args + assert call_kwargs == model_kwargs + + +def test_infer_marion_diffuse_model_invalid(location, system_no_aoi): + text = 'could not infer the IAM model to be used with marion_diffuse' + with pytest.raises(ValueError, match=text): + mc = ModelChain( + system_no_aoi, + location, + aoi_model='no_loss', + iam_diffuse_model='marion_diffuse', + spectral_model='no_loss', + ) + mc.iam_diffuse_model() + + +def test_diffuse_iam_with_no_sky_diffuse_components( + location, sapm_dc_snl_ac_system, weather): + text = 'transposition_model does not provide component-level sky diffuse' + with pytest.warns(UserWarning, match=text): + mc = ModelChain( + sapm_dc_snl_ac_system, + location, + transposition_model='klucher', + iam_diffuse_model='marion_diffuse', + ) + mc.run_model(weather) + assert not mc.results.ac.empty + + +def test_diffuse_iam_component_not_present_warning( + location, sapm_dc_snl_ac_system, weather): + text = 'The selected iam_diffuse_model does not provide' + with pytest.warns(UserWarning, match=text): + mc = ModelChain( + sapm_dc_snl_ac_system, + location, + transposition_model='perez', + iam_diffuse_model='martin_ruiz_diffuse', + ) + mc.run_model(weather) + + def constant_spectral_loss(mc): mc.results.spectral_modifier = 0.9 From 845b3dd545ffffc844653cd4a7d5975dd34c1df6 Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Fri, 21 Aug 2026 15:02:19 +0100 Subject: [PATCH 27/28] Small fixes --- pvlib/modelchain.py | 22 ++++++++++++---------- tests/test_modelchain.py | 14 +++++++------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index eb8e3838d0..b8c939b3ff 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -1222,23 +1222,25 @@ def _eff_irrad(module_parameters, total_irrad, spect_mod, aoi_mod, if not has_sky_components: # The transposition model does not provide component-level - # sky diffuse irradiance, so the sky diffuse component cannot - # be corrected with the selected diffuse IAM model. + # sky diffuse irradiance, so the sky diffuse component + # cannot be corrected with the selected diffuse IAM model. warnings.warn( 'The selected transposition_model does not provide ' - 'component-level sky diffuse irradiance required by the ' - 'selected iam_diffuse_model. Using an IAM of 1.0 for ' - '"poa_sky_diffuse".', + 'component-level sky diffuse irradiance required by ' + 'the selected iam_diffuse_model. Using an IAM of 1.0 ' + 'for "poa_sky_diffuse".', UserWarning, ) iam_ground = iam_diffuse_mod['ground'] - diffuse = (total_irrad['poa_sky_diffuse'] - + total_irrad['poa_ground_diffuse'] * iam_ground) + diffuse = ( + total_irrad['poa_sky_diffuse'] + + total_irrad['poa_ground_diffuse'] * iam_ground + ) else: available_components = { - irradiance_key: iam_key - for irradiance_key, iam_key in diffuse_components.items() - if irradiance_key in total_irrad + irrad_key: iam_key + for irrad_key, iam_key in diffuse_components.items() + if irrad_key in total_irrad } diffuse = 0.0 diff --git a/tests/test_modelchain.py b/tests/test_modelchain.py index 4a13af0ed2..d4567dacd7 100644 --- a/tests/test_modelchain.py +++ b/tests/test_modelchain.py @@ -1561,12 +1561,12 @@ def test_infer_aoi_model_invalid(location, system_no_aoi): @pytest.mark.parametrize( - 'iam_diffuse_model, expected_call_count',[ - ('marion_diffuse', 1), - ('martin_ruiz_diffuse', 1), - ('schlick_diffuse', 1), - ('no_loss', 0), - (None, 0)]) + 'iam_diffuse_model, expected_call_count', [ + ('marion_diffuse', 1), + ('martin_ruiz_diffuse', 1), + ('schlick_diffuse', 1), + ('no_loss', 0), + (None, 0)]) def test_iam_diffuse_models(sapm_dc_snl_ac_system, location, iam_diffuse_model, expected_call_count, weather, mocker): @@ -1680,7 +1680,7 @@ def test_infer_marion_diffuse_model( def test_infer_iam_marion_diffuse_model_with_extra_params( location, system_no_aoi, weather, mocker): model_kwargs = {'n': 1.526, 'K': 4.0, 'L': 0.002, # required - 'n_ar': 1.8} # extra + 'n_ar': 1.8} # extra # test extra parameters not defined at iam._IAM_MODEL_PARAMS are passed m = mocker.spy(iam, 'physical') system_no_aoi.arrays[0].module_parameters.update(**model_kwargs) From b65c6123175f03a4b88c930b7afbda873359e5b0 Mon Sep 17 00:00:00 2001 From: cbcrespo Date: Fri, 21 Aug 2026 15:07:39 +0100 Subject: [PATCH 28/28] Add whatsnew entry --- docs/sphinx/source/whatsnew/v0.16.0.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/sphinx/source/whatsnew/v0.16.0.rst b/docs/sphinx/source/whatsnew/v0.16.0.rst index d7ea94ae8d..34b70710d0 100644 --- a/docs/sphinx/source/whatsnew/v0.16.0.rst +++ b/docs/sphinx/source/whatsnew/v0.16.0.rst @@ -73,6 +73,8 @@ Enhancements * Add support for diffuse IAM in the :py:class:`pvlib.pvsystem.Array` and :py:class:`pvlib.pvsystem.PVSystem` classes (see `pvlib.pvsystem.Array.get_iam_diffuse` and `pvlib.pvsystem.PVSystem.get_iam_diffuse`). (:issue:`2812`, :pull:`2845`) +* Add support for sky diffuse irradiance components and component-specific optical losses + (IAM) in the :py:class:`pvlib.modelchain.ModelChain` class. (:issue:`2846`, :pull:`2847`) Documentation