diff --git a/docs/sphinx/source/whatsnew/v0.16.0.rst b/docs/sphinx/source/whatsnew/v0.16.0.rst index 78fe64be82..34b70710d0 100644 --- a/docs/sphinx/source/whatsnew/v0.16.0.rst +++ b/docs/sphinx/source/whatsnew/v0.16.0.rst @@ -70,6 +70,11 @@ 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`) +* 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 diff --git a/pvlib/irradiance.py b/pvlib/irradiance.py index 9d6ddbe665..73f6ddf5b0 100644 --- a/pvlib/irradiance.py +++ b/pvlib/irradiance.py @@ -697,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/pvlib/modelchain.py b/pvlib/modelchain.py index 32af129fd7..b8c939b3ff 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,117 @@ 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): + 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): + 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 +1197,90 @@ 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'] + # circumsolar is treated as 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', + } + sky_components = { + 'poa_isotropic', + 'poa_circumsolar', + 'poa_horizon', + } + has_sky_components = any( + component in total_irrad for component in sky_components + ) + + 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, + ) + iam_ground = iam_diffuse_mod['ground'] + diffuse = ( + total_irrad['poa_sky_diffuse'] + + total_irrad['poa_ground_diffuse'] * iam_ground + ) + else: + available_components = { + irrad_key: iam_key + for irrad_key, iam_key in diffuse_components.items() + if irrad_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) 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 @@ -1390,6 +1594,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'], @@ -1398,7 +1604,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=diffuse_components, ) return self @@ -1643,6 +1850,7 @@ def run_model(self, weather): weather = _to_tuple(weather) self.prepare_inputs(weather) self.aoi_model() + self.iam_diffuse_model() self.spectral_model() self.effective_irradiance_model() @@ -1775,6 +1983,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/pvlib/pvsystem.py b/pvlib/pvsystem.py index 02b16edaf1..f69a7fb578 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -307,7 +307,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 @@ -334,6 +334,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 @@ -373,7 +378,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 ) @@ -382,8 +389,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 @@ -411,6 +418,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', + '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'. + + kwargs : dict, optional + Additional keyword arguments passed to the IAM model function. + + Returns + ------- + iam_diffuse : dict or DataFrame + 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): @@ -1093,7 +1142,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. @@ -1121,6 +1170,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 @@ -1161,20 +1215,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 @@ -1213,6 +1270,79 @@ 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): + """ + 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', + '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'. + + Returns + ------- + iam_diffuse : dict or DataFrame + 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 == '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()) + 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') + + 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_modelchain.py b/tests/test_modelchain.py index e15e155dbb..d4567dacd7 100644 --- a/tests/test_modelchain.py +++ b/tests/test_modelchain.py @@ -1560,6 +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, 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): + 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 == 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 @@ -2052,6 +2223,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' diff --git a/tests/test_pvsystem.py b/tests/test_pvsystem.py index 78aaed70c4..1ba409600d 100644 --- a/tests/test_pvsystem.py +++ b/tests/test_pvsystem.py @@ -113,6 +113,74 @@ 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(sapm_module_params, 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') + m.assert_called_with(model='ashrae', surface_tilt=tilt, + **model_params) + 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='sapm') + assert isinstance(iam, pd.DataFrame) + + +@pytest.mark.parametrize('iam_model', ['martin_ruiz_diffuse', + 'schlick_diffuse']) +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) + tilt = 30 + iam = system.get_iam_diffuse(tilt, iam_model=iam_model) + 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') + 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') + + +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_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"): + 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()`.