Skip to content

Support list of weather input for ModelChain with a single-array PVSystem #1157

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 10 commits into from
Feb 4, 2021
Merged
105 changes: 72 additions & 33 deletions pvlib/modelchain.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,22 +268,47 @@ class ModelChainResult:
_T = TypeVar('T')
PerArray = Union[_T, Tuple[_T, ...]]
"""Type for fields that vary between arrays"""

# these attributes are used in __setattr__ to determine the correct type.
_singleton_tuples: bool = field(default=False)
_per_array_fields = {'total_irrad', 'aoi', 'aoi_modifier',
'spectral_modifier', 'cell_temperature',
'effective_irradiance', 'dc', 'diode_params'}

# system-level information
solar_position: Optional[pd.DataFrame] = field(default=None)
airmass: Optional[pd.DataFrame] = field(default=None)
ac: Optional[pd.Series] = field(default=None)
# per DC array information
tracking: Optional[pd.DataFrame] = field(default=None)

# per DC array information
total_irrad: Optional[PerArray[pd.DataFrame]] = field(default=None)
aoi: Optional[PerArray[pd.Series]] = field(default=None)
aoi_modifier: Optional[PerArray[pd.Series]] = field(default=None)
spectral_modifier: Optional[PerArray[pd.Series]] = field(default=None)
aoi_modifier: Optional[PerArray[Union[pd.Series, float]]] = \
field(default=None)
spectral_modifier: Optional[PerArray[Union[pd.Series, float]]] = \
field(default=None)
cell_temperature: Optional[PerArray[pd.Series]] = field(default=None)
effective_irradiance: Optional[PerArray[pd.Series]] = field(default=None)
dc: Optional[PerArray[Union[pd.Series, pd.DataFrame]]] = \
field(default=None)
diode_params: Optional[PerArray[pd.DataFrame]] = field(default=None)

def _result_type(self, value):
"""Coerce `value` to the correct type according to
``self._singleton_tuples``."""
# Allow None to pass through without being wrapped in a tuple
if (self._singleton_tuples
and not isinstance(value, tuple)
and value is not None):
return (value,)
return value

def __setattr__(self, key, value):
if key in ModelChainResult._per_array_fields:
Copy link
Member

Choose a reason for hiding this comment

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

could we use typing.get_type_hints and delete the private set?

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 would be nice, but I can't seem to figure out how to compare the concrete types returned from get_type_hints with the generic Optional[PerArray[_T]] type.

Copy link
Member

@wholmgren wholmgren Feb 4, 2021

Choose a reason for hiding this comment

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

After further research, my idea seems like a misuse of the typing module at this point in time, so I'm good with your solution.

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'm a little bummed... I was excited to actually put the types to use.

value = self._result_type(value)
super().__setattr__(key, value)


class ModelChain:
"""
Expand Down Expand Up @@ -684,12 +709,9 @@ def infer_dc_model(self):
'set the model with the dc_model kwarg.')

def sapm(self):
self.results.dc = self.system.sapm(self.results.effective_irradiance,
self.results.cell_temperature)

self.results.dc = self.system.scale_voltage_current_power(
self.results.dc)

dc = self.system.sapm(self.results.effective_irradiance,
self.results.cell_temperature)
self.results.dc = self.system.scale_voltage_current_power(dc)
return self

def _singlediode(self, calcparams_model_function):
Expand Down Expand Up @@ -745,18 +767,14 @@ def pvwatts_dc(self):
pvlib.pvsystem.PVSystem.pvwatts_dc
pvlib.pvsystem.PVSystem.scale_voltage_current_power
"""
self.results.dc = self.system.pvwatts_dc(
self.results.effective_irradiance, self.results.cell_temperature)
if isinstance(self.results.dc, tuple):
temp = tuple(
pd.DataFrame(s, columns=['p_mp']) for s in self.results.dc)
else:
temp = pd.DataFrame(self.results.dc, columns=['p_mp'])
scaled = self.system.scale_voltage_current_power(temp)
if isinstance(scaled, tuple):
self.results.dc = tuple(s['p_mp'] for s in scaled)
else:
self.results.dc = scaled['p_mp']
dc = self.system.pvwatts_dc(
self.results.effective_irradiance,
self.results.cell_temperature,
unwrap=False
)
p_mp = tuple(pd.DataFrame(s, columns=['p_mp']) for s in dc)
scaled = self.system.scale_voltage_current_power(p_mp)
self.results.dc = _tuple_from_dfs(scaled, "p_mp")
return self

@property
Expand Down Expand Up @@ -866,23 +884,29 @@ def infer_aoi_model(self):

def ashrae_aoi_loss(self):
self.results.aoi_modifier = self.system.get_iam(
self.results.aoi, iam_model='ashrae')
self.results.aoi,
iam_model='ashrae'
)
return self

