Skip to content

Commit 47654a0

Browse files
authored
Support list of weather input for ModelChain with a single-array PVSystem (#1157)
* Add tests covering singleton input to ModelChain entry points Covers passing a length-one list of weather DataFrames to each of - `ModelChain.run_model()` - `ModelChain.run_model_from_poa()` - `ModelChain.run_model_from_effective_irradiance()` When the PVSystem being modeled has only one Array. In this case the output stored in each field of `ModelChain.results` should be a length-one tuple. * Support tuple input to PVSystem.get_ac for single-array systems Allow p_dc and v_dc to be length-1 tuples even when the system has only one Array. This helps keep the API consistent so that if the dc power methods are called with `unwrap=False` their output can be passed directly to `PVSystem.get_ac()`. * Add ModelChain._assign_result() method This method is responsible for ensuring that per-array results match the type of ModelChain.weather. This is an issue when ModelChain.system has only one Array, but ModelChain.weather is a tuple of length 1. In this case we want the results attributes to also be length-1 tuples for the sake of consistency and to ensure that calculations down-stream can proceed without needing to worry about mixed types (e.g. total_irrad is a Series, but weather is a tuple). * Clean up whitespace in test_modelchain.py Accidentally under-indented a whole test: test_run_model_from_poa_singleton_weather_single_array() * Support singleton weather input for ModelChain with spectral loss Add tests for spectral_model != 'no_loss' and aoi_model != 'no_loss' when calling ModelChain.run_model([weather]) on a system with 1 Array. Handles the type of ModelChain.weather correctly in ModelChain.first_solar_spectral_loss() by using _tuple_from_dfs() to get 'precipitable_water'. Still needs support for constant spectral loss * PVSystem.first_solar_spectral_loss() validates `pw` param against Extends `PVSystem.first_solar_spectral_loss()` to treat `pw` as a per-Array or system wide parameter. * Use itertools.starmap in in PVSystem.first_solar_spectral_loss() A bit cleaner than using a generator expression. * Refactor: replace _assign_result() with ModelChainResult.__setattr__ Add a configuration step in ModelChain._assign_weather() that sets a flag on the ModelChain.results to indicate whether non-tuples assigned to per-array fields should be wrapped in length-1 tuples. This is accomplished by a custom __setattr__ methon on ModelChainResult. The setter checks whether the attribute is a per-array attribute and coerces it if the _singleton_tuples flag is set. * Add float to the type for ModelChainResult.aoi_modifier Covers the case where the aoi_model is 'no_loss'. * Tidy up ModelChainResult Re-order fields and comments. Add docstring to _result_type() method.
1 parent 975b798 commit 47654a0

File tree

4 files changed

+206
-48
lines changed

4 files changed

+206
-48
lines changed

pvlib/modelchain.py

Lines changed: 72 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -268,22 +268,47 @@ class ModelChainResult:
268268
_T = TypeVar('T')
269269
PerArray = Union[_T, Tuple[_T, ...]]
270270
"""Type for fields that vary between arrays"""
271+
272+
# these attributes are used in __setattr__ to determine the correct type.
273+
_singleton_tuples: bool = field(default=False)
274+
_per_array_fields = {'total_irrad', 'aoi', 'aoi_modifier',
275+
'spectral_modifier', 'cell_temperature',
276+
'effective_irradiance', 'dc', 'diode_params'}
277+
271278
# system-level information
272279
solar_position: Optional[pd.DataFrame] = field(default=None)
273280
airmass: Optional[pd.DataFrame] = field(default=None)
274281
ac: Optional[pd.Series] = field(default=None)
275-
# per DC array information
276282
tracking: Optional[pd.DataFrame] = field(default=None)
283+
284+
# per DC array information
277285
total_irrad: Optional[PerArray[pd.DataFrame]] = field(default=None)
278286
aoi: Optional[PerArray[pd.Series]] = field(default=None)
279-
aoi_modifier: Optional[PerArray[pd.Series]] = field(default=None)
280-
spectral_modifier: Optional[PerArray[pd.Series]] = field(default=None)
287+
aoi_modifier: Optional[PerArray[Union[pd.Series, float]]] = \
288+
field(default=None)
289+
spectral_modifier: Optional[PerArray[Union[pd.Series, float]]] = \
290+
field(default=None)
281291
cell_temperature: Optional[PerArray[pd.Series]] = field(default=None)
282292
effective_irradiance: Optional[PerArray[pd.Series]] = field(default=None)
283293
dc: Optional[PerArray[Union[pd.Series, pd.DataFrame]]] = \
284294
field(default=None)
285295
diode_params: Optional[PerArray[pd.DataFrame]] = field(default=None)
286296

297+
def _result_type(self, value):
298+
"""Coerce `value` to the correct type according to
299+
``self._singleton_tuples``."""
300+
# Allow None to pass through without being wrapped in a tuple
301+
if (self._singleton_tuples
302+
and not isinstance(value, tuple)
303+
and value is not None):
304+
return (value,)
305+
return value
306+
307+
def __setattr__(self, key, value):
308+
if key in ModelChainResult._per_array_fields:
309+
value = self._result_type(value)
310+
super().__setattr__(key, value)
311+
287312

288313
class ModelChain:
289314
"""
@@ -684,12 +709,9 @@ def infer_dc_model(self):
684709
'set the model with the dc_model kwarg.')
685710

686711
def sapm(self):
687-
self.results.dc = self.system.sapm(self.results.effective_irradiance,
688-
self.results.cell_temperature)
689-
690-
self.results.dc = self.system.scale_voltage_current_power(
691-
self.results.dc)
692-
712+
dc = self.system.sapm(self.results.effective_irradiance,
713+
self.results.cell_temperature)
714+
self.results.dc = self.system.scale_voltage_current_power(dc)
693715
return self
694716

695717
def _singlediode(self, calcparams_model_function):
@@ -745,18 +767,14 @@ def pvwatts_dc(self):
745767
pvlib.pvsystem.PVSystem.pvwatts_dc
746768
pvlib.pvsystem.PVSystem.scale_voltage_current_power
747769
"""
748-
self.results.dc = self.system.pvwatts_dc(
749-
self.results.effective_irradiance, self.results.cell_temperature)
750-
if isinstance(self.results.dc, tuple):
751-
temp = tuple(
752-
pd.DataFrame(s, columns=['p_mp']) for s in self.results.dc)
753-
else:
754-
temp = pd.DataFrame(self.results.dc, columns=['p_mp'])
755-
scaled = self.system.scale_voltage_current_power(temp)
756-
if isinstance(scaled, tuple):
757-
self.results.dc = tuple(s['p_mp'] for s in scaled)
758-
else:
759-
self.results.dc = scaled['p_mp']
770+
dc = self.system.pvwatts_dc(
771+
self.results.effective_irradiance,
772+
self.results.cell_temperature,
773+
unwrap=False
774+
)
775+
p_mp = tuple(pd.DataFrame(s, columns=['p_mp']) for s in dc)
776+
scaled = self.system.scale_voltage_current_power(p_mp)
777+
self.results.dc = _tuple_from_dfs(scaled, "p_mp")
760778
return self
761779

762780
@property
@@ -866,23 +884,29 @@ def infer_aoi_model(self):
866884

867885
def ashrae_aoi_loss(self):
868886
self.results.aoi_modifier = self.system.get_iam(
869-
self.results.aoi, iam_model='ashrae')
887+
self.results.aoi,
888+
iam_model='ashrae'
889+
)
870890
return self
871891

