Skip to content

DC Ohmic Losses for pvsystem and modelchain #1168

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Mar 16, 2021
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/sphinx/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ Pvsyst model
temperature.pvsyst_cell
pvsystem.calcparams_pvsyst
pvsystem.singlediode
pvsystem.dc_ohms_from_percent

PVWatts model
^^^^^^^^^^^^^
Expand Down Expand Up @@ -389,6 +390,8 @@ Loss models
:toctree: generated/

pvsystem.combine_loss_factors
pvsystem.dc_ohms_from_percent
modelchain.ModelChain.dc_ohmic_model

Snow
----
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
43 changes: 43 additions & 0 deletions pvlib/modelchain.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,11 @@ class ModelChain:
The ModelChain instance will be passed as the first argument to a
user-defined function.

dc_ohmic_model: None, str or function, default None
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.
Expand All @@ -402,6 +407,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=None,
losses_model='no_loss', name=None):

self.name = name
Expand All @@ -420,7 +426,9 @@ 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
Expand Down Expand Up @@ -1068,6 +1076,40 @@ 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any idea why codecov says this line isn't tested? It looks like it should be.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was confusing for me as well. And now it is telling me that blank lines 1082 and 1095 are not covered, and that 1090 is not covered which it should be as there is a test specifically for dc_ohmic_model='no_loss'. I'd welcome an assist from someone more familiar with the tests on what might be leading to the failed coverage if anyone has an idea.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran a coverage report locally and cannot reproduce the codecov results. Adding exceptions to these lines causes errors, as you would expect.


@dc_ohmic_model.setter
def dc_ohmic_model(self, model):
if model is None:
self._dc_ohmic_model = self.no_dc_ohmic_loss
elif 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()
self.dc_ohmic_losses = pvsystem.dc_ohmic_losses(Rw, self.dc['i_mp'])
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized this might need to be rewritten to handle the case where there are multiple arrays and so dc_ohms_from_percent returns a tuple instead of a single value. In that case what will self.dc[i_mp] be? Commenting to mark to investigate and fix.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.results.dc will be a tuple of DataFrame.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I added functionality for multiple arrays in the latest commit. I think all of the comments are addressed now and it is ready for the review and push forward.

self.dc['p_mp'] = self.dc['p_mp'] - self.dc_ohmic_losses
return self

def no_dc_ohmic_loss(self):
return self

@property
def losses_model(self):
return self._losses_model
Expand Down Expand Up @@ -1737,6 +1779,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()

Expand Down
117 changes: 115 additions & 2 deletions pvlib/pvsystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry, should have caught this earlier.... it seems repetitive to call this parameter array_losses_parameters since it's already on an Array. I suppose there is some risk of user confusion if this and the PVSystem losses_parameters are called the same thing, but it doesn't hurt to have extra keys in these dictionaries. It's unlikely that would change in the future.

Maybe we should live with the current API on master in an alpha release or two and then maybe take up renaming in a follow up issue. I could see something like #1176 providing more clarity on this.

Thoughts @ncroft-b4 @cwhanse ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm OK with either array_losses_parameters or Array.losses_parameters (and PVSystem.losses_parameters) in this PR.

I think as loss models are added to pvlib and we work those models into ModelChain, we'll end up refactoring the parameter containers.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think my concern was making it distinguishable from the PVSystem parameter. Also, to keep compatibility with initializing a PVSystem directly, there needs to be a difference between the losses_parameters that would be passed into the Array and the ones that would stay with the PVSystem.

That makes me realize that I added array_losses_parameters to the list of keys that get passed to Array from PVSystem if an Array is not specified, but I did not add array_losses_parameters explicitly to the documentation for PVSystem or to the init. That makes sense to be because array_losses_parameters is only an Array parameter, but it does make it different from the other Array parameters that are still part of PVSystem by legacy. I'm good with it as it is but if you want to keep it uniform with the legacy parameters we could change it.

Supported keys are dc_ohmic_percent.

"""

def __init__(self,
Expand All @@ -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

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -1372,6 +1398,28 @@ 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`,
`self.losses_parameters["dc_ohmic_percent"]`,
`self.module_parameters["V_mp_ref"]`,
`self.module_parameters["I_mp_ref"]`,
`self.modules_per_string`, and `self.strings_per_inverter`.
See :py:func:`pvlib.pvsystem.dc_ohms_from_percent` for details.
"""