def physical_aoi_loss(self):
self.results.aoi_modifier = self.system.get_iam(self.results.aoi,
iam_model='physical')
self.results.aoi_modifier = self.system.get_iam(
self.results.aoi,
iam_model='physical'
)
return self

def sapm_aoi_loss(self):
self.results.aoi_modifier = self.system.get_iam(self.results.aoi,
iam_model='sapm')
self.results.aoi_modifier = self.system.get_iam(
self.results.aoi,
iam_model='sapm'
)
return self

def martin_ruiz_aoi_loss(self):
self.results.aoi_modifier = self.system.get_iam(
self.results.aoi,
iam_model='martin_ruiz')
self.results.aoi, iam_model='martin_ruiz'
)
return self

def no_aoi_loss(self):
Expand Down Expand Up @@ -934,13 +958,15 @@ def infer_spectral_model(self):

def first_solar_spectral_loss(self):
self.results.spectral_modifier = self.system.first_solar_spectral_loss(
self.weather['precipitable_water'],
self.results.airmass['airmass_absolute'])
_tuple_from_dfs(self.weather, 'precipitable_water'),
self.results.airmass['airmass_absolute']
)
return self

def sapm_spectral_loss(self):
self.results.spectral_modifier = self.system.sapm_spectral_loss(
self.results.airmass['airmass_absolute'])
self.results.airmass['airmass_absolute']
)
return self

def no_spectral_loss(self):
Expand Down Expand Up @@ -1066,7 +1092,7 @@ def infer_losses_model(self):

def pvwatts_losses(self):
self.losses = (100 - self.system.pvwatts_losses()) / 100.
if self.system.num_arrays > 1:
if isinstance(self.results.dc, tuple):
for dc in self.results.dc:
dc *= self.losses
else:
Expand Down Expand Up @@ -1271,6 +1297,17 @@ def _verify(data, index=None):
for (i, array_data) in enumerate(data):
_verify(array_data, i)

def _configure_results(self):
Copy link
Member

Choose a reason for hiding this comment

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

an alternative to this method might be to instantiate ModelChain with self.results = None (or not at all), then put this line at the start of each run_model workflow:

self.results = ModelChainResults(singleton_tuples=(self.system.num_arrays == 1 and isinstance(self.weather, tuple))

I guess maybe you'd still want to extract that logic into a method like this one. Then the difference comes down to setting the right value vs instantiating the object with the right value. 🤷

Copy link
Contributor Author

@wfvining wfvining Feb 3, 2021

Choose a reason for hiding this comment

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

This would basically be a transformation from _configure_results to _prepare_results. I kind of like that better, but I don't think it makes a big difference. It would also take a little bit of work, since some of the tests (and ModelChain methods) make the (reasonable) assumption that all the results fields exist before one of the run_model workflows is triggered (e.g. test__assign_total_irrad and ModelChain.complete_irradiance).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Should _configure_results() (or some ModelChainResult method) iterate over per-array fields to and wrap them in tuple if _singleton_tuples is changed from False to True?

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 viewing ModelChainResult as a single use object so I don't think we need to watch for that kind of state change.

"""Configure the type used for per-array fields in ModelChainResult.

Must be called after ``self.weather`` has been assigned. If
``self.weather`` is a tuple and the number of arrays in the system
is 1, then per-array results are stored as length-1 tuples.
"""
self.results._singleton_tuples = (
self.system.num_arrays == 1 and isinstance(self.weather, tuple)
)

def _assign_weather(self, data):
def _build_weather(data):
key_list = [k for k in WEATHER_KEYS if k in data]
Expand All @@ -1286,6 +1323,7 @@ def _build_weather(data):
self.weather = tuple(
_build_weather(weather) for weather in data
)
self._configure_results()
return self

def _assign_total_irrad(self, data):
Expand Down Expand Up @@ -1383,7 +1421,8 @@ def prepare_inputs(self, weather):
_tuple_from_dfs(self.weather, 'ghi'),
_tuple_from_dfs(self.weather, 'dhi'),
airmass=self.results.airmass['airmass_relative'],
model=self.transposition_model)
model=self.transposition_model
)

return self