872892
def physical_aoi_loss(self):
873-
self.results.aoi_modifier = self.system.get_iam(self.results.aoi,
874-
iam_model='physical')
893+
self.results.aoi_modifier = self.system.get_iam(
894+
self.results.aoi,
895+
iam_model='physical'
896+
)
875897
return self
876898

877899
def sapm_aoi_loss(self):
878-
self.results.aoi_modifier = self.system.get_iam(self.results.aoi,
879-
iam_model='sapm')
900+
self.results.aoi_modifier = self.system.get_iam(
901+
self.results.aoi,
902+
iam_model='sapm'
903+
)
880904
return self
881905

882906
def martin_ruiz_aoi_loss(self):
883907
self.results.aoi_modifier = self.system.get_iam(
884-
self.results.aoi,
885-
iam_model='martin_ruiz')
908+
self.results.aoi, iam_model='martin_ruiz'
909+
)
886910
return self
887911

888912
def no_aoi_loss(self):
@@ -934,13 +958,15 @@ def infer_spectral_model(self):
934958

935959
def first_solar_spectral_loss(self):
936960
self.results.spectral_modifier = self.system.first_solar_spectral_loss(
937-
self.weather['precipitable_water'],
938-
self.results.airmass['airmass_absolute'])
961+
_tuple_from_dfs(self.weather, 'precipitable_water'),
962+
self.results.airmass['airmass_absolute']
963+
)
939964
return self
940965