kwargs = _build_kwargs(['dc_ohmic_percent'],
self.array_losses_parameters)

kwargs.update(_build_kwargs(['V_mp_ref', 'I_mp_ref'],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if there is a way to extend this to the other DC module models. If module_parameters is taken from the CEC database, it will include V_mp_ref and I_mp_ref.

The other cases are Pvsyst, SAPM and PVWatts.

For Pvsyst, pvlib provides no parameter database. The pvsyst DC power functions don't require V_mp_ref or I_mp_ref, so the user would need to know that these must be added.

For SAPM, the expected names are Vmpo and Impo.

For PVWatts, the ohmic losses model can't be applied since PVWatts only deals with power (not voltage and current separately).

V_mp_ref, I_mp_ref and dc_ohmic_percent are required by the downstream method. Maybe raise a ValueError here if any are missing at this step.

In the ModelChain dc_ohms_from_percent method, we could check dc_model and raise a more informative message how to correct the ValueError.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. I need to sort out the relations between the DC models and the module parameters, which have always been a bit confusing, I admit. I would like to make this applicable for CEC, SAPM, and PVsyst models. Sounds like for SAPM, I can add the option to use Vmpo and Impo if those are present in the module parameters. But for PVsyst, it may require a different workflow, or like you say, to raise a ValueError to say that those parameters need to be added. I will look into this to add an update.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know if the Pvsyst software names these two quantities? If so, I'd use those names. A user will have to add them to module_parameters regardless since there's no data source from which to inherit the names.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a section that checks from and adds the parameters in sets of either V_mp_ref, I_mp_ref; Vmpo, Impo; or Vmpp, Impp` (pvsyst-like, from the documentation and pan file parameters). It should raise a value error if these are not found. If this looks like a workable solution, should more tests be added for each case?

self.module_parameters))

kwargs.update({'modules_per_string': self.modules_per_string,
'strings_per_inverter': self.strings})

return dc_ohms_from_percent(**kwargs)


def calcparams_desoto(effective_irradiance, temp_cell,
alpha_sc, a_ref, I_L_ref, I_o_ref, R_sh_ref, R_s,
Expand Down Expand Up @@ -2810,6 +2858,71 @@ def pvwatts_losses(soiling=2, shading=3, snow=0, mismatch=2, wiring=2,
return losses


def dc_ohms_from_percent(V_mp_ref, I_mp_ref, dc_ohmic_percent,
modules_per_string=1,
strings_per_inverter=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:`L_{stc}` is the input dc loss as a percent, e.g. 1.5% loss is
input as 1.5

Parameters
----------
V_mp_ref: numeric
I_mp_ref: numeric
dc_ohmic_percent: numeric, default 0
modules_per_string: numeric, default 1
strings_per_inverter: numeric, default 1

Returns
----------
Rw: numeric
Equivalent resistance in ohms

References
----------
-- [1] PVsyst 7 Help. "Array ohmic wiring loss".
https://www.pvsyst.com/help/ohmic_loss.htm
"""
vmp = modules_per_string * V_mp_ref

imp = strings_per_inverter * I_mp_ref

Rw = (dc_ohmic_percent / 100) * (vmp / imp)

return Rw


def dc_ohmic_losses(ohms, current):
"""
Returns ohmic losses in in units of power from the equivalent
resistance of of the wires and the operating current.

Parameters
----------
ohms: numeric, float
current: numeric, float or array-like

Returns
----------
numeric
Single or array-like value of the losses in units of power

References
----------
-- [1] PVsyst 7 Help. "Array ohmic wiring loss".
https://www.pvsyst.com/help/ohmic_loss.htm
"""
return ohms * current * current


def combine_loss_factors(index, *losses, fill_method='ffill'):
r"""
Combines Series loss fractions while setting a common index.
Expand Down
67 changes: 67 additions & 0 deletions pvlib/tests/test_modelchain.py
Original file line number Diff line number Diff line change
Expand Up @@ -1491,6 +1491,73 @@ 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,
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.dc_ohmic_losses, pd.Series)


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 m.call_count == 1
assert hasattr(mc, 'dc_ohmic_losses') is False


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
Expand Down
Loading