From 5283390f227a163285fd739de82b600571976ff4 Mon Sep 17 00:00:00 2001 From: Will Vining Date: Mon, 1 Feb 2021 14:57:56 -0700 Subject: [PATCH 01/10] 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. --- pvlib/tests/test_modelchain.py | 48 ++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py index 63f5c56153..7581fbc853 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 From 8c9e7da0120812dc292607517b06b28c1a278ce9 Mon Sep 17 00:00:00 2001 From: Will Vining Date: Tue, 2 Feb 2021 09:42:14 -0700 Subject: [PATCH 02/10] 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()`. --- pvlib/pvsystem.py | 28 +++++++++++++++------------- pvlib/tests/test_pvsystem.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index 33696bce81..1260e64705 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -891,29 +891,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_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( From 5e363129a2d4f1886a2e2c30245873c49ae7bd28 Mon Sep 17 00:00:00 2001 From: Will Vining Date: Tue, 2 Feb 2021 10:07:16 -0700 Subject: [PATCH 03/10] 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). --- pvlib/modelchain.py | 139 +++++++++++++++++++++++++++++--------------- 1 file changed, 93 insertions(+), 46 deletions(-) diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 0685d56336..04fb480d77 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -609,6 +609,22 @@ def getmcattr(self, attr): return ('ModelChain: \n ' + '\n '.join( f'{attr}: {getmcattr(self, attr)}' for attr in attrs)) + def _assign_result(self, field, value): + """Assign `value` to self.results.field, taking care to match + the type of weather for single-array models. + + Parameters + ---------- + field : str + Name of the results field. + value : object + Value to be assigned. + """ + if self.system.num_arrays == 1 and isinstance(self.weather, tuple): + setattr(self.results, field, (value,)) + else: + setattr(self.results, field, value) + @property def orientation_strategy(self): return self._orientation_strategy @@ -684,12 +700,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, + dc = self.system.sapm(self.results.effective_irradiance, self.results.cell_temperature) - - self.results.dc = self.system.scale_voltage_current_power( - self.results.dc) - + self._assign_result("dc", self.system.scale_voltage_current_power(dc)) return self def _singlediode(self, calcparams_model_function): @@ -704,6 +717,9 @@ def _make_diode_params(photocurrent, saturation_current, params = calcparams_model_function(self.results.effective_irradiance, self.results.cell_temperature, unwrap=False) + # We bypass self._assign_result() here since these are initially + # all tuples of results. Below we use self._assign_result() to + # clean up when there is only one array. self.results.diode_params = tuple(itertools.starmap( _make_diode_params, params)) self.results.dc = tuple(itertools.starmap( @@ -716,8 +732,8 @@ def _make_diode_params(photocurrent, saturation_current, # If the system has one Array, unwrap the single return value # to preserve the original behavior of ModelChain if self.system.num_arrays == 1: - self.results.diode_params = self.results.diode_params[0] - self.results.dc = self.results.dc[0] + self._assign_result("diode_params", self.results.diode_params[0]) + self._assign_result("dc", self.results.dc[0]) return self def desoto(self): @@ -745,18 +761,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']) + dc = self.system.pvwatts_dc( + self.results.effective_irradiance, + self.results.cell_temperature, + unwrap=False + ) + temp = tuple(pd.DataFrame(s, columns=['p_mp']) for s in dc) 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'] + self._assign_result("dc", _tuple_from_dfs(scaled, "p_mp")) return self @property @@ -865,31 +877,41 @@ def infer_aoi_model(self): 'kwarg; or set aoi_model="no_loss".') def ashrae_aoi_loss(self): - self.results.aoi_modifier = self.system.get_iam( - self.results.aoi, iam_model='ashrae') + self._assign_result( + "aoi_modifier", + self.system.get_iam(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._assign_result( + "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._assign_result( + "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._assign_result( + "aoi_modifier", + self.system.get_iam(self.results.aoi, iam_model='martin_ruiz') + ) return self def no_aoi_loss(self): if self.system.num_arrays == 1: - self.results.aoi_modifier = 1.0 + self._assign_result("aoi_modifier", 1.0) else: - self.results.aoi_modifier = (1.0,) * self.system.num_arrays + self._assign_result( + "aoi_modifier", + (1.0,) * self.system.num_arrays + ) return self @property @@ -933,21 +955,37 @@ def infer_spectral_model(self): 'spectral_model="no_loss".') 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']) + self._assign_result( + "spectral_modifier", + self.system.first_solar_spectral_loss( + # TODO Check this with run_model([weather]) and + # spectral_model='first_solar' + self.weather['precipitable_water'], + # TODO probably need _tuple_from_dfs + 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._assign_result( + "spectral_modifier", + # TODO Add test coverage with weather=[weather] and + # spectral_model='sapm' + self.system.sapm_spectral_loss( + self.results.airmass['airmass_absolute'] + ) + ) return self def no_spectral_loss(self): if self.system.num_arrays == 1: - self.results.spectral_modifier = 1 + self._assign_result("spectral_modifier", 1) else: - self.results.spectral_modifier = (1,) * self.system.num_arrays + self._assign_result( + "spectral_modifier", + (1,) * self.system.num_arrays + ) return self @property @@ -1027,7 +1065,8 @@ def _set_celltemp(self, model): self.results.effective_irradiance) temp_air = _tuple_from_dfs(self.weather, 'temp_air') wind_speed = _tuple_from_dfs(self.weather, 'wind_speed') - self.results.cell_temperature = model(poa, temp_air, wind_speed) + self._assign_result( + "cell_temperature", model(poa, temp_air, wind_speed)) return self def sapm_temp(self): @@ -1066,7 +1105,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: @@ -1082,6 +1121,8 @@ def _eff_irrad(module_parameters, total_irrad, spect_mod, aoi_mod): fd = module_parameters.get('FD', 1.) return spect_mod * (total_irrad['poa_direct'] * aoi_mod + fd * total_irrad['poa_diffuse']) + # Bypassing self._assign_result since this is based on the type + # of self.results.total_irrad as opposed to the number of arrays if isinstance(self.results.total_irrad, tuple): self.results.effective_irradiance = tuple( _eff_irrad(array.module_parameters, ti, sm, am) for @@ -1234,6 +1275,8 @@ def _prep_inputs_tracking(self): self.results.tracking['surface_azimuth'] = ( self.results.tracking['surface_azimuth'] .fillna(self.system.axis_azimuth)) + # Bypass _assign_result because tracking systems do not + # support multiple Arrays. self.results.aoi = self.results.tracking['aoi'] return self @@ -1241,9 +1284,9 @@ def _prep_inputs_fixed(self): """ Calculate AOI for fixed tilt system """ - self.results.aoi = self.system.get_aoi( + self._assign_result('aoi', self.system.get_aoi( self.results.solar_position['apparent_zenith'], - self.results.solar_position['azimuth']) + self.results.solar_position['azimuth'])) return self def _verify_df(self, data, required): @@ -1378,12 +1421,16 @@ def prepare_inputs(self, weather): self.results.solar_position['apparent_zenith'], self.results.solar_position['azimuth']) - self.results.total_irrad = get_irradiance( - _tuple_from_dfs(self.weather, 'dni'), - _tuple_from_dfs(self.weather, 'ghi'), - _tuple_from_dfs(self.weather, 'dhi'), - airmass=self.results.airmass['airmass_relative'], - model=self.transposition_model) + self._assign_result( + 'total_irrad', + get_irradiance( + _tuple_from_dfs(self.weather, 'dni'), + _tuple_from_dfs(self.weather, 'ghi'), + _tuple_from_dfs(self.weather, 'dhi'), + airmass=self.results.airmass['airmass_relative'], + model=self.transposition_model + ) + ) return self From 42f7ca0c94e7cbb45d26d188530a2133aeaa011a Mon Sep 17 00:00:00 2001 From: Will Vining Date: Tue, 2 Feb 2021 10:20:08 -0700 Subject: [PATCH 04/10] Clean up whitespace in test_modelchain.py Accidentally under-indented a whole test: test_run_model_from_poa_singleton_weather_single_array() --- pvlib/tests/test_modelchain.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py index 7581fbc853..d49b17b225 100644 --- a/pvlib/tests/test_modelchain.py +++ b/pvlib/tests/test_modelchain.py @@ -1098,15 +1098,15 @@ def test_run_model_singleton_weather_single_array(cec_dc_snl_ac_system, 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) + 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( From 7dd6134612f1dde44c682c27784e194db01ebf44 Mon Sep 17 00:00:00 2001 From: Will Vining Date: Tue, 2 Feb 2021 15:22:24 -0700 Subject: [PATCH 05/10] 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 --- pvlib/modelchain.py | 7 +------ pvlib/tests/test_modelchain.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 04fb480d77..32e8e72446 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -958,10 +958,7 @@ def first_solar_spectral_loss(self): self._assign_result( "spectral_modifier", self.system.first_solar_spectral_loss( - # TODO Check this with run_model([weather]) and - # spectral_model='first_solar' - self.weather['precipitable_water'], - # TODO probably need _tuple_from_dfs + _tuple_from_dfs(self.weather, 'precipitable_water'), self.results.airmass['airmass_absolute'] ) ) @@ -970,8 +967,6 @@ def first_solar_spectral_loss(self): def sapm_spectral_loss(self): self._assign_result( "spectral_modifier", - # TODO Add test coverage with weather=[weather] and - # spectral_model='sapm' self.system.sapm_spectral_loss( self.results.airmass['airmass_absolute'] ) diff --git a/pvlib/tests/test_modelchain.py b/pvlib/tests/test_modelchain.py index d49b17b225..c71b3a4cd8 100644 --- a/pvlib/tests/test_modelchain.py +++ b/pvlib/tests/test_modelchain.py @@ -1372,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') @@ -1430,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 From 4461f45a1f49a094d95658ddc13ee654e98bbf95 Mon Sep 17 00:00:00 2001 From: Will Vining Date: Tue, 2 Feb 2021 15:28:47 -0700 Subject: [PATCH 06/10] 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. --- pvlib/pvsystem.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index 1260e64705..e04fea4a43 100644 --- a/pvlib/pvsystem.py +++ b/pvlib/pvsystem.py @@ -811,8 +811,8 @@ def first_solar_spectral_loss(self, pw, airmass_absolute): effective irradiance, i.e., the irradiance that is converted to electrical current. """ - - def _spectral_correction(array): + pw = self._validate_per_array(pw, system_wide=True) + def _spectral_correction(array, pw): if 'first_solar_spectral_coefficients' in \ array.module_parameters.keys(): coefficients = \ @@ -828,7 +828,10 @@ def _spectral_correction(array): pw, airmass_absolute, module_type, coefficients ) - return tuple(_spectral_correction(array) for array in self.arrays) + return tuple( + _spectral_correction(array, pw) + for array, pw in zip(self.arrays, pw) + ) def singlediode(self, photocurrent, saturation_current, resistance_series, resistance_shunt, nNsVth, From 6ab9bab445bd1403a9033dce26af77ded2da436c Mon Sep 17 00:00:00 2001 From: Will Vining Date: Tue, 2 Feb 2021 15:39:41 -0700 Subject: [PATCH 07/10] Use itertools.starmap in in PVSystem.first_solar_spectral_loss() A bit cleaner than using a generator expression. --- pvlib/pvsystem.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pvlib/pvsystem.py b/pvlib/pvsystem.py index e04fea4a43..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 @@ -812,6 +813,7 @@ def first_solar_spectral_loss(self, pw, airmass_absolute): electrical current. """ pw = self._validate_per_array(pw, system_wide=True) + def _spectral_correction(array, pw): if 'first_solar_spectral_coefficients' in \ array.module_parameters.keys(): @@ -829,8 +831,7 @@ def _spectral_correction(array, pw): module_type, coefficients ) return tuple( - _spectral_correction(array, pw) - for array, pw in zip(self.arrays, pw) + itertools.starmap(_spectral_correction, zip(self.arrays, pw)) ) def singlediode(self, photocurrent, saturation_current, From 88a5e154f1b10e2e051c78b7f27b579a8cfbeaa8 Mon Sep 17 00:00:00 2001 From: Will Vining Date: Wed, 3 Feb 2021 08:48:24 -0700 Subject: [PATCH 08/10] 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. --- pvlib/modelchain.py | 144 +++++++++++++++++++++----------------------- 1 file changed, 67 insertions(+), 77 deletions(-) diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 32e8e72446..30d084a6a7 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -267,6 +267,10 @@ def get_orientation(strategy, **kwargs): class ModelChainResult: _T = TypeVar('T') PerArray = Union[_T, Tuple[_T, ...]] + _singleton_tuples: bool = field(default=False) + _per_array_fields = {'total_irrad', 'aoi', 'aoi_modifier', + 'spectral_modifier', 'cell_temperature', + 'effective_irradiance', 'dc', 'diode_params'} """Type for fields that vary between arrays""" # system-level information solar_position: Optional[pd.DataFrame] = field(default=None) @@ -277,13 +281,27 @@ class ModelChainResult: 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) + 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): + # 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: """ @@ -609,22 +627,6 @@ def getmcattr(self, attr): return ('ModelChain: \n ' + '\n '.join( f'{attr}: {getmcattr(self, attr)}' for attr in attrs)) - def _assign_result(self, field, value): - """Assign `value` to self.results.field, taking care to match - the type of weather for single-array models. - - Parameters - ---------- - field : str - Name of the results field. - value : object - Value to be assigned. - """ - if self.system.num_arrays == 1 and isinstance(self.weather, tuple): - setattr(self.results, field, (value,)) - else: - setattr(self.results, field, value) - @property def orientation_strategy(self): return self._orientation_strategy @@ -701,8 +703,8 @@ def infer_dc_model(self): def sapm(self): dc = self.system.sapm(self.results.effective_irradiance, - self.results.cell_temperature) - self._assign_result("dc", self.system.scale_voltage_current_power(dc)) + self.results.cell_temperature) + self.results.dc = self.system.scale_voltage_current_power(dc) return self def _singlediode(self, calcparams_model_function): @@ -717,9 +719,6 @@ def _make_diode_params(photocurrent, saturation_current, params = calcparams_model_function(self.results.effective_irradiance, self.results.cell_temperature, unwrap=False) - # We bypass self._assign_result() here since these are initially - # all tuples of results. Below we use self._assign_result() to - # clean up when there is only one array. self.results.diode_params = tuple(itertools.starmap( _make_diode_params, params)) self.results.dc = tuple(itertools.starmap( @@ -732,8 +731,8 @@ def _make_diode_params(photocurrent, saturation_current, # If the system has one Array, unwrap the single return value # to preserve the original behavior of ModelChain if self.system.num_arrays == 1: - self._assign_result("diode_params", self.results.diode_params[0]) - self._assign_result("dc", self.results.dc[0]) + self.results.diode_params = self.results.diode_params[0] + self.results.dc = self.results.dc[0] return self def desoto(self): @@ -766,9 +765,9 @@ def pvwatts_dc(self): self.results.cell_temperature, unwrap=False ) - temp = tuple(pd.DataFrame(s, columns=['p_mp']) for s in dc) - scaled = self.system.scale_voltage_current_power(temp) - self._assign_result("dc", _tuple_from_dfs(scaled, "p_mp")) + 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 @@ -877,41 +876,37 @@ def infer_aoi_model(self): 'kwarg; or set aoi_model="no_loss".') def ashrae_aoi_loss(self): - self._assign_result( - "aoi_modifier", - self.system.get_iam(self.results.aoi, iam_model='ashrae') + self.results.aoi_modifier = self.system.get_iam( + self.results.aoi, + iam_model='ashrae' ) return self def physical_aoi_loss(self): - self._assign_result( - "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._assign_result( - "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._assign_result( - "aoi_modifier", - self.system.get_iam(self.results.aoi, iam_model='martin_ruiz') + self.results.aoi_modifier = self.system.get_iam( + self.results.aoi, iam_model='martin_ruiz' ) return self def no_aoi_loss(self): if self.system.num_arrays == 1: - self._assign_result("aoi_modifier", 1.0) + self.results.aoi_modifier = 1.0 else: - self._assign_result( - "aoi_modifier", - (1.0,) * self.system.num_arrays - ) + self.results.aoi_modifier = (1.0,) * self.system.num_arrays return self @property @@ -955,32 +950,23 @@ def infer_spectral_model(self): 'spectral_model="no_loss".') def first_solar_spectral_loss(self): - self._assign_result( - "spectral_modifier", - self.system.first_solar_spectral_loss( - _tuple_from_dfs(self.weather, 'precipitable_water'), - self.results.airmass['airmass_absolute'] - ) + self.results.spectral_modifier = self.system.first_solar_spectral_loss( + _tuple_from_dfs(self.weather, 'precipitable_water'), + self.results.airmass['airmass_absolute'] ) return self def sapm_spectral_loss(self): - self._assign_result( - "spectral_modifier", - self.system.sapm_spectral_loss( - self.results.airmass['airmass_absolute'] - ) + self.results.spectral_modifier = self.system.sapm_spectral_loss( + self.results.airmass['airmass_absolute'] ) return self def no_spectral_loss(self): if self.system.num_arrays == 1: - self._assign_result("spectral_modifier", 1) + self.results.spectral_modifier = 1 else: - self._assign_result( - "spectral_modifier", - (1,) * self.system.num_arrays - ) + self.results.spectral_modifier = (1,) * self.system.num_arrays return self @property @@ -1060,8 +1046,7 @@ def _set_celltemp(self, model): self.results.effective_irradiance) temp_air = _tuple_from_dfs(self.weather, 'temp_air') wind_speed = _tuple_from_dfs(self.weather, 'wind_speed') - self._assign_result( - "cell_temperature", model(poa, temp_air, wind_speed)) + self.results.cell_temperature = model(poa, temp_air, wind_speed) return self def sapm_temp(self): @@ -1116,8 +1101,6 @@ def _eff_irrad(module_parameters, total_irrad, spect_mod, aoi_mod): fd = module_parameters.get('FD', 1.) return spect_mod * (total_irrad['poa_direct'] * aoi_mod + fd * total_irrad['poa_diffuse']) - # Bypassing self._assign_result since this is based on the type - # of self.results.total_irrad as opposed to the number of arrays if isinstance(self.results.total_irrad, tuple): self.results.effective_irradiance = tuple( _eff_irrad(array.module_parameters, ti, sm, am) for @@ -1270,8 +1253,6 @@ def _prep_inputs_tracking(self): self.results.tracking['surface_azimuth'] = ( self.results.tracking['surface_azimuth'] .fillna(self.system.axis_azimuth)) - # Bypass _assign_result because tracking systems do not - # support multiple Arrays. self.results.aoi = self.results.tracking['aoi'] return self @@ -1279,9 +1260,9 @@ def _prep_inputs_fixed(self): """ Calculate AOI for fixed tilt system """ - self._assign_result('aoi', self.system.get_aoi( + self.results.aoi = self.system.get_aoi( self.results.solar_position['apparent_zenith'], - self.results.solar_position['azimuth'])) + self.results.solar_position['azimuth']) return self def _verify_df(self, data, required): @@ -1309,6 +1290,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] @@ -1324,6 +1316,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): @@ -1416,15 +1409,12 @@ def prepare_inputs(self, weather): self.results.solar_position['apparent_zenith'], self.results.solar_position['azimuth']) - self._assign_result( - 'total_irrad', - get_irradiance( - _tuple_from_dfs(self.weather, 'dni'), - _tuple_from_dfs(self.weather, 'ghi'), - _tuple_from_dfs(self.weather, 'dhi'), - airmass=self.results.airmass['airmass_relative'], - model=self.transposition_model - ) + self.results.total_irrad = get_irradiance( + _tuple_from_dfs(self.weather, 'dni'), + _tuple_from_dfs(self.weather, 'ghi'), + _tuple_from_dfs(self.weather, 'dhi'), + airmass=self.results.airmass['airmass_relative'], + model=self.transposition_model ) return self From 562d72910f708ff7074094e56040729ed06e4e83 Mon Sep 17 00:00:00 2001 From: Will Vining Date: Wed, 3 Feb 2021 08:55:53 -0700 Subject: [PATCH 09/10] Add float to the type for ModelChainResult.aoi_modifier Covers the case where the aoi_model is 'no_loss'. --- pvlib/modelchain.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 30d084a6a7..2bda066c38 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -280,9 +280,10 @@ class ModelChainResult: tracking: Optional[pd.DataFrame] = field(default=None) 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[Union[pd.Series, float]]] \ - = 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]]] = \ From 081c2586447bdab9ace9d9c407c8df1d79aba0f5 Mon Sep 17 00:00:00 2001 From: Will Vining Date: Thu, 4 Feb 2021 07:42:22 -0700 Subject: [PATCH 10/10] Tidy up ModelChainResult Re-order fields and comments. Add docstring to _result_type() method. --- pvlib/modelchain.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/pvlib/modelchain.py b/pvlib/modelchain.py index 2bda066c38..dbca739942 100644 --- a/pvlib/modelchain.py +++ b/pvlib/modelchain.py @@ -267,17 +267,21 @@ def get_orientation(strategy, **kwargs): 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'} - """Type for fields that vary between arrays""" + # 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[Union[pd.Series, float]]] = \ @@ -291,6 +295,8 @@ class ModelChainResult: 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)