941966
def sapm_spectral_loss(self):
942967
self.results.spectral_modifier = self.system.sapm_spectral_loss(
943-
self.results.airmass['airmass_absolute'])
968+
self.results.airmass['airmass_absolute']
969+
)
944970
return self
945971

946972
def no_spectral_loss(self):
@@ -1066,7 +1092,7 @@ def infer_losses_model(self):
10661092

10671093
def pvwatts_losses(self):
10681094
self.losses = (100 - self.system.pvwatts_losses()) / 100.
1069-
if self.system.num_arrays > 1:
1095+
if isinstance(self.results.dc, tuple):
10701096
for dc in self.results.dc:
10711097
dc *= self.losses
10721098
else:
@@ -1271,6 +1297,17 @@ def _verify(data, index=None):
12711297
for (i, array_data) in enumerate(data):
12721298
_verify(array_data, i)
12731299

1300+
def _configure_results(self):
1301+
"""Configure the type used for per-array fields in ModelChainResult.
1302+
1303+
Must be called after ``self.weather`` has been assigned. If
1304+
``self.weather`` is a tuple and the number of arrays in the system
1305+
is 1, then per-array results are stored as length-1 tuples.
1306+
"""
1307+
self.results._singleton_tuples = (
1308+
self.system.num_arrays == 1 and isinstance(self.weather, tuple)
1309+
)
1310+
12741311
def _assign_weather(self, data):
12751312
def _build_weather(data):
12761313
key_list = [k for k in WEATHER_KEYS if k in data]
@@ -1286,6 +1323,7 @@ def _build_weather(data):
12861323
self.weather = tuple(
12871324
_build_weather(weather) for weather in data
12881325
)
1326+
self._configure_results()
12891327
return self
12901328

12911329
def _assign_total_irrad(self, data):
@@ -1383,7 +1421,8 @@ def prepare_inputs(self, weather):
13831421
_tuple_from_dfs(self.weather, 'ghi'),
13841422
_tuple_from_dfs(self.weather, 'dhi'),
13851423
airmass=self.results.airmass['airmass_relative'],
1386-
model=self.transposition_model)
1424+
model=self.transposition_model
1425+
)
13871426

13881427
return self
13891428

