diff --git a/docs/sphinx/source/api.rst b/docs/sphinx/source/api.rst index 8805d199a4..43f598c7c7 100644 --- a/docs/sphinx/source/api.rst +++ b/docs/sphinx/source/api.rst @@ -331,6 +331,8 @@ Pvsyst model temperature.pvsyst_cell pvsystem.calcparams_pvsyst pvsystem.singlediode + pvsystem.dc_ohms_from_percent + pvsystem.dc_ohmic_losses PVWatts model ^^^^^^^^^^^^^ @@ -389,6 +391,7 @@ Loss models :toctree: generated/ pvsystem.combine_loss_factors + pvsystem.dc_ohms_from_percent Snow ---- @@ -615,6 +618,7 @@ ModelChain properties that are aliases for your specific modeling functions. modelchain.ModelChain.aoi_model modelchain.ModelChain.spectral_model modelchain.ModelChain.temperature_model + modelchain.ModelChain.dc_ohmic_model modelchain.ModelChain.losses_model modelchain.ModelChain.effective_irradiance_model @@ -645,6 +649,8 @@ ModelChain model definitions. modelchain.ModelChain.pvsyst_temp modelchain.ModelChain.faiman_temp modelchain.ModelChain.fuentes_temp + modelchain.ModelChain.dc_ohmic_model + modelchain.ModelChain.no_dc_ohmic_loss modelchain.ModelChain.pvwatts_losses modelchain.ModelChain.no_extra_losses diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 1c07857745..95411b3359 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -273,7 +273,8 @@ class ModelChainResult: _singleton_tuples: bool = field(default=False) _per_array_fields = {'total_irrad', 'aoi', 'aoi_modifier', 'spectral_modifier', 'cell_temperature', - 'effective_irradiance', 'dc', 'diode_params'} + 'effective_irradiance', 'dc', 'diode_params', + 'dc_ohmic_losses'} # system-level information solar_position: Optional[pd.DataFrame] = field(default=None) @@ -293,6 +294,7 @@ class ModelChainResult: dc: Optional[PerArray[Union[pd.Series, pd.DataFrame]]] = \ field(default=None) diode_params: Optional[PerArray[pd.DataFrame]] = field(default=None) + dc_ohmic_losses: Optional[PerArray[pd.Series]] = field(default=None) def _result_type(self, value): """Coerce `value` to the correct type according to @@ -380,6 +382,11 @@ class ModelChain: The ModelChain instance will be passed as the first argument to a user-defined function. + dc_ohmic_model: str or function, default 'no_loss' + Valid strings are 'dc_ohms_from_percent', 'no_loss'. The ModelChain + instance will be passed as the first argument to a user-defined + function. + losses_model: str or function, default 'no_loss' Valid strings are 'pvwatts', 'no_loss'. The ModelChain instance will be passed as the first argument to a user-defined function. @@ -402,6 +409,7 @@ def __init__(self, system, location, airmass_model='kastenyoung1989', dc_model=None, ac_model=None, aoi_model=None, spectral_model=None, temperature_model=None, + dc_ohmic_model='no_loss', losses_model='no_loss', name=None): self.name = name @@ -420,8 +428,8 @@ def __init__(self, system, location, self.spectral_model = spectral_model self.temperature_model = temperature_model + self.dc_ohmic_model = dc_ohmic_model self.losses_model = losses_model - self.orientation_strategy = orientation_strategy self.weather = None self.times = None @@ -1068,6 +1076,49 @@ def faiman_temp(self): def fuentes_temp(self): return self._set_celltemp(self.system.fuentes_celltemp) + @property + def dc_ohmic_model(self): + return self._dc_ohmic_model + + @dc_ohmic_model.setter + def dc_ohmic_model(self, model): + if isinstance(model, str): + model = model.lower() + if model == 'dc_ohms_from_percent': + self._dc_ohmic_model = self.dc_ohms_from_percent + elif model == 'no_loss': + self._dc_ohmic_model = self.no_dc_ohmic_loss + else: + raise ValueError(model + ' is not a valid losses model') + else: + self._dc_ohmic_model = partial(model, self) + + def dc_ohms_from_percent(self): + """ + Calculate time series of ohmic losses and apply those to the mpp power + output of the `dc_model` based on the pvsyst equivalent resistance + method. Uses a `dc_ohmic_percent` parameter in the `losses_parameters` + of the PVsystem. + """ + Rw = self.system.dc_ohms_from_percent() + if isinstance(self.results.dc, tuple): + self.results.dc_ohmic_losses = tuple( + pvsystem.dc_ohmic_losses(Rw, df['i_mp']) + for Rw, df in zip(Rw, self.results.dc) + ) + for df, loss in zip(self.results.dc, self.results.dc_ohmic_losses): + df['p_mp'] = df['p_mp'] - loss + else: + self.results.dc_ohmic_losses = pvsystem.dc_ohmic_losses( + Rw, self.results.dc['i_mp'] + ) + self.results.dc['p_mp'] = (self.results.dc['p_mp'] + - self.results.dc_ohmic_losses) + return self + + def no_dc_ohmic_loss(self): + return self + @property def losses_model(self): return self._losses_model @@ -1737,6 +1788,7 @@ def _run_from_effective_irrad(self, data=None): """ self._prepare_temperature(data) self.dc_model() + self.dc_ohmic_model() self.losses_model() self.ac_model() diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index a727673265..612dc69e11 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -190,6 +190,11 @@ def __init__(self, racking_model=None, losses_parameters=None, name=None): if arrays is None: + if losses_parameters is None: + array_losses_parameters = {} + else: + array_losses_parameters = _build_kwargs(['dc_ohmic_percent'], + losses_parameters) self.arrays = (Array( surface_tilt, surface_azimuth, @@ -201,7 +206,8 @@ def __init__(self, temperature_model_parameters, modules_per_string, strings_per_inverter, - racking_model + racking_model, + array_losses_parameters, ),) else: self.arrays = tuple(arrays) @@ -1017,6 +1023,17 @@ def pvwatts_ac(self, pdc): return inverter.pvwatts(pdc, self.inverter_parameters['pdc0'], **kwargs) + @_unwrap_single_value + def dc_ohms_from_percent(self): + """ + Calculates the equivalent resistance of the wires for each array using + :py:func:`pvlib.pvsystem.dc_ohms_from_percent` + + See :py:func:`pvlib.pvsystem.dc_ohms_from_percent` for details. + """ + + return tuple(array.dc_ohms_from_percent() for array in self.arrays) + @property @_unwrap_single_value def module_parameters(self): @@ -1149,6 +1166,9 @@ class Array: Valid strings are 'open_rack', 'close_mount', and 'insulated_back'. Used to identify a parameter set for the SAPM cell temperature model. + array_losses_parameters: None, dict or Series, default None. + Supported keys are 'dc_ohmic_percent'. + """ def __init__(self, @@ -1158,7 +1178,8 @@ def __init__(self, module_parameters=None, temperature_model_parameters=None, modules_per_string=1, strings=1, - racking_model=None, name=None): + racking_model=None, array_losses_parameters=None, + name=None): self.surface_tilt = surface_tilt self.surface_azimuth = surface_azimuth @@ -1186,6 +1207,11 @@ def __init__(self, else: self.temperature_model_parameters = temperature_model_parameters + if array_losses_parameters is None: + self.array_losses_parameters = {} + else: + self.array_losses_parameters = array_losses_parameters + self.name = name def __repr__(self): @@ -1372,6 +1398,72 @@ def get_iam(self, aoi, iam_model='physical'): else: raise ValueError(model + ' is not a valid IAM model') + def dc_ohms_from_percent(self): + """ + Calculates the equivalent resistance of the wires using + :py:func:`pvlib.pvsystem.dc_ohms_from_percent` + + Makes use of array module parameters according to the + following DC models: + + CEC: + + * `self.module_parameters["V_mp_ref"]` + * `self.module_parameters["I_mp_ref"]` + + SAPM: + + * `self.module_parameters["Vmpo"]` + * `self.module_parameters["Impo"]` + + PVsyst-like or other: + + * `self.module_parameters["Vmpp"]` + * `self.module_parameters["Impp"]` + + Other array parameters that are used are: + `self.losses_parameters["dc_ohmic_percent"]`, + `self.modules_per_string`, and + `self.strings`. + + See :py:func:`pvlib.pvsystem.dc_ohms_from_percent` for more details. + """ + + # get relevent Vmp and Imp parameters from CEC parameters + if all([elem in self.module_parameters + for elem in ['V_mp_ref', 'I_mp_ref']]): + vmp_ref = self.module_parameters['V_mp_ref'] + imp_ref = self.module_parameters['I_mp_ref'] + + # get relevant Vmp and Imp parameters from SAPM parameters + elif all([elem in self.module_parameters + for elem in ['Vmpo', 'Impo']]): + vmp_ref = self.module_parameters['Vmpo'] + imp_ref = self.module_parameters['Impo'] + + # get relevant Vmp and Imp parameters if they are PVsyst-like + elif all([elem in self.module_parameters + for elem in ['Vmpp', 'Impp']]): + vmp_ref = self.module_parameters['Vmpp'] + imp_ref = self.module_parameters['Impp'] + + # raise error if relevant Vmp and Imp parameters are not found + else: + raise ValueError('Parameters for Vmp and Imp could not be found ' + 'in the array module parameters. Module ' + 'parameters must include one set of ' + '{"V_mp_ref", "I_mp_Ref"}, ' + '{"Vmpo", "Impo"}, or ' + '{"Vmpp", "Impp"}.' + ) + + return dc_ohms_from_percent( + vmp_ref, + imp_ref, + self.array_losses_parameters['dc_ohmic_percent'], + self.modules_per_string, + self.strings) + def calcparams_desoto(effective_irradiance, temp_cell, alpha_sc, a_ref, I_L_ref, I_o_ref, R_sh_ref, R_s, @@ -2810,6 +2902,80 @@ def pvwatts_losses(soiling=2, shading=3, snow=0, mismatch=2, wiring=2, return losses +def dc_ohms_from_percent(vmp_ref, imp_ref, dc_ohmic_percent, + modules_per_string=1, + strings=1): + """ + Calculates the equivalent resistance of the wires from a percent + ohmic loss at STC. + + Equivalent resistance is calculated with the function: + + .. math:: + Rw = (L_{stc} / 100) * (Varray / Iarray) + + :math:`Rw` is the equivalent resistance in ohms + :math:`Varray` is the Vmp of the modules times modules per string + :math:`Iarray` is the Imp of the modules times strings per array + :math:`L_{stc}` is the input dc loss percent + + Parameters + ---------- + vmp_ref: numeric + Voltage at maximum power in reference conditions [V] + imp_ref: numeric + Current at maximum power in reference conditions [V] + dc_ohmic_percent: numeric, default 0 + input dc loss as a percent, e.g. 1.5% loss is input as 1.5 + modules_per_string: int, default 1 + Number of modules per string in the array. + strings: int, default 1 + Number of parallel strings in the array. + + Returns + ---------- + Rw: numeric + Equivalent resistance [ohm] + + References + ---------- + .. [1] PVsyst 7 Help. "Array ohmic wiring loss". + https://www.pvsyst.com/help/ohmic_loss.htm + """ + vmp = modules_per_string * vmp_ref + + imp = strings * imp_ref + + Rw = (dc_ohmic_percent / 100) * (vmp / imp) + + return Rw + + +def dc_ohmic_losses(resistance, current): + """ + Returns ohmic losses in units of power from the equivalent + resistance of the wires and the operating current. + + Parameters + ---------- + resistance: numeric + Equivalent resistance of wires [ohm] + current: numeric, float or array-like + Operating current [A] + + Returns + ---------- + loss: numeric + Power Loss [W] + + References + ---------- + .. [1] PVsyst 7 Help. "Array ohmic wiring loss". + https://www.pvsyst.com/help/ohmic_loss.htm + """ + return resistance * current * current + + def combine_loss_factors(index, *losses, fill_method='ffill'): r""" Combines Series loss fractions while setting a common index. diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py index c45fd5026f..28a3adef45 100644 --- a/pvlib/tests/test_modelchain.py +++ b/pvlib/tests/test_modelchain.py @@ -1491,6 +1491,91 @@ def constant_losses(mc): mc.results.dc *= mc.losses +def dc_constant_losses(mc): + mc.results.dc['p_mp'] *= 0.9 + + +def test_dc_ohmic_model_ohms_from_percent(cec_dc_snl_ac_system, + cec_dc_snl_ac_arrays, + location, + weather, + mocker): + + m = mocker.spy(pvsystem, 'dc_ohms_from_percent') + + system = cec_dc_snl_ac_system + + for array in system.arrays: + array.array_losses_parameters = dict(dc_ohmic_percent=3) + + mc = ModelChain(system, location, + aoi_model='no_loss', + spectral_model='no_loss', + dc_ohmic_model='dc_ohms_from_percent') + mc.run_model(weather) + + assert m.call_count == 1 + + assert isinstance(mc.results.dc_ohmic_losses, pd.Series) + + system = cec_dc_snl_ac_arrays + + for array in system.arrays: + array.array_losses_parameters = dict(dc_ohmic_percent=3) + + mc = ModelChain(system, location, + aoi_model='no_loss', + spectral_model='no_loss', + dc_ohmic_model='dc_ohms_from_percent') + mc.run_model(weather) + + assert m.call_count == 3 + assert len(mc.results.dc_ohmic_losses) == len(mc.system.arrays) + + assert isinstance(mc.results.dc_ohmic_losses, tuple) + + +def test_dc_ohmic_model_no_dc_ohmic_loss(cec_dc_snl_ac_system, + location, + weather, + mocker): + + m = mocker.spy(modelchain.ModelChain, 'no_dc_ohmic_loss') + mc = ModelChain(cec_dc_snl_ac_system, location, + aoi_model='no_loss', + spectral_model='no_loss', + dc_ohmic_model='no_loss') + mc.run_model(weather) + + assert mc.dc_ohmic_model == mc.no_dc_ohmic_loss + assert m.call_count == 1 + assert mc.results.dc_ohmic_losses is None + + +def test_dc_ohmic_ext_def(cec_dc_snl_ac_system, location, + weather, mocker): + m = mocker.spy(sys.modules[__name__], 'dc_constant_losses') + mc = ModelChain(cec_dc_snl_ac_system, location, + aoi_model='no_loss', + spectral_model='no_loss', + dc_ohmic_model=dc_constant_losses) + mc.run_model(weather) + + assert m.call_count == 1 + assert isinstance(mc.results.ac, (pd.Series, pd.DataFrame)) + assert not mc.results.ac.empty + + +def test_dc_ohmic_not_a_model(cec_dc_snl_ac_system, location, + weather, mocker): + exc_text = 'not_a_dc_model is not a valid losses model' + with pytest.raises(ValueError, match=exc_text): + ModelChain(cec_dc_snl_ac_system, location, + aoi_model='no_loss', + spectral_model='no_loss', + dc_ohmic_model='not_a_dc_model') + + def test_losses_models_pvwatts(pvwatts_dc_pvwatts_ac_system, location, weather, mocker): age = 1 diff --git a/pvlib/tests/test_pvsystem.py b/pvlib/tests/test_pvsystem.py index 5c0ef7b4a2..21d0af5564 100644 --- a/pvlib/tests/test_pvsystem.py +++ b/pvlib/tests/test_pvsystem.py @@ -2044,3 +2044,91 @@ def test_combine_loss_factors(): def test_no_extra_kwargs(): with pytest.raises(TypeError, match="arbitrary_kwarg"): pvsystem.PVSystem(arbitrary_kwarg='value') + + +def test_dc_ohms_from_percent(): + expected = .1425 + out = pvsystem.dc_ohms_from_percent(38, 8, 3, 1, 1) + assert_allclose(out, expected) + + +def test_PVSystem_dc_ohms_from_percent(mocker): + mocker.spy(pvsystem, 'dc_ohms_from_percent') + + expected = .1425 + system = pvsystem.PVSystem(losses_parameters={'dc_ohmic_percent': 3}, + module_parameters={'I_mp_ref': 8, + 'V_mp_ref': 38}) + out = system.dc_ohms_from_percent() + + pvsystem.dc_ohms_from_percent.assert_called_once_with( + dc_ohmic_percent=3, + vmp_ref=38, + imp_ref=8, + modules_per_string=1, + strings=1 + ) + + assert_allclose(out, expected) + + +def test_dc_ohmic_losses(): + expected = 9.12 + out = pvsystem.dc_ohmic_losses(.1425, 8) + assert_allclose(out, expected) + + +def test_Array_dc_ohms_from_percent(mocker): + mocker.spy(pvsystem, 'dc_ohms_from_percent') + + expected = .1425 + + array = pvsystem.Array(array_losses_parameters={'dc_ohmic_percent': 3}, + module_parameters={'I_mp_ref': 8, + 'V_mp_ref': 38}) + out = array.dc_ohms_from_percent() + pvsystem.dc_ohms_from_percent.assert_called_with( + dc_ohmic_percent=3, + vmp_ref=38, + imp_ref=8, + modules_per_string=1, + strings=1 + ) + assert_allclose(out, expected) + + array = pvsystem.Array(array_losses_parameters={'dc_ohmic_percent': 3}, + module_parameters={'Impo': 8, + 'Vmpo': 38}) + out = array.dc_ohms_from_percent() + pvsystem.dc_ohms_from_percent.assert_called_with( + dc_ohmic_percent=3, + vmp_ref=38, + imp_ref=8, + modules_per_string=1, + strings=1 + ) + assert_allclose(out, expected) + + array = pvsystem.Array(array_losses_parameters={'dc_ohmic_percent': 3}, + module_parameters={'Impp': 8, + 'Vmpp': 38}) + out = array.dc_ohms_from_percent() + + pvsystem.dc_ohms_from_percent.assert_called_with( + dc_ohmic_percent=3, + vmp_ref=38, + imp_ref=8, + modules_per_string=1, + strings=1 + ) + assert_allclose(out, expected) + + with pytest.raises(ValueError, + match=('Parameters for Vmp and Imp could not be found ' + 'in the array module parameters. Module ' + 'parameters must include one set of ' + '{"V_mp_ref", "I_mp_Ref"}, ' + '{"Vmpo", "Impo"}, or ' + '{"Vmpp", "Impp"}.')): + array = pvsystem.Array(array_losses_parameters={'dc_ohmic_percent': 3}) + out = array.dc_ohms_from_percent()