diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 0685d56336..dbca739942 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -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: + value = self._result_type(value) + super().__setattr__(key, value) + class ModelChain: """ @@ -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): @@ -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 @@ -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): @@ -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): @@ -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: @@ -1271,6 +1297,17 @@ def _verify(data, index=None): for (i, array_data) in enumerate(data): _verify(array_data, i) + def _configure_results(self): + """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] @@ -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): @@ -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 diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index 33696bce81..a727673265 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -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 @@ -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 = \ @@ -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, @@ -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) - 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.', diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py index 63f5c56153..c71b3a4cd8 100644 --- a/pvlib/tests/test_modelchain.py +++ b/pvlib/tests/test_modelchain.py @@ -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 @@ -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') @@ -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 diff --git a/pvlib/tests/test_pvsystem.py b/pvlib/tests/test_pvsystem.py index c1b2cd76d9..5c0ef7b4a2 100644 --- a/pvlib/tests/test_pvsystem.py +++ b/pvlib/tests/test_pvsystem.py @@ -1472,6 +1472,40 @@ def test_PVSystem_get_ac_pvwatts_multi( system.get_ac('pvwatts', (pdcs, pdcs, pdcs)) +@pytest.mark.parametrize('model', ['sandia', 'adr', 'pvwatts']) +def test_PVSystem_get_ac_single_array_tuple_input( + model, + pvwatts_system_defaults, + cec_inverter_parameters, + adr_inverter_parameters): + vdcs = { + 'sandia': pd.Series(np.linspace(0, 50, 3)), + 'pvwatts': None, + 'adr': pd.Series([135, 154, 390, 420, 551]) + } + pdcs = {'adr': pd.Series([135, 1232, 1170, 420, 551]), + 'sandia': pd.Series(np.linspace(0, 11, 3)) * vdcs['sandia'], + 'pvwatts': 50} + inverter_parameters = { + 'sandia': cec_inverter_parameters, + 'adr': adr_inverter_parameters, + 'pvwatts': pvwatts_system_defaults.inverter_parameters + } + expected = { + 'adr': pd.Series([np.nan, 1161.5745, 1116.4459, 382.6679, np.nan]), + 'sandia': pd.Series([-0.020000, 132.004308, 250.000000]) + } + system = pvsystem.PVSystem( + arrays=[pvsystem.Array()], + inverter_parameters=inverter_parameters[model] + ) + ac = system.get_ac(p_dc=(pdcs[model],), v_dc=(vdcs[model],), model=model) + if model == 'pvwatts': + assert ac < pdcs['pvwatts'] + else: + assert_series_equal(ac, expected[model]) + + def test_PVSystem_get_ac_adr(adr_inverter_parameters, mocker): mocker.spy(inverter, 'adr') system = pvsystem.PVSystem(