Expand Down
36 changes: 21 additions & 15 deletions pvlib/pvsystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from collections import OrderedDict
import functools
import io
import itertools
import os
from urllib.request import urlopen
import numpy as np
Expand Down Expand Up @@ -811,8 +812,9 @@ def first_solar_spectral_loss(self, pw, airmass_absolute):
effective irradiance, i.e., the irradiance that is converted to
electrical current.
"""
pw = self._validate_per_array(pw, system_wide=True)

def _spectral_correction(array):
def _spectral_correction(array, pw):
if 'first_solar_spectral_coefficients' in \
array.module_parameters.keys():
coefficients = \
Expand All @@ -828,7 +830,9 @@ def _spectral_correction(array):
pw, airmass_absolute,
module_type, coefficients
)
return tuple(_spectral_correction(array) for array in self.arrays)
return tuple(
itertools.starmap(_spectral_correction, zip(self.arrays, pw))
)

def singlediode(self, photocurrent, saturation_current,
resistance_series, resistance_shunt, nNsVth,
Expand Down Expand Up @@ -891,29 +895,31 @@ def get_ac(self, model, p_dc, v_dc=None):
model = model.lower()
multiple_arrays = self.num_arrays > 1
if model == 'sandia':
p_dc = self._validate_per_array(p_dc)
v_dc = self._validate_per_array(v_dc)
if multiple_arrays:
p_dc = self._validate_per_array(p_dc)
Copy link
Member

@cwhanse cwhanse Feb 2, 2021

Choose a reason for hiding this comment

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

why move the _validate_per_array outside if multiple_arrays? IIRC, that doesn't work when p_dc and v_dc are floats.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If p_dc and v_dc are floats they will get wrapped up as (p_dc,) and (v_dc,). Doing this regardless of the number of arrays lets us always subscript when passing to the single array inverter models. If we didn't wrap them up we would need extra logic to handle unwrapping if the input was initially a singleton tuple.

The goal is to be able to do both of these and have them act the same:

system = PVSystem(arrays=[Array()], ...)
system.get_ac(p_dc=50, model='pvwatts')
system.get_ac(p_dc=(50,), model='pvwatts')

Copy link
Member

Choose a reason for hiding this comment

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

As written, get_ac references the inverter functions directly, not through a PVSystem method for each inverter function. The inverter functions don't know what to do with a tuple of values; they assume numeric.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The tuples are always subscripted before being passed to the inverter functions when multiple_arrays is False. For example:

        if model == 'sandia':
            p_dc = self._validate_per_array(p_dc)
            v_dc = self._validate_per_array(v_dc)
            if multiple_arrays:
                return inverter.sandia_multi(
                    v_dc, p_dc, self.inverter_parameters)
            return inverter.sandia(v_dc[0], p_dc[0], self.inverter_parameters)

When multiple_arrays is True the tuples are passed directly to inverter.sandia_multi, but p_dc[0] and v_dc[0] is used for inverter.sandia.

Copy link
Member

Choose a reason for hiding this comment

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

I think we can now move the _validate_per_array calls above the if model ==... block and save a few lines:

p_dc = self._validate_per_array(p_dc)
if v_dc is not None:
    v_dc = self._validate_per_array(v_dc)

v_dc = self._validate_per_array(v_dc)
inv_fun = inverter.sandia_multi
else:
inv_fun = inverter.sandia
return inv_fun(v_dc, p_dc, self.inverter_parameters)
return inverter.sandia_multi(
v_dc, p_dc, self.inverter_parameters)
return inverter.sandia(v_dc[0], p_dc[0], self.inverter_parameters)
elif model == 'pvwatts':
kwargs = _build_kwargs(['eta_inv_nom', 'eta_inv_ref'],
self.inverter_parameters)
p_dc = self._validate_per_array(p_dc)
if multiple_arrays:
p_dc = self._validate_per_array(p_dc)
inv_fun = inverter.pvwatts_multi
else:
inv_fun = inverter.pvwatts
return inv_fun(p_dc, self.inverter_parameters['pdc0'], **kwargs)
return inverter.pvwatts_multi(
p_dc, self.inverter_parameters['pdc0'], **kwargs)
return inverter.pvwatts(
p_dc[0], self.inverter_parameters['pdc0'], **kwargs)
elif model == 'adr':
if multiple_arrays:
raise ValueError(
'The adr inverter function cannot be used for an inverter',
' with multiple MPPT inputs')
else:
return inverter.adr(v_dc, p_dc, self.inverter_parameters)
# While this is only used for single-array systems, calling
# _validate_per_arry lets us pass in singleton tuples.
p_dc = self._validate_per_array(p_dc)
v_dc = self._validate_per_array(v_dc)
return inverter.adr(v_dc[0], p_dc[0], self.inverter_parameters)
else:
raise ValueError(
model + ' is not a valid AC power model.',
Expand Down
79 changes: 79 additions & 0 deletions pvlib/tests/test_modelchain.py
Original file line number Diff line number Diff line change
Expand Up @@ -1080,6 +1080,54 @@ def test_run_model_from_effective_irradiance_missing_poa(
(data_complete, data_incomplete))


def test_run_model_singleton_weather_single_array(cec_dc_snl_ac_system,
location, weather):
mc = ModelChain(cec_dc_snl_ac_system, location,
aoi_model="no_loss", spectral_model="no_loss")
mc.run_model([weather])
assert isinstance(mc.results.total_irrad, tuple)
assert isinstance(mc.results.aoi, tuple)
assert isinstance(mc.results.aoi_modifier, tuple)
assert isinstance(mc.results.spectral_modifier, tuple)
assert isinstance(mc.results.effective_irradiance, tuple)
assert isinstance(mc.results.dc, tuple)
assert isinstance(mc.results.cell_temperature, tuple)
assert len(mc.results.cell_temperature) == 1
assert isinstance(mc.results.cell_temperature[0], pd.Series)


def test_run_model_from_poa_singleton_weather_single_array(
sapm_dc_snl_ac_system, location, total_irrad):
mc = ModelChain(sapm_dc_snl_ac_system, location,
aoi_model='no_loss', spectral_model='no_loss')
ac = mc.run_model_from_poa([total_irrad]).results.ac
expected = pd.Series(np.array([149.280238, 96.678385]),
index=total_irrad.index)
assert isinstance(mc.results.cell_temperature, tuple)
assert len(mc.results.cell_temperature) == 1
assert isinstance(mc.results.cell_temperature[0], pd.Series)
assert_series_equal(ac, expected)


def test_run_model_from_effective_irradiance_weather_single_array(
sapm_dc_snl_ac_system, location, weather, total_irrad):
data = weather.copy()
data[['poa_global', 'poa_diffuse', 'poa_direct']] = total_irrad
data['effective_irradiance'] = data['poa_global']
mc = ModelChain(sapm_dc_snl_ac_system, location, aoi_model='no_loss',
spectral_model='no_loss')
ac = mc.run_model_from_effective_irradiance([data]).results.ac
expected = pd.Series(np.array([149.280238, 96.678385]),
index=data.index)
assert isinstance(mc.results.cell_temperature, tuple)
assert len(mc.results.cell_temperature) == 1
assert isinstance(mc.results.cell_temperature[0], pd.Series)
assert isinstance(mc.results.dc, tuple)
assert len(mc.results.dc) == 1
assert isinstance(mc.results.dc[0], pd.DataFrame)
assert_series_equal(ac, expected)


def poadc(mc):
mc.results.dc = mc.results.total_irrad['poa_global'] * 0.2
mc.results.dc.name = None # assert_series_equal will fail without this
Expand Down Expand Up @@ -1324,6 +1372,22 @@ def test_aoi_models(sapm_dc_snl_ac_system, location, aoi_model,
assert mc.results.ac[1] < 1


@pytest.mark.parametrize('aoi_model', [
'sapm', 'ashrae', 'physical', 'martin_ruiz'
])
def test_aoi_models_singleon_weather_single_array(
sapm_dc_snl_ac_system, location, aoi_model, weather):
mc = ModelChain(sapm_dc_snl_ac_system, location, dc_model='sapm',
aoi_model=aoi_model, spectral_model='no_loss')
mc.run_model(weather=[weather])
assert isinstance(mc.results.aoi_modifier, tuple)
assert len(mc.results.aoi_modifier) == 1
assert isinstance(mc.results.ac, pd.Series)
assert not mc.results.ac.empty
assert mc.results.ac[0] > 150 and mc.results.ac[0] < 200
assert mc.results.ac[1] < 1


def test_aoi_model_no_loss(sapm_dc_snl_ac_system, location, weather):
mc = ModelChain(sapm_dc_snl_ac_system, location, dc_model='sapm',
aoi_model='no_loss', spectral_model='no_loss')
Expand Down Expand Up @@ -1382,6 +1446,21 @@ def test_spectral_models(sapm_dc_snl_ac_system, location, spectral_model,
assert isinstance(spectral_modifier, (pd.Series, float, int))


@pytest.mark.parametrize('spectral_model', [
'sapm', 'first_solar', 'no_loss', constant_spectral_loss
])
def test_spectral_models_singleton_weather_single_array(
sapm_dc_snl_ac_system, location, spectral_model, weather):
# add pw to weather dataframe
weather['precipitable_water'] = [0.3, 0.5]
mc = ModelChain(sapm_dc_snl_ac_system, location, dc_model='sapm',
aoi_model='no_loss', spectral_model=spectral_model)
spectral_modifier = mc.run_model([weather]).results.spectral_modifier
assert isinstance(spectral_modifier, tuple)
assert len(spectral_modifier) == 1
assert isinstance(spectral_modifier[0], (pd.Series, float, int))


def constant_losses(mc):
mc.losses = 0.9
mc.results.dc *= mc.losses
Expand Down
Loading