pvlib/pvsystem.py

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from collections import OrderedDict
77
import functools
88
import io
9+
import itertools
910
import os
1011
from urllib.request import urlopen
1112
import numpy as np
@@ -811,8 +812,9 @@ def first_solar_spectral_loss(self, pw, airmass_absolute):
811812
effective irradiance, i.e., the irradiance that is converted to
812813
electrical current.
813814
"""
815+
pw = self._validate_per_array(pw, system_wide=True)
814816

815-
def _spectral_correction(array):
817+
def _spectral_correction(array, pw):
816818
if 'first_solar_spectral_coefficients' in \
817819
array.module_parameters.keys():
818820
coefficients = \
@@ -828,7 +830,9 @@ def _spectral_correction(array):
828830
pw, airmass_absolute,
829831
module_type, coefficients
830832
)
831-
return tuple(_spectral_correction(array) for array in self.arrays)
833+
return tuple(
834+
itertools.starmap(_spectral_correction, zip(self.arrays, pw))
835+
)
832836

833837
def singlediode(self, photocurrent, saturation_current,
834838
resistance_series, resistance_shunt, nNsVth,
@@ -891,29 +895,31 @@ def get_ac(self, model, p_dc, v_dc=None):
891895
model = model.lower()
892896
multiple_arrays = self.num_arrays > 1
893897
if model == 'sandia':
898+
p_dc = self._validate_per_array(p_dc)
899+
v_dc = self._validate_per_array(v_dc)
894900
if multiple_arrays:
895-
p_dc = self._validate_per_array(p_dc)
896-
v_dc = self._validate_per_array(v_dc)
897-
inv_fun = inverter.sandia_multi
898-
else:
899-
inv_fun = inverter.sandia
900-
return inv_fun(v_dc, p_dc, self.inverter_parameters)
901+
return inverter.sandia_multi(
902+
v_dc, p_dc, self.inverter_parameters)
903+
return inverter.sandia(v_dc[0], p_dc[0], self.inverter_parameters)
901904
elif model == 'pvwatts':
902905
kwargs = _build_kwargs(['eta_inv_nom', 'eta_inv_ref'],
903906
self.inverter_parameters)
907+
p_dc = self._validate_per_array(p_dc)
904908
if multiple_arrays:
905-
p_dc = self._validate_per_array(p_dc)
906-
inv_fun = inverter.pvwatts_multi
907-
else:
908-
inv_fun = inverter.pvwatts
909-
return inv_fun(p_dc, self.inverter_parameters['pdc0'], **kwargs)
909+
return inverter.pvwatts_multi(
910+
p_dc, self.inverter_parameters['pdc0'], **kwargs)
911+
return inverter.pvwatts(
912+
p_dc[0], self.inverter_parameters['pdc0'], **kwargs)
910913
elif model == 'adr':
911914
if multiple_arrays:
912915
raise ValueError(
913916
'The adr inverter function cannot be used for an inverter',
914917
' with multiple MPPT inputs')
915-
else:
916-
return inverter.adr(v_dc, p_dc, self.inverter_parameters)
918+
# While this is only used for single-array systems, calling
919+
# _validate_per_arry lets us pass in singleton tuples.
920+
p_dc = self._validate_per_array(p_dc)
921+
v_dc = self._validate_per_array(v_dc)
922+
return inverter.adr(v_dc[0], p_dc[0], self.inverter_parameters)
917923
else:
918924
raise ValueError(
919925
model + ' is not a valid AC power model.',

pvlib/tests/test_modelchain.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1080,6 +1080,54 @@ def test_run_model_from_effective_irradiance_missing_poa(
10801080
(data_complete, data_incomplete))
10811081

10821082

1083+
def test_run_model_singleton_weather_single_array(cec_dc_snl_ac_system,
1084+
location, weather):
1085+
mc = ModelChain(cec_dc_snl_ac_system, location,
1086+
aoi_model="no_loss", spectral_model="no_loss")
1087+
mc.run_model([weather])
1088+
assert isinstance(mc.results.total_irrad, tuple)
1089+
assert isinstance(mc.results.aoi, tuple)
1090+
assert isinstance(mc.results.aoi_modifier, tuple)
1091+
assert isinstance(mc.results.spectral_modifier, tuple)
1092+
assert isinstance(mc.results.effective_irradiance, tuple)
1093+
assert isinstance(mc.results.dc, tuple)
1094+
assert isinstance(mc.results.cell_temperature, tuple)
1095+
assert len(mc.results.cell_temperature) == 1
1096+
assert isinstance(mc.results.cell_temperature[0], pd.Series)
1097+
1098+
1099+
def test_run_model_from_poa_singleton_weather_single_array(
1100+
sapm_dc_snl_ac_system, location, total_irrad):
1101+
mc = ModelChain(sapm_dc_snl_ac_system, location,
1102+
aoi_model='no_loss', spectral_model='no_loss')
1103+
ac = mc.run_model_from_poa([total_irrad]).results.ac
1104+
expected = pd.Series(np.array([149.280238, 96.678385]),
1105+
index=total_irrad.index)
1106+
assert isinstance(mc.results.cell_temperature, tuple)
1107+
assert len(mc.results.cell_temperature) == 1
1108+
assert isinstance(mc.results.cell_temperature[0], pd.Series)
1109+
assert_series_equal(ac, expected)
1110+
1111+
1112+
def test_run_model_from_effective_irradiance_weather_single_array(
1113+
sapm_dc_snl_ac_system, location, weather, total_irrad):
1114+
data = weather.copy()
1115+
data[['poa_global', 'poa_diffuse', 'poa_direct']] = total_irrad
1116+
data['effective_irradiance'] = data['poa_global']
1117+
mc = ModelChain(sapm_dc_snl_ac_system, location, aoi_model='no_loss',
1118+
spectral_model='no_loss')
1119+
ac = mc.run_model_from_effective_irradiance([data]).results.ac
1120+
expected = pd.Series(np.array([149.280238, 96.678385]),
1121+
index=data.index)
1122+
assert isinstance(mc.results.cell_temperature, tuple)
1123+
assert len(mc.results.cell_temperature) == 1
1124+
assert isinstance(mc.results.cell_temperature[0], pd.Series)
1125+
assert isinstance(mc.results.dc, tuple)
1126+
assert len(mc.results.dc) == 1
1127+
assert isinstance(mc.results.dc[0], pd.DataFrame)
1128+
assert_series_equal(ac, expected)
1129+
1130+
10831131
def poadc(mc):
10841132
mc.results.dc = mc.results.total_irrad['poa_global'] * 0.2
10851133
mc.results.dc.name = None # assert_series_equal will fail without this
@@ -1324,6 +1372,22 @@ def test_aoi_models(sapm_dc_snl_ac_system, location, aoi_model,
13241372
assert mc.results.ac[1] < 1
13251373

13261374

1375+
@pytest.mark.parametrize('aoi_model', [
1376+
'sapm', 'ashrae', 'physical', 'martin_ruiz'
1377+
])
1378+
def test_aoi_models_singleon_weather_single_array(
1379+
sapm_dc_snl_ac_system, location, aoi_model, weather):
1380+
mc = ModelChain(sapm_dc_snl_ac_system, location, dc_model='sapm',
1381+
aoi_model=aoi_model, spectral_model='no_loss')
1382+
mc.run_model(weather=[weather])
1383+
assert isinstance(mc.results.aoi_modifier, tuple)
1384+
assert len(mc.results.aoi_modifier) == 1
1385+
assert isinstance(mc.results.ac, pd.Series)
1386+
assert not mc.results.ac.empty
1387+
assert mc.results.ac[0] > 150 and mc.results.ac[0] < 200
1388+
assert mc.results.ac[1] < 1
1389+
1390+
13271391
def test_aoi_model_no_loss(sapm_dc_snl_ac_system, location, weather):
13281392
mc = ModelChain(sapm_dc_snl_ac_system, location, dc_model='sapm',
13291393
aoi_model='no_loss', spectral_model='no_loss')
@@ -1382,6 +1446,21 @@ def test_spectral_models(sapm_dc_snl_ac_system, location, spectral_model,
13821446
assert isinstance(spectral_modifier, (pd.Series, float, int))
13831447

13841448

1449+
@pytest.mark.parametrize('spectral_model', [
1450+
'sapm', 'first_solar', 'no_loss', constant_spectral_loss
1451+
])
1452+
def test_spectral_models_singleton_weather_single_array(
1453+
sapm_dc_snl_ac_system, location, spectral_model, weather):
1454+
# add pw to weather dataframe
1455+
weather['precipitable_water'] = [0.3, 0.5]
1456+
mc = ModelChain(sapm_dc_snl_ac_system, location, dc_model='sapm',
1457+
aoi_model='no_loss', spectral_model=spectral_model)
1458+
spectral_modifier = mc.run_model([weather]).results.spectral_modifier
1459+
assert isinstance(spectral_modifier, tuple)
1460+
assert len(spectral_modifier) == 1
1461+
assert isinstance(spectral_modifier[0], (pd.Series, float, int))
1462+
1463+
13851464
def constant_losses(mc):
13861465
mc.losses = 0.9
13871466
mc.results.dc *= mc.losses

0 commit comments

Comments
 (0)